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
@@ -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<BaseAnchor>()
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : 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,
@@ -36,6 +36,12 @@ namespace ProjectM.Server
? SystemAPI.GetComponent<GoalProgress>(dir)
: default;
// END-1: persist the Engine Core integrity (a wounded base stays wounded across save/quit).
var core = SystemAPI.HasComponent<CoreIntegrity>(dir)
? SystemAPI.GetComponent<CoreIntegrity>(dir)
: default;
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
var buffer = SystemAPI.GetBuffer<StorageEntry>(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,
@@ -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
{
/// <summary>
/// END-1 — the Engine Core takes the hit a siege breaks through to. Server-only, plain
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> so it reads each Husk's POST-move
/// position this tick (Husks are interpolated ghosts moved server-only by <see cref="EnemyAISystem"/>; the Core
/// integrity rides the GLOBAL CycleDirector ghost). Any living Husk within <see cref="CoreReachRadius"/> of the
/// base <see cref="BaseGridMath.PlotCenter"/> BREACHES: it drains <c>CoreDamagePerHusk</c> integrity and is
/// consumed (despawned via the ECB — at-most-once, each Husk visited once per tick). Pure planar XZ check
/// (<see cref="EnemyAIMath.InAttackRange"/>); the per-Husk damage is the live <see cref="TuningConfig"/> knob with
/// the baked fallback. Once <see cref="CoreIntegrity.Current"/> hits 0 this system idles — the SOFT-loss edge in
/// <see cref="ProjectM.Simulation"/>'s CyclePhaseSystem owns resolution (the locked DR-029 soft fork).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct CoreDamageSystem : ISystem
{
/// <summary>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.</summary>
const float CoreReachRadius = 3f;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CoreIntegrity>();
state.RequireForUpdate<BaseAnchor>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
if (core.Current <= 0)
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
var tune = SystemAPI.TryGetSingleton<TuningConfig>(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<RefRO<LocalTransform>>().WithAll<EnemyTag>().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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9a3eeda43e19f1946abd8e74126c3a62
@@ -0,0 +1,55 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// 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 <see cref="SimulationSystemGroup"/>. Regenerates ONLY in
/// <see cref="CyclePhase.Calm"/> (no regen mid-Siege): +1 integrity every <c>CoreRegenIntervalTicks</c> server
/// ticks toward <see cref="CoreIntegrity.Max"/>. Deterministic + server-only (no rollback) so the plain
/// <c>now % interval</c> tick gate is safe (the server advances exactly one fixed tick per step). The interval is
/// the live <see cref="TuningConfig"/> knob with the baked fallback.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct CoreRestoreSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CoreIntegrity>();
state.RequireForUpdate<CycleState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
return; // heal only between sieges
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
if (core.Current >= core.Max)
return;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var tune = SystemAPI.TryGetSingleton<TuningConfig>(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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3acd11f97b97d240b97e8c0ad096df7
@@ -70,6 +70,18 @@ namespace ProjectM.Server
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
var destLedger = ecb.SetBuffer<StorageEntry>(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<CoreIntegrity>(spawner.Prefab))
{
var bakedCore = SystemAPI.GetComponent<CoreIntegrity>(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);
}
@@ -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;