From b34945c2d2a8d112eb482c6f3cb555f85a276cf9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Wed, 15 Jul 2026 15:27:12 -0700 Subject: [PATCH] LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7 Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector, CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components, CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files). Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked), EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues; no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core bar, siege banner, terminal banner, outcome flash, onboarding hook all gone), MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant (SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved), StorageMath.DrainFraction deleted, HowToPlay copy rewritten. Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome + conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed. 390 tests green; Play world-creation clean (player + waves live, no exceptions). Co-Authored-By: Claude Fable 5 --- .../Authoring/World/CycleDirectorAuthoring.cs | 84 +--- .../Client/Debug/DebugCommandSendSystem.cs | 7 +- .../Scripts/Client/Debug/DebugOverlay.cs | 11 +- .../_Project/Scripts/Client/Onboarding.meta | 8 - .../Client/Onboarding/OnboardingState.cs | 26 -- .../Client/Onboarding/OnboardingState.cs.meta | 2 - .../Client/Onboarding/OnboardingStepMath.cs | 145 ------- .../Onboarding/OnboardingStepMath.cs.meta | 2 - .../Client/Onboarding/OnboardingSystem.cs | 332 --------------- .../Onboarding/OnboardingSystem.cs.meta | 2 - .../Client/Presentation/AimReticleSystem.cs | 6 +- .../Client/Presentation/AmbientAudioSystem.cs | 66 +-- .../Presentation/ClassPrepPortalHudSystem.cs | 4 +- .../Presentation/CoreVisualFeedbackSystem.cs | 91 ---- .../CoreVisualFeedbackSystem.cs.meta | 2 - .../Scripts/Client/Presentation/HudSystem.cs | 380 ++--------------- .../Client/Presentation/MetaShopHudSystem.cs | 4 +- .../Client/Presentation/MusicSystem.cs | 57 +-- .../Scripts/Client/UI/HowToPlayPanel.cs | 27 +- .../Scripts/Client/UI/WorldLauncher.cs | 22 +- .../Scripts/Server/Combat/EnemyAISystem.cs | 46 +- .../Scripts/Server/Combat/WaveSystem.cs | 3 - .../Server/Debug/DebugCommandReceiveSystem.cs | 70 +-- .../Server/Persistence/SaveWriteSystem.cs | 36 +- .../Scripts/Server/World/CoreDamageSystem.cs | 79 ---- .../Server/World/CoreDamageSystem.cs.meta | 2 - .../Scripts/Server/World/CoreRestoreSystem.cs | 59 --- .../Server/World/CoreRestoreSystem.cs.meta | 2 - .../Server/World/CycleDirectorSpawnSystem.cs | 71 +--- .../Scripts/Server/World/CyclePhaseSystem.cs | 195 --------- .../Server/World/CyclePhaseSystem.cs.meta | 2 - .../Scripts/Server/World/GoalReachedSystem.cs | 90 ---- .../Server/World/GoalReachedSystem.cs.meta | 2 - .../Scripts/Server/World/RunDirectorSystem.cs | 47 +-- .../Server/World/ThreatDirectorSystem.cs | 138 ------ .../Server/World/ThreatDirectorSystem.cs.meta | 2 - .../Simulation/Debug/DebugCommandRequest.cs | 15 +- .../Scripts/Simulation/Debug/TuningConfig.cs | 42 +- .../Simulation/HomeBase/StorageMath.cs | 19 +- .../Simulation/Persistence/SaveApply.cs | 7 +- .../Simulation/Persistence/SaveComponents.cs | 44 +- .../Simulation/Persistence/SaveData.cs | 47 +-- .../Simulation/Persistence/SaveService.cs | 8 +- .../Persistence/SaveStructureScan.cs | 3 +- .../Scripts/Simulation/World/CoreIntegrity.cs | 34 -- .../Simulation/World/CoreIntegrity.cs.meta | 2 - .../Simulation/World/CycleComponents.cs | 70 +-- .../Scripts/Simulation/World/GoalProgress.cs | 22 - .../Simulation/World/GoalProgress.cs.meta | 2 - .../Simulation/World/RunStateComponents.cs | 61 --- .../World/RunStateComponents.cs.meta | 2 - .../Simulation/World/ThreatComponents.cs | 88 ---- .../Simulation/World/ThreatComponents.cs.meta | 2 - .../Tests/EditMode/CoreSystemsTests.cs | 181 -------- .../Tests/EditMode/CoreSystemsTests.cs.meta | 2 - .../Tests/EditMode/CyclePhaseSystemTests.cs | 252 ----------- .../EditMode/CyclePhaseSystemTests.cs.meta | 2 - .../DebugCommandReceiveSystemTests.cs | 24 +- .../Tests/EditMode/EndgameWinLoseTests.cs | 399 ------------------ .../EditMode/EndgameWinLoseTests.cs.meta | 2 - .../Tests/EditMode/OnboardingStepTests.cs | 223 ---------- .../EditMode/OnboardingStepTests.cs.meta | 2 - .../Tests/EditMode/ReadyCheckSystemTests.cs | 17 - .../EditMode/RunDirectorTraversalTests.cs | 11 +- .../EditMode/RunRosterRegressionTests.cs | 2 +- .../Tests/EditMode/SavePersistenceTests.cs | 115 ++--- .../Tests/EditMode/StorageMathTests.cs | 44 +- .../EditMode/SystemOrderingCycleTests.cs | 12 +- .../EditMode/TerminalCampaignRollTests.cs | 50 --- .../TerminalCampaignRollTests.cs.meta | 2 - .../EditMode/ThreatDirectorSystemTests.cs | 205 --------- .../ThreatDirectorSystemTests.cs.meta | 2 - .../Tests/EditMode/TuningConfigTests.cs | 10 +- .../Tests/EditMode/WaveSystemTests.cs | 41 +- 74 files changed, 233 insertions(+), 3955 deletions(-) delete mode 100644 Assets/_Project/Scripts/Client/Onboarding.meta delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs delete mode 100644 Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs delete mode 100644 Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs delete mode 100644 Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs delete mode 100644 Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs delete mode 100644 Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs delete mode 100644 Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs delete mode 100644 Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs.meta delete mode 100644 Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs delete mode 100644 Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta delete mode 100644 Assets/_Project/Scripts/Simulation/World/GoalProgress.cs delete mode 100644 Assets/_Project/Scripts/Simulation/World/GoalProgress.cs.meta delete mode 100644 Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs delete mode 100644 Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs.meta delete mode 100644 Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs delete mode 100644 Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/CoreSystemsTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/OnboardingStepTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/OnboardingStepTests.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta delete mode 100644 Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs delete mode 100644 Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs.meta diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index cb1cf3838..afce26453 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -5,99 +5,33 @@ using UnityEngine; namespace ProjectM.Authoring { /// - /// Authoring for the GLOBAL cycle-director ghost prefab: an ownerless INTERPOLATED ghost (the + /// Authoring for the GLOBAL director ghost prefab: an ownerless INTERPOLATED ghost (the /// GhostAuthoringComponent is inherited when this prefab is duplicated from UpgradePickup.prefab) that - /// carries the replicated macro-loop state () and the shared resource ledger - /// (a buffer marked by ). It is GLOBAL — it must + /// carries the shared resource ledger (a buffer marked by + /// ), the replicated run-lifecycle FSM (), the + /// expedition-objective readout, and the per-class permanent-meta tier buffer. It is GLOBAL — it must /// carry NO so GhostRelevancy keeps it relevant to every connection regardless of - /// region. The server CycleDirectorSpawnSystem overrides the baked CycleState at spawn (real PhaseEndTick) - /// and adds the server-only CycleRuntime. + /// region. (The old cycle/siege/goal/core state is retired — LANTERN purge.) /// public class CycleDirectorAuthoring : MonoBehaviour { - [Header("Threat — post-expedition retaliation")] - [Tooltip("A completed expedition (a player returning to base) can draw a retaliation siege.")] - public bool PostExpeditionEnabled = true; - - [Tooltip("Telegraph/arming delay (server ticks @60) between the return and the siege spawning.")] - public uint PostExpeditionDelayTicks = 300; - - [Tooltip("Siege size floor (Husk count) for a post-expedition retaliation.")] - [Min(0)] public int SiegeSizeBase = 5; - - [Tooltip("Extra Husks per unit of resources hauled back this run (0 = flat SiegeSizeBase).")] - [Min(0)] public int SiegeSizePerResource = 0; - - [Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")] - public uint SiegeTimeoutTicks = 3600; - [Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")] - [Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")] - public bool ScheduleEnabled = false; - - [Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")] - public uint ScheduleIntervalTicks = 2700; - - [Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")] - [Min(0)] public int ScheduleSizePerWave = 1; - - [Header("Endgame — Engine Core (END-1)")] - [Tooltip("Baked integrity ceiling of the losable Engine Core. Current is born full (or the persisted wounded value on Continue).")] - [Min(1)] public int CoreIntegrityMax = 100; - - - private class CycleDirectorBaker : Baker { public override void Bake(CycleDirectorAuthoring authoring) { var entity = GetEntity(authoring, TransformUsageFlags.Dynamic); - AddComponent(entity, new CycleState - { - Phase = CyclePhase.Calm, - CycleNumber = 1, - PhaseEndTick = 0u, - }); AddComponent(entity); AddBuffer(entity); - AddComponent(entity, new GoalProgress { Charge = 0, Target = 2 }); // DR-044 demo pacing: 2 expedition clears -> the climactic final siege (~10-20 min hands-off run) - // END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full; - // CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue. - AddComponent(entity, new CoreIntegrity - { - Current = authoring.CoreIntegrityMax, - Max = authoring.CoreIntegrityMax, - OverrunTick = 0u, - }); - // END-2: the terminal run outcome is REPLICATED ([GhostField]) so the client HUD shows the win/loss - // banner by observing it. Baked here -> part of the ghost serializer (one re-bake). Born InProgress; - // CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue. - AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress }); // DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout). - // Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the - // runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity. + // Born Idle; RoomEnemyDirectorSystem is the sole writer. AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 }); - // Expedition redesign: the replicated run-lifecycle FSM (RunInfo, 17 [GhostField]s) + the per-class - // permanent-meta tier buffer (MetaTierState) BOTH land in this ONE coordinated re-bake (front-loaded - // ghost layout — the writer systems arrive across Steps 2–13 while the state sits inert/default). - // Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are the sole writers. + // Expedition redesign: the replicated run-lifecycle FSM (RunInfo) + the per-class permanent-meta + // tier buffer (MetaTierState). Born Staging/empty; server RunDirectorSystem / MetaSpendSystem are + // the sole writers. AddComponent(entity, new RunInfo { Lifecycle = RunLifecycle.Staging }); AddBuffer(entity); - - - AddComponent(entity, new ThreatConfig - { - PostExpeditionEnabled = (byte)(authoring.PostExpeditionEnabled ? 1 : 0), - PostExpeditionDelayTicks = authoring.PostExpeditionDelayTicks, - SizeBase = authoring.SiegeSizeBase, - SizePerExpeditionResource = authoring.SiegeSizePerResource, - StartCondition = ThreatStartCondition.Immediate, - SiegeTimeoutTicks = authoring.SiegeTimeoutTicks, - ScheduleEnabled = (byte)(authoring.ScheduleEnabled ? 1 : 0), - ScheduleIntervalTicks = authoring.ScheduleIntervalTicks, - ScheduleSizePerWave = authoring.ScheduleSizePerWave, - }); } } } diff --git a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs index eb5ac20c9..81c84ce1f 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugCommandSendSystem.cs @@ -26,18 +26,15 @@ namespace ProjectM.Client => s_Pending.Add(new Pending { Op = op, ArgA = argA, ArgB = argB }); // Convenience wrappers (overlay buttons + execute_code). - public static void SpawnWave(int size) => Send(DebugOp.SpawnWave, size); - public static void EndSiege() => Send(DebugOp.EndSiege); + public static void SpawnWave() => Send(DebugOp.SpawnWave); // re-meant: force the next wave now + public static void StopWaves() => Send(DebugOp.EndSiege); // re-meant: quiet the arena (cull + delay waves) public static void ClearEnemies() => Send(DebugOp.ClearEnemies); - public static void SetCalm() => Send(DebugOp.SetCalm); public static void GrantResource(byte itemId, int count) => Send(DebugOp.GrantResource, itemId, count); public static void GrantUpgrade() => Send(DebugOp.GrantUpgrade); public static void Teleport(byte region) => Send(DebugOp.Teleport, region); public static void ToggleGod() => Send(DebugOp.ToggleGod); public static void Heal() => Send(DebugOp.Heal); public static void Kill() => Send(DebugOp.KillPlayer); - public static void AdvanceGoal(int by) => Send(DebugOp.AdvanceGoal, by); - public static void SetHeat(int heat) => Send(DebugOp.SetHeat, heat); /// Set the knob to value (server-applied, x1000 fixed-point; MC-0). public static void SetTuning(byte knob, float value) => Send(DebugOp.SetTuning, knob, Mathf.RoundToInt(value * 1000f)); /// Swap the sender's class to (a byte); server-authoritative (class-switch dev tool). diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index 2b281ca51..3ce36ae71 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -15,7 +15,6 @@ namespace ProjectM.Client public class DebugOverlay : MonoBehaviour { bool _open = true; - int _siegeSize = 5; int _grantAmount = 50; bool _tuningOpen; Vector2 _scroll; @@ -37,12 +36,9 @@ namespace ProjectM.Client _scroll = GUILayout.BeginScrollView(_scroll); GUILayout.Label("- World -"); - _siegeSize = IntField("Siege size", _siegeSize); - if (GUILayout.Button("Spawn Wave / Force Siege")) DebugCommandSendSystem.SpawnWave(_siegeSize); - if (GUILayout.Button("End Siege")) DebugCommandSendSystem.EndSiege(); + if (GUILayout.Button("Force Next Wave")) DebugCommandSendSystem.SpawnWave(); + if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves(); if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies(); - if (GUILayout.Button("Force Calm")) DebugCommandSendSystem.SetCalm(); - if (GUILayout.Button("Advance Goal +1")) DebugCommandSendSystem.AdvanceGoal(1); GUILayout.Space(6); GUILayout.Label("- Resources -"); @@ -112,9 +108,6 @@ namespace ProjectM.Client TuningRow("Melee combo len", TuningKnob.MeleeComboLength, 1f, "0"); GUILayout.Space(4); TuningRow("Struct aggro w", TuningKnob.StructureAggroWeight, 0.1f, "0.00"); // EB-1: <1 prefers structures - TuningRow("Core dmg/husk", TuningKnob.CoreDamagePerHusk, 1f, "0"); // END-1: integrity per breaching Husk - TuningRow("Core regen int", TuningKnob.CoreRegenIntervalTicks, 1f, "0"); // END-1: ticks between +1 in Calm - TuningRow("Core overrun %", TuningKnob.CoreOverrunDrainPct, 0.05f, "0.00"); // END-1: ledger fraction lost on breach } diff --git a/Assets/_Project/Scripts/Client/Onboarding.meta b/Assets/_Project/Scripts/Client/Onboarding.meta deleted file mode 100644 index 7e2971492..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 0861914135cacf948ae2adfd7f7d6870 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs deleted file mode 100644 index f43c3fecf..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs +++ /dev/null @@ -1,26 +0,0 @@ -using UnityEngine; - -namespace ProjectM.Client -{ - /// - /// Tiny static coordination bridge for the first-run onboarding overlay. is true while a - /// coach-mark step is on screen (set each frame by ); - /// reads it to suppress its own ad-hoc location/gate hint so the player ever sees a single prompt voice. - /// A presentation-layer static, so it is RESET on play-enter (the CLAUDE.md stale-static rule) to avoid a - /// stale flag surviving a fast-enter-playmode domain reload and leaving the HUD hint suppressed. - /// - public static class OnboardingState - { - /// True while the coach-mark sequence is the active prompt voice (a step is being shown). - public static bool Active; - - /// True only while an EARLY base-framing step (Welcome/Move/Build/Fabricator/ReadyUp) is showing, so - /// HudSystem blanks its own location line for those (the coach-mark owns the voice) but LEAVES the room/siege/ - /// out-of-ammo cues visible during the combat steps (D4). Set each frame by OnboardingSystem. - public static bool SuppressLocationLine; - - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - static void ResetOnPlayEnter() { Active = false; SuppressLocationLine = false; } - } -} diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta deleted file mode 100644 index 122cf07d9..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingState.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4e9b3bb074eef1c40b90591e85b90e32 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs deleted file mode 100644 index db9ca715f..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs +++ /dev/null @@ -1,145 +0,0 @@ -using ProjectM.Simulation; - -namespace ProjectM.Client -{ - /// - /// Pure, engine-free logic for the first-run onboarding coach-mark sequence — the testable core of - /// (mirrors the project's *Math helper discipline; no UnityEngine / - /// Entities types so it unit-tests as plain C#). Defines the ordered step list, a of the - /// observable client state each step reads, the deterministic per-step completion test, prompt copy, the - /// spatial-cue kind, and the persisted-mask helpers. - /// - /// RE-AUTHORED for the expedition redesign (demo polish): base nodes are gone (mining happens IN the run), - /// so the old at-base Mine step is dead — the first lap is now Build (grubstake Ore) → Fabricator → - /// READY UP → fight rooms (mine in-run) → boon → boss/return → defend. Step BITS are re-meant, never - /// renamed-in-place semantics: a veteran's full mask stays dormant; a partial first-run mask at worst - /// replays one beat. - /// - /// Pacing (operator-locked = soft-gated): a step shows until its action is performed (no per-step timeout), - /// EXCEPT two info beats — and — which also auto-advance, plus - /// the timed strip. Veteran / co-op auto-suppress falls out for free: the count-based - /// steps (, ) test an ABSOLUTE structure count, so a client joining - /// an already-built base satisfies them on entry and skips straight past. - /// - public static class OnboardingStepMath - { - // ---- ordered steps (byte ids; bit i of GameSettings.OnboardingMask = step i complete) ---- - public const byte Welcome = 0; // tiny win-condition framing strip (timed) - public const byte Move = 1; - public const byte Build = 2; // open palette + place a Turret (the 50-Ore grubstake covers it) - public const byte Fabricator = 3; // Ore -> Charge (soft info beat) - public const byte ReadyUp = 4; // press T / click READY — the party launches together - public const byte Rooms = 5; // fight the rooms; attack crystal nodes to haul resources - public const byte Boon = 6; // clear a room -> pick 1 of 3 boons + choose the path - public const byte Return = 7; // fell the boss — the haul + the Engine charge come home - public const byte Defend = 8; // survive the retaliation siege (soft info beat) - public const byte Done = 9; // closing beat - public const byte StepCount = 10; - - // ---- tunable thresholds (public so the EditMode tests pin the contract) ---- - public const float WelcomeSeconds = 5f; - public const float MoveThreshold = 3f; // accumulated player movement (world units) - public const float FabricatorSoftSeconds = 14f; // soft beat auto-advance if no Fabricator built - public const float DefendNoSiegeSeconds = 20f; // advance if no siege ever materialises - public const float DoneSeconds = 6f; // closing beat lingers before going dormant - public const float RoomsSeconds = 7f; // D2: keep the mine-the-crystals prompt + node pointer up a beat AFTER teleport (past the ~3s launch countdown -> ~4s in the room) - public const float ReturnMaxSeconds = 75f; // D3: soft backstop so a missed homecoming signal can NEVER stall the sequence - - // ---- spatial-cue kinds the System resolves to a live world target ---- - public const byte PointerNone = 0; - public const byte PointerOreNode = 1; - public const byte PointerBaseGate = 2; // RETIRED target (walk-in gate died with the redesign); byte kept - public const byte PointerExpeditionGate = 3; // RETIRED target; byte kept for mask/step stability - - /// Observable client state for one evaluation. Built by the System from ECS + input each frame. - public struct Snapshot - { - public float StepElapsed; // seconds the current step has been shown - public float MoveDistance; // accumulated player movement since the Move step began - public int TurretCount; // live Turret structures (absolute) - public int FabricatorCount; // live Fabricator structures (absolute) - public bool LocalReady; // the LOCAL player's replicated PlayerReady flag - public byte Lifecycle; // replicated RunInfo.Lifecycle (RunLifecycle.*) - public bool OnExpedition; // local player is in the expedition region - public byte ObjectiveState; // ExpeditionObjective.State (Idle/Active/Cleared) - public bool SawSiege; // a Siege phase was observed while the Defend step was showing - public bool WasOnExpedition;// D3: latched true once the player was seen on expedition during the Return step (so a start-at-base Return doesn't instantly satisfy) - - public byte Phase; // CycleState.Phase (Calm/Siege) - } - - /// True when the step's taught action is complete (or its soft timeout has elapsed). - public static bool IsSatisfied(byte step, in Snapshot s) - { - switch (step) - { - case Welcome: return s.StepElapsed >= WelcomeSeconds; - case Move: return s.MoveDistance >= MoveThreshold; - case Build: return s.TurretCount >= 1; - case Fabricator: return s.FabricatorCount >= 1 || s.StepElapsed >= FabricatorSoftSeconds; - case ReadyUp: return s.LocalReady || s.Lifecycle != RunLifecycle.Staging; - case Rooms: return s.OnExpedition && s.StepElapsed >= RoomsSeconds; // D2: show the mine prompt + node pointer IN the room, not the instant we teleport - case Boon: return s.ObjectiveState == ExpeditionObjectiveState.Cleared - || s.Lifecycle == RunLifecycle.RoomReward - || s.Lifecycle == RunLifecycle.RouteSelect; - case Return: return (s.WasOnExpedition && !s.OnExpedition) || s.StepElapsed >= ReturnMaxSeconds; // D3: home AFTER being on expedition, else a soft timeout (never gate on the 1-tick Returning edge) - case Defend: return s.SawSiege ? s.Phase == CyclePhase.Calm : s.StepElapsed >= DefendNoSiegeSeconds; - case Done: return s.StepElapsed >= DoneSeconds; - default: return true; - } - } - - /// Which world target (if any) the prompt should point at this step. - public static byte PointerKind(byte step) - { - switch (step) - { - case Rooms: return PointerOreNode; // in-run crystal nodes (base nodes no longer exist) - default: return PointerNone; - } - } - - /// Ultra-short, verb-first prompt copy with the player's real input glyph (scheme-aware). - public static string Prompt(byte step, bool gamepad) - { - string move = gamepad ? "Left Stick" : "WASD"; - string build = gamepad ? "Y" : "Tab"; // matches the existing HUD build-discovery chip glyph - switch (step) - { - case Welcome: return "CLEAR 2 EXPEDITIONS to charge the Engine, then survive the FINAL SIEGE. (Space to continue · Esc → How to Play)"; - case Move: return move + " — Move " + (gamepad ? "B" : "LShift") + " — Dash (brief invulnerability)"; - case Build: return build + " — open Build, place a Turret by your Core (your " + ProjectM.Simulation.Tuning.StartingOre + " starting Ore covers it)"; - case Fabricator: return "Build a Fabricator — turrets need Charge (Ore → ammo)"; - case ReadyUp: return "Press T (or click READY UP) — when everyone is ready, the party launches"; - case Rooms: return (gamepad ? "X attack · LT class ability" : "LMB attack · RMB class ability") + " — clear the room; shoot the glowing crystals for your haul"; - case Boon: return "Clear the room — pick 1 of 3 BOONS, then choose your path on the map"; - case Return: return "Fell the ALPHA HUSK, then return home — your haul + the Engine charge (+1) come with you"; - case Defend: return "Defend the Core! — a completed run provokes a retaliation siege"; - case Done: return "You've got it — clear 2 expeditions to fill the Engine, then hold the final siege to win."; - default: return ""; - } - } - - // ---- persisted-mask helpers (GameSettings.OnboardingMask) ---- - - /// D4: the early BASE-framing steps (the coach-mark owns the prompt voice, so the HUD blanks its own - /// location line) vs the combat steps (the HUD MUST keep showing room/siege/out-of-ammo cues). Welcome..ReadyUp - /// are early. Read each frame by OnboardingSystem to set OnboardingState.SuppressLocationLine. - public static bool IsEarlyStep(byte step) => step <= ReadyUp; - - /// All steps complete (the sequence is dormant). - public static bool AllComplete(int mask) - { - int all = (1 << StepCount) - 1; - return (mask & all) == all; - } - - /// The lowest not-yet-completed step (resume point); when all are complete. - public static byte FirstIncomplete(int mask) - { - for (byte i = 0; i < StepCount; i++) - if ((mask & (1 << i)) == 0) return i; - return Done; - } - } -} diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta deleted file mode 100644 index 5c73bab17..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingStepMath.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c264496096436e74ebba163a7a5d2205 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs deleted file mode 100644 index e227bc5cc..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs +++ /dev/null @@ -1,332 +0,0 @@ -using ProjectM.Simulation; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; -using UnityEngine; -using UnityEngine.UIElements; - -namespace ProjectM.Client -{ - /// - /// First-run onboarding overlay — a CLIENT-ONLY, observe-only presentation in - /// (same shape/constraints as : never mutates the - /// sim, never destroys a ghost, reads already-replicated state once per frame). Owns its own runtime UIDocument - /// (sortingOrder 60 — above the HUD's 50, below the pause overlay's 100) showing a single bottom-center - /// coach-mark prompt plus a world-space directional pointer. - /// - /// The sequence is PER-CLIENT and client-local: progress lives in - /// (via ), keyed to THIS player's own first-encounter — so a veteran host sees - /// nothing, a brand-new join-client is still taught, and a save wipe never re-teaches the host (the mask is in - /// settings.json, not the host-only SaveData). Soft-gated pacing: a step shows until its action is done; the - /// pure rules + auto-suppress (absolute count checks) live in . - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class OnboardingSystem : SystemBase - { - const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // player x past this = the +1000 expedition region (mirrors HudSystem) - - GameObject _go; - UIDocument _doc; - bool _built; - Label _prompt; - Label _pointer; - - // step machine (in-memory; persisted to the mask on each completion) - bool _maskLoaded; - int _mask; - byte _step; - bool _stepInit; - float _stepElapsed; - float _moveAccum; - float3 _lastPos; - bool _sawSiege; - bool _wasOnExpedition; // D3: latched true once the player is seen on expedition during the Return step - - - protected override void OnStartRunning() - { - if (_go != null) return; - _go = new GameObject("~Onboarding"); - _doc = _go.AddComponent(); - _doc.panelSettings = MenuUi.LoadPanelSettings(); - _doc.sortingOrder = 60; // above HUD (50), below pause (100) - } - - protected override void OnDestroy() - { - OnboardingState.Active = false; // never let the static outlive its owning system (HUD suppression) - if (_go != null) Object.Destroy(_go); - } - - protected override void OnUpdate() - { - if (_doc == null) return; - var root = _doc.rootVisualElement; - if (root == null) return; - if (!_built) { BuildTree(root); _built = true; } - OnboardingState.SuppressLocationLine = false; // D4: default off each frame; a shown step sets it per-step below - - - float dt = SystemAPI.Time.DeltaTime; // wall-frame delta — correct in a presentation system - - // ---- local player presence + position ---- - bool havePlayer = false; float3 playerPos = default; - foreach (var lt in SystemAPI.Query>().WithAll()) - { havePlayer = true; playerPos = lt.ValueRO.Position; break; } - - var settings = SettingsService.Current; - if (!_maskLoaded) - { - _mask = settings.OnboardingMask; - _step = OnboardingStepMath.FirstIncomplete(_mask); - _stepInit = false; - _maskLoaded = true; - } - bool hintsOn = settings.TutorialHints != 0; - - // Dormant (hints off / all steps done) or no local player yet → fully hidden, no voice. - if (!hintsOn || OnboardingStepMath.AllComplete(_mask) || !havePlayer) - { - OnboardingState.Active = false; - root.style.display = DisplayStyle.None; - return; - } - - // ---- remaining observable state ---- - byte lifecycle = RunLifecycle.Staging; - if (SystemAPI.TryGetSingleton(out var runInfo)) lifecycle = runInfo.Lifecycle; - bool localReady = false; - foreach (var pr in SystemAPI.Query>().WithAll()) - { localReady = pr.ValueRO.Value != 0; break; } - CountStructures(out int turrets, out int fabs); - byte phase = CyclePhase.Calm; - if (SystemAPI.TryGetSingleton(out var cyc)) phase = cyc.Phase; - byte objState = ExpeditionObjectiveState.Idle; - if (SystemAPI.TryGetSingleton(out var obj)) objState = obj.State; - bool onExp = playerPos.x > ExpeditionRegionXMin; - - // ---- per-step entry init (baselines) ---- - if (!_stepInit) - { - _stepElapsed = 0f; _moveAccum = 0f; _sawSiege = false; _wasOnExpedition = false; - _lastPos = playerPos; - _stepInit = true; - } - - // ---- advance (FROZEN while the pause overlay is open, so the timed beats — Welcome/Fabricator/ - // Defend/Done — aren't silently lost behind the pause dim that sits above this overlay) ---- - if (!PauseMenuController.Open) - { - _stepElapsed += dt; - if (_step == OnboardingStepMath.Move) _moveAccum += math.distance(playerPos, _lastPos); - if (_step == OnboardingStepMath.Defend && phase == CyclePhase.Siege) _sawSiege = true; - if (_step == OnboardingStepMath.Return && onExp) _wasOnExpedition = true; // D3: latch "was on expedition" so a start-at-base Return doesn't instantly satisfy - - - var snap = new OnboardingStepMath.Snapshot - { - StepElapsed = _stepElapsed, - MoveDistance = _moveAccum, - LocalReady = localReady, - Lifecycle = lifecycle, - TurretCount = turrets, - FabricatorCount = fabs, - OnExpedition = onExp, - ObjectiveState = objState, - SawSiege = _sawSiege, - WasOnExpedition = _wasOnExpedition, - Phase = phase, - }; - - // The two pure-message beats can be dismissed with any input EXCEPT Esc (Esc opens Pause; see - // AnyInputPressed) so following the "Esc → Pause → How to Play" hint doesn't self-skip the framing. - bool skip = (_step == OnboardingStepMath.Welcome && ConfirmPressed()) || (_step == OnboardingStepMath.Done && AnyInputPressed()); // D5: Welcome dismisses ONLY on an explicit confirm (not WASD) so the win-condition strip is actually read - if (skip || OnboardingStepMath.IsSatisfied(_step, snap)) - { - _mask |= (1 << _step); - Persist(_mask); - _step = OnboardingStepMath.FirstIncomplete(_mask); // auto-suppressed steps cascade one/frame - _stepInit = false; - if (OnboardingStepMath.AllComplete(_mask)) - { - OnboardingState.Active = false; - root.style.display = DisplayStyle.None; - return; - } - } - } - _lastPos = playerPos; - - // ---- show the current step ---- - OnboardingState.Active = true; - OnboardingState.SuppressLocationLine = OnboardingStepMath.IsEarlyStep(_step); // D4: blank the HUD line only for the early base-framing steps - root.style.display = DisplayStyle.Flex; - bool gamepad = AimPresentation.Scheme == InputSchemeId.Gamepad; - _prompt.text = OnboardingStepMath.Prompt(_step, gamepad); - UpdatePointer(_step, playerPos); - } - - // ---- state gathering helpers ---- - - - - void CountStructures(out int turrets, out int fabs) - { - turrets = 0; fabs = 0; - foreach (var ps in SystemAPI.Query>()) - { - byte t = ps.ValueRO.Type; - if (t == StructureType.Turret) turrets++; - else if (t == StructureType.Fabricator) fabs++; - } - } - - void Persist(int mask) - { - var s = SettingsService.Current; - s.OnboardingMask = mask; - SettingsService.Save(s); // atomic write; ~once per completed step - } - - static bool AnyInputPressed() - { - var kb = UnityEngine.InputSystem.Keyboard.current; - // any key dismisses a message beat — EXCEPT Esc, which is the pause key (don't self-skip the framing). - if (kb != null && kb.anyKey.wasPressedThisFrame && !kb.escapeKey.wasPressedThisFrame) return true; - var ms = UnityEngine.InputSystem.Mouse.current; - if (ms != null && ms.leftButton.wasPressedThisFrame) return true; - var gp = UnityEngine.InputSystem.Gamepad.current; - if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true; - return false; - } - - // D5: an EXPLICIT confirm only (Space/Enter/click/South) — movement keys must NOT skip the Welcome strip. - static bool ConfirmPressed() - { - var kb = UnityEngine.InputSystem.Keyboard.current; - if (kb != null && (kb.spaceKey.wasPressedThisFrame || kb.enterKey.wasPressedThisFrame)) return true; - var ms = UnityEngine.InputSystem.Mouse.current; - if (ms != null && ms.leftButton.wasPressedThisFrame) return true; - var gp = UnityEngine.InputSystem.Gamepad.current; - if (gp != null && (gp.buttonSouth.wasPressedThisFrame || gp.startButton.wasPressedThisFrame)) return true; - return false; - } - - -// ---- world-space pointer ---- - - bool ResolveTarget(byte kind, float3 playerPos, out float3 target) - { - target = default; - if (kind == OnboardingStepMath.PointerOreNode) - { - float best = float.MaxValue; bool found = false; - foreach (var lt in SystemAPI.Query>().WithAll()) - { - float d = math.distancesq(lt.ValueRO.Position, playerPos); - if (d < best) { best = d; target = lt.ValueRO.Position; found = true; } - } - return found; - } - // Step 11: the walk-in ExpeditionGate was RETIRED (the ready-check launches runs), so gate pointers - // have no world target — hide the arrow; the step text still teaches. Step 14's HUD pass re-points - // onboarding at the READY panel instead. - return false; - } - - void UpdatePointer(byte step, float3 playerPos) - { - byte kind = OnboardingStepMath.PointerKind(step); - var cam = Camera.main; - var root = _doc.rootVisualElement; - if (kind == OnboardingStepMath.PointerNone || cam == null || !ResolveTarget(kind, playerPos, out float3 target)) - { _pointer.style.display = DisplayStyle.None; return; } - - float pw = root.layout.width, ph = root.layout.height; - if (pw <= 1f || ph <= 1f) { _pointer.style.display = DisplayStyle.None; return; } - - Vector3 sp = cam.WorldToScreenPoint((Vector3)target); - bool behind = sp.z < 0f; - float px = (sp.x / Mathf.Max(1f, Screen.width)) * pw; - float py = (1f - sp.y / Mathf.Max(1f, Screen.height)) * ph; - if (behind) { px = pw - px; py = ph - py; } - - const float margin = 64f; - bool off = behind || px < margin || px > pw - margin || py < margin || py > ph - margin; - - float cx = pw * 0.5f, cy = ph * 0.5f; - float dx = px - cx, dy = py - cy; - float len = Mathf.Sqrt(dx * dx + dy * dy); - if (len < 0.001f) { dx = 1f; dy = 0f; len = 1f; } - float ndx = dx / len, ndy = dy / len; - - float ax, ay; - if (off) - { - // intersect the center→target ray with the margin rectangle (edge arrow) - float tx = (ndx > 0 ? (pw - margin - cx) : (margin - cx)) / (Mathf.Abs(ndx) < 1e-4f ? (ndx < 0 ? -1e-4f : 1e-4f) : ndx); - float ty = (ndy > 0 ? (ph - margin - cy) : (margin - cy)) / (Mathf.Abs(ndy) < 1e-4f ? (ndy < 0 ? -1e-4f : 1e-4f) : ndy); - float tt = Mathf.Min(Mathf.Abs(tx), Mathf.Abs(ty)); - ax = cx + ndx * tt; ay = cy + ndy * tt; - } - else { ax = px; ay = py - 44f; } // float just above the on-screen target - - float angle = Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; // "▶" art points +x at 0° - _pointer.style.left = ax - 15f; - _pointer.style.top = ay - 18f; - _pointer.style.rotate = new StyleRotate(new Rotate(new Angle(angle))); - _pointer.style.display = DisplayStyle.Flex; - } - - // ---- UITK construction ---- - - void BuildTree(VisualElement root) - { - root.style.position = Position.Absolute; - root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; - root.pickingMode = PickingMode.Ignore; // never eat world clicks - - var panel = new VisualElement(); - panel.style.position = Position.Absolute; - panel.style.bottom = 210; panel.style.left = 0; panel.style.right = 0; - panel.style.flexDirection = FlexDirection.Row; - panel.style.justifyContent = Justify.Center; - panel.style.alignItems = Align.Center; - panel.pickingMode = PickingMode.Ignore; - - var chip = new VisualElement(); - chip.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.92f); - chip.style.paddingLeft = 22; chip.style.paddingRight = 22; - chip.style.paddingTop = 10; chip.style.paddingBottom = 10; - chip.style.maxWidth = 920; - chip.pickingMode = PickingMode.Ignore; - MenuUi.Round(chip, 8); - MenuUi.Border(chip, new Color(MenuUi.Accent.r, MenuUi.Accent.g, MenuUi.Accent.b, 0.55f), 1); - - _prompt = new Label(string.Empty); - _prompt.style.color = MenuUi.TextCol; - _prompt.style.fontSize = 18; - _prompt.style.unityFontStyleAndWeight = FontStyle.Bold; - _prompt.style.unityTextAlign = TextAnchor.MiddleCenter; - _prompt.style.whiteSpace = WhiteSpace.Normal; - var theme = HudTheme.Get(); - if (theme != null) theme.ApplyBody(_prompt.style); - chip.Add(_prompt); - panel.Add(chip); - root.Add(panel); - - _pointer = new Label("▶"); // ▶ right-pointing triangle (rotated toward the target) - _pointer.style.position = Position.Absolute; - _pointer.style.fontSize = 30; - _pointer.style.color = MenuUi.Accent; - _pointer.style.unityFontStyleAndWeight = FontStyle.Bold; - _pointer.pickingMode = PickingMode.Ignore; - _pointer.style.display = DisplayStyle.None; - root.Add(_pointer); - - root.style.display = DisplayStyle.None; - } - } -} diff --git a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta b/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta deleted file mode 100644 index 112f5e451..000000000 --- a/Assets/_Project/Scripts/Client/Onboarding/OnboardingSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b4828f5a68386fa4da379dcddbf629de \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs index e5071dd50..d502c5212 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AimReticleSystem.cs @@ -144,10 +144,8 @@ namespace ProjectM.Client // Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an // unfocused editor or a windowed session is never stranded with an invisible pointer. - // END-2: while the run is over (terminal banner up) keep the cursor visible so the player can click the - // Play Again / Quit buttons, regardless of aim state. AimReticleSystem is the sole Cursor.visible writer. - bool runOver = SystemAPI.TryGetSingleton(out var ro) && ro.Value != RunOutcomeId.InProgress; - bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible && !runOver; + // AimReticleSystem is the sole Cursor.visible writer. + bool wantHidden = haveTarget && Application.isFocused && !AimPresentation.ForceCursorVisible; if (wantHidden != _cursorHidden) { if (wantHidden) Cursor.lockState = CursorLockMode.None; diff --git a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs index 4529342de..c839d68c4 100644 --- a/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/AmbientAudioSystem.cs @@ -6,13 +6,12 @@ using UnityEngine; namespace ProjectM.Client { /// - /// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation - /// (, main thread, no Burst) that OBSERVES the replicated - /// and never touches the simulation. On start it plays a low, seamless-looping - /// procedural drone (asset-free, AudioClip.Create like CombatFeedbackSystem.MakeClip); each - /// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase - /// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server - /// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable. + /// Client-only AMBIENT audio bed + run cues. A managed presentation + /// (, main thread, no Burst) that plays a low, seamless-looping + /// procedural drone (asset-free, AudioClip.Create like CombatFeedbackSystem.MakeClip) plus + /// launch-countdown beeps and the boss-arrival roar — replicated-state observations only. Lives only in the + /// client world, so the server never creates audio and nothing here affects determinism. Volumes are + /// deliberately conservative. (The cycle-phase stingers + Core alarm retired with the siege loop — LANTERN purge.) /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] @@ -20,29 +19,17 @@ namespace ProjectM.Client { AudioSource _ambient; AudioClip _ambientClip; - AudioClip _stingExpedition; - AudioClip _stingDefend; - AudioClip _stingBuild; GameObject _root; - byte _lastPhase; - bool _phaseInit; - AudioClip _stingCoreHit; - int _lastCore = -1; - float _coreStingCooldown; AudioClip _stingBeep, _stingRoar; int _lastCountdownSec = -1; bool _bossRoared; - const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x + const float AmbientBaseVolume = 0.10f; // low ambient bed protected override void OnCreate() { _ambientClip = MakeDrone(); - _stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy" - _stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming" - _stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm - _stingCoreHit = MakeSting(240f, 70f, 0.30f, 0.55f); // harsh falling "Core hit" alarm _stingBeep = MakeSting(880f, 880f, 0.09f, 0.30f); // countdown tick _stingRoar = MakeSting(90f, 38f, 0.90f, 0.60f); // boss-arrival roar (low falling growl) } @@ -68,36 +55,8 @@ namespace ProjectM.Client protected override void OnUpdate() { if (_ambient == null) return; - if (!SystemAPI.TryGetSingleton(out var cyc)) return; - byte phase = cyc.Phase; - if (!_phaseInit) - { - _lastPhase = phase; // adopt the current phase silently (no stinger on first observe) - _phaseInit = true; - } - else if (phase != _lastPhase) - { - PlaySting(phase); - _lastPhase = phase; - } - - // Ease the drone intensity toward the phase target (tenser during Defend). - float target = phase == CyclePhase.Siege ? AmbientBaseVolume * 1.7f : AmbientBaseVolume; - _ambient.volume = Mathf.MoveTowards(_ambient.volume, target * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f); - - // Core-under-attack alarm: a falling sting on each CoreIntegrity drop (rate-limited) so a base - // breach is AUDIBLE even when the fight has the player's eyes elsewhere. - _coreStingCooldown -= SystemAPI.Time.DeltaTime; - if (SystemAPI.TryGetSingleton(out var core)) - { - if (_lastCore >= 0 && core.Current < _lastCore && _coreStingCooldown <= 0f) - { - _ambient.PlayOneShot(_stingCoreHit, 0.8f * GameVolume.Sfx); - _coreStingCooldown = 0.7f; - } - _lastCore = core.Current; - } + _ambient.volume = Mathf.MoveTowards(_ambient.volume, AmbientBaseVolume * GameVolume.Music, SystemAPI.Time.DeltaTime * 0.25f); // Launch countdown beeps (3-2-1) + the boss-arrival roar — replicated-state observations only. if (SystemAPI.TryGetSingleton(out var runAudio)) @@ -131,13 +90,6 @@ namespace ProjectM.Client } } - void PlaySting(byte phase) - { - AudioClip clip = phase == CyclePhase.Siege ? _stingDefend : _stingBuild; - if (clip != null && _ambient != null) - _ambient.PlayOneShot(clip, 0.6f * GameVolume.Music); - } - // ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ---- // A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer @@ -176,6 +128,6 @@ namespace ProjectM.Client } // Short one-shot tone sweeping f0->f1 with an exponential decay envelope. -static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f); + static AudioClip MakeSting(float f0, float f1, float dur, float vol) => FeedbackFx.MakeClip("sting", f0, f1, dur, vol, decay: 3.5f); } } diff --git a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs index 72f47abf4..a2c7ff471 100644 --- a/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/ClassPrepPortalHudSystem.cs @@ -63,8 +63,6 @@ namespace ProjectM.Client } bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); - bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; // Resources from the ledger (last entry per type wins, matching the core loop). int aether = 0, ore = 0, bio = 0; @@ -93,7 +91,7 @@ namespace ProjectM.Client // Faithful reproduction of the original `metaShow` gate: class/prep were shown on the SAME condition as // the meta shop, which requires the meta catalog + tier buffer to exist. DynamicBuffer metaRecord = default; - bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege + bool metaShow = haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated && SystemAPI.TryGetSingletonBuffer(out metaRecord, true); diff --git a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs deleted file mode 100644 index 9329d82fe..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Generic; -using ProjectM.Simulation; -using Unity.Entities; -using UnityEngine; -using UnityEngine.SceneManagement; - -namespace ProjectM.Client -{ - /// - /// The Engine Core's CRYSTAL answers its replicated (07-01 backlog: the mesh sat - /// static while draining). Client-only observe-only presentation: the cosmetic CoreCrystals / - /// CoreMachine GameObjects in Game.unity (classic URP renderers, not entities) are tinted via - /// MaterialPropertyBlock — darker + blood-shifted as integrity falls, a white-hot flash on each hit (pairs - /// with the AmbientAudioSystem alarm). Property-guarded per the shader-value rule (only materials exposing - /// _BaseColor are touched); per-renderer MPBs never mutate the shared material assets (no bleed). - /// - [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class CoreVisualFeedbackSystem : SystemBase - { - static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); - static readonly string[] TargetNames = { "CoreCrystals", "CoreMachine" }; - const float FlashSeconds = 0.35f; - - readonly List _renderers = new(); - readonly List _authoredColors = new(); - MaterialPropertyBlock _mpb; - bool _resolved; - int _lastCore = -1; - float _flashLeft; - - protected override void OnUpdate() - { - if (!SystemAPI.TryGetSingleton(out var core) || core.Max <= 0) - return; - - // One-time renderer resolve, deferred until Game.unity is actually the active scene (the frontend - // path creates this world a frame BEFORE the scene loads — latching early would find nothing). - if (!_resolved) - { - var scene = SceneManager.GetActiveScene(); - if (!scene.isLoaded || scene.name != "Game") return; - Resolve(); - } - if (_renderers.Count == 0) return; - - if (_lastCore >= 0 && core.Current < _lastCore) - _flashLeft = FlashSeconds; // hit edge -> white-hot pop (the audio alarm fires beside it) - _lastCore = core.Current; - _flashLeft -= SystemAPI.Time.DeltaTime; - - float frac = Mathf.Clamp01(core.Current / (float)core.Max); - float dim = Mathf.Lerp(0.35f, 1f, frac); - float flash = _flashLeft > 0f ? Mathf.Clamp01(_flashLeft / FlashSeconds) : 0f; - for (int i = 0; i < _renderers.Count; i++) - { - var r = _renderers[i]; - if (r == null) continue; - var c0 = _authoredColors[i]; - // Wounded shift: keep red-ish energy, drain green/blue with integrity (reads as bleeding light). - var wounded = new Color( - Mathf.Min(1f, c0.r * dim + (1f - frac) * 0.25f), - c0.g * dim * (0.45f + 0.55f * frac), - c0.b * dim * (0.45f + 0.55f * frac), - c0.a); - var col = flash > 0f ? Color.Lerp(wounded, Color.white, flash) : wounded; - r.GetPropertyBlock(_mpb); - _mpb.SetColor(BaseColorId, col); - r.SetPropertyBlock(_mpb); - } - } - - void Resolve() - { - _resolved = true; - _mpb = new MaterialPropertyBlock(); - foreach (var name in TargetNames) - { - var go = GameObject.Find(name); - if (go == null) continue; - foreach (var r in go.GetComponentsInChildren()) - { - var m = r.sharedMaterial; - if (m == null || !m.HasProperty(BaseColorId)) continue; - _renderers.Add(r); - _authoredColors.Add(m.GetColor(BaseColorId)); - } - } - } - } -} diff --git a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta deleted file mode 100644 index f21d22610..000000000 --- a/Assets/_Project/Scripts/Client/Presentation/CoreVisualFeedbackSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 47b2a19145125ac4e98411cab7f9b569 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 973c0cf45..7ca3b1fe8 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -26,14 +26,12 @@ namespace ProjectM.Client /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] - [UpdateAfter(typeof(OnboardingSystem))] // read OnboardingState.Active same-frame (single prompt voice) public partial class HudSystem : SystemBase { // ---- palette (Aether language; Synty white skins are tinted into these) ---- static readonly Color AetherCyan = new(0.30f, 0.85f, 1f); static readonly Color OreAmber = new(1f, 0.72f, 0.35f); static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f); - static readonly Color CoreRed = new(1f, 0.40f, 0.32f); // END-1 Engine Core integrity bar static readonly Color PanelDark = new(0.08f, 0.11f, 0.15f, 0.90f); static readonly Color PanelWarm = new(0.16f, 0.09f, 0.09f, 0.88f); @@ -43,7 +41,6 @@ namespace ProjectM.Client static readonly Color SlotIdleBg = new(0.09f, 0.11f, 0.15f, 0.92f); static readonly Color SlotSelBg = new(0.16f, 0.26f, 0.32f, 0.95f); static readonly Color SlotIdleBorder = new(1f, 1f, 1f, 0.08f); - const int MaxPips = 12; const float ExpeditionRegionXMin = RegionMath.RegionBoundaryX; // camera x past this = the +1000 expedition region (DR-013) GameObject _hudGo; @@ -59,18 +56,9 @@ namespace ProjectM.Client VisualElement _threatPanel, _threatIcon; Label _threatNum; - // macro: banner + location + goal - VisualElement _banner, _goalContainer, _goalPipsRow, _goalBar, _goalFill; - Label _phaseText, _cycleText, _locationText, _goalText; - - // END-1: Engine Core integrity (losable base-heart) + overrun flash edge-detector - VisualElement _coreContainer, _coreBar, _coreFill; - Label _coreText; - uint _lastOverrunTick; - float _overrunFlashLeft; - // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). - VisualElement _runBanner; - Label _runBannerText, _runBannerSub; + // macro: banner + location line + VisualElement _banner; + Label _phaseText, _locationText; // Demo polish: the clickable READY panel (Staging/Launching). VisualElement _readyPanel, _readyPipRow; Button _readyBtn; @@ -84,12 +72,8 @@ namespace ProjectM.Client VisualElement _depthPanel; int _depthShownFor; bool _depthBuilt; - VisualElement _outcomeFlash; // one-shot gold/red full-screen flash when the outcome banner first lands - float _outcomeFlashLeft; - byte _outcomeFlashedFor; - readonly List _pips = new(); // resources Label _aetherNum, _oreNum, _bioNum; @@ -158,84 +142,34 @@ namespace ProjectM.Client bool haveTick = SystemAPI.TryGetSingleton(out var nt); int huskCount = _huskQuery.CalculateEntityCount(); - // ---- Macro: phase + cycle + countdown (center-top banner) ---- - bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); // hoisted: the phase banner is lifecycle-aware (Phase 0 fix — it read "AT BASE" inside expedition rooms) - - // B6: run-failed read (review-confirmed design: NEVER key on Returning — it is a 1-tick transient and - // the Charge bank lands a tick after it; detect the (in-run)->Staging edge with a launch-cached Charge. - // Lifecycle + Charge ride the SAME director ghost snapshot, so at the Staging edge the bank has arrived). + // ---- Macro banner: run-lifecycle header (the siege/cycle machinery is retired — LANTERN purge) ---- + bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); + bool onRun = haveRun && runInfo.Lifecycle != RunLifecycle.Staging; if (haveRun) { - bool haveGoalNow = SystemAPI.TryGetSingleton(out var goalSnap); - byte lcNow = runInfo.Lifecycle; - if (lcNow == RunLifecycle.Launching && _prevRunLifecycle == RunLifecycle.Staging) - { - _chargeAtLaunch = haveGoalNow ? goalSnap.Charge : 0; - _wentInRun = false; - } - if (lcNow == RunLifecycle.InRoom) _wentInRun = true; - if (lcNow == RunLifecycle.Staging && _prevRunLifecycle != RunLifecycle.Staging && _wentInRun) - { - if (haveGoalNow && goalSnap.Charge <= _chargeAtLaunch) - _runFailedUntil = (float)SystemAPI.Time.ElapsedTime + 6f; // wipe/abort: nothing banked - _wentInRun = false; - } - _prevRunLifecycle = lcNow; - } - bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); - bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; - bool goalFull = SystemAPI.TryGetSingleton(out var goalNow) && goalNow.Target > 0 && goalNow.Charge >= goalNow.Target; - bool finalSiege = siege && goalFull; // END-2: the climactic final siege (goal cap reached) - bool onRun = haveRun && !siege && runInfo.Lifecycle != RunLifecycle.Staging; // mid-run: the base's Calm label is wrong - if (haveCycle) - { - var endTick = new NetworkTick(cyc.PhaseEndTick); - bool arming = haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick); - bool finalArming = !siege && goalFull && arming; // the cap-reached arming window before the final wave - int secs = arming ? (endTick.TicksSince(nt.ServerTick) / 60 + 1) : 0; - string detail; - if (siege) - detail = (finalSiege ? "FINAL SIEGE" : "WAVE " + cyc.WaveNumber) + " - " + huskCount + " HUSKS"; - else if (arming) - detail = (finalArming ? "FINAL SIEGE INCOMING" : "INCURSION") + " - " + secs + "s"; - else - detail = ""; - // END-2: the climax reads distinct (intense red), not a normal incursion/wave. - var col = finalSiege || finalArming ? new Color(1f, 0.28f, 0.22f) : PhaseColor(cyc.Phase); - _phaseText.text = (finalSiege ? "HOLD THE ENGINE" : onRun ? "ON EXPEDITION" : PhaseLabel(cyc.Phase)) + (detail.Length > 0 ? " - " + detail : ""); + var col = onRun ? new Color(1f, 0.8f, 0.4f) : new Color(0.45f, 0.9f, 0.7f); + _phaseText.text = onRun ? "ON EXPEDITION" : "AT BASE"; _phaseText.style.color = col; - _cycleText.text = "CYCLE " + cyc.CycleNumber; _banner.style.borderBottomColor = col; - RetintPanel(_banner, siege ? PanelWarm : PanelDark); + RetintPanel(_banner, PanelDark); } else { _phaseText.text = ""; - _cycleText.text = ""; } // ---- Location line (banner sub-line) — Step 14: driven by the replicated RunInfo lifecycle FSM ---- - // (the old camera-X + walk-in-gate copy died with the gate; siege/final overrides below still win). var cam = Camera.main; // camera-X region signal still feeds downstream panels (atmosphere/threat) bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin; SystemAPI.TryGetSingleton(out var obj); - if (haveRun && !siege && !finalSiege) + if (haveRun) { switch (runInfo.Lifecycle) { case RunLifecycle.Staging: // The READY panel (bottom-center) owns the action + N/M count; the top line frames intent. - if ((float)SystemAPI.Time.ElapsedTime < _runFailedUntil) - { - // B6: a silent wipe used to land players home with ZERO explanation. - _locationText.text = "EXPEDITION FAILED - the party fell; nothing was banked"; - _locationText.style.color = new Color(1f, 0.35f, 0.3f); - } - else - { - _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; - _locationText.style.color = new Color(0.55f, 0.85f, 1f); - } + _locationText.text = "AT THE BASE - build defenses, buy upgrades, READY UP to launch"; + _locationText.style.color = new Color(0.55f, 0.85f, 1f); break; case RunLifecycle.Launching: { @@ -280,33 +214,18 @@ namespace ProjectM.Client break; } } - else if (!haveRun) - { - _locationText.text = finalSiege - ? "FINAL SIEGE - hold the Engine, this is the last stand" - : siege ? "DEFEND THE BASE - hold the line" - : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD"; - _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) - : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f); - } else { - _locationText.text = finalSiege - ? "FINAL SIEGE - hold the Engine, this is the last stand" - : "DEFEND THE BASE - hold the line"; - _locationText.style.color = finalSiege ? new Color(1f, 0.3f, 0.25f) : new Color(1f, 0.55f, 0.4f); + _locationText.text = ""; } - // The clickable READY panel (Staging/Launching, hidden once the outcome latched — the banner owns - // the screen then). Counts are the replicated send-to-all PlayerReady flags. + // The clickable READY panel (Staging/Launching). Counts are the replicated send-to-all PlayerReady flags. int rTotal = 0, rReady = 0; bool localReady = false; int launchSecs = 0; - bool terminal = SystemAPI.TryGetSingleton(out var readyOc) - && readyOc.Value != RunOutcomeId.InProgress; - bool readyShow = haveRun && !terminal && !goalFull /* D6: goal full -> final defense armed, launching is refused server-side */ + bool readyShow = haveRun && (runInfo.Lifecycle == RunLifecycle.Staging || runInfo.Lifecycle == RunLifecycle.Launching); if (readyShow) { @@ -352,35 +271,6 @@ namespace ProjectM.Client // Run-depth dots — keeps the roguelite spine visible while fighting (the map only shows at gates). UpdateRunDepth(haveRun ? runInfo : default, haveRun); - // ---- Goal (hex-pip meter, or a continuous bar for large targets) ---- - if (SystemAPI.TryGetSingleton(out var goal)) - { - _goalContainer.style.display = DisplayStyle.Flex; - float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f; - _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; - if (goal.Target >= 1 && goal.Target <= MaxPips) - { - _goalPipsRow.style.display = DisplayStyle.Flex; - _goalBar.style.display = DisplayStyle.None; - int active = Mathf.Min(goal.Charge, goal.Target); // Charge is the integer pip count; never over-fill - for (int i = 0; i < _pips.Count; i++) - { - bool show = i < goal.Target; - _pips[i].style.display = show ? DisplayStyle.Flex : DisplayStyle.None; - if (show) SetPip(_pips[i], i < active); - } - } - else - { - _goalPipsRow.style.display = DisplayStyle.None; - _goalBar.style.display = DisplayStyle.Flex; - HudUi.SetFill(_goalFill, gfrac); - } - } - else - { - _goalContainer.style.display = DisplayStyle.None; - } // ---- Resources (feed palette affordability) ---- int aether = 0, ore = 0, bio = 0; @@ -400,104 +290,21 @@ namespace ProjectM.Client _bioNum.text = bio.ToString(); -// ---- Engine Core integrity (END-1): a red base-heart bar; an overrun stamps a transient pulse we flash ---- - if (SystemAPI.TryGetSingleton(out var core) && core.Max > 0) - { - _coreContainer.style.display = DisplayStyle.Flex; - float cfrac = Mathf.Clamp01(core.Current / (float)core.Max); - HudUi.SetFill(_coreFill, cfrac); - _coreText.text = "CORE " + core.Current + " / " + core.Max; - _coreText.style.color = Color.Lerp(BlightRed, CoreRed, cfrac); // shifts to danger as it drops - if (core.OverrunTick != 0 && core.OverrunTick != _lastOverrunTick) - { - _lastOverrunTick = core.OverrunTick; // edge-detect the replicated breach pulse - _overrunFlashLeft = 3.5f; - } - } - else - { - _coreContainer.style.display = DisplayStyle.None; - } - // Overrun flash overrides the location line (runs AFTER the EB-2 cue so it wins; at a breach Phase is Calm). - if (_overrunFlashLeft > 0f) - { - _overrunFlashLeft -= dt; - _locationText.text = "BASE OVERRUN - resources lost; the Core will recover"; - _locationText.style.color = new Color(1f, 0.3f, 0.25f); - } - // First-run onboarding owns the prompt voice: while a coach-mark step is showing, blank the HUD's own - // location/gate hint so the player sees a single prompt (OnboardingSystem drives its own overlay). - if (OnboardingState.SuppressLocationLine) _locationText.text = ""; // D4: blank only for the early base-framing steps; room/siege/charge cues survive - // D6: goal full but the final siege hasn't spawned yet (the arming gap) -> the READY panel is hidden; tell the - // player what's coming instead of a stale base line (goalFull is replicated; RunPhase is server-only). - if (haveRun && goalFull && !terminal && !siege && !finalSiege) - { - _locationText.text = "GOAL REACHED - FINAL DEFENSE INCOMING: hold the Engine!"; - _locationText.style.color = new Color(1f, 0.35f, 0.28f); - } -// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ---- - if (SystemAPI.TryGetSingleton(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress) - { - bool win = runOutcome.Value == RunOutcomeId.Victory; - if (_outcomeFlashedFor != runOutcome.Value) - { - // One-shot landing flourish: full-screen color flash + camera kick — the beat gets a payoff. - _outcomeFlashedFor = runOutcome.Value; - _outcomeFlashLeft = win ? 0.9f : 0.7f; - PrototypeCameraRig.PunchFov(win ? 5f : 3f, win ? 420f : 260f); - PrototypeCameraRig.AddShake(win ? 0.25f : 0.5f); - } - _runBanner.style.display = DisplayStyle.Flex; - _runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN"; - _runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f); - _runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL"; - _runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f); - } - else - { - _runBanner.style.display = DisplayStyle.None; - _outcomeFlashedFor = 0; - } - - // Outcome flash decay (lazy element; the banner dim sits above it, the world below). - if (_outcomeFlashLeft > 0f) - { - if (_outcomeFlash == null && _doc != null && _doc.rootVisualElement != null) - { - _outcomeFlash = new VisualElement { pickingMode = PickingMode.Ignore }; - _outcomeFlash.style.position = Position.Absolute; - _outcomeFlash.style.left = 0; _outcomeFlash.style.right = 0; - _outcomeFlash.style.top = 0; _outcomeFlash.style.bottom = 0; - _doc.rootVisualElement.Add(_outcomeFlash); - } - _outcomeFlashLeft -= dt; - if (_outcomeFlash != null) - { - bool winFlash = _outcomeFlashedFor == RunOutcomeId.Victory; - var fc = winFlash ? new Color(1f, 0.85f, 0.35f) : new Color(1f, 0.20f, 0.15f); - _outcomeFlash.style.backgroundColor = new Color(fc.r, fc.g, fc.b, Mathf.Clamp01(_outcomeFlashLeft) * 0.35f); - _outcomeFlash.style.display = DisplayStyle.Flex; - } - } - else if (_outcomeFlash != null) - { - _outcomeFlash.style.display = DisplayStyle.None; - } - // ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ---- - bool showThreat = siege || huskCount > 0; + // ---- Threat readout (top-right) — hidden entirely with zero husks; its reappearance is the cue ---- + bool showThreat = huskCount > 0; _threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None; if (showThreat) { float intensity = Mathf.Clamp01(huskCount / 30f); - Color tc = siege ? Color.Lerp(ThreatWarm, BlightRed, intensity) : ThreatWarm; + Color tc = Color.Lerp(ThreatWarm, BlightRed, intensity); _threatNum.text = huskCount.ToString(); _threatNum.style.color = tc; _threatIcon.style.unityBackgroundImageTintColor = tc; - RetintPanel(_threatPanel, siege ? PanelWarm : PanelDark); + RetintPanel(_threatPanel, PanelDark); } // ---- Build palette + control hints (bottom-center) ---- @@ -546,7 +353,7 @@ namespace ProjectM.Client break; } - _doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None; + _doc.rootVisualElement.style.display = (found || haveRun) ? DisplayStyle.Flex : DisplayStyle.None; // ---- Low-health vignette + hurt flash (full-screen) ---- _flash = HudVisualMath.DecayFlash(_flash, dt); @@ -645,22 +452,7 @@ namespace ProjectM.Client else p.style.backgroundColor = c; } - void SetPip(VisualElement pip, bool active) - { - var theme = HudTheme.Get(); - var spr = active ? theme?.PipActive : theme?.PipInactive; - if (spr != null) - { - pip.style.backgroundImage = new StyleBackground(Background.FromSprite(spr)); - pip.style.unityBackgroundImageTintColor = active ? AetherCyan : PipDim; - pip.style.backgroundSize = new StyleBackgroundSize(new BackgroundSize(BackgroundSizeType.Contain)); - } - else - { - pip.style.backgroundColor = active ? AetherCyan : PipDim; - MenuUi.Round(pip, 3); - } - } + // LANTERN purge: the automation buildables are deleted; Pylon stays hidden from the build palette (cosmetic-only). static bool IsPaletteType(byte type) => type != StructureType.Pylon; @@ -804,7 +596,6 @@ namespace ProjectM.Client BuildDiscoveryChip(root); BuildDowned(root); BuildInventory(root); - BuildRunBanner(root); } void BuildVignette(VisualElement root) @@ -932,69 +723,13 @@ namespace ProjectM.Client _banner.Add(bIcon); _phaseText = HudUi.Display("", 30, AetherCyan, TextAnchor.MiddleCenter); _banner.Add(_phaseText); - _cycleText = HudUi.Text("", 14, MenuUi.SubCol, TextAnchor.MiddleCenter); - _cycleText.style.marginLeft = 14; - _banner.Add(_cycleText); macro.Add(_banner); _locationText = HudUi.Text("", 15, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter); _locationText.style.marginTop = 5; macro.Add(_locationText); - // goal: hex-pip meter (or fallback bar) + numeral - _goalContainer = HudUi.Group(Align.Center); - _goalContainer.style.marginTop = 8; - var goalLine = new VisualElement(); - goalLine.style.flexDirection = FlexDirection.Row; - goalLine.style.alignItems = Align.Center; - goalLine.pickingMode = PickingMode.Ignore; - - _goalPipsRow = new VisualElement(); - _goalPipsRow.style.flexDirection = FlexDirection.Row; - _goalPipsRow.style.alignItems = Align.Center; - _goalPipsRow.pickingMode = PickingMode.Ignore; - for (int i = 0; i < MaxPips; i++) - { - var pip = new VisualElement(); - pip.style.width = 22; pip.style.height = 22; - pip.style.marginLeft = 2; pip.style.marginRight = 2; - pip.style.flexShrink = 0; - pip.pickingMode = PickingMode.Ignore; - pip.style.display = DisplayStyle.None; - _pips.Add(pip); - _goalPipsRow.Add(pip); - } - goalLine.Add(_goalPipsRow); - - _goalText = HudUi.Display("GOAL 0 / 10", 16, AetherCyan, TextAnchor.MiddleCenter); - _goalText.style.marginLeft = 10; - goalLine.Add(_goalText); - _goalContainer.Add(goalLine); - - // fallback continuous bar (large targets) - _goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill); - _goalBar.style.marginTop = 4; - _goalBar.style.display = DisplayStyle.None; - _goalContainer.Add(_goalBar); - - macro.Add(_goalContainer); - - // END-1: Engine Core integrity bar (red) — the losable base-heart meter. - _coreContainer = HudUi.Group(Align.Center); - _coreContainer.style.marginTop = 6; - var coreLine = new VisualElement(); - coreLine.style.flexDirection = FlexDirection.Row; - coreLine.style.alignItems = Align.Center; - coreLine.pickingMode = PickingMode.Ignore; - _coreBar = HudUi.Bar(360, 14, CoreRed, out _coreFill); - coreLine.Add(_coreBar); - _coreText = HudUi.Text("CORE 100 / 100", 13, CoreRed, TextAnchor.MiddleLeft); - _coreText.style.marginLeft = 10; - coreLine.Add(_coreText); - _coreContainer.Add(coreLine); - _coreContainer.style.display = DisplayStyle.None; - macro.Add(_coreContainer); root.Add(macro); } @@ -1108,53 +843,7 @@ namespace ProjectM.Client _downed.style.display = DisplayStyle.None; root.Add(_downed); } - void BuildRunBanner(VisualElement root) - { - _runBanner = new VisualElement(); - _runBanner.style.position = Position.Absolute; - _runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0; - _runBanner.style.alignItems = Align.Center; - _runBanner.style.justifyContent = Justify.Center; - _runBanner.pickingMode = PickingMode.Ignore; - _runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f); - var col = HudUi.Group(Align.Center); - _runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter); - col.Add(_runBannerText); - _runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter); - _runBannerSub.style.marginTop = 8; - col.Add(_runBannerSub); - // END-2 (SL-5): the terminal banner offers a clear action so the player isn't hunting for Esc. - // SINGLE: PLAY AGAIN Continues as a fresh campaign (base+meta kept — the terminal save rolls - // forward on stage). CO-OP (operator-locked): the honest exit is a clean teardown for everyone — - // the host ends the session (each joiner's ConnectionWatchdog returns them to the menu with a - // reason), a joiner just leaves. All self-guard on WorldLauncher.Busy. The row picks (Position) - // even though the banner root Ignores. - var btnRow = new VisualElement(); - btnRow.style.flexDirection = FlexDirection.Row; - btnRow.style.justifyContent = Justify.Center; - btnRow.style.marginTop = 28; - btnRow.pickingMode = PickingMode.Position; - switch (WorldLauncher.LastMode) - { - case SessionMode.Host: - btnRow.Add(MenuUi.Button("END SESSION — ALL TO MENU", WorldLauncher.TeardownToMenu)); - break; - case SessionMode.Join: - btnRow.Add(MenuUi.Button("LEAVE TO MENU", WorldLauncher.TeardownToMenu)); - break; - default: - var again = MenuUi.Button("PLAY AGAIN", - () => WorldLauncher.StartSession(SessionMode.Single, null, SaveService.HasSave())); - again.style.marginRight = 12; - btnRow.Add(again); - btnRow.Add(MenuUi.Button("QUIT TO MENU", WorldLauncher.TeardownToMenu)); - break; - } - col.Add(btnRow); - _runBanner.Add(col); - _runBanner.style.display = DisplayStyle.None; - root.Add(_runBanner); - } + void BuildInventory(VisualElement root) @@ -1280,25 +969,9 @@ namespace ProjectM.Client } - static Color PhaseColor(byte phase) - { - switch (phase) - { - case CyclePhase.Calm: return new Color(0.45f, 0.9f, 0.7f); - case CyclePhase.Siege: return new Color(1f, 0.45f, 0.3f); - default: return Color.white; - } - } - static string PhaseLabel(byte phase) - { - switch (phase) - { - case CyclePhase.Calm: return "AT BASE"; - case CyclePhase.Siege: return "UNDER SIEGE"; - default: return ""; - } - } + + static string StructureName(byte type) { @@ -1509,11 +1182,6 @@ namespace ProjectM.Client } - // B6 run-failed read (client-local edge state; see the tracker near the top of OnUpdate). - byte _prevRunLifecycle; - int _chargeAtLaunch; - bool _wentInRun; - float _runFailedUntil; } } diff --git a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs index 91305f93e..57a2edab5 100644 --- a/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/MetaShopHudSystem.cs @@ -60,8 +60,6 @@ namespace ProjectM.Client } bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); - bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); - bool siege = haveCycle && cyc.Phase == CyclePhase.Siege; // Aether from the ledger (the sole meta-shop currency; last entry wins, matching the core loop). int aether = 0; @@ -86,7 +84,7 @@ namespace ProjectM.Client bool metaShow = false; BlobAssetReference metaPool = default; DynamicBuffer metaRecord = default; - if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && !siege + if (haveRun && runInfo.Lifecycle == RunLifecycle.Staging && haveLocalPlayer && SystemAPI.TryGetSingleton(out var metaCat) && metaCat.Value.IsCreated && SystemAPI.TryGetSingletonBuffer(out metaRecord, true)) { diff --git a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs index 69f7e2d9a..a363d6093 100644 --- a/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/MusicSystem.cs @@ -11,9 +11,8 @@ namespace ProjectM.Client /// every AudioSource starts the same frame with loop=true and identical clip length, and per-bar amplitude /// envelopes reach ~zero at each bar boundary so both the chord changes and the loop point are click-free /// (the AmbientAudioSystem.Snap trick generalized to enveloped segments). The MIX is the state - /// machine: layer volumes ease toward targets chosen from replicated state only — - /// lifecycle (staging / combat / boss / reward lull), siege, and the terminal - /// (one-shot victory/defeat sting + aftermath bed). Observe-only presentation + /// machine: layer volumes ease toward targets chosen from replicated state only — the + /// lifecycle (staging / launch / combat / boss / reward lull / return). Observe-only presentation /// system: no sim writes, no determinism surface; asset-free per the project convention. Sits under SFX at /// × ; the low AmbientAudioSystem drone /// (vol 0.10) remains as texture beneath it. @@ -43,7 +42,6 @@ namespace ProjectM.Client GameObject _root; AudioSource _bass, _pad, _arp, _pulse; float _vBass, _vPad, _vArp, _vPulse; // current smoothed volumes (pre-master) - byte _outcomePlayed; // which terminal outcome's sting has fired (0 = none) protected override void OnStartRunning() { @@ -80,27 +78,7 @@ namespace ProjectM.Client // ---- pick the mix from replicated state (defaults = quiet staging bed) ---- float tBass = 0.50f, tPad = 0.55f, tArp = 0.12f, tPulse = 0f; - bool haveRun = SystemAPI.TryGetSingleton(out var run); - SystemAPI.TryGetSingleton(out var cyc); - bool siege = cyc.Phase == CyclePhase.Siege; - bool finalSiege = siege && SystemAPI.TryGetSingleton(out var goal) - && goal.Target > 0 && goal.Charge >= goal.Target; - - byte outcome = SystemAPI.TryGetSingleton(out var oc) ? oc.Value : RunOutcomeId.InProgress; - if (outcome != RunOutcomeId.InProgress) - { - if (_outcomePlayed != outcome) - { - _outcomePlayed = outcome; - PlayOutcomeSting(outcome == RunOutcomeId.Victory); - } - tBass = 0.20f; tPad = 0.45f; tArp = 0f; tPulse = 0f; // aftermath bed under the banner - } - else if (siege) - { - tBass = 0.70f; tPad = 0.40f; tArp = 0.60f; tPulse = finalSiege ? 0.90f : 0.65f; - } - else if (haveRun) + if (SystemAPI.TryGetSingleton(out var run)) { switch (run.Lifecycle) { @@ -138,15 +116,7 @@ namespace ProjectM.Client _pulse.volume = _vPulse * master; } - void PlayOutcomeSting(bool victory) - { - // Victory: rising A-minor->major-feel arpeggio; defeat: falling minor third crawl. - float[] notes = victory - ? new[] { 220f, 277.18f, 329.63f, 440f } // A3 C#4 E4 A4 (picardy lift) - : new[] { 220f, 207.65f, 174.61f, 146.83f }; // A3 Ab3 F3 D3 - var clip = BuildStingClip(notes, victory ? 0.16f : 0.28f, victory ? 0.45f : 0.40f); - if (_pad != null) _pad.PlayOneShot(clip, 0.9f * GameVolume.Music); - } + // ================= clip builders (deterministic, asset-free) ================= @@ -263,23 +233,6 @@ namespace ProjectM.Client return clip; } - static AudioClip BuildStingClip(float[] notes, float noteLen, float vol) - { - int len = (int)(notes.Length * noteLen * SampleRate) + SampleRate / 2; // + half-second tail - var clip = AudioClip.Create("music_sting", len, 1, SampleRate, false); - var data = new float[len]; - for (int n = 0; n < notes.Length; n++) - { - int start = (int)(n * noteLen * SampleRate); - int dur = (int)(SampleRate * (noteLen + (n == notes.Length - 1 ? 0.5f : 0.05f))); - for (int i = 0; i < dur && start + i < len; i++) - { - float tn = i / (float)SampleRate; - data[start + i] += Mathf.Sin(2f * Mathf.PI * notes[n] * tn) * Mathf.Exp(-4.5f * tn) * vol; - } - } - clip.SetData(data, 0); - return clip; - } + } } diff --git a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs index 636efbc3b..222b9f5fa 100644 --- a/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs +++ b/Assets/_Project/Scripts/Client/UI/HowToPlayPanel.cs @@ -16,7 +16,7 @@ namespace ProjectM.Client /// public static class HowToPlayPanel { - static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats", "Win / Lose" }; + static readonly string[] Tabs = { "Controls", "The Loop", "Build & Economy", "Threats" }; public static VisualElement Build(Action onClose) { @@ -85,35 +85,24 @@ namespace ProjectM.Client Body(c, "Pause — Esc"); break; case 1: // The Loop - Head(c, "THE LOOP — expedition RUNS are how you win"); - Body(c, "1. BASE — pick your CLASS + buy PREP buffs (this run only); build Turrets + a Fabricator; spend Aether on PERMANENT class upgrades."); + Head(c, "THE LOOP — descend, fight, bank"); + Body(c, "1. BASE — pick your suit-frame, buy PREP buffs (this run only), spend Aether on PERMANENT upgrades."); Body(c, "2. READY UP [T] — when everyone is ready, the party launches into a multi-room run together."); - Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path on the map, mine the crystals."); - Body(c, "4. BOSS — fell it and the party returns with the haul: +1 on the Engine meter. A wipe banks nothing."); - Body(c, "5. SIEGE — a completed run provokes retaliation at base. Defend the Core!"); - Body(c, "6. WIN — fill the Engine meter, then hold the final siege."); + Body(c, "3. RUN — clear each room, pick 1 of 3 BOONS (this run only), choose your path, mine the crystals."); + Body(c, "4. BOSS — fell it and the party returns with the haul. A wipe banks nothing but the depth record."); break; case 2: // Build & Economy Head(c, "RESOURCES & BUILDING"); Body(c, "Ore — main currency. Attack the glowing crystals INSIDE runs and haul them home (they're scarce — spend well)."); - Body(c, "Turret (40 Ore) — auto-fires at enemies. Needs Charge as ammo."); - Body(c, "Fabricator (30 Ore) — converts Ore → Charge so turrets keep firing."); Body(c, "Wall (Biomass) — a cheap barrier that blocks enemies."); - Body(c, "Aether — rare; fuels PERMANENT class upgrades at the base between runs."); + Body(c, "Aether — rare; fuels PERMANENT upgrades at the base between runs."); Body(c, "Open Build with Tab (Y), pick a piece, click a green tile to place it."); break; - case 3: // Threats + default: // Threats Head(c, "THREATS"); - Body(c, "Husks assault the base during a Siege — keep them off the Engine Core."); - Body(c, "A Husk that reaches the Core drains it. Lose the Core in the final siege and the run ends."); Body(c, "Runs escalate room by room — elites guard the deep paths, a boss guards the end."); Body(c, "Enemy variety scales the deeper you push."); - break; - default: // Win / Lose - Head(c, "WIN / LOSE"); - Body(c, "WIN — clear expeditions to fill the Engine meter, then hold the final siege."); - Body(c, "LOSE — the Engine Core falls during the final siege."); - Body(c, "A Core breach mid-run is only a setback: resources lost, the Core recovers in Calm."); + Body(c, "What the light doesn't hold, the dark takes back."); break; } } diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index fbb130803..8703867b5 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -129,17 +129,11 @@ namespace ProjectM.Client static void StagePendingSave(World server) { var data = SaveService.Load(); - // A terminal (won/lost) save Continues as a FRESH campaign — base+meta kept, outcome/meter/core reset - // (else the dead outcome banner re-latches from the first snapshot). - SaveService.RollTerminalCampaignForward(data); if (data == null) return; var em = server.EntityManager; var e = em.CreateEntity(); - // v5->v6 migration (operator-approved): an old save's Charge counted boss-cleared runs (DR-042), so a - // missing RunsCompleted floors to it — the HUD never shows "Charge 3/4" beside "Runs completed: 0". - int runsCompleted = data.RunsCompleted > data.GoalCharge ? data.RunsCompleted : data.GoalCharge; - em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, RunsCompleted = runsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 }); + em.AddComponentData(e, new PendingSave { RunsCompleted = data.RunsCompleted, MaxDepthReached = data.MaxDepthReached, HasData = 1 }); // v6: stage the meta tiers UNCONDITIONALLY (empty OK — the Bursted spawn system GetBuffers it in the // HasData block; a conditional buffer would throw on any v<=5 Continue). Rows verbatim, no clamping. var mbuf = em.AddBuffer(e); @@ -159,10 +153,6 @@ namespace ProjectM.Client var sbuf = em.AddBuffer(se); foreach (var s in data.Structures) sbuf.Add(SaveApply.ToPending(s)); // EB-1: pure mapping (unit-tested, incl. the wounded HP) - var iobuf = em.AddBuffer(se); - if (data.StructureIo != null) - foreach (var io in data.StructureIo) - iobuf.Add(new PendingStructureIo { StructureIndex = io.StructureIndex, Slot = io.Slot, ResourceId = io.ResourceId, Count = io.Count }); } } @@ -175,9 +165,6 @@ namespace ProjectM.Client using var q = em.CreateEntityQuery(ComponentType.ReadOnly()); if (q.IsEmptyIgnoreFilter) return; var dir = q.GetSingletonEntity(); - var goal = em.HasComponent(dir) ? em.GetComponentData(dir) : default; - var core = em.HasComponent(dir) ? em.GetComponentData(dir) : default; // END-1 - var outcome = em.HasComponent(dir) ? em.GetComponentData(dir) : default; // END-2 var buffer = em.GetBuffer(dir, true); var rows = new LedgerRow[buffer.Length]; @@ -195,21 +182,16 @@ namespace ProjectM.Client // v6: the permanent-meta slice via the ONE shared collector — omitting it HERE (the most common // exit path) would silently WIPE all meta progression on quit (the meta review's top blocker). MetaSaveScan.Collect(em, dir, out var metaRows, out var runsCompleted, out var maxDepth); - SaveStructureScan.Collect(em, nowTick, out var structures, out var structureIo); + SaveStructureScan.Collect(em, nowTick, out var structures); SaveService.Save(new SaveData { - GoalCharge = goal.Charge, - GoalTarget = goal.Target, - CoreCurrent = core.Current, RunsCompleted = runsCompleted, MaxDepthReached = maxDepth, MetaUpgrades = metaRows, - RunOutcome = outcome.Value, Ledger = rows, Structures = structures, - StructureIo = structureIo, SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); } diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index d072f04c3..31dce0464 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -85,13 +85,7 @@ namespace ProjectM.Server decoyPositions.Add(dx.ValueRO.Position); } - // END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended - // Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling. - bool coreAlive = SystemAPI.HasSingleton() - && SystemAPI.TryGetSingleton(out var coreInteg) && coreInteg.Current > 0; - float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton()) : float3.zero; - - if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive) + if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0) { playerEntities.Dispose(); playerPositions.Dispose(); @@ -137,7 +131,6 @@ namespace ProjectM.Server { float3 pos = xform.ValueRO.Position; byte huskRegion = region.ValueRO.Region; - bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base; // Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position. var kb = knockback.ValueRO; @@ -178,12 +171,10 @@ namespace ProjectM.Server } else { - if (tgtIdx < 0 && !huskCoreAlive) - continue; // no decoy, no player/structure, and no Core -> nothing to seek - targetEntity = tgtIdx < 0 ? Entity.Null - : (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]); - targetPos = tgtIdx < 0 ? corePos - : (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]); + if (tgtIdx < 0) + continue; // no decoy, no player/structure -> nothing to seek + targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]; + targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]; } // Seek: stop just inside strike range so the Husk holds position to attack. @@ -271,7 +262,6 @@ namespace ProjectM.Server { float3 pos = xform.ValueRO.Position; byte cHuskRegion = region.ValueRO.Region; - bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base; // 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer). var kb = knockback.ValueRO; @@ -296,12 +286,10 @@ namespace ProjectM.Server // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper). EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx); - if (cIdx < 0 && !cHuskCoreAlive) + if (cIdx < 0) continue; - Entity cTargetEntity = cIdx < 0 ? Entity.Null - : (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]); - float3 cTargetPos = cIdx < 0 ? corePos - : (cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]); + Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]; + float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]; // 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff. var lg = lunge.ValueRO; @@ -414,7 +402,6 @@ namespace ProjectM.Server { float3 pos = xform.ValueRO.Position; byte sRegion = region.ValueRO.Region; - bool sCoreAlive = coreAlive && sRegion == RegionId.Base; // 1. Knockback overrides everything (sole Position writer preserved). var kb = knockback.ValueRO; @@ -434,14 +421,12 @@ namespace ProjectM.Server knockback.ValueRW.UntilTick = 0; } - // 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes. + // 2. Target (region-scoped shared helper); no target -> idle. EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx); - if (sIdx < 0 && !sCoreAlive) + if (sIdx < 0) continue; - Entity sTargetEntity = sIdx < 0 ? Entity.Null - : (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]); - float3 sTargetPos = sIdx < 0 ? corePos - : (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]); + Entity sTargetEntity = sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]; + float3 sTargetPos = sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]; // 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target. var sp = spitter.ValueRO; @@ -504,7 +489,7 @@ namespace ProjectM.Server float sDist = math.length(sToTarget); bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance; bool sCornered = sDist <= sp.CorneredRange; - if (sReady && (sInBand || sCornered) && (sTargetEntity != Entity.Null || sCoreAlive)) + if (sReady && (sInBand || sCornered)) { uint wTicks = (uint)math.max(1, sp.WindupTicks); windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks); @@ -625,9 +610,8 @@ namespace ProjectM.Server } EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx); - bool nCoreAlive = coreAlive && nRegion == RegionId.Base; - bool hasTarget = nIdx >= 0 || nCoreAlive; - float3 nTarget = nIdx < 0 ? corePos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]); + bool hasTarget = nIdx >= 0; + float3 nTarget = nIdx < 0 ? npos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]); bool wantsToClose = hasTarget && !committed && math.distance(npos.xz, nTarget.xz) > nstats.ValueRO.AttackRange * 1.15f; diff --git a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs index 386c66309..ea01f7e30 100644 --- a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs @@ -39,9 +39,6 @@ namespace ProjectM.Server if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; - // Player-driven loop: the base-defense wave only spawns during a Siege. - if (SystemAPI.TryGetSingleton(out var cycle) && cycle.Phase != CyclePhase.Siege) - return; var director = SystemAPI.GetSingleton(); var directorEntity = SystemAPI.GetSingletonEntity(); diff --git a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs index a7fcba6e9..543add7d6 100644 --- a/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs +++ b/Assets/_Project/Scripts/Server/Debug/DebugCommandReceiveSystem.cs @@ -11,12 +11,12 @@ namespace ProjectM.Server /// /// EDITOR-ONLY server receiver for dev-tool RPCs (from the DebugOverlay or /// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work - /// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill, - /// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner - /// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses - /// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR - /// (stripped from builds); the wire TYPE () is unconditional so the RPC - /// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant. + /// over a live connection too: force/stop waves, clear enemies, grant resources/upgrades, teleport, god-mode, + /// heal/kill, class swap, gym enemy spawns. Sender-targeted ops resolve the player via SourceConnection -> + /// NetworkId -> GhostOwner (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the + /// predicted loop). The whole system is #if UNITY_EDITOR (stripped from builds); the wire TYPE + /// () is unconditional so the RPC collection hash matches across peers. + /// Non-Burst (managed-simple, editor-only) — perf is irrelevant. /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] @@ -41,7 +41,8 @@ namespace ProjectM.Server foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess()) playerByConn[owner.ValueRO.NetworkId] = e; - bool haveCycle = SystemAPI.TryGetSingletonEntity(out var cycleEntity); + uint now = SystemAPI.TryGetSingleton(out var netTime) && netTime.ServerTick.IsValid + ? netTime.ServerTick.TickIndexForValidTick : 0u; foreach (var (request, receive, reqEntity) in SystemAPI.Query, RefRO>().WithEntityAccess()) @@ -54,43 +55,29 @@ namespace ProjectM.Server switch (cmd.Op) { - case DebugOp.SpawnWave: - if (haveCycle && SystemAPI.HasComponent(cycleEntity)) + case DebugOp.SpawnWave: // re-meant (LANTERN): force the NEXT wave to start this tick + if (SystemAPI.TryGetSingletonEntity(out var forceWaveE)) { - var ts = SystemAPI.GetComponent(cycleEntity); - ts.PendingSiegeSize = math.max(1, cmd.ArgA); - ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it - SystemAPI.SetComponent(cycleEntity, ts); + var fw = SystemAPI.GetComponent(forceWaveE); + fw.Phase = WavePhase.Lull; + fw.NextActionTick = 0; // due immediately -> WaveSystem starts the next (bigger) wave + SystemAPI.SetComponent(forceWaveE, fw); } break; - case DebugOp.EndSiege: - case DebugOp.SetCalm: + case DebugOp.EndSiege: // re-meant (LANTERN): "quiet the arena" — cull husks + push the next wave far out CullHusks(ref ecb); - if (SystemAPI.TryGetSingletonEntity(out var we)) + if (SystemAPI.TryGetSingletonEntity(out var stopWaveE)) { - var w = SystemAPI.GetComponent(we); + var w = SystemAPI.GetComponent(stopWaveE); w.Phase = WavePhase.Lull; w.RemainingToSpawn = 0; - SystemAPI.SetComponent(we, w); - } - if (haveCycle && SystemAPI.HasComponent(cycleEntity)) - { - var ts = SystemAPI.GetComponent(cycleEntity); - ts.PendingSiegeSize = 0; - ts.ArmTick = 0; - ts.SiegeStartTick = 0; - SystemAPI.SetComponent(cycleEntity, ts); - } - if (cmd.Op == DebugOp.SetCalm && haveCycle) - { - var cs = SystemAPI.GetComponent(cycleEntity); - cs.Phase = CyclePhase.Calm; - cs.PhaseEndTick = 0; - SystemAPI.SetComponent(cycleEntity, cs); + w.NextActionTick = TickUtil.NonZero(now + 216000u); // ~1 h @ 60 Hz: waves stay quiet for the session + SystemAPI.SetComponent(stopWaveE, w); } break; + case DebugOp.ClearEnemies: CullHusks(ref ecb); break; @@ -147,23 +134,6 @@ namespace ProjectM.Server } break; - case DebugOp.AdvanceGoal: - if (haveCycle && SystemAPI.HasComponent(cycleEntity)) - { - var g = SystemAPI.GetComponent(cycleEntity); - g.Charge += math.max(1, cmd.ArgA); - SystemAPI.SetComponent(cycleEntity, g); - } - break; - - case DebugOp.SetHeat: - if (haveCycle && SystemAPI.HasComponent(cycleEntity)) - { - var ts = SystemAPI.GetComponent(cycleEntity); - ts.Heat = cmd.ArgA; - SystemAPI.SetComponent(cycleEntity, ts); - } - break; case DebugOp.SetTuning: if (SystemAPI.TryGetSingleton(out var tuningCfg)) { diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs index 8794e30e6..5320c04c8 100644 --- a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -7,11 +7,9 @@ namespace ProjectM.Server { /// /// Host-only autosave writer. A managed (file IO => NO Burst) that reacts to the - /// flag the Bursted CyclePhaseSystem raises on the Siege->Calm checkpoint: - /// reads the authoritative + shared resource ledger off the CycleDirector ghost, - /// writes the JSON save (), then clears the flag. ServerSimulation-only, so a pure - /// (Join) client never writes. Deliberately carries NO [UpdateAfter(CyclePhaseSystem)] (that would risk - /// a sort-cycle); a one-tick-late autosave is irrelevant. + /// flag RunDirectorSystem raises on the terminal bank: reads the shared + /// resource ledger + permanent meta off the director ghost, writes the JSON save (), + /// then clears the flag. ServerSimulation-only, so a pure (Join) client never writes. /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] public partial class SaveWriteSystem : SystemBase @@ -32,46 +30,26 @@ namespace ProjectM.Server req.Pending = 0; SystemAPI.SetComponent(dir, req); - var goal = SystemAPI.HasComponent(dir) - ? SystemAPI.GetComponent(dir) - : default; - - // END-1: persist the Engine Core integrity (a wounded base stays wounded across save/quit). - var core = SystemAPI.HasComponent(dir) - ? SystemAPI.GetComponent(dir) - : default; - // END-2: persist the terminal run outcome so a won/lost run loads finished (no re-arm on Continue). - var outcome = SystemAPI.HasComponent(dir) - ? SystemAPI.GetComponent(dir) - : default; - - - // The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer). + // The shared ledger lives on this same director ghost (ResourceLedger-tagged StorageEntry buffer). var buffer = SystemAPI.GetBuffer(dir); var rows = new LedgerRow[buffer.Length]; for (int i = 0; i < buffer.Length; i++) rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count }; - // M7: also persist player-built structures + their production tick-state / inventory (single shared scan). + // Persist player-built structures (single shared scan; drift-proof vs the quit-to-menu writer). uint nowTick = SystemAPI.GetSingleton().ServerTick.TickIndexForValidTick; - SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo); - // v6: the permanent-meta slice via the ONE shared collector (drift-proof vs the quit-to-menu writer). + SaveStructureScan.Collect(EntityManager, nowTick, out var structures); + // v6: the permanent-meta slice via the ONE shared collector. MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth); - SaveService.Save(new SaveData { - GoalCharge = goal.Charge, - GoalTarget = goal.Target, - CoreCurrent = core.Current, - RunOutcome = outcome.Value, RunsCompleted = runsCompleted, MaxDepthReached = maxDepth, MetaUpgrades = metaRows, Ledger = rows, Structures = structures, - StructureIo = structureIo, SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); } diff --git a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs deleted file mode 100644 index fd63e751d..000000000 --- a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs +++ /dev/null @@ -1,79 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Server -{ - /// - /// END-1 — the Engine Core takes the hit a siege breaks through to. Server-only, plain - /// [UpdateAfter(EnemyAISystem)] so it reads each Husk's POST-move - /// position this tick (Husks are interpolated ghosts moved server-only by ; the Core - /// integrity rides the GLOBAL CycleDirector ghost). Any living Husk within of the - /// base BREACHES: it drains CoreDamagePerHusk integrity and is - /// consumed (despawned via the ECB — at-most-once, each Husk visited once per tick). Pure planar XZ check - /// (); the per-Husk damage is the live knob with - /// the baked fallback. Once hits 0 this system idles — the SOFT-loss edge in - /// 's CyclePhaseSystem owns resolution (the locked DR-029 soft fork). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(EnemyAISystem))] - public partial struct CoreDamageSystem : ISystem - { - /// How close (planar XZ) a Husk must get to the Engine Core to breach it. A STRUCTURAL reach radius - /// (not a per-session feel knob) — generous so a Husk pushing into the base interior reads as a breach. - const float CoreReachRadius = 3f; - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var coreEntity = SystemAPI.GetSingletonEntity(); - var core = SystemAPI.GetComponent(coreEntity); - if (core.Current <= 0) - return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution. - - // END-2: once the run is decided (Victory/Loss latched) the Core takes no more damage. Defensive — the - // siege already despawned its Husks on resolution; this mirrors the CoreRestoreSystem terminal-halt guard. - if (SystemAPI.TryGetSingleton(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress) - return; - - float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton()); - var tune = SystemAPI.TryGetSingleton(out var tcfg) ? tcfg : TuningConfig.Defaults(); - int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk); - - var ecb = new EntityCommandBuffer(Allocator.Temp); - int drained = 0; - foreach (var (xform, entity) in - SystemAPI.Query>().WithAll().WithNone().WithEntityAccess()) // corpses don't drain the Core (B3) - { - if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius)) - continue; - drained += dmgPerHusk; - ecb.DestroyEntity(entity); // a breaching Husk is consumed (each Husk visited once -> at-most-once) - } - - if (drained > 0) - { - core.Current = math.max(0, core.Current - drained); - SystemAPI.SetComponent(coreEntity, core); - } - - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta deleted file mode 100644 index ad469ff99..000000000 --- a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9a3eeda43e19f1946abd8e74126c3a62 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs deleted file mode 100644 index cbbac0a30..000000000 --- a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs +++ /dev/null @@ -1,59 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// END-1 — a chipped-but-survived Engine Core heals between sieges, so a breach is a SETBACK you recover from, - /// not a death spiral. Server-only, plain . Regenerates ONLY in - /// (no regen mid-Siege): +1 integrity every CoreRegenIntervalTicks server - /// ticks toward . Deterministic + server-only (no rollback) so the plain - /// now % interval tick gate is safe (the server advances exactly one fixed tick per step). The interval is - /// the live knob with the baked fallback. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - public partial struct CoreRestoreSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - // END-2: once the run is decided (Victory/Loss latched), the Core freezes at its terminal value (no regen). - if (SystemAPI.TryGetSingleton(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress) - return; - - if (SystemAPI.GetSingleton().Phase != CyclePhase.Calm) - return; // heal only between sieges - - var coreEntity = SystemAPI.GetSingletonEntity(); - var core = SystemAPI.GetComponent(coreEntity); - if (core.Current >= core.Max) - return; - - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var tune = SystemAPI.TryGetSingleton(out var tcfg) ? tcfg : TuningConfig.Defaults(); - uint interval = (uint)math.max(1f, tune.CoreRegenIntervalTicks); - if (now % interval != 0) - return; - - core.Current = math.min(core.Max, core.Current + 1); - SystemAPI.SetComponent(coreEntity, core); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta deleted file mode 100644 index 19f5efe20..000000000 --- a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e3acd11f97b97d240b97e8c0ad096df7 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 36dd81564..00b2e5df8 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -8,13 +8,13 @@ using Unity.Transforms; namespace ProjectM.Server { /// - /// Server-only, one-shot spawner for the GLOBAL cycle-director ghost (mirrors SharedStorageSpawnSystem, - /// but MINUS the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every - /// region). On its first update it reads the baked + NetworkTime, - /// instantiates the ghost, initializes (Expedition, cycle 1, PhaseEndTick = - /// now + the initial phase delay), adds the server-only , and - /// places it at the base center (preserving the prefab's baked LocalTransform scale — FromPosition would - /// reset the replicated Scale GhostField), then destroys the spawner so it idles. + /// Server-only, one-shot spawner for the GLOBAL director ghost (mirrors SharedStorageSpawnSystem, but MINUS + /// the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every region). On its + /// first update it reads the baked + NetworkTime, instantiates the ghost + /// — the shared-ledger / RunInfo / meta host (its old cycle/siege/goal/core state is retired, LANTERN purge) — + /// applies a menu-staged save born-correct, and places it at the base center (preserving the prefab's baked + /// LocalTransform scale — FromPosition would reset the replicated Scale GhostField), then destroys the + /// spawner so it idles. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] @@ -49,24 +49,11 @@ namespace ProjectM.Server xform.Position = BaseGridMath.PlotCenter(anchor); ecb.SetComponent(director, xform); - // Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges. - ecb.SetComponent(director, new CycleState - { - Phase = CyclePhase.Calm, - CycleNumber = 1, - PhaseEndTick = 0u, - }); - ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 }); - ecb.AddComponent(director, new ThreatState()); - // END-2: server-only run-phase marker (Normal until the goal cap arms the final siege). Added at - // spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab. - ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal }); - // Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted - // meta counters — ALL added UNCONDITIONALLY at spawn (the CycleRuntime/ThreatState/RunPhase idiom; - // D-F2: a New-Game boot must have the components the bank block reads; Continue restores VALUES only, - // inside the HasData block — Step 12b). HostSalt starts a fixed non-tick seed lineage (bumped per - // launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge. + // meta counters — ALL added UNCONDITIONALLY at spawn (D-F2: a New-Game boot must have the components + // the bank block reads; Continue restores VALUES only, inside the HasData block — Step 12b). + // HostSalt starts a fixed non-tick seed lineage (bumped per launch); the save folds persisted + // RunsCompleted in at restore so cross-session runs diverge. ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u }); ecb.AddComponent(director, default(RouteCommand)); ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch @@ -74,47 +61,25 @@ namespace ProjectM.Server ecb.AddComponent(director, default(MetaCounters)); // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director + // ghost never serializes an empty ledger to clients (no replication flicker). // DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger. bool restoredLedger = false; - // ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker). if (SystemAPI.TryGetSingletonEntity(out var pendingEntity)) { var pending = SystemAPI.GetComponent(pendingEntity); if (pending.HasData != 0) { - // END-2: clamp the restored Target to the baked run-length so a pre-v5 save carrying the old - // Target=10 still honours the slice's baked Target=4 (the final siege stays reachable). - int bakedTarget = SystemAPI.HasComponent(spawner.Prefab) - ? SystemAPI.GetComponent(spawner.Prefab).Target : pending.GoalTarget; - int restoredTarget = pending.GoalTarget > 0 && pending.GoalTarget < bakedTarget - ? pending.GoalTarget : bakedTarget; - ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = restoredTarget }); var srcLedger = SystemAPI.GetBuffer(pendingEntity); var destLedger = ecb.SetBuffer(director); SaveApply.WriteLedger(srcLedger, destLedger); restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c) - // END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a - // persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full. - if (SystemAPI.HasComponent(spawner.Prefab)) - { - var bakedCore = SystemAPI.GetComponent(spawner.Prefab); - int restoredCore = pending.CoreCurrent > 0 - ? (pending.CoreCurrent < bakedCore.Max ? pending.CoreCurrent : bakedCore.Max) - : bakedCore.Max; - ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u }); - } - - // END-2: born-correct the terminal run outcome (a won/lost run loads finished + halted; a pre-v5 - // save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard. - ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome }); - // v6: restore the permanent meta — counters (VALUES only; the component was added // unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty // [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown // ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's - // sole-writer rule, like CycleState/RunOutcome above), and the HostSalt fold (cross-session - // first-run maps diverge once you've banked clears — the promise at the RunRuntime add). + // sole-writer rule), and the HostSalt fold (cross-session first-run maps diverge once + // you've banked clears — the promise at the RunRuntime add). ecb.SetComponent(director, new MetaCounters { RunsCompleted = pending.RunsCompleted, @@ -140,13 +105,13 @@ namespace ProjectM.Server ecb.DestroyEntity(pendingEntity); } - // DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold - // deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended - // BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker). + // DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a + // cold start with nothing to place. Appended BEFORE Playback so the ghost first-serializes WITH + // the seed (no empty-ledger replication flicker). if (!restoredLedger) ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre }); - // Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint. + // Host-only autosave flag; SaveWriteSystem consumes it (RunDirectorSystem raises it on bank). ecb.AddComponent(director, new SaveRequest { Pending = 0 }); } diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs deleted file mode 100644 index c8b316361..000000000 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ /dev/null @@ -1,195 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst;using Unity.Collections; - -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in Calm - /// (persistent, unhurried — build/prep at your pace, no countdown) until the arms a - /// siege, then flips to Siege (the base-defense wave) and back to Calm when the wave is cleared. - /// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only - /// ), read client-side by the HUD, so one global byte never has to represent - /// "player A out / player B home." Maintains the replicated singleton and gates - /// (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup - /// before WaveSystem. All timing is wrap-safe NetworkTick math ( - /// + ), never raw uint compares. Lives on the - /// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(WaveSystem))] - public partial struct CyclePhaseSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var cycleEntity = SystemAPI.GetSingletonEntity(); - var cycle = SystemAPI.GetComponent(cycleEntity); - var runtime = SystemAPI.GetComponent(cycleEntity); - - if (cycle.Phase == CyclePhase.Calm) - { - // Default calm: no pending siege => no countdown. - cycle.PhaseEndTick = 0; - - if (SystemAPI.HasComponent(cycleEntity)) - { - var threat = SystemAPI.GetComponent(cycleEntity); - if (threat.PendingSiegeSize > 0) - { - // Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an - // "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms. - cycle.PhaseEndTick = threat.ArmTick; - - bool armed = threat.ArmTick == 0 - || !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick); - - if (armed && SystemAPI.TryGetSingletonEntity(out var waveEntity)) - { - // ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing - // Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes - // RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen - // size and WaveSystem stays the sole WaveState writer thereafter. ---- - var w = SystemAPI.GetComponent(waveEntity); - runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this) - w.WaveNumber += 1; - w.Phase = WavePhase.Spawning; - w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize); - w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick - SystemAPI.SetComponent(waveEntity, w); - - cycle.Phase = CyclePhase.Siege; - cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed. - - threat.PendingSiegeSize = 0; // consume once - threat.ArmTick = 0; - SystemAPI.SetComponent(cycleEntity, threat); - } - } - } - } - else if (cycle.Phase == CyclePhase.Siege) - { - // END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent- - // guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths. - bool isFinal = SystemAPI.HasComponent(cycleEntity) - && SystemAPI.GetComponent(cycleEntity).Value == RunPhaseId.FinalDefense; - - // The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the - // sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer). - bool overrun = SystemAPI.HasComponent(cycleEntity) - && SystemAPI.GetComponent(cycleEntity).Current <= 0; - if (overrun) - { - cycle.Phase = CyclePhase.Calm; - cycle.PhaseEndTick = 0; - - // The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the - // wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths. - var ecb = new EntityCommandBuffer(Allocator.Temp); - // Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must - // survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the - // zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared. - foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithNone().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy (cross-ECB double-destroy) - if (hr.ValueRO.Region == RegionId.Base) - ecb.DestroyEntity(he); - ecb.Playback(state.EntityManager); - ecb.Dispose(); - if (SystemAPI.TryGetSingletonEntity(out var waveLost)) - { - var wl = SystemAPI.GetComponent(waveLost); - wl.RemainingToSpawn = 0; - wl.Phase = WavePhase.Lull; - wl.NextActionTick = 0; - SystemAPI.SetComponent(waveLost, wl); - } - - if (isFinal) - { - // END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO - // ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner - // (from the replicated RunOutcome), not the soft "the Core will recover" flash. - SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss }); - } - else - { - // END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient - // overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork). - var tuneL = SystemAPI.TryGetSingleton(out var tcfgL) ? tcfgL : TuningConfig.Defaults(); - if (SystemAPI.HasBuffer(cycleEntity)) - { - var ledger = SystemAPI.GetBuffer(cycleEntity); - StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct); - } - var coreL = SystemAPI.GetComponent(cycleEntity); - coreL.OverrunTick = TickUtil.NonZero(now); - SystemAPI.SetComponent(cycleEntity, coreL); - } - - // Autosave the checkpoint (a breach / final loss is a meaningful save point). - if (SystemAPI.HasComponent(cycleEntity)) - SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); - } - else if (DefendCleared(ref state, runtime.DefendStartWave)) - { - cycle.Phase = CyclePhase.Calm; - cycle.PhaseEndTick = 0; - if (isFinal) - { - // END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt; - // do NOT increment the (already-capped) goal. - SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory }); - if (SystemAPI.HasComponent(cycleEntity)) - SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 }); - } - // DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win - // path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win- - // driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by - // ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward - // (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch - // above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target. - } - } - - // Surface the live wave number on the replicated CycleState for the HUD (single writer). - if (SystemAPI.TryGetSingleton(out var waveSync)) - cycle.WaveNumber = waveSync.WaveNumber; - - SystemAPI.SetComponent(cycleEntity, cycle); - SystemAPI.SetComponent(cycleEntity, runtime); - } - - // The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned, - // and no Husks remain alive. - bool DefendCleared(ref SystemState state, int defendStartWave) - { - if (!SystemAPI.TryGetSingleton(out var wave)) - return false; - // Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition}) - // must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem). - int baseHusks = 0; - foreach (var hr in SystemAPI.Query>().WithAll().WithNone()) // LIVING only (B3) - if (hr.ValueRO.Region == RegionId.Base) baseHusks++; - return wave.WaveNumber > defendStartWave - && wave.RemainingToSpawn == 0 - && baseHusks == 0; - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs.meta b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs.meta deleted file mode 100644 index 963b88f00..000000000 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: c325c252dce9fba4a938d5c8db903042 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs b/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs deleted file mode 100644 index 82fdc7a53..000000000 --- a/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs +++ /dev/null @@ -1,90 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// END-2 — arms the FINAL siege when the long-arc goal meter fills. Server-only, plain - /// , [UpdateAfter(CyclePhaseSystem)] so it reads - /// AFTER the survived-siege increment that may have just reached Target. - /// On the Charge >= Target rising edge — guarded by + - /// so it fires EXACTLY once — it: - /// - /// arms a bigger siege through the existing single entry point : - /// the would-be-next normal siege size (SizeBase + ScheduleSizePerWave*wave) times the live - /// (floored at 1 so the final siege is never smaller), telegraphed - /// via (wrap-safe ); - /// flips to . - /// - /// It NEVER writes .Phase / WaveState (CyclePhaseSystem stays the sole writer) nor - /// .Charge (CyclePhaseSystem clamps it at the increment site) — it only READS the edge. - /// CyclePhaseSystem then consumes the next tick exactly like any other - /// armed siege; ThreatDirectorSystem stops arming once leaves Normal, so no normal - /// siege can stomp the final one. Plain server group => one run per tick, no rollback/predicted exposure. - /// Bytes, never enums (Burst-safe). - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(CyclePhaseSystem))] - public partial struct GoalReachedSystem : ISystem - { - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var cycleEntity = SystemAPI.GetSingletonEntity(); - - // Exactly-once guards: a decided run, or one already in the final siege, arms nothing. - if (SystemAPI.HasComponent(cycleEntity) - && SystemAPI.GetComponent(cycleEntity).Value != RunOutcomeId.InProgress) - return; - var runPhase = SystemAPI.GetComponent(cycleEntity); - if (runPhase.Value != RunPhaseId.Normal) - return; - - // Goal cap reached? (Charge is clamped to Target at the CyclePhaseSystem increment site.) - if (!SystemAPI.HasComponent(cycleEntity)) - return; - var goal = SystemAPI.GetComponent(cycleEntity); - if (goal.Target <= 0 || goal.Charge < goal.Target) - return; - - if (!SystemAPI.HasComponent(cycleEntity) || !SystemAPI.HasComponent(cycleEntity)) - return; - var threat = SystemAPI.GetComponent(cycleEntity); - var config = SystemAPI.GetComponent(cycleEntity); - - int wave = SystemAPI.TryGetSingleton(out var ws) ? ws.WaveNumber : 0; - float mult = math.max(1f, SystemAPI.TryGetSingleton(out var tcfg) - ? tcfg.FinalSiegeMultiplier - : TuningConfig.Defaults().FinalSiegeMultiplier); - int normalSize = config.SizeBase + config.ScheduleSizePerWave * wave; - int finalSize = math.max(1, (int)(normalSize * mult)); - - // Arm the final siege (overwrites any pending normal siege — the final supersedes; at the goal-reach tick - // PendingSiegeSize is 0 anyway, the just-cleared siege having consumed it). CyclePhaseSystem consumes it. - threat.PendingSiegeSize = finalSize; - threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks); - SystemAPI.SetComponent(cycleEntity, threat); - - runPhase.Value = RunPhaseId.FinalDefense; - SystemAPI.SetComponent(cycleEntity, runPhase); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs.meta b/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs.meta deleted file mode 100644 index 4d544b6c5..000000000 --- a/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 472c137c49b85e141b0ee00b1d1fa076 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs index c86c96eac..2ac93f927 100644 --- a/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs @@ -10,8 +10,7 @@ namespace ProjectM.Server { /// /// SOLE writer of the replicated run-lifecycle FSM () and its server-only working state - /// () — the expedition redesign's counterpart of CyclePhaseSystem's single-writer - /// discipline (that system stays the sole writer of the BASE Calm↔Siege posture; the two FSMs are distinct). + /// (). /// /// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) → /// InRoom (fight; the clear edge arrives as the replicated .State == Cleared, @@ -24,20 +23,12 @@ namespace ProjectM.Server /// /// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth /// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine - /// boss-clear terminal () credits the win meter - /// (.Charge, clamped), RunsCompleted, the retaliation inputs - /// (.PendingReturns/ExpeditionsCompleted — carried from the retired gate, C7) and - /// requests a save. An abort/wipe banks NOTHING but the depth high-water (D-F3). - /// - /// Ordering: [UpdateBefore(CyclePhaseSystem)] ONLY (GoalReachedSystem is [UpdateAfter(CyclePhaseSystem)] — - /// transitively after this system, so the Charge credit lands before it reads the edge). Per the hard rule, - /// NOTHING in the room chain adds another CyclePhase edge (a sort cycle is invisible to EditMode and throws only - /// at Play world creation). + /// boss-clear terminal () credits RunsCompleted and requests a + /// save. An abort/wipe banks NOTHING but the depth high-water (D-F3). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateBefore(typeof(CyclePhaseSystem))] public partial struct RunDirectorSystem : ISystem { /// "All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts. @@ -95,15 +86,7 @@ namespace ProjectM.Server { case RunLifecycle.Staging: { - // F2 cross-FSM launch guard: no new run while a final siege arms/runs or the outcome latched. - // Guards default OPEN when the server-only markers are absent (EditMode worlds). - bool launchAllowed = - (!SystemAPI.HasComponent(dirEntity) - || SystemAPI.GetComponent(dirEntity).Value == RunPhaseId.Normal) - && (!SystemAPI.HasComponent(dirEntity) - || SystemAPI.GetComponent(dirEntity).Value == RunOutcomeId.InProgress); - - if (allReady && run.WasAllReady == 0 && launchAllowed) + if (allReady && run.WasAllReady == 0) { // Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage, // never a tick, never 0, equality-compared downstream. @@ -364,25 +347,9 @@ case RunLifecycle.RouteSelect: info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror } - // Boss-clear only: the win meter, the retaliation inputs (C7), and a save checkpoint. - if (run.LastTerminalCleared != 0) - { - if (SystemAPI.HasComponent(dirEntity)) - { - var goal = SystemAPI.GetComponent(dirEntity); - goal.Charge = math.min(goal.Charge + 1, goal.Target); - SystemAPI.SetComponent(dirEntity, goal); - } - if (SystemAPI.HasComponent(dirEntity)) - { - var threat = SystemAPI.GetComponent(dirEntity); - threat.PendingReturns += 1; - threat.ExpeditionsCompleted += 1; - SystemAPI.SetComponent(dirEntity, threat); - } - if (SystemAPI.HasComponent(dirEntity)) - SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 }); - } + // Boss-clear only: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge). + if (run.LastTerminalCleared != 0 && SystemAPI.HasComponent(dirEntity)) + SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 }); } // TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs deleted file mode 100644 index 53274d378..000000000 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ /dev/null @@ -1,138 +0,0 @@ -using ProjectM.Simulation; -using Unity.Burst; -using Unity.Collections; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; - -namespace ProjectM.Server -{ - /// - /// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN - /// and HOW BIG a siege is; owns the Calm↔Siege transition. The single documented - /// hand-off is (this system sets it; CyclePhaseSystem consumes it). - /// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear - /// return by into — the retired - /// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege of - /// Husks after a - /// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with - /// no re-bake. It also enforces a BOUNDED siege lifetime (): an - /// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain - /// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a - /// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero + - /// NetworkTick.IsNewerThan / TicksSince), never raw uint. - /// - [BurstCompile] - [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] - [UpdateInGroup(typeof(SimulationSystemGroup))] - [UpdateAfter(typeof(RunDirectorSystem))] - [UpdateBefore(typeof(CyclePhaseSystem))] - public partial struct ThreatDirectorSystem : ISystem - { - - [BurstCompile] - public void OnCreate(ref SystemState state) - { - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - state.RequireForUpdate(); - } - - [BurstCompile] - public void OnUpdate(ref SystemState state) - { - var serverTick = SystemAPI.GetSingleton().ServerTick; - if (!serverTick.IsValid) - return; - uint now = serverTick.TickIndexForValidTick; - - var cycleEntity = SystemAPI.GetSingletonEntity(); - var cycle = SystemAPI.GetComponent(cycleEntity); - var threat = SystemAPI.GetComponent(cycleEntity); - var config = SystemAPI.GetComponent(cycleEntity); - // END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The - // SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with - // HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour. - byte runPhase = SystemAPI.HasComponent(cycleEntity) - ? SystemAPI.GetComponent(cycleEntity).Value : RunPhaseId.Normal; - byte runOutcome = SystemAPI.HasComponent(cycleEntity) - ? SystemAPI.GetComponent(cycleEntity).Value : RunOutcomeId.InProgress; - bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress; - - - // ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns - // collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ---- - if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0) - { - if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm) - { - int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked) - threat.PendingSiegeSize = math.max(1, size); - threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks); - } - threat.PendingReturns = 0; // consume regardless so returns can't pile up - } - - // ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so - // the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build - // grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ---- - if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0) - { - if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm) - { - // Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval - // AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long. - threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks); - } - else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm - && !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick)) - { - int wave = SystemAPI.TryGetSingleton(out var ws) ? ws.WaveNumber : 0; - threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave); - threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks); - threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks); - } - } - - // ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull - // the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ---- - if (cycle.Phase == CyclePhase.Siege) - { - if (threat.SiegeStartTick == 0) - { - threat.SiegeStartTick = TickUtil.NonZero(now); - } - else if (config.SiegeTimeoutTicks > 0 && runPhase != RunPhaseId.FinalDefense) - { - var start = new NetworkTick(threat.SiegeStartTick); - if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks) - { - // Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also - // EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3). - var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithNone().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy - if (hr.ValueRO.Region == RegionId.Base) - ecb.DestroyEntity(he); - ecb.Playback(state.EntityManager); - ecb.Dispose(); - - if (SystemAPI.TryGetSingletonEntity(out var waveEntity)) - { - var w = SystemAPI.GetComponent(waveEntity); - w.RemainingToSpawn = 0; - SystemAPI.SetComponent(waveEntity, w); - } - threat.SiegeStartTick = 0; - } - } - } - else - { - threat.SiegeStartTick = 0; // not under siege - } - - SystemAPI.SetComponent(cycleEntity, threat); - } - } -} diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs.meta deleted file mode 100644 index a77f3930f..000000000 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 3cd1beb28c2b1f84398722a95d1ee784 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs index 1f122e345..636e5ac3d 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/DebugCommandRequest.cs @@ -25,17 +25,17 @@ namespace ProjectM.Simulation /// Opcodes for (bytes — never an enum on the wire). public static class DebugOp { - /// Arm + immediately fire a siege of ArgA Husks (ArgA = size). + /// Force the NEXT wave to start this tick (re-meant from the old arm-a-siege op; args unused). public const byte SpawnWave = 0; - /// Collapse the current siege (cull Husks, stop spawning, clear pending) -> back to Calm. + /// Quiet the arena: cull Husks + push the next wave ~1 h out (re-meant from the old end-siege op). public const byte EndSiege = 1; - /// Cull every living Husk now (leaves the phase alone). + /// Cull every living Husk now (leaves wave state alone). public const byte ClearEnemies = 2; - /// Hard-reset the run-state to Calm (clears any siege/pending). - public const byte SetCalm = 3; + // RETIRED (LANTERN purge — keep the byte values reserved, never renumber a wire byte): + // 3 = SetCalm · 10 = AdvanceGoal · 11 = SetHeat /// Deposit ArgB of resource ArgA (a ) into the shared ledger. public const byte GrantResource = 4; @@ -55,11 +55,6 @@ namespace ProjectM.Simulation /// Kill the sender (Health -> 0; the normal death/respawn loop takes over). public const byte KillPlayer = 9; - /// Add ArgA to the long-arc goal charge. - public const byte AdvanceGoal = 10; - - /// Set ThreatState.Heat to ArgA (inert until the Heat source ships). - public const byte SetHeat = 11; /// Set the ArgA to ArgB/1000f (live dash/Charger feel-tuning; MC-0). public const byte SetTuning = 12; diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index 13beab0d9..9b6086e4b 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -50,16 +50,6 @@ namespace ProjectM.Simulation // preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem. public float StructureAggroWeight; - // END-1 Engine Core (live feel knobs; read server-side by CoreDamageSystem/CoreRestoreSystem/CyclePhaseSystem). - // CoreDamagePerHusk + CoreOverrunDrainPct are value knobs (>=0); CoreRegenIntervalTicks is a tick knob (>=1). - public float CoreDamagePerHusk; // integrity drained by one breaching Husk (~5 unintercepted = serious dent) - public float CoreRegenIntervalTicks; // ticks between +1 regen in Calm (18 -> +1/0.3s -> ~full over one short Calm) - public float CoreOverrunDrainPct; // fraction (0..1) of the shared ledger lost on a breach (soft-loss penalty) - - // END-2 final siege: the would-be-next normal siege size is multiplied by this for the FINAL siege so the - // climax reads visibly larger. Floors at 1 (the default ClampKnob bucket) — a final siege is never smaller - // than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site. - public float FinalSiegeMultiplier; // Phase 1 B1/B2 (design review wf_fd177263): poise threshold on the EXISTING KnockbackState.Speed channel // (light melee 6 nudges; finisher 6x1.8=10.8 and cone 8 stagger), and the separation pass's push-speed cap. @@ -89,10 +79,6 @@ namespace ProjectM.Simulation MeleeFinisherMult = 1.8f, // finisher (last hit) scales dmg/range/recover/knockback MeleeComboLength = 3f, // light, light, finisher StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable - CoreDamagePerHusk = 10f, // END-1: 10 breaching Husks = full loss; ~5 = a serious dent - CoreRegenIntervalTicks = 18f, // END-1: +1 integrity / 0.3s in Calm (~30s to refill 100 from 0) - CoreOverrunDrainPct = 0.5f, // END-1: a breach costs half the shared ledger (soft-loss penalty) - FinalSiegeMultiplier = 2.5f, // END-2: the final siege is ~2.5x the would-be-next normal siege StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling }; @@ -114,8 +100,6 @@ namespace ProjectM.Simulation case TuningKnob.MeleeKnockbackSpeed: case TuningKnob.MeleeFinisherMult: case TuningKnob.StructureAggroWeight: - case TuningKnob.CoreDamagePerHusk: - case TuningKnob.CoreOverrunDrainPct: case TuningKnob.StaggerKnockbackSpeed: case TuningKnob.SeparationMaxSpeed: return math.max(0f, value); @@ -152,10 +136,6 @@ namespace ProjectM.Simulation case TuningKnob.MeleeFinisherMult: c.MeleeFinisherMult = value; break; case TuningKnob.MeleeComboLength: c.MeleeComboLength = value; break; case TuningKnob.StructureAggroWeight: c.StructureAggroWeight = value; break; - case TuningKnob.CoreDamagePerHusk: c.CoreDamagePerHusk = value; break; - case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break; - case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break; - case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = value; break; case TuningKnob.StaggerKnockbackSpeed: c.StaggerKnockbackSpeed = value; break; case TuningKnob.SeparationMaxSpeed: c.SeparationMaxSpeed = value; break; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) @@ -187,10 +167,6 @@ namespace ProjectM.Simulation case TuningKnob.MeleeFinisherMult: return c.MeleeFinisherMult; case TuningKnob.MeleeComboLength: return c.MeleeComboLength; case TuningKnob.StructureAggroWeight: return c.StructureAggroWeight; - case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk; - case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks; - case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct; - case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier; case TuningKnob.StaggerKnockbackSpeed: return c.StaggerKnockbackSpeed; case TuningKnob.SeparationMaxSpeed: return c.SeparationMaxSpeed; default: return 0f; @@ -220,10 +196,6 @@ namespace ProjectM.Simulation MeleeFinisherMult = c.MeleeFinisherMult, MeleeComboLength = c.MeleeComboLength, StructureAggroWeight = c.StructureAggroWeight, - CoreDamagePerHusk = c.CoreDamagePerHusk, - CoreRegenIntervalTicks = c.CoreRegenIntervalTicks, - CoreOverrunDrainPct = c.CoreOverrunDrainPct, - FinalSiegeMultiplier = c.FinalSiegeMultiplier, StaggerKnockbackSpeed = c.StaggerKnockbackSpeed, SeparationMaxSpeed = c.SeparationMaxSpeed, }; @@ -251,10 +223,6 @@ namespace ProjectM.Simulation MeleeFinisherMult = r.MeleeFinisherMult, MeleeComboLength = r.MeleeComboLength, StructureAggroWeight = r.StructureAggroWeight, - CoreDamagePerHusk = r.CoreDamagePerHusk, - CoreRegenIntervalTicks = r.CoreRegenIntervalTicks, - CoreOverrunDrainPct = r.CoreOverrunDrainPct, - FinalSiegeMultiplier = r.FinalSiegeMultiplier, StaggerKnockbackSpeed = r.StaggerKnockbackSpeed, SeparationMaxSpeed = r.SeparationMaxSpeed, }; @@ -283,10 +251,8 @@ namespace ProjectM.Simulation public const byte MeleeFinisherMult = 17; public const byte MeleeComboLength = 18; public const byte StructureAggroWeight = 19; - public const byte CoreDamagePerHusk = 20; - public const byte CoreRegenIntervalTicks = 21; - public const byte CoreOverrunDrainPct = 22; - public const byte FinalSiegeMultiplier = 23; + // RETIRED knob ids (LANTERN purge — keep 20-23 reserved, never renumber): + // 20 = CoreDamagePerHusk · 21 = CoreRegenIntervalTicks · 22 = CoreOverrunDrainPct · 23 = FinalSiegeMultiplier public const byte StaggerKnockbackSpeed = 24; public const byte SeparationMaxSpeed = 25; @@ -322,10 +288,6 @@ namespace ProjectM.Simulation public float MeleeFinisherMult; public float MeleeComboLength; public float StructureAggroWeight; - public float CoreDamagePerHusk; - public float CoreRegenIntervalTicks; - public float CoreOverrunDrainPct; - public float FinalSiegeMultiplier; public float StaggerKnockbackSpeed; public float SeparationMaxSpeed; } diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs b/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs index 2584a706d..447ee9693 100644 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs +++ b/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs @@ -76,24 +76,7 @@ namespace ProjectM.Simulation /// END-1 soft-loss penalty: remove a FRACTION (0..1) of EVERY row, floored per row, dropping any /// row that hits zero. Pure/deterministic (no RNG, no wall-clock), Burst-safe; iterates back-to-front so a /// dropped row never skips its successor. No-op for fraction <= 0. - public static void DrainFraction(DynamicBuffer buffer, float fraction) - { - fraction = math.clamp(fraction, 0f, 1f); - if (fraction <= 0f) - return; - for (int i = buffer.Length - 1; i >= 0; i--) - { - var entry = buffer[i]; - int drop = (int)math.floor(entry.Count * fraction); - if (drop <= 0) - continue; - entry.Count -= drop; - if (entry.Count <= 0) - buffer.RemoveAt(i); - else - buffer[i] = entry; - } - } + } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs index a4547be14..833ce916f 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveApply.cs @@ -16,6 +16,9 @@ namespace ProjectM.Simulation dest.Add(new StorageEntry { ItemId = src[i].ItemId, Count = src[i].Count }); } + /// EB-1: map a serialized to the staged + /// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the + /// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP. /// EB-1: map a serialized to the staged /// (the menu->ServerWorld copy in WorldLauncher). Pure so the field-for-field copy — including the /// easy-to-miss HP — is unit-tested; an omitted field here silently restores every structure at full HP. @@ -24,10 +27,6 @@ namespace ProjectM.Simulation Type = s.Type, CellX = s.CellX, CellZ = s.CellZ, - Direction = s.Direction, - RemainingTicks = s.RemainingTicks, - ConveyorResId = s.ConveyorResId, - ConveyorCount = s.ConveyorCount, HP = s.HP, }; } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs index 8ebcf9e7a..207daa2a7 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -5,23 +5,13 @@ namespace ProjectM.Simulation /// /// Server-world, UNMANAGED bridge holding a save slice the menu staged for a "Continue" session, applied /// AT SPAWN by the server CycleDirectorSpawnSystem so the director ghost is BORN correct — it never - /// serializes a default / empty ledger to clients (no replication flicker). The - /// menu creates exactly one of these (with the buffer) in the freshly - /// created ServerWorld BEFORE the gameplay subscene streams in; the spawn system consumes + destroys it. - /// Unmanaged so the Bursted spawn system reads it without a managed bridge. + /// serializes an empty ledger to clients (no replication flicker). The menu creates exactly one of these + /// (with the buffer) in the freshly created ServerWorld BEFORE the + /// gameplay subscene streams in; the spawn system consumes + destroys it. Unmanaged so the Bursted spawn + /// system reads it without a managed bridge. /// public struct PendingSave : IComponentData { - public int GoalCharge; - public int GoalTarget; - - /// END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max). - public int CoreCurrent; - - /// END-2: terminal run outcome to restore (0 = InProgress / pre-v5 save / New Game; 1 = Victory, - /// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn. - public byte RunOutcome; - /// v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror. public int RunsCompleted; public int MaxDepthReached; @@ -40,14 +30,15 @@ namespace ProjectM.Simulation /// One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's /// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at /// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system - /// GetBuffers it inside the HasData block and a missing buffer would throw on any v<=5 Continue. + /// GetBuffers it inside the HasData block and a missing buffer would throw on any Continue. public struct PendingMetaRow : IBufferElementData { public byte ClassId; public byte UpgradeId; public byte Tier; } - /// One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it + + /// One staged player-built structure row for a Continue session; BaseRestoreSystem replays it /// charge-free into the freshly-streamed base. Mirrors but as an unmanaged ECS /// buffer element (staged in the ServerWorld before the subscene streams). public struct PendingStructure : IBufferElementData @@ -55,28 +46,13 @@ namespace ProjectM.Simulation public byte Type; public int CellX; public int CellZ; - public byte Direction; - public uint RemainingTicks; - public byte ConveyorResId; - public int ConveyorCount; public float HP; // EB-1: staged hit points (BaseRestoreSystem restores 0 -> baked Max) } - /// One staged machine I/O row (M7), joined to the buffer by index. - /// Slot 0 = MachineInput, 1 = MachineOutput. - public struct PendingStructureIo : IBufferElementData - { - public int StructureIndex; - public byte Slot; - public byte ResourceId; - public int Count; - } - - /// - /// Host-only autosave request flag on the CycleDirector entity (added at spawn). The Bursted CyclePhaseSystem - /// sets =1 on the Siege->Calm checkpoint; the managed SaveWriteSystem reads it, writes - /// the JSON save, and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop). + /// Host-only autosave request flag on the director entity (added at spawn). RunDirectorSystem sets + /// =1 on the terminal bank; the managed SaveWriteSystem reads it, writes the JSON save, + /// and clears it. A plain byte => Burst-safe (no managed/string/file touch in the sim loop). /// public struct SaveRequest : IComponentData { diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs index 12c8f28ef..51fd88bfc 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -21,67 +21,38 @@ namespace ProjectM.Simulation public byte Tier; } - /// - /// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production - /// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a - /// fresh session; the in-flight conveyor item (if any) rides here, while variable-length machine I/O buffers - /// live in the flat table keyed by index. - /// + /// One serialized player-built structure. Flat scalars (JsonUtility has no int2). [Serializable] public struct StructureSave { public byte Type; public int CellX; public int CellZ; - public byte Direction; // conveyor facing (0 for non-conveyors) - public uint RemainingTicks; // production/cooldown ticks left at save time - public byte ConveyorResId; // in-flight conveyor item resource (0 = none) - public int ConveyorCount; - public float HP; // EB-1: hit points at save time (0 from a pre-v3 save -> restored to baked Max) + public float HP; // EB-1: hit points at save time (0 -> restored to baked Max) } /// - /// One serialized machine I/O buffer row, joined to by - /// . A flat top-level array (JsonUtility can't nest arrays-of-arrays); Slot 0 = - /// MachineInput, Slot 1 = MachineOutput. - /// - [Serializable] - public struct StructureIoRow - { - public int StructureIndex; - public byte Slot; - public byte ResourceId; - public int Count; - } - - /// - /// Versioned, host-authoritative save slice (the FOUNDATION): the long-arc goal charge/target + the shared - /// resource ledger. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array). - /// The schema is intentionally ADDITIVE: future fields (placed structures, threat, storage) append without - /// breaking old saves, gated by migration. + /// Versioned, host-authoritative save slice: the shared resource ledger + player-built structures + the + /// permanent meta. JsonUtility-friendly — a class with flat fields and an array FIELD (never a root array). + /// The schema is ADDITIVE going forward, gated by migration. /// [Serializable] public class SaveData { - public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcome; v4 added CoreCurrent + public const int CurrentVersion = 7; // v7: LANTERN fresh epoch — goal/core/outcome + conveyor/machine-I/O fields dropped - /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. - public const int MinLoadableVersion = 2; + /// Oldest save schema the loader accepts. v7 is a FRESH EPOCH (operator-approved): older saves are ignored. + public const int MinLoadableVersion = 7; public int Version = CurrentVersion; - public int GoalCharge; - public int GoalTarget; - public int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max) - public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished - // v6 — permanent meta-progression (0/empty-defaults on any v<=5 save): + // Permanent meta-progression: public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore) public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned) public MetaUpgradeSave[] MetaUpgrades = Array.Empty(); // sparse per-class tiers public LedgerRow[] Ledger = Array.Empty(); public StructureSave[] Structures = Array.Empty(); - public StructureIoRow[] StructureIo = Array.Empty(); public long SavedAtMs; } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs index 8c6141050..9970ef25d 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveService.cs @@ -44,13 +44,7 @@ namespace ProjectM.Simulation /// placed structures survive untouched. Without this, Continue/PLAY AGAIN after a win re-latches the dead /// outcome banner on the first replicated snapshot. Pure + idempotent; no-op for in-progress saves. /// - public static void RollTerminalCampaignForward(SaveData data) - { - if (data == null || data.RunOutcome == 0) return; - data.RunOutcome = 0; - data.GoalCharge = 0; - data.CoreCurrent = 0; - } + public static void Save(SaveData data) diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs index 9e227c940..7dee2a64c 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveStructureScan.cs @@ -13,7 +13,7 @@ namespace ProjectM.Simulation /// public static class SaveStructureScan { - public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures, out StructureIoRow[] io) + public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures) { var structs = new List(); @@ -38,7 +38,6 @@ namespace ProjectM.Simulation } structures = structs.ToArray(); - io = System.Array.Empty(); // machine I/O retired with the automation chain (row type dies at save v7) } } } diff --git a/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs b/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs deleted file mode 100644 index 1d3b40312..000000000 --- a/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// END-1 — the losable Engine Core. An aggregate base-integrity meter that rides the GLOBAL CycleDirector - /// ghost (the untagged ghost already carrying //the resource - /// ledger), so it is visible to every player regardless of region with NO new ghost and NO relevancy work — it - /// must NEVER be region-tagged (the shared-global-state rule; SetIsIrrelevant would hide it cross-region). - /// - /// A Husk that breaches to the Core radius drains and despawns (server-only - /// CoreDamageSystem); in Calm the Core regenerates toward (CoreRestoreSystem) so a - /// chipped-but-survived base reads as "we got hurt but we're okay." When reaches 0 during a - /// Siege the SOFT-loss edge fires once in CyclePhaseSystem (the sole Phase writer): the siege ends, the - /// shared ledger is drained, the base persists wounded (no rollback — the locked DR-029 fork). is - /// baked from CycleDirectorAuthoring; is born-correct at spawn (full, or the persisted - /// wounded value from a Continue save). - /// - /// - public struct CoreIntegrity : IComponentData - { - /// Current integrity (0 = breached/overrun). Server-authoritative; replicated for the HUD bar. - [GhostField] public int Current; - - /// Integrity ceiling (baked from authoring; not persisted — a restored Core caps at the baked Max). - [GhostField] public int Max; - - /// Server tick of the most recent overrun breach (0 = never). A TRANSIENT pulse the client HUD - /// edge-detects to flash a "BASE OVERRUN" banner — a SOFT loss is non-terminal ("keep playing"), so this is - /// the right shape, not a latching run-outcome (the terminal Victory latch is END-2's job). - [GhostField] public uint OverrunTick; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta b/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta deleted file mode 100644 index 7f3411124..000000000 --- a/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: a933c59d7c550d844b615e3672b333f6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs index 4f5813b7c..85afab368 100644 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs +++ b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs @@ -3,75 +3,15 @@ using Unity.NetCode; namespace ProjectM.Simulation { - /// - /// Macro-loop state for "The Aether Cycle": which phase the run is in, the cycle number, and the server - /// tick the current (timed) phase ends. Server-authoritative, maintained by CyclePhaseSystem. Currently a - /// server-side singleton; the [GhostField]s below are inert until it is moved onto the runtime-spawned - /// CycleDirector ghost (when the client HUD is wired), at which point the same struct replicates unchanged. - /// The Defend phase is NOT timed — it ends when the base-defense wave is cleared — so PhaseEndTick is only - /// meaningful in Expedition/Build (0 during Defend). - /// - public struct CycleState : IComponentData - { - /// Current phase (see ). - [GhostField] public byte Phase; + // NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED. + // ExpeditionObjective below is the surviving replicated room-objective readout (live consumers: + // RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it). - /// 1-based cycle counter (increments when a new Expedition begins). - [GhostField] public int CycleNumber; - - /// Server tick the current timed phase ends (Expedition/Build only; 0 in Defend). - [GhostField] public uint PhaseEndTick; - - /// Live Husk wave number during Defend, synced from the server-only WaveState by CyclePhaseSystem so the replicated-state-only HUD can show it (holds the last wave number outside Defend; the HUD gates the display to the Defend phase). - [GhostField] public int WaveNumber; - } - - /// Phase constants for — the GLOBAL shared posture (a byte, not an enum, for trivial Burst/serialization). Being out on an expedition is per-player presence (server-only RegionTag), NOT a global phase. - public static class CyclePhase - { - // Re-meaned IN PLACE — the byte VALUES are unchanged from the old Expedition/Defend/Build, so the - // [GhostField] serializer layout is identical (the const re-mean alone forces no re-bake). slot 0 (was - // Expedition) -> Calm; slot 1 (was Defend) -> Siege; slot 2 (was Build) -> retired. - - /// The persistent, unhurried home base — the DEFAULT posture. No countdown; build/prep at your pace. - public const byte Calm = 0; - - /// The base is under assault by a Husk wave (event-triggered; ends when the wave is cleared). - public const byte Siege = 1; - - } - - /// - /// Server-only bookkeeping for the run-state machine that must NOT replicate (kept separate from the - /// replicated ). Records the wave number captured when the current Siege began plus - /// the procedural-expedition-field session epoch (bumped when the expedition region goes empty->occupied so - /// the field reseeds per sortie). - /// - public struct CycleRuntime : IComponentData - { - /// WaveState.WaveNumber captured the moment the current Siege started (DefendCleared tests > this). - public int DefendStartWave; - - /// Monotonic expedition-field session counter; bumped on the expedition region's empty->occupied edge so each sortie reseeds. RNG seed (never tick math; never 0, via max(1, ...)). - public int ExpeditionEpoch; - - /// The the field was last seeded for (compared by int equality). - public int LastSpawnedEpoch; - - /// Previous-tick expedition occupancy (1 = at least one player out), for the empty<->occupied edge. - public byte PrevExpeditionOccupied; - - /// The a zone-clear Ore reward was last banked for — gates the once-per-epoch reward so two same-tick co-op returners pay once and gate re-entry can't farm (int equality, never tick math). - public int LastRewardedEpoch; - - /// 1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear. - public byte ClearedThisEpoch; - } /// /// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an - /// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost - /// (alongside / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it + /// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so + /// GhostRelevancy.SetIsIrrelevant never hides it /// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy /// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns /// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]). diff --git a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs b/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs deleted file mode 100644 index b7a435723..000000000 --- a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in - /// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless - /// of region. Sole PRODUCTION writer (DR-042): ExpeditionGateSystem increments by - /// one per cleared EXPEDITION (on the player's return). GoalReachedSystem only READS the Charge==Target - /// edge to arm the climactic final siege. (DebugCommandReceiveSystem is a manual dev-op writer.) The HUD - /// observes it for a progress bar. - /// - public struct GoalProgress : IComponentData - { - /// Accumulated progress. - [GhostField] public int Charge; - - /// Charge required to reach the goal. - [GhostField] public int Target; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs.meta b/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs.meta deleted file mode 100644 index 26c98439b..000000000 --- a/Assets/_Project/Scripts/Simulation/World/GoalProgress.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: e1f60b3396850074ca0e44b831b5980c \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs b/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs deleted file mode 100644 index 36995b2da..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Simulation -{ - /// - /// END-2 — server-only marker of which run-phase the macro loop is in. Lives on the GLOBAL CycleDirector - /// entity beside //; NOT replicated - /// (the client never needs to distinguish "the final siege is armed" — the larger wave + telegraph already read - /// as escalation; the client shows the terminal banner from the replicated instead). - /// SINGLE writer: GoalReachedSystem flips -> - /// exactly once when reaches Target. - /// Added at spawn by CycleDirectorSpawnSystem (like CycleRuntime/ThreatState), so it is server-world-only - /// and never on the ghost serializer (no re-hash). A byte (never an enum) so a Bursted reader can't trip - /// the cross-assembly-enum Burst ICE. - /// - public struct RunPhase : IComponentData - { - public byte Value; - } - - /// Phase constants for (bytes — never an enum on a Bursted path). - public static class RunPhaseId - { - /// Normal play: scheduled / post-expedition sieges arm; the goal meter climbs +1 per survived siege. - public const byte Normal = 0; - - /// The goal cap was reached: the larger FINAL siege is armed/running. No further sieges arm. - public const byte FinalDefense = 1; - } - - /// - /// END-2 — the terminal run outcome: the LATCHING win/lose state, REPLICATED so the client HUD shows the - /// victory/loss banner by simply observing it (no fragile client-side reconstruction). Rides the GLOBAL - /// untagged CycleDirector ghost (relevant to every connection in every region — it must NEVER be region-tagged; - /// the shared-global-state rule), one [GhostField] byte alongside / - /// . SINGLE writer: CyclePhaseSystem latches - /// (final siege cleared) or (Core breached during the final siege). Once it is - /// non- the run HALTS (GoalReachedSystem + ThreatDirectorSystem stop arming; - /// CoreRestoreSystem stops regen). Baked onto the prefab so it is part of the ghost (adding this [GhostField] - /// re-hashes the CycleDirector ghost -> one re-bake); born-correct at spawn (InProgress for a New Game, or the - /// persisted value on Continue — SaveData v5). - /// - public struct RunOutcome : IComponentData - { - [GhostField] public byte Value; - } - - /// Outcome constants for (bytes — never an enum on a Bursted/serialized path). - public static class RunOutcomeId - { - /// The run is live (no terminal result yet). - public const byte InProgress = 0; - - /// The final siege was survived — the Engine holds. Terminal; the run halts. - public const byte Victory = 1; - - /// The Core was breached during the final siege — overrun. Terminal; the run halts. - public const byte Loss = 2; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs.meta b/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs.meta deleted file mode 100644 index 7dab0c3b7..000000000 --- a/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8ce481dc9a135834fa6d59882895b0f5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs b/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs deleted file mode 100644 index 70dea5794..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Unity.Entities; - -namespace ProjectM.Simulation -{ - /// - /// Baked, server-only tuning for the composite ThreatDirector (the data-driven base-attack scheduler). Lives - /// on the global CycleDirector entity but is NOT a [GhostField] — the server alone decides when a siege - /// fires; clients learn about it only through the replicated flip to Siege. - /// Flat scalar fields (never an enum/array — dodges the MCP authoring-drop gotcha + stays Burst-trivial). This - /// slice wires only the POST-EXPEDITION source; the Heat/Schedule fields are baked-but-inert so those sources - /// drop in later additively with NO re-bake (server-only layout, not a ghost serializer change). - /// - public struct ThreatConfig : IComponentData - { - // ---- Post-expedition retaliation (the only source wired this slice) ---- - - /// 1 = a completed expedition (a player returning to base) can draw a retaliation siege. - public byte PostExpeditionEnabled; - - /// Telegraph/arming delay (server ticks) between the trigger and the siege actually spawning. - public uint PostExpeditionDelayTicks; - - /// Siege size floor (Husk count) for a post-expedition retaliation. - public int SizeBase; - - /// Extra Husks per unit of resources hauled back this run (0 = a flat siege). - public int SizePerExpeditionResource; - - /// How a pending siege starts (see ). - public byte StartCondition; - - /// Max server ticks a Siege may run before it auto-collapses (remaining Husks culled) so an unattended/empty-base siege can never soft-lock. 0 = no cap. - public uint SiegeTimeoutTicks; - - // ---- Reserved, present-but-inert this slice (additive later, no re-bake) ---- - - public byte HeatEnabled; - public float HeatPerTickAtBase; - public float HeatPerHarvest; - public float HeatThreshold; - public byte ScheduleEnabled; - public uint ScheduleIntervalTicks; - /// Extra Husks per surviving wave for a SCHEDULED base siege (size = SizeBase + this*WaveNumber). 0 = flat SizeBase. - public int ScheduleSizePerWave; - - } - - /// Start-condition constants for (bytes — never an enum, never in an RPC). - public static class ThreatStartCondition - { - /// DEFAULT: arm via the telegraph countdown () then fire — even at an empty base. - public const byte Immediate = 0; - - /// Hold the pending siege until ≥1 player is in the base region OR the arm tick + a grace window elapses (bounded — never a soft-lock). - public const byte RequirePlayerAtBase = 1; - } - - /// - /// Server-only runtime state of the ThreatDirector, on the global CycleDirector entity beside - /// . NOT replicated. is the single documented entry - /// point: any source (post-expedition, dev tools, later Heat/Schedule) sets it; CyclePhaseSystem - /// consumes it on the Calm→Siege edge and zeroes it. All stored ticks are wrap-safe (TickUtil.NonZero + - /// NetworkTick compares), never raw uint. - /// - public struct ThreatState : IComponentData - { - /// Husk count of the armed siege; 0 = none pending. Consumed (zeroed) by CyclePhaseSystem at Siege entry. - public int PendingSiegeSize; - - /// Server tick the pending siege fires (telegraph). 0 = fire as soon as seen. Routed through TickUtil.NonZero. - public uint ArmTick; - - /// Server tick the current Siege began (0 = not under siege). The bounded-resolution timeout measures from here (TickUtil.NonZero) so an unattended/empty-base siege can never soft-lock. - public uint SiegeStartTick; - - /// Count of expeditions completed (a player returned to base). Drives the post-expedition source + stats. - public int ExpeditionsCompleted; - - /// Return events the gate has signalled but the director has not yet consumed (the gate teleports the player out of its radius, so one increment per return — natural de-dup). - public int PendingReturns; - - /// Accumulated heat (inert this slice; the Heat source reads/writes it later). - public float Heat; - - /// Next scheduled-siege tick (inert this slice; the Schedule source uses it later). TickUtil.NonZero when used. - public uint NextScheduledTick; - } -} diff --git a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs.meta b/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs.meta deleted file mode 100644 index 019c19ce9..000000000 --- a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 2e66b1e7c715ceb418459c9323853271 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs deleted file mode 100644 index 9fb0f4c71..000000000 --- a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs +++ /dev/null @@ -1,181 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.Mathematics; -using Unity.NetCode; -using Unity.Transforms; - -namespace ProjectM.Tests -{ - /// - /// END-1 — plain-Entities EditMode tests for the Engine Core server systems. : - /// a Husk that reaches the base drains integrity (the live - /// default with no singleton) and is consumed; a distant Husk is untouched; at 0 - /// the system idles (the lose-edge owns resolution). : the Core regenerates - /// exactly +1 across one regen interval ONLY in Calm, never mid-Siege, and never past Max. The lose-edge - /// itself is covered in CyclePhaseSystemTests. BaseAnchor is configured so PlotCenter == origin. - /// - public class CoreSystemsTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) - where T : unmanaged, ISystem - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - SetServerTick(world, serverTick); - return (world, group); - } - - static void SetServerTick(World world, uint tick) - { - var em = world.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity(); - em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - static Entity MakeCore(EntityManager em, int current, int max) - { - var e = em.CreateEntity(typeof(CoreIntegrity)); - em.SetComponentData(e, new CoreIntegrity { Current = current, Max = max }); - return e; - } - - // PlotCenter = GridOrigin.xz + GridDims*CellSize*0.5; origin + zero dims => (0,0,0). - static void MakeBaseAnchor(EntityManager em) - { - var e = em.CreateEntity(typeof(BaseAnchor)); - em.SetComponentData(e, new BaseAnchor - { - AnchorPos = float3.zero, - GridOrigin = float3.zero, - CellSize = 1f, - GridDims = int2.zero, - }); - } - - static void MakeHusk(EntityManager em, float3 pos) - { - var e = em.CreateEntity(typeof(EnemyTag), typeof(LocalTransform)); - em.SetComponentData(e, LocalTransform.FromPosition(pos)); - } - - static Entity MakeCycle(EntityManager em, byte phase) - { - var e = em.CreateEntity(typeof(CycleState)); - em.SetComponentData(e, new CycleState { Phase = phase }); - return e; - } - - [Test] - public void CoreDamage_Breaching_Husk_Drains_And_Is_Consumed() - { - var (world, group) = MakeWorld("CoreDamage", serverTick: 100); - using (world) - { - var em = world.EntityManager; - var core = MakeCore(em, current: 100, max: 100); - MakeBaseAnchor(em); - MakeHusk(em, new float3(0, 0, 0)); // at the Core -> breaches - MakeHusk(em, new float3(20, 0, 20)); // far -> safe - - group.Update(); - - Assert.AreEqual(90, em.GetComponentData(core).Current, - "one breaching Husk drains the default 10 integrity."); - using var hq = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(1, hq.CalculateEntityCount(), - "the breaching Husk is consumed; the distant one survives."); - } - } - - [Test] - public void CoreDamage_Idles_When_Already_Breached() - { - var (world, group) = MakeWorld("CoreBreached", serverTick: 100); - using (world) - { - var em = world.EntityManager; - MakeCore(em, current: 0, max: 100); - MakeBaseAnchor(em); - MakeHusk(em, float3.zero); - - group.Update(); - - using var hq = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(1, hq.CalculateEntityCount(), - "at 0 integrity CoreDamageSystem idles (the lose-edge owns resolution)."); - } - } - - [Test] - public void CoreRestore_Regens_Exactly_Once_Per_Interval_In_Calm() - { - var (world, group) = MakeWorld("CoreRegenCalm", serverTick: 100); - using (world) - { - var em = world.EntityManager; - var core = MakeCore(em, current: 50, max: 100); - MakeCycle(em, CyclePhase.Calm); - - // Across one full default interval (18) of consecutive ticks, exactly ONE is on the regen boundary. - const uint interval = 18; - for (uint t = 100; t < 100 + interval; t++) - { - SetServerTick(world, t); - group.Update(); - } - - Assert.AreEqual(51, em.GetComponentData(core).Current, - "Calm regenerates exactly +1 across one regen interval."); - } - } - - [Test] - public void CoreRestore_Does_Not_Regen_During_Siege() - { - var (world, group) = MakeWorld("CoreNoRegenSiege", serverTick: 100); - using (world) - { - var em = world.EntityManager; - var core = MakeCore(em, current: 50, max: 100); - MakeCycle(em, CyclePhase.Siege); - - for (uint t = 100; t < 100 + 18; t++) - { - SetServerTick(world, t); - group.Update(); - } - - Assert.AreEqual(50, em.GetComponentData(core).Current, - "no regen mid-Siege (a chipped Core heals only between sieges)."); - } - } - - [Test] - public void CoreRestore_Never_Exceeds_Max() - { - var (world, group) = MakeWorld("CoreCap", serverTick: 100); - using (world) - { - var em = world.EntityManager; - var core = MakeCore(em, current: 100, max: 100); - MakeCycle(em, CyclePhase.Calm); - - for (uint t = 100; t < 100 + 18; t++) - { - SetServerTick(world, t); - group.Update(); - } - - Assert.AreEqual(100, em.GetComponentData(core).Current, - "regen clamps at Max."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta b/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta deleted file mode 100644 index 0c447a0d0..000000000 --- a/Assets/_Project/Tests/EditMode/CoreSystemsTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 0b316df3c18e66c47b2a29316eeaba0e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs deleted file mode 100644 index 52d393220..000000000 --- a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs +++ /dev/null @@ -1,252 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only — the PLAYER-DRIVEN - /// run-state director (Calm ↔ Siege). A bare world is seeded with a NetworkTime singleton and a cycle entity - /// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase - /// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so - /// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds - /// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm WITHOUT charging the goal (DR-042: expedition clears drive the win); - /// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math. - /// - public class CyclePhaseSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - return (world, group); - } - - static Entity MakeCycle(EntityManager em, byte phase, int defendStartWave) - { - var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); - em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 }); - em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave }); - return e; - } - - static void AddThreat(EntityManager em, Entity cycle, int pendingSiegeSize, uint armTick) - { - em.AddComponentData(cycle, new ThreatState { PendingSiegeSize = pendingSiegeSize, ArmTick = armTick }); - } - - static Entity MakeWaveState(EntityManager em, int waveNumber, byte phase, int remainingToSpawn) - { - var e = em.CreateEntity(typeof(WaveState)); - em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remainingToSpawn }); - return e; - } - - [Test] - public void Calm_Holds_When_No_PendingSiege() - { - var (world, group) = MakeWorld("CalmHolds", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); - AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0); - - group.Update(); - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, - "With no pending siege the base stays Calm — no forced timer."); - } - } - - [Test] - public void PendingSiege_Enters_Siege_And_Seeds_WaveState_Spawning_With_Exact_Size() - { - var (world, group) = MakeWorld("PendingSiege", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); - AddThreat(em, cycle, pendingSiegeSize: 7, armTick: 0); // armTick 0 => fire immediately - var wave = MakeWaveState(em, waveNumber: 5, phase: WavePhase.Lull, remainingToSpawn: 0); - - group.Update(); - - Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(cycle).Phase, - "An armed pending siege enters Siege."); - - var w = em.GetComponentData(wave); - Assert.AreEqual(WavePhase.Spawning, w.Phase, - "WaveState is driven into Spawning (bypassing the Lull escalation recompute)."); - Assert.AreEqual(7, w.RemainingToSpawn, - "RemainingToSpawn is the EXACT director-chosen siege size (not the escalation curve)."); - Assert.AreEqual(6, w.WaveNumber, "WaveNumber advances by one for the siege."); - - Assert.AreEqual(5, em.GetComponentData(cycle).DefendStartWave, - "DefendStartWave captures the pre-bump wave number."); - Assert.AreEqual(0, em.GetComponentData(cycle).PendingSiegeSize, - "The pending siege is consumed (zeroed) so it fires exactly once."); - } - } - - [Test] - public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal() - { - var (world, group) = MakeWorld("SiegeClears", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); - em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 }); - // Wave advanced past the captured start, fully spawned, no Husks alive (none created). - MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0); - - group.Update(); - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, - "A cleared siege returns to Calm."); - Assert.AreEqual(0, em.GetComponentData(cycle).Charge, - "DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed)."); - } - } - - [Test] - public void Coop_Split_Presence_Keeps_Global_Phase_Calm() - { - var (world, group) = MakeWorld("CoopSplit", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Calm, defendStartWave: 0); - AddThreat(em, cycle, pendingSiegeSize: 0, armTick: 0); - - // One player out on expedition, one home — the GLOBAL phase machine must ignore presence. - var pOut = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag)); - em.SetComponentData(pOut, new RegionTag { Region = RegionId.Expedition }); - var pHome = em.CreateEntity(typeof(RegionTag), typeof(PlayerTag)); - em.SetComponentData(pHome, new RegionTag { Region = RegionId.Base }); - - group.Update(); - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, - "Split presence (one out, one home) never drives the single global phase — Expedition is per-player."); - } - } - - [Test] - public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud() - { - var (world, group) = MakeWorld("WaveSync", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); - MakeWaveState(em, waveNumber: 4, phase: WavePhase.Spawning, remainingToSpawn: 2); - - group.Update(); - - Assert.AreEqual(4, em.GetComponentData(cycle).WaveNumber, - "CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber for the replicated-state-only HUD."); - } - } - - [Test] - public void Siege_Overrun_Ends_Siege_Drains_Ledger_Despawns_Husks_No_Goal_Charge() - { - var (world, group) = MakeWorld("SiegeOverrun", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); - em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 }); - em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached - var ledger = em.AddBuffer(cycle); - ledger.Add(new StorageEntry { ItemId = 2, Count = 100 }); - ledger.Add(new StorageEntry { ItemId = 4, Count = 40 }); - MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3); - // two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base) - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - - group.Update(); - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase, - "an overrun ends the siege -> Calm (soft loss)."); - Assert.AreEqual(3, em.GetComponentData(cycle).Charge, - "NO goal charge on a loss (you were overrun, not survived)."); - var l = em.GetBuffer(cycle); - Assert.AreEqual(50, l[0].Count, "ledger row 1 drained 50% (100 -> 50)."); - Assert.AreEqual(20, l[1].Count, "ledger row 2 drained 50% (40 -> 20)."); - Assert.AreNotEqual(0u, em.GetComponentData(cycle).OverrunTick, - "the overrun pulse is stamped for the HUD flash."); - using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(0, huskQ.CalculateEntityCount(), - "remaining husks are despawned (the siege disperses)."); - } - } - - [Test] - public void Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks() - { - // Slice 3 regression: a BASE Core breach must NOT wipe an in-progress EXPEDITION wave (both share - // EnemyTag but live in different regions). A region-blind cull would also spuriously trip the zone - // director's aliveZone==0 clear/reward edge on the player's return. - var (world, group) = MakeWorld("BaseOverrunSparesExpedition", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); - em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 }); - em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached - var ledger = em.AddBuffer(cycle); - ledger.Add(new StorageEntry { ItemId = 2, Count = 100 }); - ledger.Add(new StorageEntry { ItemId = 4, Count = 40 }); - MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // BASE husk (RegionTag defaults to Region 0 = Base) - var exp = em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - em.SetComponentData(exp, new RegionTag { Region = RegionId.Expedition }); // a husk out on the expedition - - group.Update(); - - Assert.IsTrue(em.Exists(exp), "the expedition husk survives a base Core breach."); - using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(1, huskQ.CalculateEntityCount(), - "only the BASE husk is dispersed by the breach; the in-progress expedition wave is untouched."); - Assert.AreEqual(RegionId.Expedition, em.GetComponentData(exp).Region, - "the survivor is the expedition-region husk."); - } - } - - [Test] - public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging() - { - var (world, group) = MakeWorld("OverrunOnce", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); - em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 }); - em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100 }); - em.AddBuffer(cycle); - MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 0); - - group.Update(); - group.Update(); // second tick: Calm branch -> must not re-resolve or charge - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(cycle).Phase); - Assert.AreEqual(0, em.GetComponentData(cycle).Charge, - "the loss never charges the goal across ticks."); - } - } - - } -} diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta deleted file mode 100644 index 9020c32fb..000000000 --- a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: def6f8080b5a28d4eb9ee4781b283752 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/DebugCommandReceiveSystemTests.cs b/Assets/_Project/Tests/EditMode/DebugCommandReceiveSystemTests.cs index 352000ef5..2c549182f 100644 --- a/Assets/_Project/Tests/EditMode/DebugCommandReceiveSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/DebugCommandReceiveSystemTests.cs @@ -57,33 +57,31 @@ namespace ProjectM.Tests } [Test] - public void SpawnWave_Arms_PendingSiege() + public void SpawnWave_Forces_Next_Wave_Now() { var (world, group) = MakeWorld("DebugSpawnWave"); using (world) { var em = world.EntityManager; - var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState)); - em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Calm }); - MakeRequest(em, DebugOp.SpawnWave, 8, 0, Entity.Null); + var wave = em.CreateEntity(typeof(WaveState)); + em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, NextActionTick = 999999, RemainingToSpawn = 0 }); + MakeRequest(em, DebugOp.SpawnWave, 0, 0, Entity.Null); group.Update(); - Assert.AreEqual(8, em.GetComponentData(dir).PendingSiegeSize, - "SpawnWave arms a pending siege of the requested size."); + var w = em.GetComponentData(wave); + Assert.AreEqual(WavePhase.Lull, w.Phase, "SpawnWave resets to a due Lull so WaveSystem starts the next wave."); + Assert.AreEqual(0u, w.NextActionTick, "SpawnWave makes the next wave due immediately."); } } [Test] - public void EndSiege_Forces_WaveState_Lull_And_Clears_Pending() + public void EndSiege_Quiets_The_Arena() { var (world, group) = MakeWorld("DebugEndSiege"); using (world) { var em = world.EntityManager; - var dir = em.CreateEntity(typeof(CycleState), typeof(ThreatState)); - em.SetComponentData(dir, new CycleState { Phase = CyclePhase.Siege }); - em.SetComponentData(dir, new ThreatState { PendingSiegeSize = 5, SiegeStartTick = 100 }); var wave = em.CreateEntity(typeof(WaveState)); em.SetComponentData(wave, new WaveState { Phase = WavePhase.Spawning, RemainingToSpawn = 3 }); for (int i = 0; i < 2; i++) @@ -96,9 +94,9 @@ namespace ProjectM.Tests var w = em.GetComponentData(wave); Assert.AreEqual(WavePhase.Lull, w.Phase, "EndSiege drives the wave to Lull."); Assert.AreEqual(0, w.RemainingToSpawn, "EndSiege stops further spawning."); - Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "EndSiege clears any pending siege."); - using var husks = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks."); + Assert.AreNotEqual(0u, w.NextActionTick, "EndSiege pushes the next wave far out (quiet arena)."); + using (var husks = em.CreateEntityQuery(typeof(EnemyTag))) + Assert.AreEqual(0, husks.CalculateEntityCount(), "EndSiege culls the remaining Husks."); } } diff --git a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs deleted file mode 100644 index a5c7bb669..000000000 --- a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs +++ /dev/null @@ -1,399 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// END-2 (SL-3) — plain-Entities EditMode tests for the final-siege win/lose spine: - /// arming + 's FinalDefense-gated Victory/Loss latches + the - /// SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a - /// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/ - /// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger - /// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege - /// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1 - /// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft - /// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the - /// final siege so a timeout can't fake a Victory. All timing is wrap-safe NetworkTick math. - /// - public class EndgameWinLoseTests - { - // ---- harness ---- - - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - // CyclePhaseSystem then GoalReachedSystem ([UpdateAfter(CyclePhaseSystem)] is honored by SortSystems). - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - return (world, group); - } - - static (World world, SimulationSystemGroup group) MakeThreatWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - return (world, group); - } - - // SizeBase 5 / ScheduleSizePerWave 1 / immediate (delay 0) arm / no timeout — the END-2 arming math is - // (5 + 1*wave) * FinalSiegeMultiplier. - static ThreatConfig Cfg() => new ThreatConfig - { - PostExpeditionEnabled = 0, - ScheduleEnabled = 0, - PostExpeditionDelayTicks = 0, - SizeBase = 5, - ScheduleSizePerWave = 1, - StartCondition = ThreatStartCondition.Immediate, - SiegeTimeoutTicks = 0, - }; - - static Entity MakeDirector(EntityManager em, byte phase, int defendStartWave, int charge, int target, - int core, byte runPhase, byte runOutcome) - { - var e = em.CreateEntity(); - em.AddComponentData(e, new CycleState { Phase = phase, PhaseEndTick = 0u, CycleNumber = 1 }); - em.AddComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave }); - em.AddComponentData(e, new ThreatState()); - em.AddComponentData(e, Cfg()); - em.AddComponentData(e, new GoalProgress { Charge = charge, Target = target }); - em.AddComponentData(e, new CoreIntegrity { Current = core, Max = 100, OverrunTick = 0u }); - em.AddComponentData(e, new RunPhase { Value = runPhase }); - em.AddComponentData(e, new RunOutcome { Value = runOutcome }); - em.AddComponentData(e, new SaveRequest { Pending = 0 }); - em.AddBuffer(e); - return e; - } - - static Entity MakeWave(EntityManager em, int waveNumber, byte phase, int remaining) - { - var e = em.CreateEntity(typeof(WaveState)); - em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, Phase = phase, RemainingToSpawn = remaining }); - return e; - } - - static int ExpectedFinalSize(int sizeBase, int perWave, int wave) - => (int)((sizeBase + perWave * wave) * TuningConfig.Defaults().FinalSiegeMultiplier); - - // ---- tests ---- - - [Test] - public void GoalReached_Arms_Final_Siege_Then_CyclePhase_Enters_It_Once() - { - var (world, group) = MakeWorld("End2Arm", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); - var wave = MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); - int expected = ExpectedFinalSize(5, 1, 4); // (5 + 4) * 2.5 = 22 - - // Tick 1: CyclePhase Calm (nothing pending) -> GoalReached arms the FINAL siege + flips FinalDefense. - group.Update(); - Assert.AreEqual(expected, em.GetComponentData(dir).PendingSiegeSize, - "final siege armed at (SizeBase + perWave*wave) * FinalSiegeMultiplier (visibly bigger than a normal siege)."); - Assert.Greater(expected, 5 + 1 * 4, "the final siege is strictly larger than the would-be normal siege."); - Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData(dir).Value, - "RunPhase flips to FinalDefense exactly when the goal cap is reached."); - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, - "still Calm on the arm tick (CyclePhase consumes the pending siege the next tick)."); - - // Tick 2: CyclePhase Calm consumes the armed siege -> Siege; GoalReached no-ops (RunPhase != Normal). - group.Update(); - Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(dir).Phase, - "the final siege starts."); - Assert.AreEqual(expected, em.GetComponentData(wave).RemainingToSpawn, - "WaveState is seeded with the EXACT multiplied final-siege size."); - Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, - "the final siege is consumed exactly once (no re-arm by GoalReached while in FinalDefense)."); - } - } - - [Test] - public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final() - { - var (world, group) = MakeWorld("End2Clamp", serverTick: 200); - using (world) - { - var em = world.EntityManager; - // DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final. - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); - MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared - - group.Update(); - - Assert.AreEqual(3, em.GetComponentData(dir).Charge, - "a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress)."); - Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData(dir).Value, - "the final siege is NOT armed by a survived siege near the cap."); - Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, - "nothing is armed (the cap is only crossed by an expedition clear)."); - } - } - - [Test] - public void Victory_Latches_Once_On_Final_DefendCleared() - { - var (world, group) = MakeWorld("End2Victory", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, - core: 100, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); - MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // cleared, no husks alive - - group.Update(); - - Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, - "surviving the final siege latches Victory."); - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the run ends in Calm."); - Assert.AreEqual(4, em.GetComponentData(dir).Charge, - "a Victory does NOT increment the already-capped goal."); - - // A second tick must not change the latched outcome (GoalReached + the branch are inert once decided). - group.Update(); - Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, - "Victory is latched (stable across ticks)."); - } - } - - [Test] - public void Loss_Latches_On_Final_Core_Breach_Without_Soft_Side_Effects() - { - var (world, group) = MakeWorld("End2Loss", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, - core: 0, RunPhaseId.FinalDefense, RunOutcomeId.InProgress); // Core breached during the final siege - var ledger = em.GetBuffer(dir); - ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); - ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); - MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - - group.Update(); - - Assert.AreEqual(RunOutcomeId.Loss, em.GetComponentData(dir).Value, - "a Core breach during the FINAL siege latches a terminal Loss."); - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the run ends."); - var l = em.GetBuffer(dir); - Assert.AreEqual(100, l[0].Count, "terminal Loss does NOT drain the ledger (unlike the soft overrun)."); - Assert.AreEqual(40, l[1].Count, "terminal Loss does NOT drain the ledger."); - Assert.AreEqual(0u, em.GetComponentData(dir).OverrunTick, - "terminal Loss does NOT stamp OverrunTick (the dedicated Loss banner shows instead of the soft flash)."); - using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(0, huskQ.CalculateEntityCount(), "the siege disperses (remaining husks despawned)."); - } - } - - [Test] - public void Normal_Overrun_Stays_Soft_When_RunPhase_Normal() - { - // REGRESSION: END-2 must not change END-1's soft-loss for a NORMAL (non-final) siege overrun. - var (world, group) = MakeWorld("End2NormalSoft", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 10, - core: 0, RunPhaseId.Normal, RunOutcomeId.InProgress); // breached, but NOT the final siege - var ledger = em.GetBuffer(dir); - ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); - ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); - MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); - - group.Update(); - - Assert.AreEqual(CyclePhase.Calm, em.GetComponentData(dir).Phase, "the soft loss ends the siege -> Calm."); - Assert.AreEqual(RunOutcomeId.InProgress, em.GetComponentData(dir).Value, - "a NORMAL overrun must NOT latch a terminal outcome (END-1 soft-loss preserved)."); - var l = em.GetBuffer(dir); - Assert.AreEqual(50, l[0].Count, "the soft loss drains the ledger 50% (END-1 behaviour, unchanged)."); - Assert.AreEqual(20, l[1].Count, "the soft loss drains the ledger 50%."); - Assert.AreNotEqual(0u, em.GetComponentData(dir).OverrunTick, - "the soft loss stamps OverrunTick for the HUD flash (END-1 behaviour, unchanged)."); - Assert.AreEqual(3, em.GetComponentData(dir).Charge, "no goal charge on a loss."); - } - } - - [Test] - public void Restored_Victory_Does_Not_Rearm_Final_Siege() - { - // Born-correct of a finished-run Continue (SaveData v5): RunOutcome=Victory restored; RunPhase boots Normal - // (server-only, not persisted). The RunOutcome guard must keep GoalReached inert so the win is durable. - var (world, group) = MakeWorld("End2Restore", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.Victory); - MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); - - group.Update(); - - Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, - "a restored Victory does NOT re-arm the final siege (Continue loads finished)."); - Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData(dir).Value, "RunPhase stays Normal (no flip)."); - Assert.AreEqual(RunOutcomeId.Victory, em.GetComponentData(dir).Value, "the win persists."); - } - } - - [Test] - public void Final_Siege_Is_Not_Culled_By_SiegeTimeout() - { - // F5: the SiegeTimeout cull must be disabled during the final siege — otherwise a timeout-cull trips - // DefendCleared and fakes a Victory. (The NORMAL-phase timeout cull is covered by ThreatDirectorSystemTests.) - var (world, group) = MakeThreatWorld("End2NoTimeoutCull", serverTick: 1000); - using (world) - { - var em = world.EntityManager; - var e = em.CreateEntity(); - em.AddComponentData(e, new CycleState { Phase = CyclePhase.Siege, CycleNumber = 1 }); - var cfg = Cfg(); - cfg.SiegeTimeoutTicks = 10; // would normally fire: 1000 - 900 = 100 ticks elapsed >> 10 - em.AddComponentData(e, cfg); - em.AddComponentData(e, new ThreatState { SiegeStartTick = 900 }); - em.AddComponentData(e, new RunPhase { Value = RunPhaseId.FinalDefense }); - em.AddComponentData(e, new RunOutcome { Value = RunOutcomeId.InProgress }); - var w = em.CreateEntity(typeof(WaveState)); - em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning }); - for (int i = 0; i < 3; i++) - em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // base husks (RegionTag defaults to Base) - - group.Update(); - - using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(3, huskQ.CalculateEntityCount(), - "the final siege is NOT culled by SiegeTimeout (a cull would fake a Victory)."); - } - } - - // ---- review-driven additions: M-3/N-4 (full-pipeline arming) + M-4 (multiplier) ---- - - static (World world, SimulationSystemGroup group) MakeFullWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - // Sorted by attributes: ThreatDirector [UpdateBefore CyclePhase] -> CyclePhase -> GoalReached [UpdateAfter]. - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - return (world, group); - } - - static void SetServerTick(World world, uint tick) - { - var em = world.EntityManager; - using var q = em.CreateEntityQuery(typeof(NetworkTime)); - em.SetComponentData(q.GetSingletonEntity(), new NetworkTime { ServerTick = new NetworkTick(tick) }); - } - - [Test] - public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler() - { - // M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the - // Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final - // siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave. - var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); - var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100; - em.SetComponentData(dir, cfg); - em.SetComponentData(dir, new ThreatState { NextScheduledTick = 150 }); // a scheduled siege is pending - var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick - int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27 - - // Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm). - group.Update(); - Assert.AreEqual(4, em.GetComponentData(dir).Charge, - "Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042)."); - Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData(dir).Value, - "GoalReached flips FinalDefense the same tick the Charge edge is crossed."); - Assert.AreEqual(expected, em.GetComponentData(dir).PendingSiegeSize, - "the final siege is armed at the multiplied size."); - - // Advance the clock so the scheduled source is DUE, then tick: it must NOT stomp the armed final siege; - // CyclePhase consumes it into the wave at the FINAL size (not a scheduled SizeBase). - SetServerTick(world, 400); - group.Update(); - Assert.AreEqual(CyclePhase.Siege, em.GetComponentData(dir).Phase, "the final siege starts."); - Assert.AreEqual(expected, em.GetComponentData(wave).RemainingToSpawn, - "the FINAL size (not a scheduled SizeBase) is seeded -> the due scheduler did not stomp it."); - Assert.AreEqual(0, em.GetComponentData(dir).PendingSiegeSize, "consumed exactly once."); - } - } - - [Test] - public void FinalSiegeMultiplier_LiveOverride_Scales_Final_Size() - { - var (world, group) = MakeWorld("End2MultOverride", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); - MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); - var tc = em.CreateEntity(typeof(TuningConfig)); - var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 1.5f; - em.SetComponentData(tc, cfg); - - group.Update(); - - int normal = 5 + 1 * 4; // 9 - Assert.AreEqual((int)(normal * 1.5f), em.GetComponentData(dir).PendingSiegeSize, - "the final size scales by the LIVE FinalSiegeMultiplier (1.5x), not the 2.5 default."); - } - } - - [Test] - public void FinalSiegeMultiplier_Below_One_Floors_To_Normal_Size() - { - var (world, group) = MakeWorld("End2MultFloor", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, defendStartWave: 0, charge: 4, target: 4, - core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress); - MakeWave(em, waveNumber: 4, phase: WavePhase.Lull, remaining: 0); - var tc = em.CreateEntity(typeof(TuningConfig)); - var cfg = TuningConfig.Defaults(); cfg.FinalSiegeMultiplier = 0.5f; // degenerate sub-1 - em.SetComponentData(tc, cfg); - - group.Update(); - - int normal = 5 + 1 * 4; // 9 - Assert.AreEqual(normal, em.GetComponentData(dir).PendingSiegeSize, - "a sub-1 multiplier floors at 1x (math.max(1,...)) -> the final siege is never smaller than a normal one."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs.meta b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs.meta deleted file mode 100644 index 25493cc7e..000000000 --- a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 51539ff68b0fdfe4da4b0210ac19afb5 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs deleted file mode 100644 index 3fc903514..000000000 --- a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -using NUnit.Framework; -using ProjectM.Client; -using ProjectM.Simulation; - -namespace ProjectM.Tests -{ - /// - /// Pure-logic coverage for the first-run onboarding step machine () — the - /// testable core of the client-only OnboardingSystem. No World/ECS needed: each case builds a - /// and asserts the deterministic advance rule, the mask helpers, - /// the scheme-aware prompts, and the pointer kinds. - /// - public class OnboardingStepTests - { - static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot(); - - // ---- mask helpers (resume point + dormant detection) ---- - - [Test] - public void FirstIncomplete_EmptyMask_IsWelcome() - => Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0)); - - [Test] - public void FirstIncomplete_SkipsCompletedPrefix() - { - int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build); - Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask)); - } - - [Test] - public void AllComplete_TrueForFullMaskAndMigrationSentinel() - { - Assert.IsFalse(OnboardingStepMath.AllComplete(0)); - int full = (1 << OnboardingStepMath.StepCount) - 1; - Assert.IsTrue(OnboardingStepMath.AllComplete(full)); - Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done - } - - // ---- per-step completion rules ---- - - [Test] - public void Welcome_AdvancesOnTimer() - { - var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s)); - s.StepElapsed = OnboardingStepMath.WelcomeSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s)); - } - - [Test] - public void Move_AdvancesAfterThreshold() - { - var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s)); - s.MoveDistance = OnboardingStepMath.MoveThreshold; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s)); - } - - [Test] - public void ReadyUp_AdvancesOnLocalReadyOrLaunch() - { - var s = Empty(); s.Lifecycle = RunLifecycle.Staging; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s)); - var ready = Empty(); ready.LocalReady = true; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready)); - var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched)); - } - - [Test] - public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase() - { - var s = Empty(); s.TurretCount = 0; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s)); - s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s)); - } - - [Test] - public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout() - { - var none = Empty(); - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none)); - var built = Empty(); built.FabricatorCount = 1; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built)); - var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut)); - } - - [Test] - public void Rooms_AdvancesInRoomAfterMinimumDwell() - { - var s = Empty(); - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s)); - var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in - var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room - var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy - } - - [Test] - public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle() - { - var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s)); - s.ObjectiveState = ExpeditionObjectiveState.Cleared; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s)); - var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward)); - var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route)); - } - - [Test] - public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout() - { - var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition - var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy - var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home - var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge) - } - - [Test] - public void Defend_WaitsForSiegeEndButTimesOutWithoutOne() - { - var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid)); - var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived)); - var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege)); - } - - [Test] - public void Done_LingersThenCompletes() - { - var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f; - Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s)); - s.StepElapsed = OnboardingStepMath.DoneSeconds; - Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s)); - } - - // ---- prompts (scheme-aware, never empty) ---- - - [Test] - public void Prompts_NonEmptyForEveryStep() - { - for (byte i = 0; i < OnboardingStepMath.StepCount; i++) - { - Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i); - Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i); - } - } - - [Test] - public void Prompts_AreSchemeAware() - { - StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false)); - StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false)); - StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true)); - } - - // ---- pointer kinds ---- - - [Test] - public void PointerKinds_OnlyTheRoomsStepPoints() - { - Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms)); - Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp)); - Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return)); - Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move)); - Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend)); - } - } - - /// - /// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check - /// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is - /// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke). - /// - public class OnboardingSettingsTests - { - [Test] - public void Defaults_TutorialOn_MaskEmpty() - { - var d = GameSettings.Defaults(); - Assert.AreEqual(1, d.TutorialHints); - Assert.AreEqual(0, d.OnboardingMask); - Assert.AreEqual(GameSettings.CurrentVersion, d.Version); - } - - [Test] - public void Clamped_NormalizesHints_PreservesMask() - { - var s = GameSettings.Defaults(); - s.TutorialHints = 5; // out of the 0/1 range - s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched - var c = s.Clamped(); - Assert.AreEqual(1, c.TutorialHints); - Assert.AreEqual(0x55, c.OnboardingMask); - } - - [Test] - public void Defaults_ForceEachLaunch_Off() - => Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch); - - [Test] - public void Clamped_NormalizesForceEachLaunchToBool() - { - var s = GameSettings.Defaults(); - s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag - Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch); - s.ForceOnboardingEachLaunch = 0; - Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch); - } - } -} diff --git a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs.meta b/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs.meta deleted file mode 100644 index ff27425cc..000000000 --- a/Assets/_Project/Tests/EditMode/OnboardingStepTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6b7226d166f601d43add545e1532c3e1 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs index 1095939b7..5141e61e8 100644 --- a/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/ReadyCheckSystemTests.cs @@ -200,23 +200,6 @@ namespace ProjectM.Tests } } - [Test] - public void LaunchGuard_BlocksWhenOutcomeLatched() - { - var (world, group, dir) = MakeWorld(); - using (world) - { - var em = world.EntityManager; - MakePlayer(em, 1); - var conn = MakeConnection(em, 1); - em.AddComponentData(dir, new RunOutcome { Value = RunOutcomeId.Victory }); - SendToggle(em, conn, 1); - group.Update(); - - Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle, - "a decided run must not launch (F2 guard)"); - } - } } } diff --git a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs index ca4436b64..f2be0782c 100644 --- a/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs +++ b/Assets/_Project/Tests/EditMode/RunDirectorTraversalTests.cs @@ -35,7 +35,7 @@ namespace ProjectM.Tests map = RunMapMath.Generate(Seed); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), - typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); + typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(SaveRequest)); em.SetComponentData(dir, new RunInfo { Lifecycle = RunLifecycle.InRoom, @@ -52,7 +52,6 @@ namespace ProjectM.Tests ActiveSubSlot = (byte)(currentRoom & 1), RoomsClearedThisRun = currentRoom, // rooms before this one were cleared }); - em.SetComponentData(dir, new GoalProgress { Charge = 0, Target = 4 }); // Mid-run fixture: the launch edge would have stamped the roster tag (RunParticipant) — fabricate it. var player = em.CreateEntity(typeof(PlayerTag), typeof(PlayerReady), typeof(RegionTag), @@ -164,19 +163,14 @@ static int RoomEntities(EntityManager em) var info = em.GetComponentData(dir); Assert.AreEqual(RunLifecycle.Staging, info.Lifecycle); Assert.AreEqual(RegionId.Base, em.GetComponentData(player).Region, "party home"); - Assert.AreEqual(1, em.GetComponentData(dir).Charge, "win meter +1 on a boss clear"); var meta = em.GetComponentData(dir); Assert.AreEqual(1, meta.RunsCompleted, "run completed"); Assert.AreEqual(bossLayer + 1, meta.MaxDepthReached, "honest depth = rooms actually cleared"); - var threat = em.GetComponentData(dir); - Assert.AreEqual(1, threat.PendingReturns, "retaliation input carried (C7)"); - Assert.AreEqual(1, threat.ExpeditionsCompleted); Assert.AreEqual(1, em.GetComponentData(dir).Pending, "save checkpoint requested"); Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated"); group.Update(); group.Update(); - Assert.AreEqual(1, em.GetComponentData(dir).Charge, "no double credit (F7)"); Assert.AreEqual(1, em.GetComponentData(dir).RunsCompleted); } } @@ -195,12 +189,9 @@ static int RoomEntities(EntityManager em) group.Update(); // Returning: depth-only bank -> Staging Assert.AreEqual(RunLifecycle.Staging, em.GetComponentData(dir).Lifecycle); - Assert.AreEqual(0, em.GetComponentData(dir).Charge, "no win credit on an abort (D-F3)"); var meta = em.GetComponentData(dir); Assert.AreEqual(0, meta.RunsCompleted, "no completed-run credit"); Assert.AreEqual(2, meta.MaxDepthReached, "honest depth: the 2 rooms actually cleared, not the plan"); - var threat = em.GetComponentData(dir); - Assert.AreEqual(0, threat.PendingReturns, "no retaliation provoked by an abort"); Assert.AreEqual(0, em.GetComponentData(dir).Pending, "no save spam on abort"); } } diff --git a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs index f357ac833..bc8af82ea 100644 --- a/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs +++ b/Assets/_Project/Tests/EditMode/RunRosterRegressionTests.cs @@ -34,7 +34,7 @@ namespace ProjectM.Tests map = RunMapMath.Generate(Seed); var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective), - typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest)); + typeof(RouteCommand), typeof(MetaCounters), typeof(SaveRequest)); return (world, group, dir); } diff --git a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs index 37e572752..369620ae8 100644 --- a/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs +++ b/Assets/_Project/Tests/EditMode/SavePersistenceTests.cs @@ -6,9 +6,10 @@ using UnityEngine; namespace ProjectM.Tests { /// - /// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe, - /// and the born-correct ledger apply () the server spawn system uses to - /// overwrite a director's StorageEntry buffer from a staged PendingSave. + /// Pure tests for the save FOUNDATION: the JSON schema round-trips (JsonUtility), version handling is safe + /// (v7 is a FRESH EPOCH — older saves are rejected at MinLoadableVersion), and the born-correct ledger apply + /// () the server spawn system uses to overwrite a director's StorageEntry + /// buffer from a staged PendingSave. /// public class SavePersistenceTests { @@ -17,8 +18,8 @@ namespace ProjectM.Tests { var data = new SaveData { - GoalCharge = 42, - GoalTarget = 10, + RunsCompleted = 7, + MaxDepthReached = 9, Ledger = new[] { new LedgerRow { ItemId = 1, Count = 5 }, @@ -31,8 +32,8 @@ namespace ProjectM.Tests var back = JsonUtility.FromJson(json); Assert.AreEqual(SaveData.CurrentVersion, back.Version); - Assert.AreEqual(42, back.GoalCharge); - Assert.AreEqual(10, back.GoalTarget); + Assert.AreEqual(7, back.RunsCompleted); + Assert.AreEqual(9, back.MaxDepthReached); Assert.AreEqual(2, back.Ledger.Length); Assert.AreEqual(1, back.Ledger[0].ItemId); Assert.AreEqual(5, back.Ledger[0].Count); @@ -46,20 +47,27 @@ namespace ProjectM.Tests { Assert.DoesNotThrow(() => JsonUtility.FromJson("{}")); - var empty = new SaveData { GoalCharge = 0, GoalTarget = 10 }; + var empty = new SaveData(); var back = JsonUtility.FromJson(JsonUtility.ToJson(empty)); Assert.IsNotNull(back.Ledger); Assert.AreEqual(0, back.Ledger.Length); } [Test] - public void SaveData_OldVersion_IsDetectable() + public void SaveData_Is_At_V7_Fresh_Epoch() { - // A stale-version blob round-trips with its Version intact, so SaveService.Load rejects it (-> New Game). - var old = new SaveData { Version = 0, GoalCharge = 7 }; - var back = JsonUtility.FromJson(JsonUtility.ToJson(old)); - Assert.AreEqual(0, back.Version); - Assert.AreNotEqual(SaveData.CurrentVersion, back.Version); + Assert.AreEqual(7, SaveData.CurrentVersion, "SaveData is at v7 (the LANTERN fresh epoch)."); + Assert.AreEqual(7, SaveData.MinLoadableVersion, "v7 is a fresh epoch: no older save loads."); + } + + [Test] + public void Pre_V7_Save_Is_Below_The_Load_Floor() + { + // A v6 (co-op-Hades era) save round-trips with its Version intact and sits BELOW MinLoadableVersion, + // so SaveService.Load rejects it (-> New Game). Fresh epoch, operator-approved (LANTERN purge). + var back = JsonUtility.FromJson("{\"Version\":6,\"RunsCompleted\":3}"); + Assert.AreEqual(6, back.Version); + Assert.Less(back.Version, SaveData.MinLoadableVersion, "a v6 save is rejected under the v7 fresh epoch."); } [Test] @@ -103,99 +111,29 @@ namespace ProjectM.Tests Assert.AreEqual(0, em.GetBuffer(e).Length); } + [Test] - public void StructureSave_HP_RoundTrips_And_Writes_V3() + public void StructureSave_HP_RoundTrips() { var data = new SaveData { Structures = new[] { new StructureSave { Type = 1, CellX = 1, CellZ = 2, HP = 37f } } }; var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); - Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2; v4 added Core, v3 HP)."); + Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version."); Assert.AreEqual(1, back.Structures.Length); Assert.AreEqual(37f, back.Structures[0].HP, 1e-4f, "the wounded HP round-trips through JSON."); } - [Test] - public void V2_Save_IsWithinLoadableRange_And_ZeroHp_Restores_Full() - { - // A pre-EB-1 v2 save sits inside the additive load floor [Min,Current], so SaveService.Load accepts it; - // an unset HP (0) is mapped by BaseRestoreSystem to the baked Max (structures come back at full HP). - var v2 = new SaveData { Version = 2, GoalCharge = 3, GoalTarget = 10, Structures = new[] { new StructureSave { Type = 1, CellX = 2, CellZ = 4 } } }; - var back = JsonUtility.FromJson(JsonUtility.ToJson(v2)); - Assert.AreEqual(2, back.Version); - Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v2 is at/above the load floor."); - Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); - Assert.AreEqual(0f, back.Structures[0].HP, 1e-4f, "unset HP (0) -> restore maps to baked Max."); - } - [Test] public void ToPending_Maps_All_Fields_Including_The_Wounded_HP() { // The WorldLauncher save->stage copy: omitting any field here silently restores at full HP (review-caught). - var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, Direction = 2, RemainingTicks = 50, ConveyorResId = 1, ConveyorCount = 4, HP = 37f }; + var s = new StructureSave { Type = 1, CellX = 3, CellZ = -2, HP = 37f }; var p = SaveApply.ToPending(s); Assert.AreEqual(1, p.Type); Assert.AreEqual(3, p.CellX); Assert.AreEqual(-2, p.CellZ); - Assert.AreEqual(2, p.Direction); - Assert.AreEqual(50u, p.RemainingTicks); - Assert.AreEqual(1, p.ConveyorResId); - Assert.AreEqual(4, p.ConveyorCount); Assert.AreEqual(37f, p.HP, 1e-4f, "the wounded HP survives the save->staging copy."); } - [Test] - public void CoreCurrent_RoundTrips_And_Writes_Current_Version() - { - var data = new SaveData { GoalCharge = 1, GoalTarget = 10, CoreCurrent = 63 }; - var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); - Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version (v5 since END-2)."); - Assert.AreEqual(63, back.CoreCurrent, "the wounded Core integrity round-trips through JSON."); - } - - [Test] - public void Pre_END1_Save_Missing_CoreCurrent_Defaults_To_Zero() - { - // A pre-END-1 save JSON lacks the CoreCurrent field -> JsonUtility defaults it to 0, which the - // born-correct spawn maps to the baked Max (the Core comes back full). Additive: no field, no break. - var back = JsonUtility.FromJson("{\"Version\":3,\"GoalCharge\":2,\"GoalTarget\":10}"); - Assert.AreEqual(0, back.CoreCurrent, "missing CoreCurrent -> 0 -> restored full at baked Max."); - Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v3 stays within the additive load floor."); - Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); - } - - [Test] - public void RunOutcome_RoundTrips_And_Writes_Current_Version() - { - var data = new SaveData { GoalCharge = 1, GoalTarget = 4, RunOutcome = RunOutcomeId.Victory }; - var back = JsonUtility.FromJson(JsonUtility.ToJson(data)); - Assert.AreEqual(SaveData.CurrentVersion, back.Version, "new saves write the current version."); - Assert.AreEqual(6, SaveData.CurrentVersion, "SaveData is at v6 (permanent meta: tier rows + run counters)."); - Assert.AreEqual((int)RunOutcomeId.Victory, back.RunOutcome, "the latched terminal outcome round-trips through JSON."); - } - - [Test] - public void Pre_END2_Save_Missing_RunOutcome_Defaults_To_InProgress() - { - // A pre-END-2 (v4) save JSON lacks RunOutcome -> JsonUtility defaults it to 0 (InProgress) -> the run loads - // as in-progress, NOT a finished run. Additive: no field, no break; v4 stays within the load floor. - var back = JsonUtility.FromJson("{\"Version\":4,\"GoalCharge\":2,\"GoalTarget\":10,\"CoreCurrent\":50}"); - Assert.AreEqual((int)RunOutcomeId.InProgress, back.RunOutcome, "missing RunOutcome -> 0 (InProgress)."); - Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v4 stays within the additive load floor."); - Assert.LessOrEqual(back.Version, SaveData.CurrentVersion); - } - - [Test] - public void Pre_v6_Save_Missing_Meta_Defaults_To_Empty() - { - // A v5 save JSON lacks MetaUpgrades/RunsCompleted/MaxDepthReached -> the field initializer keeps the - // array EMPTY (never null) and the counters 0-default. Additive: no field, no break; v5 loads. - var back = JsonUtility.FromJson("{\"Version\":5,\"GoalCharge\":3,\"GoalTarget\":4}"); - Assert.IsNotNull(back.MetaUpgrades, "missing MetaUpgrades -> empty array, never null."); - Assert.AreEqual(0, back.MetaUpgrades.Length); - Assert.AreEqual(0, back.RunsCompleted, "missing RunsCompleted -> 0 (StagePendingSave floors it to GoalCharge)."); - Assert.AreEqual(0, back.MaxDepthReached); - Assert.GreaterOrEqual(back.Version, SaveData.MinLoadableVersion, "v5 stays within the additive load floor."); - } - [Test] public void MetaUpgrades_And_Counters_RoundTrip() { @@ -218,6 +156,5 @@ namespace ProjectM.Tests Assert.AreEqual(3, back.MetaUpgrades[0].Tier); Assert.AreEqual(200, back.MetaUpgrades[1].UpgradeId, "an unknown upgrade id is preserved on disk, not clamped at save time (preserve-don't-crash)."); } - } } diff --git a/Assets/_Project/Tests/EditMode/StorageMathTests.cs b/Assets/_Project/Tests/EditMode/StorageMathTests.cs index 61e57de4d..446f16bd1 100644 --- a/Assets/_Project/Tests/EditMode/StorageMathTests.cs +++ b/Assets/_Project/Tests/EditMode/StorageMathTests.cs @@ -18,49 +18,11 @@ namespace ProjectM.Tests return (world, e); } - [Test] - public void DrainFraction_Removes_Floored_Fraction_Of_Each_Row() - { - var (world, e) = MakeWorld(); - try - { - var buf = world.EntityManager.GetBuffer(e); - StorageMath.Deposit(buf, 2, 100); // Ore - StorageMath.Deposit(buf, 4, 51); // Charge - StorageMath.DrainFraction(buf, 0.5f); - Assert.AreEqual(50, StorageMath.TotalOf(buf, 2), "100 -> floor(50) drained -> 50 left"); - Assert.AreEqual(26, StorageMath.TotalOf(buf, 4), "51 -> floor(25) drained -> 26 left"); - } - finally { world.Dispose(); } - } - [Test] - public void DrainFraction_Drops_Rows_That_Hit_Zero_And_Clamps_Above_One() - { - var (world, e) = MakeWorld(); - try - { - var buf = world.EntityManager.GetBuffer(e); - StorageMath.Deposit(buf, 2, 4); - StorageMath.DrainFraction(buf, 1.5f); // clamps to 1.0 -> removes all -> row dropped - Assert.AreEqual(0, buf.Length, "a fully-drained row is removed"); - } - finally { world.Dispose(); } - } - [Test] - public void DrainFraction_Zero_Is_NoOp() - { - var (world, e) = MakeWorld(); - try - { - var buf = world.EntityManager.GetBuffer(e); - StorageMath.Deposit(buf, 2, 10); - StorageMath.DrainFraction(buf, 0f); - Assert.AreEqual(10, StorageMath.TotalOf(buf, 2), "0 fraction drains nothing"); - } - finally { world.Dispose(); } - } + + + [Test] diff --git a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs index 7b2a9ecdf..668b32542 100644 --- a/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs +++ b/Assets/_Project/Tests/EditMode/SystemOrderingCycleTests.cs @@ -27,13 +27,13 @@ namespace ProjectM.Tests // RPC-receive systems ordered before the run director Add(); Add(); Add(); Add(); Add(); Add(); Add(); - // Run director + the systems ordered around it and the cycle phase - Add(); Add(); Add(); - Add(); Add(); Add(); - Add(); Add(); + // Run director + the systems ordered around it (the cycle/siege spine is deleted — LANTERN purge) + Add(); Add(); + Add(); Add(); + Add(); // Combat sub-chain in the same group - Add(); Add(); Add(); - Add(); Add(); Add(); + Add(); Add(); + Add(); Add(); Assert.DoesNotThrow(() => group.SortSystems(), "A [UpdateBefore/After] cycle in the run/cycle/combat chain throws here instead of only at Play world-creation."); diff --git a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs deleted file mode 100644 index c6fab40c8..000000000 --- a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using NUnit.Framework; -using ProjectM.Simulation; - -namespace ProjectM.Tests -{ - /// - /// Pins : a TERMINAL save (Victory/Loss latched) - /// Continues as a fresh campaign — outcome / goal meter / core reset to 0 (the spawn restore guards re-map - /// 0 to InProgress / empty / baked-full) — while the permanent channel (meta tiers, run counters, ledger, - /// structures) survives untouched. In-progress saves and null are no-ops. - /// - public class TerminalCampaignRollTests - { - [Test] - public void TerminalSave_RollsForward_KeepingBaseAndMeta() - { - var data = new SaveData - { - RunOutcome = 1, // Victory latched - GoalCharge = 4, - GoalTarget = 4, - CoreCurrent = 37, - RunsCompleted = 4, - MaxDepthReached = 7, - MetaUpgrades = new[] { new MetaUpgradeSave { ClassId = 0, UpgradeId = 1, Tier = 2 } }, - Ledger = new[] { new LedgerRow { ItemId = ResourceId.Ore, Count = 123 } }, - }; - - SaveService.RollTerminalCampaignForward(data); - - Assert.AreEqual(0, data.RunOutcome, "outcome latch reset"); - Assert.AreEqual(0, data.GoalCharge, "goal meter reset"); - Assert.AreEqual(0, data.CoreCurrent, "core resets to 0 -> restore guard re-maps to baked-full"); - Assert.AreEqual(4, data.RunsCompleted, "permanent counters survive"); - Assert.AreEqual(7, data.MaxDepthReached, "permanent counters survive"); - Assert.AreEqual(2, data.MetaUpgrades[0].Tier, "meta tiers survive"); - Assert.AreEqual(123, data.Ledger[0].Count, "the ledger survives"); - } - - [Test] - public void InProgressSave_AndNull_AreNoOps() - { - var data = new SaveData { RunOutcome = 0, GoalCharge = 2, CoreCurrent = 50 }; - SaveService.RollTerminalCampaignForward(data); - Assert.AreEqual(2, data.GoalCharge); - Assert.AreEqual(50, data.CoreCurrent); - Assert.DoesNotThrow(() => SaveService.RollTerminalCampaignForward(null)); - } - } -} diff --git a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta b/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta deleted file mode 100644 index 23bf06ce2..000000000 --- a/Assets/_Project/Tests/EditMode/TerminalCampaignRollTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 085790610f52ea347aa4ccd208849316 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs deleted file mode 100644 index d4efda0f2..000000000 --- a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using NUnit.Framework; -using ProjectM.Server; -using ProjectM.Simulation; -using Unity.Core; -using Unity.Entities; -using Unity.NetCode; - -namespace ProjectM.Tests -{ - /// - /// Plain-Entities EditMode tests for the server-only — the composite - /// base-attack scheduler. A bare world is seeded with a NetworkTime singleton and a CycleDirector entity - /// carrying CycleState + ThreatState + ThreatConfig. These pin the post-expedition source (a return arms a - /// siege of the configured size, with simultaneous returns de-duped to one), that the event-siege size is the - /// config floor — never the WaveSystem escalation curve — that the telegraph ArmTick is now + delay, and that - /// an unattended siege auto-collapses after the timeout (no soft-lock). All timing is wrap-safe NetworkTick. - /// - public class ThreatDirectorSystemTests - { - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) - { - var world = new World(name); - var group = world.GetOrCreateSystemManaged(); - group.AddSystemToUpdateList(world.GetOrCreateSystem()); - group.SortSystems(); - world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); - var em = world.EntityManager; - var nt = em.CreateEntity(typeof(NetworkTime)); - em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - return (world, group); - } - - static ThreatConfig DefaultConfig() => new ThreatConfig - { - PostExpeditionEnabled = 1, - PostExpeditionDelayTicks = 300, - SizeBase = 5, - SizePerExpeditionResource = 0, - StartCondition = ThreatStartCondition.Immediate, - SiegeTimeoutTicks = 3600, - }; - - static Entity MakeDirector(EntityManager em, byte phase, ThreatState threat, ThreatConfig config) - { - var e = em.CreateEntity(typeof(CycleState), typeof(ThreatState), typeof(ThreatConfig)); - em.SetComponentData(e, new CycleState { Phase = phase, CycleNumber = 1 }); - em.SetComponentData(e, threat); - em.SetComponentData(e, config); - return e; - } - - [Test] - public void PostExpedition_Return_Edge_Sets_PendingSiegeSize() - { - var (world, group) = MakeWorld("ThreatReturn", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig()); - - group.Update(); - - var ts = em.GetComponentData(dir); - Assert.AreEqual(5, ts.PendingSiegeSize, "A return arms a siege of SizeBase Husks."); - Assert.AreNotEqual(0u, ts.ArmTick, "The siege is armed with a telegraph tick."); - Assert.AreEqual(0, ts.PendingReturns, "The return is consumed."); - } - } - - [Test] - public void Multi_Player_Simultaneous_Return_Charges_Pending_Once() - { - var (world, group) = MakeWorld("ThreatMultiReturn", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 3 }, DefaultConfig()); - - group.Update(); - - var ts = em.GetComponentData(dir); - Assert.AreEqual(5, ts.PendingSiegeSize, "Three simultaneous returns still arm exactly one siege (de-dup)."); - Assert.AreEqual(0, ts.PendingReturns, "All returns are consumed."); - } - } - - [Test] - public void Siege_Size_Equals_Config_Not_Escalation_Curve() - { - var (world, group) = MakeWorld("ThreatSizeConfig", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, DefaultConfig()); - // A high wave number must NOT influence the event-siege size. - var w = em.CreateEntity(typeof(WaveState)); - em.SetComponentData(w, new WaveState { WaveNumber = 30 }); - - group.Update(); - - Assert.AreEqual(5, em.GetComponentData(dir).PendingSiegeSize, - "Event-siege size is the config SizeBase, never the WaveSystem escalation curve."); - } - } - - [Test] - public void StartCondition_Immediate_Arms_Via_ArmTick() - { - var (world, group) = MakeWorld("ThreatArm", serverTick: 1000); - using (world) - { - var em = world.EntityManager; - var config = DefaultConfig(); - config.PostExpeditionDelayTicks = 120; - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { PendingReturns = 1 }, config); - - group.Update(); - - Assert.AreEqual(1120u, em.GetComponentData(dir).ArmTick, - "Immediate start arms the siege at now + the telegraph delay (1000 + 120)."); - } - } - - [Test] - public void Empty_Base_Siege_Auto_Resolves_Bounded() - { - var (world, group) = MakeWorld("ThreatTimeout", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var config = DefaultConfig(); - config.SiegeTimeoutTicks = 60; - // SiegeStartTick 100, now 200 => 100 ticks elapsed > 60 timeout. - var dir = MakeDirector(em, CyclePhase.Siege, new ThreatState { SiegeStartTick = 100 }, config); - - var w = em.CreateEntity(typeof(WaveState)); - em.SetComponentData(w, new WaveState { RemainingToSpawn = 2, Phase = WavePhase.Spawning }); - - // Three Husks still on the field with no one to clear them. - for (int i = 0; i < 3; i++) - { - var h = em.CreateEntity(typeof(EnemyTag)); - em.AddComponentData(h, new RegionTag { Region = RegionId.Base }); - } - - group.Update(); - - using var huskQuery = em.CreateEntityQuery(typeof(EnemyTag)); - Assert.AreEqual(0, huskQuery.CalculateEntityCount(), - "A timed-out (unattended) siege culls the remaining Husks so it can never soft-lock."); - Assert.AreEqual(0, em.GetComponentData(w).RemainingToSpawn, - "The wave stops spawning when the siege collapses."); - Assert.AreEqual(0u, em.GetComponentData(dir).SiegeStartTick, - "The siege clock resets after collapse."); - } - } - - [Test] - public void Schedule_First_Pass_Seeds_NextTick_Without_Firing() - { - var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200); - using (world) - { - var em = world.EntityManager; - var config = DefaultConfig(); - config.PostExpeditionEnabled = 0; - config.ScheduleEnabled = 1; - config.ScheduleIntervalTicks = 100; - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config); - - group.Update(); - - var ts = em.GetComponentData(dir); - Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100)."); - Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately."); - } - } - - [Test] - public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition() - { - var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400); - using (world) - { - var em = world.EntityManager; - var config = DefaultConfig(); - config.PostExpeditionEnabled = 0; // isolate the schedule source - config.ScheduleEnabled = 1; - config.ScheduleIntervalTicks = 100; - config.ScheduleSizePerWave = 0; - config.SizeBase = 5; - config.PostExpeditionDelayTicks = 10; - // NextScheduledTick 300 <= now 400 => the scheduled siege is due. - var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config); - - group.Update(); - - var ts = em.GetComponentData(dir); - Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip."); - Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10)."); - Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100)."); - } - } - } -} diff --git a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs.meta deleted file mode 100644 index c36fc0d75..000000000 --- a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 351a99057b08e3847b239782bfef893e \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/TuningConfigTests.cs b/Assets/_Project/Tests/EditMode/TuningConfigTests.cs index f6f82bb95..99949303f 100644 --- a/Assets/_Project/Tests/EditMode/TuningConfigTests.cs +++ b/Assets/_Project/Tests/EditMode/TuningConfigTests.cs @@ -35,10 +35,6 @@ namespace ProjectM.Tests // GruntWindup must stay the canonical Tuning const (TelegraphTests couples to it). Assert.AreEqual((float)Tuning.AttackWindupTicks, d.GruntWindupTicks, 1e-6f, "GruntWindupTicks == Tuning.AttackWindupTicks"); Assert.AreEqual(0.7f, d.StructureAggroWeight, 1e-6f, "EB-1 StructureAggroWeight default (<1 prefers structures)"); - Assert.AreEqual(10f, d.CoreDamagePerHusk, 1e-6f, "END-1 CoreDamagePerHusk default"); - Assert.AreEqual(18f, d.CoreRegenIntervalTicks, 1e-6f, "END-1 CoreRegenIntervalTicks default"); - Assert.AreEqual(0.5f, d.CoreOverrunDrainPct, 1e-6f, "END-1 CoreOverrunDrainPct default (half the ledger on a breach)"); - Assert.AreEqual(2.5f, d.FinalSiegeMultiplier, 1e-6f, "END-2 FinalSiegeMultiplier default (~2.5x a normal siege)"); } @@ -47,6 +43,7 @@ namespace ProjectM.Tests { for (byte knob = 0; knob < TuningKnob.Count; knob++) { + if (knob >= 20 && knob <= 23) continue; // retired END-1/END-2 knob ids (LANTERN purge; reserved) var c = TuningConfig.Defaults(); float baseline = TuningConfig.Get(c, knob); float target = baseline + 7f; // survives both clamps (positive) @@ -56,7 +53,7 @@ namespace ProjectM.Tests // every OTHER knob is untouched var d = TuningConfig.Defaults(); for (byte other = 0; other < TuningKnob.Count; other++) - if (other != knob) + if (other != knob && !(other >= 20 && other <= 23)) Assert.AreEqual(TuningConfig.Get(d, other), TuningConfig.Get(c, other), 1e-4f, $"knob {other} unchanged while editing {knob}"); } @@ -126,7 +123,10 @@ namespace ProjectM.Tests TuningConfig.Apply(ref c, TuningKnob.ChargerWhiffStaggerTicks, 50f); var c2 = TuningConfig.FromReport(TuningConfig.ToReport(c)); for (byte knob = 0; knob < TuningKnob.Count; knob++) + { + if (knob >= 20 && knob <= 23) continue; // retired knob ids (LANTERN purge) Assert.AreEqual(TuningConfig.Get(c, knob), TuningConfig.Get(c2, knob), 1e-6f, $"knob {knob} survives ToReport/FromReport"); + } } // ---- consumption (world) ---- diff --git a/Assets/_Project/Tests/EditMode/WaveSystemTests.cs b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs index 53575ab2c..88fd1225c 100644 --- a/Assets/_Project/Tests/EditMode/WaveSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/WaveSystemTests.cs @@ -9,16 +9,17 @@ using Unity.Transforms; namespace ProjectM.Tests { /// - /// Plain-Entities EditMode tests for the server-only (Husk wave/threat director). - /// A bare world is seeded with NetworkTime + CycleState singletons and a director entity carrying - /// WaveDirector + WaveState + a WaveEnemyPrefab buffer (whose prefab is a real Prefab-tagged entity so - /// it is excluded from the alive-Husk query and Instantiate yields plain Husk instances). Pins: a due Lull - /// starts the next (escalating) wave; Spawning emits one Husk per interval; the director is gated off outside - /// Defend; a fully-spawned, cleared wave returns to Lull. + /// Plain-Entities EditMode tests for the server-only (Husk wave director). + /// A bare world is seeded with a NetworkTime singleton and a director entity carrying WaveDirector + + /// WaveState + a WaveEnemyPrefab buffer (whose prefab is a real Prefab-tagged entity so it is + /// excluded from the alive-Husk query and Instantiate yields plain Husk instances). Waves are UNGATED + /// (LANTERN purge: the old CycleState Siege gate is deleted — placement of a WaveDirector decides). + /// Pins: a due Lull starts the next (escalating) wave; Spawning emits one Husk per interval; a + /// fully-spawned, cleared wave returns to Lull. /// public class WaveSystemTests { - static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase) + static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); @@ -28,8 +29,6 @@ namespace ProjectM.Tests var em = world.EntityManager; var nt = em.CreateEntity(typeof(NetworkTime)); em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); - var cyc = em.CreateEntity(typeof(CycleState)); - em.SetComponentData(cyc, new CycleState { Phase = cyclePhase }); return (world, group); } @@ -68,7 +67,7 @@ namespace ProjectM.Tests [Test] public void Due_Lull_Starts_Wave_With_Escalating_Count() { - var (world, group) = MakeWorld("WaveLullStart", serverTick: 100, cyclePhase: CyclePhase.Siege); + var (world, group) = MakeWorld("WaveLullStart", serverTick: 100); using (world) { var em = world.EntityManager; @@ -88,7 +87,7 @@ namespace ProjectM.Tests [Test] public void Spawning_Emits_One_Husk_And_Decrements_Remaining() { - var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100, cyclePhase: CyclePhase.Siege); + var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100); using (world) { var em = world.EntityManager; @@ -104,28 +103,10 @@ namespace ProjectM.Tests } } - [Test] - public void Director_Is_Gated_Off_Outside_Defend() - { - var (world, group) = MakeWorld("WaveGated", serverTick: 100, cyclePhase: CyclePhase.Calm); - using (world) - { - var em = world.EntityManager; - var prefab = MakeHuskPrefab(em); - var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0); - - group.Update(); - - var w = em.GetComponentData(dir); - Assert.AreEqual(WavePhase.Lull, w.Phase, "Outside Defend the director does not run."); - Assert.AreEqual(0, w.WaveNumber, "Wave number stays put outside Defend."); - } - } - [Test] public void Fully_Spawned_Cleared_Wave_Returns_To_Lull() { - var (world, group) = MakeWorld("WaveCleared", serverTick: 100, cyclePhase: CyclePhase.Siege); + var (world, group) = MakeWorld("WaveCleared", serverTick: 100); using (world) { var em = world.EntityManager;