diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index b207d8a82..547e6c247 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -59,7 +59,7 @@ namespace ProjectM.Authoring }); AddComponent(entity); AddBuffer(entity); - AddComponent(entity, new GoalProgress { Charge = 0, Target = 10 }); + AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th) // 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 @@ -68,6 +68,11 @@ namespace ProjectM.Authoring 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 }); + AddComponent(entity, new ThreatConfig { diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 35f324f0f..57797b3d7 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -64,6 +64,10 @@ namespace ProjectM.Client Label _coreText; uint _lastOverrunTick; float _overrunFlashLeft; + // END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side). + VisualElement _runBanner; + Label _runBannerText, _runBannerSub; + readonly List _pips = new(); @@ -250,6 +254,21 @@ namespace ProjectM.Client _locationText.text = "BASE OVERRUN - resources lost; the Core will recover"; _locationText.style.color = new Color(1f, 0.3f, 0.25f); } + // ---- 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; + _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; + } + // ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ---- @@ -565,6 +584,7 @@ namespace ProjectM.Client BuildHintBar(root); BuildDowned(root); BuildInventory(root); + BuildRunBanner(root); } void BuildVignette(VisualElement root) @@ -839,6 +859,29 @@ 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); + var hint = HudUi.Text("Esc - menu", 15, MenuUi.SubCol, TextAnchor.MiddleCenter); + hint.style.marginTop = 24; + col.Add(hint); + _runBanner.Add(col); + _runBanner.style.display = DisplayStyle.None; + root.Add(_runBanner); + } + void BuildInventory(VisualElement root) { diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index 456704c46..94919ecee 100644 --- a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs +++ b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs @@ -107,7 +107,7 @@ namespace ProjectM.Client if (data == null) return; var em = server.EntityManager; var e = em.CreateEntity(); - em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, HasData = 1 }); + em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, HasData = 1 }); var buf = em.AddBuffer(e); if (data.Ledger != null) foreach (var row in data.Ledger) @@ -139,6 +139,7 @@ namespace ProjectM.Client 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]; @@ -160,6 +161,7 @@ namespace ProjectM.Client GoalCharge = goal.Charge, GoalTarget = goal.Target, CoreCurrent = core.Current, + RunOutcome = outcome.Value, Ledger = rows, Structures = structures, diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs index 2a2f97211..838e20372 100644 --- a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -40,6 +40,10 @@ namespace ProjectM.Server 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). @@ -57,6 +61,7 @@ namespace ProjectM.Server GoalCharge = goal.Charge, GoalTarget = goal.Target, CoreCurrent = core.Current, + RunOutcome = outcome.Value, Ledger = rows, Structures = structures, diff --git a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs index c4a5f5293..bbfb7007f 100644 --- a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs @@ -46,6 +46,11 @@ namespace ProjectM.Server 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); diff --git a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs index 90198c5da..cbbac0a30 100644 --- a/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs @@ -30,6 +30,10 @@ namespace ProjectM.Server [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 diff --git a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs index 1312cef74..323232dce 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -58,6 +58,9 @@ namespace ProjectM.Server }); 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 }); // Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director // ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker). @@ -66,7 +69,13 @@ namespace ProjectM.Server var pending = SystemAPI.GetComponent(pendingEntity); if (pending.HasData != 0) { - ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget }); + // 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); @@ -82,6 +91,10 @@ namespace ProjectM.Server 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 }); + } ecb.DestroyEntity(pendingEntity); } diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index b77444a01..b88859f72 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -90,9 +90,13 @@ namespace ProjectM.Server } else if (cycle.Phase == CyclePhase.Siege) { - // END-1 soft-loss edge (checked BEFORE survival): the Engine Core breached to 0 -> the siege ENDS - // overrun, the shared ledger is drained, the base persists wounded. No rollback, NO goal reward - // (you lost) — the locked DR-029 soft fork. CyclePhaseSystem stays the sole Phase/WaveState writer. + // 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) @@ -100,23 +104,8 @@ namespace ProjectM.Server cycle.Phase = CyclePhase.Calm; cycle.PhaseEndTick = 0; - // Penalty: drain a fraction of the shared ledger (the ResourceLedger StorageEntry buffer on - // THIS director ghost). The drain pct is the live tuning knob with the baked fallback. - var tuneL = SystemAPI.TryGetSingleton(out var tcfgL) ? tcfgL : TuningConfig.Defaults(); - if (SystemAPI.HasBuffer(cycleEntity)) - { - var ledger = SystemAPI.GetBuffer(cycleEntity); - StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct); - } - - // Transient overrun pulse for the HUD flash; Current stays 0 and regenerates in Calm - // (CoreRestoreSystem) -> the base is wounded, not dead. - var coreL = SystemAPI.GetComponent(cycleEntity); - coreL.OverrunTick = TickUtil.NonZero(now); - SystemAPI.SetComponent(cycleEntity, coreL); - - // The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the - // wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). + // The siege ends: despawn every remaining Husk (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 husks = m_AliveHusks.ToEntityArray(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp); for (int hi = 0; hi < husks.Length; hi++) @@ -133,7 +122,29 @@ namespace ProjectM.Server SystemAPI.SetComponent(waveLost, wl); } - // Autosave the wounded state (a breach is a meaningful checkpoint). + 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 }); } @@ -141,11 +152,21 @@ namespace ProjectM.Server { cycle.Phase = CyclePhase.Calm; cycle.PhaseEndTick = 0; - // Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle). - if (SystemAPI.HasComponent(cycleEntity)) + 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 }); + } + else if (SystemAPI.HasComponent(cycleEntity)) + { + // Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the + // increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem + // only READS this edge to arm the final siege. var goal = SystemAPI.GetComponent(cycleEntity); - goal.Charge += 1; + goal.Charge = math.min(goal.Charge + 1, goal.Target); SystemAPI.SetComponent(cycleEntity, goal); // Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag). if (SystemAPI.HasComponent(cycleEntity)) diff --git a/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs b/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs new file mode 100644 index 000000000..82fdc7a53 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs @@ -0,0 +1,90 @@ +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 new file mode 100644 index 000000000..4d544b6c5 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/GoalReachedSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 472c137c49b85e141b0ee00b1d1fa076 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs index 3cb8cc5ae..ccfea6d78 100644 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs @@ -52,12 +52,21 @@ namespace ProjectM.Server 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) + 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); @@ -77,7 +86,7 @@ namespace ProjectM.Server // 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 + 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; @@ -95,7 +104,7 @@ namespace ProjectM.Server { threat.SiegeStartTick = TickUtil.NonZero(now); } - else if (config.SiegeTimeoutTicks > 0) + else if (config.SiegeTimeoutTicks > 0 && runPhase != RunPhaseId.FinalDefense) { var start = new NetworkTick(threat.SiegeStartTick); if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks) diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index cd2a09d17..8ee326d57 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -56,6 +56,11 @@ namespace ProjectM.Simulation 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; + /// The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path. public static TuningConfig Defaults() => new TuningConfig { @@ -82,6 +87,7 @@ namespace ProjectM.Simulation 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 }; /// Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path @@ -104,7 +110,8 @@ namespace ProjectM.Simulation case TuningKnob.CoreDamagePerHusk: case TuningKnob.CoreOverrunDrainPct: return math.max(0f, value); - // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem) + // tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem). + // FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one. default: return math.max(1f, value); } @@ -139,6 +146,7 @@ namespace ProjectM.Simulation 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; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) } } @@ -171,6 +179,7 @@ namespace ProjectM.Simulation case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk; case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks; case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct; + case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier; default: return 0f; } } @@ -201,6 +210,7 @@ namespace ProjectM.Simulation CoreDamagePerHusk = c.CoreDamagePerHusk, CoreRegenIntervalTicks = c.CoreRegenIntervalTicks, CoreOverrunDrainPct = c.CoreOverrunDrainPct, + FinalSiegeMultiplier = c.FinalSiegeMultiplier, }; /// Reconstruct the full config from a wire snapshot (FULL state, not a delta). @@ -229,6 +239,7 @@ namespace ProjectM.Simulation CoreDamagePerHusk = r.CoreDamagePerHusk, CoreRegenIntervalTicks = r.CoreRegenIntervalTicks, CoreOverrunDrainPct = r.CoreOverrunDrainPct, + FinalSiegeMultiplier = r.FinalSiegeMultiplier, }; } @@ -258,9 +269,10 @@ namespace ProjectM.Simulation public const byte CoreDamagePerHusk = 20; public const byte CoreRegenIntervalTicks = 21; public const byte CoreOverrunDrainPct = 22; + public const byte FinalSiegeMultiplier = 23; /// Knob count (overlay iteration bound). - public const byte Count = 23; + public const byte Count = 24; } /// @@ -294,5 +306,6 @@ namespace ProjectM.Simulation public float CoreDamagePerHusk; public float CoreRegenIntervalTicks; public float CoreOverrunDrainPct; + public float FinalSiegeMultiplier; } } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs index f18daa84f..a82508297 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -18,6 +18,10 @@ namespace ProjectM.Simulation /// 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; + /// 0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn. public byte HasData; } diff --git a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs index 22a4f9fb1..a96924a88 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveData.cs @@ -51,7 +51,7 @@ namespace ProjectM.Simulation [Serializable] public class SaveData { - public const int CurrentVersion = 4; // END-1: v4 adds CoreCurrent (a wounded base persists) + public const int CurrentVersion = 5; // END-2: v5 adds RunOutcome (a won/lost run loads finished); v4 added CoreCurrent /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. public const int MinLoadableVersion = 2; @@ -60,6 +60,7 @@ namespace ProjectM.Simulation 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 public LedgerRow[] Ledger = Array.Empty(); public StructureSave[] Structures = Array.Empty(); diff --git a/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs b/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs new file mode 100644 index 000000000..36995b2da --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs @@ -0,0 +1,61 @@ +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 new file mode 100644 index 000000000..7dab0c3b7 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/RunStateComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8ce481dc9a135834fa6d59882895b0f5 \ No newline at end of file