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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:51:43 -07:00
parent 3fdac3517b
commit 60e1e21dd3
18 changed files with 396 additions and 16 deletions
@@ -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<CoreIntegrity>(cycleEntity)
&& SystemAPI.GetComponent<CoreIntegrity>(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<TuningConfig>(out var tcfgL) ? tcfgL : TuningConfig.Defaults();
if (SystemAPI.HasBuffer<StorageEntry>(cycleEntity))
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(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<CoreIntegrity>(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<WaveState>(out var waveLost))
{
var wl = SystemAPI.GetComponent<WaveState>(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<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
}
else if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;