using System; using ProjectI.Runs; namespace ProjectI.Enemies { public enum EnemyDirectorBirthResult { Deferred, Rejected, Committed } public sealed class EnemySpawnScheduler { public abstract class World { public abstract bool Authoritative { get; } public abstract bool TryCaptureInputs(out RunDirectorInputs inputs); public abstract EnemySpawnSelectionStatus Prepare(EnemyAdmissionLedger.AdmissionView admission, double now); public abstract EnemyDirectorBirthResult TryBirth(EnemyAdmissionLedger.AdmissionView admission, int slot, double now); public abstract void ResetPlacement(); } public EnemyGroupSelection.SavingIntent Saving { get; private set; } public ulong PendingAdmissionId { get; private set; } public double NextOpportunityAt { get; private set; } public uint RngState { get; private set; } #if UNITY_EDITOR || DEVELOPMENT_BUILD public double NextMemberAt => nextMember; #endif private readonly EnemyDirectorDefinition.Settings settings; private readonly EnemyAdmissionLedger ledger; private readonly World world; private double lastTick, nextMember; private uint generation; private bool ordinaryFirst, stopped; public EnemySpawnScheduler(EnemyDirectorDefinition.Settings settings, EnemyAdmissionLedger ledger, World world, uint seed, double now) { if (settings == null || ledger == null || world == null || !Clock(now)) throw new ArgumentException("Scheduler requires captured definitions, ledger, world and clock."); this.settings = settings; this.ledger = ledger; this.world = world; RngState = seed; generation = ledger.ProfileGeneration; lastTick = now; NextOpportunityAt = Future(now, ledger.Profile.PurchaseInterval); } public void TickServer(double now) { if (stopped || !world.Authoritative || !Clock(now) || now < lastTick) return; if (!ledger.Active || !world.TryCaptureInputs(out var inputs) || !ledger.UpdateInputs(inputs)) { Stop(); return; } if (generation != ledger.ProfileGeneration) { generation = ledger.ProfileGeneration; Saving = null; PendingAdmissionId = 0; ordinaryFirst = false; world.ResetPlacement(); NextOpportunityAt = Future(now, ledger.Profile.PurchaseInterval); lastTick = now; return; } lastTick = now; ledger.Accrue(now, EnemyDirectorRules.Income(ledger.Profile.BaseIncome, inputs.Stage, inputs.ActiveMinutes, inputs.Difficulty, inputs.Participants)); bool blockedAtStart = PendingAdmissionId != 0; AdvancePending(now); if (now < NextOpportunityAt) return; NextOpportunityAt = Future(now, ledger.Profile.PurchaseInterval); if (!Clock(NextOpportunityAt) || NextOpportunityAt <= now) { Stop(); return; } if (blockedAtStart || PendingAdmissionId != 0) return; var context = ledger.SelectionContext; if (Saving != null) { if (!Saving.IsValid(settings, context, now) || !ledger.Eligible(Saving.Choice)) { Saving = null; ordinaryFirst = true; return; } if (Saving.Choice.TotalCost <= ledger.AvailableCredit && Reserve(Saving.Choice, now)) Saving = null; return; } if (ordinaryFirst) { ordinaryFirst = false; Ordinary(context, now); return; } double chance = Next() / 4294967296d; if (chance < ledger.Profile.SavingChance && EnemyGroupSelection.TryCreateSavingIntent(settings, context, Next(), now, out var intent)) { Saving = intent; RngState = intent.Choice.RngState; return; } Ordinary(context, now); } private void Ordinary(EnemyGroupSelection.Context context, double now) { uint seed = Next(); // Failure still consumes owned RNG; a failed mode cannot pin future opportunities. if (!EnemyGroupSelection.TryChoose(settings, context, seed, out var choice)) return; RngState = choice.RngState; Reserve(choice, now); } private bool Reserve(EnemyGroupSelection.Result choice, double now) { if (!ledger.TryReservePurchase(choice, ledger.RunId, ledger.StageId, ledger.ProfileGeneration, now, out var id)) return false; PendingAdmissionId = id; nextMember = now; world.ResetPlacement(); return true; } private void AdvancePending(double now) { if (PendingAdmissionId == 0) return; if (!ledger.TryGetAdmission(PendingAdmissionId, out var admission) || admission.NextPendingSlot < 0) { ClearPending(); return; } if (admission.ProfileGeneration != ledger.ProfileGeneration || now >= admission.ExpiresAt || ledger.NonbossCount > ledger.Profile.AdmissionLimit) { CancelPending(); return; } var status = world.Prepare(admission, now); if (status == EnemySpawnSelectionStatus.Rejected) { CancelPending(); return; } if (status != EnemySpawnSelectionStatus.Accepted || now < nextMember) return; var born = world.TryBirth(admission, admission.NextPendingSlot, now); if (born == EnemyDirectorBirthResult.Rejected) { CancelPending(); return; } if (born != EnemyDirectorBirthResult.Committed) return; nextMember = Future(now, ledger.Profile.MemberStagger); if (ledger.TryGetAdmission(admission.Id, out var updated) && updated.NextPendingSlot < 0) ClearPending(); } private void CancelPending() { ledger.Cancel(PendingAdmissionId, ledger.RunId, ledger.StageId, ledger.ProfileGeneration); ClearPending(); } private void ClearPending() { PendingAdmissionId = 0; world.ResetPlacement(); } public void Stop() { RetireForProfileChange(); ledger.Close(); } internal void RetireForProfileChange() { if (stopped) return; stopped = true; Saving = null; PendingAdmissionId = 0; world.ResetPlacement(); } private uint Next() { uint value = RngState == 0 ? 0x6D2B79F5u : RngState; value ^= value << 13; value ^= value >> 17; value ^= value << 5; RngState = value; return value; } private static double Future(double now, double interval) => now + interval; private static bool Clock(double value) => EnemyDirectorDefinition.Finite(value) && value >= 0; } }