From 60e1e21dd3044828cc43971bd048ae10308642c3 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Fri, 12 Jun 2026 21:51:43 -0700 Subject: [PATCH] END-1: the base can be lost - a losable Engine Core with integrity Adds CoreIntegrity{[GhostField] Current,Max,OverrunTick} on the GLOBAL CycleDirector ghost (no new ghost/relevancy). CoreDamageSystem (server, after EnemyAISystem): a Husk within ~3u of PlotCenter drains + is consumed; CoreRestoreSystem regenerates only in Calm. The SOFT-loss edge lives inside CyclePhaseSystem (sole Phase writer): Current<=0 in Siege flips to Calm with NO goal reward, StorageMath.DrainFraction drains the shared ledger, all Husks despawn, and OverrunTick is stamped (a transient HUD-flash pulse, not a latching outcome - the Victory latch is END-2's). EnemyAISystem treats the Core as a FALLBACK target so an undefended base is overrun instead of idling. SaveData -> v4 persists CoreCurrent (0 -> born full, the EB-1 HP sentinel); 3 live TuningConfig knobs + a red HUD Core bar. Soft-loss + targeting + breach-resolution forks operator-locked. See DR-034. Co-Authored-By: Claude Opus 4.8 --- .../Authoring/World/CycleDirectorAuthoring.cs | 14 ++++ .../Scripts/Client/Debug/DebugOverlay.cs | 4 + .../Scripts/Client/Presentation/HudSystem.cs | 53 +++++++++++++ .../Scripts/Client/UI/WorldLauncher.cs | 6 +- .../Scripts/Server/Combat/EnemyAISystem.cs | 30 +++++--- .../Server/Persistence/SaveWriteSystem.cs | 8 ++ .../Scripts/Server/World/CoreDamageSystem.cs | 74 +++++++++++++++++++ .../Server/World/CoreDamageSystem.cs.meta | 2 + .../Scripts/Server/World/CoreRestoreSystem.cs | 55 ++++++++++++++ .../Server/World/CoreRestoreSystem.cs.meta | 2 + .../Server/World/CycleDirectorSpawnSystem.cs | 12 +++ .../Scripts/Server/World/CyclePhaseSystem.cs | 52 ++++++++++++- .../Scripts/Simulation/Debug/TuningConfig.cs | 31 +++++++- .../Simulation/HomeBase/StorageMath.cs | 26 ++++++- .../Simulation/Persistence/SaveComponents.cs | 3 + .../Simulation/Persistence/SaveData.cs | 4 +- .../Scripts/Simulation/World/CoreIntegrity.cs | 34 +++++++++ .../Simulation/World/CoreIntegrity.cs.meta | 2 + 18 files changed, 396 insertions(+), 16 deletions(-) create mode 100644 Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs create mode 100644 Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs create mode 100644 Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs create mode 100644 Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs index f720a28f2..b207d8a82 100644 --- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs @@ -40,6 +40,11 @@ namespace ProjectM.Authoring [Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")] 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).")] + public int CoreIntegrityMax = 100; + + private class CycleDirectorBaker : Baker { @@ -55,6 +60,15 @@ namespace ProjectM.Authoring AddComponent(entity); AddBuffer(entity); AddComponent(entity, new GoalProgress { Charge = 0, Target = 10 }); + // 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, + }); + AddComponent(entity, new ThreatConfig { PostExpeditionEnabled = (byte)(authoring.PostExpeditionEnabled ? 1 : 0), diff --git a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs index bebbe5e38..21276056e 100644 --- a/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs +++ b/Assets/_Project/Scripts/Client/Debug/DebugOverlay.cs @@ -106,6 +106,10 @@ 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 + } GUILayout.EndScrollView(); diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index 3aa41b9c4..35f324f0f 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -29,6 +29,8 @@ namespace ProjectM.Client static readonly Color OreAmber = new(1f, 0.72f, 0.35f); static readonly Color BioGreen = new(0.55f, 0.85f, 0.45f); static readonly Color ChargeViolet = new(0.80f, 0.45f, 1f); + 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); static readonly Color PipDim = new(0.25f, 0.30f, 0.36f, 0.9f); @@ -56,6 +58,13 @@ namespace ProjectM.Client // 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; + readonly List _pips = new(); // resources @@ -216,6 +225,33 @@ namespace ProjectM.Client _locationText.style.color = new Color(1f, 0.4f, 0.9f); } +// ---- 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); + } + + // ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ---- bool showThreat = siege || huskCount > 0; _threatPanel.style.display = showThreat ? DisplayStyle.Flex : DisplayStyle.None; @@ -703,6 +739,23 @@ namespace ProjectM.Client _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); } diff --git a/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs b/Assets/_Project/Scripts/Client/UI/WorldLauncher.cs index fb285e21d..456704c46 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, HasData = 1 }); + em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, HasData = 1 }); var buf = em.AddBuffer(e); if (data.Ledger != null) foreach (var row in data.Ledger) @@ -138,6 +138,8 @@ namespace ProjectM.Client 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 buffer = em.GetBuffer(dir, true); var rows = new LedgerRow[buffer.Length]; for (int i = 0; i < buffer.Length; i++) @@ -157,6 +159,8 @@ namespace ProjectM.Client { GoalCharge = goal.Charge, GoalTarget = goal.Target, + CoreCurrent = core.Current, + Ledger = rows, Structures = structures, StructureIo = structureIo, diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index cb0ba61a3..9be6b0bed 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -67,7 +67,13 @@ namespace ProjectM.Server structurePositions.Add(sx.ValueRO.Position); } - if (playerEntities.Length == 0 && structureEntities.Length == 0) + // 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 && !coreAlive) { playerEntities.Dispose(); playerPositions.Dispose(); @@ -117,10 +123,12 @@ namespace ProjectM.Server // EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/ // turret is the preferred target unless a player is in the way (closer after weighting). EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx); - if (tgtIdx < 0) - continue; // no target (covered by the early-return, but stay safe) - Entity targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]; - float3 targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]; + if (tgtIdx < 0 && !coreAlive) + continue; // no player/structure and no Core -> nothing to seek + Entity targetEntity = tgtIdx < 0 ? Entity.Null + : (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]); + float3 targetPos = tgtIdx < 0 ? corePos + : (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]); // Seek: stop just inside strike range so the Husk holds position to attack. float stopDistance = stats.ValueRO.AttackRange * 0.9f; @@ -154,7 +162,7 @@ namespace ProjectM.Server var windTick = new NetworkTick(windRaw); if (!(windTick.IsValid && windTick.IsNewerThan(serverTick))) { - ecb.AppendToBuffer(targetEntity, new DamageEvent + if (targetEntity != Entity.Null) ecb.AppendToBuffer(targetEntity, new DamageEvent { Amount = stats.ValueRO.AttackDamage, SourceNetworkId = -1, // environment / Husk, not a player @@ -220,10 +228,12 @@ namespace ProjectM.Server // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper). EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx); - if (cIdx < 0) + if (cIdx < 0 && !coreAlive) continue; - Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]; - float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]; + Entity cTargetEntity = cIdx < 0 ? Entity.Null + : (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]); + float3 cTargetPos = cIdx < 0 ? corePos + : (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; @@ -241,7 +251,7 @@ namespace ProjectM.Server if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange)) { - ecb.AppendToBuffer(cTargetEntity, new DamageEvent + if (cTargetEntity != Entity.Null) ecb.AppendToBuffer(cTargetEntity, new DamageEvent { Amount = stats.ValueRO.AttackDamage, SourceNetworkId = -1, diff --git a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs index db39ddc07..2a2f97211 100644 --- a/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs +++ b/Assets/_Project/Scripts/Server/Persistence/SaveWriteSystem.cs @@ -36,6 +36,12 @@ namespace ProjectM.Server ? 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; + + // The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer). var buffer = SystemAPI.GetBuffer(dir); var rows = new LedgerRow[buffer.Length]; @@ -50,6 +56,8 @@ namespace ProjectM.Server { GoalCharge = goal.Charge, GoalTarget = goal.Target, + CoreCurrent = core.Current, + Ledger = rows, Structures = structures, StructureIo = structureIo, diff --git a/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs new file mode 100644 index 000000000..c4a5f5293 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs @@ -0,0 +1,74 @@ +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. + + 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().WithEntityAccess()) + { + 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 new file mode 100644 index 000000000..ad469ff99 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/CoreDamageSystem.cs.meta @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000..90198c5da --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs @@ -0,0 +1,55 @@ +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) + { + 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 new file mode 100644 index 000000000..19f5efe20 --- /dev/null +++ b/Assets/_Project/Scripts/Server/World/CoreRestoreSystem.cs.meta @@ -0,0 +1,2 @@ +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 464f557f4..1312cef74 100644 --- a/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CycleDirectorSpawnSystem.cs @@ -70,6 +70,18 @@ namespace ProjectM.Server var srcLedger = SystemAPI.GetBuffer(pendingEntity); var destLedger = ecb.SetBuffer(director); SaveApply.WriteLedger(srcLedger, destLedger); + + // 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 }); + } + } ecb.DestroyEntity(pendingEntity); } diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index 947bdbfe3..b77444a01 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -1,5 +1,6 @@ using ProjectM.Simulation; -using Unity.Burst; +using Unity.Burst;using Unity.Collections; + using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; @@ -89,7 +90,54 @@ namespace ProjectM.Server } else if (cycle.Phase == CyclePhase.Siege) { - if (DefendCleared(ref state, runtime.DefendStartWave)) + // 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. + bool overrun = SystemAPI.HasComponent(cycleEntity) + && SystemAPI.GetComponent(cycleEntity).Current <= 0; + if (overrun) + { + 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). + var husks = m_AliveHusks.ToEntityArray(Allocator.Temp); + var ecb = new EntityCommandBuffer(Allocator.Temp); + for (int hi = 0; hi < husks.Length; hi++) + ecb.DestroyEntity(husks[hi]); + ecb.Playback(state.EntityManager); + ecb.Dispose(); + husks.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); + } + + // Autosave the wounded state (a breach is a meaningful checkpoint). + 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; diff --git a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs index 2c552b59b..cd2a09d17 100644 --- a/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs +++ b/Assets/_Project/Scripts/Simulation/Debug/TuningConfig.cs @@ -50,6 +50,12 @@ 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) + /// The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path. public static TuningConfig Defaults() => new TuningConfig { @@ -73,6 +79,9 @@ 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) }; /// Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path @@ -92,6 +101,8 @@ namespace ProjectM.Simulation case TuningKnob.MeleeKnockbackSpeed: case TuningKnob.MeleeFinisherMult: case TuningKnob.StructureAggroWeight: + 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) default: @@ -125,6 +136,9 @@ 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; // unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem) } } @@ -154,6 +168,9 @@ 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; default: return 0f; } } @@ -181,6 +198,9 @@ namespace ProjectM.Simulation MeleeFinisherMult = c.MeleeFinisherMult, MeleeComboLength = c.MeleeComboLength, StructureAggroWeight = c.StructureAggroWeight, + CoreDamagePerHusk = c.CoreDamagePerHusk, + CoreRegenIntervalTicks = c.CoreRegenIntervalTicks, + CoreOverrunDrainPct = c.CoreOverrunDrainPct, }; /// Reconstruct the full config from a wire snapshot (FULL state, not a delta). @@ -206,6 +226,9 @@ namespace ProjectM.Simulation MeleeFinisherMult = r.MeleeFinisherMult, MeleeComboLength = r.MeleeComboLength, StructureAggroWeight = r.StructureAggroWeight, + CoreDamagePerHusk = r.CoreDamagePerHusk, + CoreRegenIntervalTicks = r.CoreRegenIntervalTicks, + CoreOverrunDrainPct = r.CoreOverrunDrainPct, }; } @@ -232,9 +255,12 @@ 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; /// Knob count (overlay iteration bound). - public const byte Count = 20; + public const byte Count = 23; } /// @@ -265,5 +291,8 @@ namespace ProjectM.Simulation public float MeleeFinisherMult; public float MeleeComboLength; public float StructureAggroWeight; + public float CoreDamagePerHusk; + public float CoreRegenIntervalTicks; + public float CoreOverrunDrainPct; } } diff --git a/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs b/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs index 98bbae6c7..2584a706d 100644 --- a/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs +++ b/Assets/_Project/Scripts/Simulation/HomeBase/StorageMath.cs @@ -1,4 +1,5 @@ -using Unity.Entities; +using Unity.Entities;using Unity.Mathematics; + namespace ProjectM.Simulation { @@ -71,5 +72,28 @@ namespace ProjectM.Simulation total += buffer[i].Count; return total; } + + /// 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/SaveComponents.cs b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs index ba9170b15..f18daa84f 100644 --- a/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs +++ b/Assets/_Project/Scripts/Simulation/Persistence/SaveComponents.cs @@ -15,6 +15,9 @@ namespace ProjectM.Simulation 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; + /// 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 60bc4671c..22a4f9fb1 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 = 3; // EB-1: v3 adds StructureSave.HP + public const int CurrentVersion = 4; // END-1: v4 adds CoreCurrent (a wounded base persists) /// Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP. public const int MinLoadableVersion = 2; @@ -59,6 +59,8 @@ namespace ProjectM.Simulation 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 LedgerRow[] Ledger = Array.Empty(); public StructureSave[] Structures = Array.Empty(); public StructureIoRow[] StructureIo = Array.Empty(); diff --git a/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs b/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs new file mode 100644 index 000000000..1d3b40312 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs @@ -0,0 +1,34 @@ +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 new file mode 100644 index 000000000..7f3411124 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/World/CoreIntegrity.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a933c59d7c550d844b615e3672b333f6 \ No newline at end of file