using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using ProjectI.Runs; using UnityEngine; namespace ProjectI.Enemies { public enum EnemyAdmissionOrigin { Purchase, BossEntry, SplinterDeath } public enum EnemyAdmissionMemberStatus { Pending, Born, Cancelled } // Only the encounter consumes native identities. Rules transitions never create an actor or reward. public sealed partial class EnemyAdmissionLedger { public sealed class MemberDefinition { public GameObject Prefab { get; } public EnemyDirectorTier Tier { get; } public EnemyArchetype Archetype { get; } public double Cost { get; } public bool Boss { get; } public EnemyDirectorDefinition.CardSettings Card { get; } internal MemberDefinition(EnemyDirectorDefinition.CardSettings card) { Card = card; Prefab = card.Prefab; Tier = card.Tier; Archetype = card.Archetype; Cost = card.Cost; } internal MemberDefinition(GameObject prefab) { Prefab = prefab; var definition = prefab.GetComponent().definition; Archetype = definition.archetype; Boss = definition.boss; } } public sealed class MemberView { public MemberDefinition Definition { get; } public EnemyAdmissionMemberStatus Status { get; } public ulong SpawnIdentity { get; } public EnemyIdentity Identity { get; } public bool Alive { get; } internal MemberView(Slot slot) { Definition = slot.Definition; Status = slot.Status; SpawnIdentity = slot.Spawn; Identity = slot.Identity; Alive = slot.Alive; } } public sealed class AdmissionView { public ulong Id { get; } public ulong PurchaseId { get; } public ulong ParentSpawnIdentity { get; } public ulong GroupId { get; } public ulong RunId { get; } public ulong StageId { get; } public uint ProfileGeneration { get; } public EnemyAdmissionOrigin Origin { get; } public EnemyFaction Faction { get; } public double ExpiresAt { get; } public bool HasRegion { get; } public EnemySpawnCircle Region { get; } public ReadOnlyCollection Members { get; } public int NextPendingSlot { get; } internal AdmissionView(Entry entry, ulong run, ulong stage) { Id = entry.Id; PurchaseId = entry.PurchaseId; ParentSpawnIdentity = entry.ParentSpawn; GroupId = entry.Group; RunId = run; StageId = stage; ProfileGeneration = entry.Generation; Origin = entry.Origin; Faction = entry.Faction; ExpiresAt = entry.ExpiresAt; HasRegion = entry.HasRegion; Region = entry.Region; Members = Array.AsReadOnly(entry.Slots.Select(s => new MemberView(s)).ToArray()); NextPendingSlot = Array.FindIndex(entry.Slots, s => s.Status == EnemyAdmissionMemberStatus.Pending); } } public sealed class ParentDeath { internal readonly EnemyAdmissionLedger Issuer; internal readonly ulong Parent, Group; internal readonly double At; internal readonly uint Generation; internal readonly Slot ParentSlot; internal readonly EnemyFaction Faction; internal readonly bool HasRegion; internal readonly EnemySpawnCircle Region; internal bool Used; internal ParentDeath(EnemyAdmissionLedger issuer, Slot slot, Entry entry, double at) { Issuer = issuer; ParentSlot = slot; Parent = slot.Spawn; Group = entry.Group; At = at; Generation = issuer.ProfileGeneration; Faction = entry.Faction; HasRegion = entry.HasRegion; Region = entry.Region; } } public sealed class BossAuthorization { internal readonly EnemyAdmissionLedger Issuer; internal readonly uint Generation; internal bool Used; internal BossAuthorization(EnemyAdmissionLedger issuer) { Issuer = issuer; Generation = issuer.ProfileGeneration; } } internal sealed class Slot { #if UNITY_EDITOR || DEVELOPMENT_BUILD internal BirthRecord NativeBirth; #endif internal MemberDefinition Definition; internal EnemyAdmissionMemberStatus Status; internal ulong Spawn; internal EnemyIdentity Identity; internal bool Alive; internal bool DeathPending; } internal sealed class Entry { #if UNITY_EDITOR || DEVELOPMENT_BUILD internal EnemySelectionMode? SelectionMode; internal string TemplateId, SingleCardId, ProfileId; #endif internal ulong Id, PurchaseId, ParentSpawn, Group; internal uint Generation; internal EnemyAdmissionOrigin Origin; internal EnemyFaction Faction; internal double ExpiresAt; internal bool HasRegion; internal EnemySpawnCircle Region; internal Slot[] Slots; } private readonly Dictionary entries = new Dictionary(); private readonly Dictionary actors = new Dictionary(); private readonly EnemyDirectorDefinition.Settings settings; private readonly PrototypeEncounterDirector owner; private ulong nextAdmission, nextPurchase; private double accrualAt; private RunDirectorInputs inputs; public ulong RunId { get; } public ulong StageId { get; } public uint ProfileGeneration { get; private set; } public EnemyDirectorDefinition.ProfileSettings Profile { get; private set; } public double AvailableCredit { get; private set; } public bool Active { get; private set; } = true; public bool BudgetActive { get; private set; } = true; private IEnumerable Counted => entries.Values.SelectMany(e => e.Slots).Where(s => s.Status == EnemyAdmissionMemberStatus.Pending || s.Status == EnemyAdmissionMemberStatus.Born && (s.Alive || s.DeathPending)); public int PendingCount => Counted.Count(s => !s.Definition.Boss && s.Status == EnemyAdmissionMemberStatus.Pending); public int LiveCount => Counted.Count(s => !s.Definition.Boss && s.Status == EnemyAdmissionMemberStatus.Born); public int NonbossCount => PendingCount + LiveCount; public int KeeperCount => Counted.Count(s => s.Definition.Archetype == EnemyArchetype.TetherKeeper); public int LancerCount => Counted.Count(s => s.Definition.Archetype == EnemyArchetype.Lancer); public ReadOnlyCollection Admissions => Array.AsReadOnly(entries.Values.Select(e => new AdmissionView(e, RunId, StageId)).ToArray()); public EnemyAdmissionLedger(EnemyDirectorDefinition.Settings settings, string profile, ulong runId, ulong stageId, uint generation, double now, PrototypeEncounterDirector owner = null) { if (settings == null || runId == 0 || stageId == 0 || generation == 0 || !Clock(now)) throw new ArgumentException("Invalid ledger scope/clock."); this.settings = settings; this.owner = owner; RunId = runId; StageId = stageId; ProfileGeneration = generation; Profile = settings.Profiles.Single(p => p.Id == profile); AvailableCredit = Profile.InitialBalance; accrualAt = now; inputs = new RunDirectorInputs(runId, stageId, 1, 1, 0, 1); } public bool UpdateInputs(RunDirectorInputs captured) { if (!Active || captured.RunId != RunId || captured.StageId != StageId || captured.Stage < 1 || captured.Participants < 1 || captured.Participants > 4 || !Clock(captured.ActiveMinutes) || !EnemyDirectorDefinition.Positive(captured.Difficulty)) return false; inputs = captured; return true; } public EnemyGroupSelection.Context SelectionContext => new EnemyGroupSelection.Context(Profile.Id, inputs.Stage, inputs.ActiveMinutes, inputs.Difficulty, inputs.Participants, AvailableCredit, AvailableCredit, Math.Max(0, Profile.AdmissionLimit - NonbossCount), KeeperCount, LancerCount, RunId, StageId, ProfileGeneration); public void Accrue(double now, double rate) { if (!Active || !BudgetActive || !Clock(now) || now < accrualAt || !Clock(rate)) return; AvailableCredit = EnemyDirectorRules.Accrue(AvailableCredit, now - accrualAt, rate, Profile.BankCeiling); accrualAt = now; } public bool TryReservePurchase(EnemyGroupSelection.Result choice, ulong runId, ulong stageId, uint generation, double now, out ulong id) { id = 0; if (!BudgetActive || !Scope(runId, stageId, generation) || !Clock(now) || now < accrualAt || choice == null || choice.Members.Count < 1 || choice.Members.Count > 5 || !EnemyDirectorDefinition.Positive(choice.TotalCost) || choice.TotalCost > AvailableCredit || choice.TotalCost != choice.Members.Sum(c => c.Cost) || Counted.Any(s => s.DeathPending) || NonbossCount + choice.Members.Count > Profile.AdmissionLimit || !Eligible(choice) || KeeperCount + choice.Members.Count(c => c.Archetype == EnemyArchetype.TetherKeeper) > 2 || LancerCount + choice.Members.Count(c => c.Archetype == EnemyArchetype.Lancer) > 2) return false; double expiry = now + Profile.PendingLifetime; if (!Clock(expiry) || expiry <= now || nextPurchase == long.MaxValue) return false; var entry = Add(choice.Members.Select(c => new MemberDefinition(c)).ToArray(), EnemyAdmissionOrigin.Purchase, expiry, choice.Faction); if (entry == null) return false; #if UNITY_EDITOR || DEVELOPMENT_BUILD entry.SelectionMode = choice.Mode; entry.TemplateId = choice.TemplateId; entry.SingleCardId = choice.SingleCardId; #endif entry.PurchaseId = 0x8000000000000000UL | ++nextPurchase; AvailableCredit -= choice.TotalCost; id = entry.Id; return true; } public bool Eligible(EnemyGroupSelection.Result choice) { if (choice == null || !choice.Members.All(c => Profile.Cards.Contains(c) && c.Eligibility.Allows(inputs.Stage, inputs.ActiveMinutes, inputs.Difficulty, inputs.Participants))) return false; if (choice.Mode == EnemySelectionMode.SingleType) return Profile.SingleWeight > 0 && choice.Members.Count >= Profile.SingleMinimum && choice.Members.Count <= Profile.SingleMaximum && EnemyGroupSelection.SingleTypeMatches(settings, Profile, choice) && EnemyGroupSelection.SingleBase(Profile, choice).Eligibility.Allows(inputs.Stage, inputs.ActiveMinutes, inputs.Difficulty, inputs.Participants); if (choice.Mode == EnemySelectionMode.Procedural) return Profile.ProceduralWeight > 0 && choice.Members.Count >= Profile.ProceduralMinimum && choice.Members.Count <= Profile.ProceduralMaximum && !choice.Members.SelectMany((a, i) => choice.Members.Skip(i + 1).Select(b => (a, b))).Any(pair => settings.HardExclusions.Any(r => EnemyDirectorDefinition.Pair(r.firstType, r.secondType) == EnemyDirectorDefinition.Pair(pair.a.TypeId, pair.b.TypeId))); var template = Profile.Templates.FirstOrDefault(t => t.Id == choice.TemplateId); return choice.Mode == EnemySelectionMode.Template && Profile.TemplateWeight > 0 && template != null && template.Members.SequenceEqual(choice.Members) && template.Faction == choice.Faction && template.Eligibility.Allows(inputs.Stage, inputs.ActiveMinutes, inputs.Difficulty, inputs.Participants); } public bool TryGetAdmission(ulong id, out AdmissionView view) { view = entries.TryGetValue(id, out var entry) ? new AdmissionView(entry, RunId, StageId) : null; return view != null; } public bool SetRegion(ulong id, EnemySpawnCircle region) { if (!Active || !region.Valid || !entries.TryGetValue(id, out var entry) || entry.Generation != ProfileGeneration) return false; // Once a member is born, its common coarse region cannot migrate during placement retries. if (entry.HasRegion && entry.Slots.Any(s => s.Status == EnemyAdmissionMemberStatus.Born)) return entry.Region.Equals(region); entry.Region = region; entry.HasRegion = true; return true; } public bool TryCommitMember(ulong id, int slot, PrototypeEnemy actor) { if (owner == null || actor == null || !owner.DirectorScopeValid(this) || !actor.IsSpawned || !actor.IsServer || !actor.IsAlive || actor.NetworkManager != owner.NetworkManager || actor.gameObject.scene != owner.gameObject.scene || actor.Encounter != owner || actor.DirectorLedger != this || actor.DirectorAdmissionId != id || actor.DirectorMemberSlot != slot || !entries.TryGetValue(id, out var entry) || slot < 0 || slot >= entry.Slots.Length) return false; var member = entry.Slots[slot].Definition; if (entry.Origin != EnemyAdmissionOrigin.SplinterDeath && actor.ServerNow >= entry.ExpiresAt || !actor.Arrival.Valid || actor.Arrival.SpawnIdentity != actor.SpawnIdentity || actor.Arrival.BornAt > actor.ServerNow || actor.Arrival.ReadyAt <= actor.ServerNow || Math.Abs(actor.Arrival.ReadyAt - actor.Arrival.BornAt - Profile.ArrivalSeconds) > .000001) return false; if (actor.DirectorSourcePrefab != member.Prefab || actor.IsElite != (member.Tier == EnemyDirectorTier.Elite) || actor.IsBoss != member.Boss || actor.definition != member.Prefab.GetComponent().definition || actor.definition.archetype != member.Archetype || actor.Navigation == null || !actor.Navigation.isOnNavMesh || member.Card != null && actor.AgentType != member.Card.AgentType) return false; #if UNITY_EDITOR || DEVELOPMENT_BUILD if (!CommitVerified(id, slot, actor.SpawnIdentity, actor.Identity)) return false; RecordNativeBirth(id, slot, actor.Arrival.BornAt); return true; #else return CommitVerified(id, slot, actor.SpawnIdentity, actor.Identity); #endif } internal bool CommitVerified(ulong id, int slot, ulong spawn, EnemyIdentity identity) { if (!Active || spawn == 0 || identity.Lifetime == 0 || actors.ContainsKey(spawn) || !entries.TryGetValue(id, out var entry) || entry.Generation != ProfileGeneration || slot < 0 || slot >= entry.Slots.Length) return false; var member = entry.Slots[slot]; if (member.Status != EnemyAdmissionMemberStatus.Pending) return false; if (!member.Definition.Boss && NonbossCount > Profile.AdmissionLimit && entry.Origin == EnemyAdmissionOrigin.Purchase) return false; member.Status = EnemyAdmissionMemberStatus.Born; member.Alive = true; member.Spawn = spawn; member.Identity = identity; actors.Add(spawn, member); return true; } internal ParentDeath ReleaseVerified(ulong spawn, EnemyIdentity identity, bool lethal, double now) { if (!Clock(now) || !actors.TryGetValue(spawn, out var member) || !member.Alive || !member.Identity.Equals(identity)) return null; member.Alive = false; var entry = entries.Values.Single(e => e.Slots.Contains(member)); if (!Active || !lethal || member.Definition.Archetype != EnemyArchetype.Splinterbeast || entry.Origin == EnemyAdmissionOrigin.SplinterDeath) return null; member.DeathPending = true; return new ParentDeath(this, member, entry, now); } // The owner closes the synchronous lethal callback even if no valid children were placed. internal void CompleteParentDeath(ParentDeath death) { if (death == null || death.Issuer != this) return; death.ParentSlot.DeathPending = false; death.Used = true; } internal BossAuthorization AuthorizeBossEntry() => Active ? new BossAuthorization(this) : null; public bool TryReserveBossEntry(BossAuthorization authorization, GameObject prefab, ulong runId, ulong stageId, double now, out ulong id) { id = 0; var actor = prefab != null ? prefab.GetComponent() : null; if (!Scope(runId, stageId, ProfileGeneration) || !Clock(now) || authorization == null || authorization.Issuer != this || authorization.Used || authorization.Generation != ProfileGeneration || actor == null || actor.definition == null || !actor.definition.Valid || !actor.definition.boss || Counted.Any(s => s.Definition.Boss)) return false; var entry = Add(new[] { new MemberDefinition(prefab) }, EnemyAdmissionOrigin.BossEntry, now + Profile.PendingLifetime, EnemyFaction.HollowKnights); if (entry == null) return false; authorization.Used = true; id = entry.Id; return true; } public bool TryReserveDescendants(ParentDeath death, int count, GameObject prefab, ulong runId, ulong stageId, double now, out ulong id) { id = 0; var actor = prefab != null ? prefab.GetComponent() : null; if (!Scope(runId, stageId, ProfileGeneration) || death == null || death.Issuer != this || death.Used || death.Generation != ProfileGeneration || !death.ParentSlot.DeathPending || death.At != now || now < accrualAt || count < 1 || count > 2 || actor == null || actor.definition == null || !actor.definition.Valid || actor.definition.boss || actor.definition.archetype != EnemyArchetype.SplinterChild) return false; var entry = Add(Enumerable.Range(0, count).Select(_ => new MemberDefinition(prefab)).ToArray(), EnemyAdmissionOrigin.SplinterDeath, now, EnemyFaction.HollowKnights); if (entry == null) return false; CompleteParentDeath(death); entry.ParentSpawn = death.Parent; entry.Group = death.Group; entry.Faction = death.Faction; entry.HasRegion = death.HasRegion; entry.Region = death.Region; id = entry.Id; if (NonbossCount > Profile.AdmissionLimit) CancelPaidPending(); return true; } public bool Cancel(ulong id, ulong runId, ulong stageId, uint generation) => Scope(runId, stageId, generation) && entries.TryGetValue(id, out var entry) && entry.Generation == generation && CancelEntry(entry, true); private bool CancelEntry(Entry entry, bool refund) { bool changed = false; foreach (var member in entry.Slots) { if (member.Status != EnemyAdmissionMemberStatus.Pending) continue; member.Status = EnemyAdmissionMemberStatus.Cancelled; changed = true; if (refund && Active && entry.Generation == ProfileGeneration && entry.Origin == EnemyAdmissionOrigin.Purchase) AvailableCredit = Math.Min(Profile.BankCeiling, AvailableCredit + member.Definition.Cost); } return changed; } private void CancelPaidPending() { foreach (var entry in entries.Values.Where(e => e.Origin == EnemyAdmissionOrigin.Purchase)) CancelEntry(entry, true); } public bool BeginProfile(string profile, uint generation, double now) { var next = settings.Profiles.FirstOrDefault(p => p.Id == profile); if (!Active || generation <= ProfileGeneration || next == null || !Clock(now) || now < accrualAt) return false; foreach (var entry in entries.Values) CancelEntry(entry, false); Profile = next; ProfileGeneration = generation; AvailableCredit = next.InitialBalance; BudgetActive = true; accrualAt = now; return true; } internal bool BeginBossProfile(string profile, double now) { if (ProfileGeneration == uint.MaxValue || !BeginProfile(profile, ProfileGeneration + 1, now)) return false; BudgetActive = false; AvailableCredit = 0; return true; } internal bool ActivateBossBudget(ulong admission, double now) { if (!Active || BudgetActive || !Clock(now) || now < accrualAt || !entries.TryGetValue(admission, out var entry) || entry.Generation != ProfileGeneration || entry.Origin != EnemyAdmissionOrigin.BossEntry || entry.Slots.Length != 1 || entry.Slots[0].Status != EnemyAdmissionMemberStatus.Born || !entry.Slots[0].Alive) return false; BudgetActive = true; AvailableCredit = Profile.InitialBalance; accrualAt = now; return true; } public void Close() { if (!Active) return; Active = false; BudgetActive = false; if (ProfileGeneration < uint.MaxValue) ProfileGeneration++; foreach (var entry in entries.Values) CancelEntry(entry, false); AvailableCredit = 0; } private Entry Add(MemberDefinition[] members, EnemyAdmissionOrigin origin, double expires, EnemyFaction faction) { if (nextAdmission == ulong.MaxValue || !Clock(expires)) return null; var entry = new Entry { Id = ++nextAdmission, Generation = ProfileGeneration, Origin = origin, ExpiresAt = expires, Faction = faction, Slots = members.Select(m => new Slot { Definition = m }).ToArray() }; #if UNITY_EDITOR || DEVELOPMENT_BUILD entry.ProfileId = Profile.Id; #endif entry.Group = entry.Id; entries.Add(entry.Id, entry); return entry; } private bool Scope(ulong run, ulong stage, uint generation) => Active && run == RunId && stage == StageId && generation == ProfileGeneration; private static bool Clock(double value) => EnemyDirectorDefinition.Finite(value) && value >= 0; } }