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,