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
@@ -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<CycleDirectorAuthoring>
{
@@ -55,6 +60,15 @@ namespace ProjectM.Authoring
AddComponent<ResourceLedger>(entity);
AddBuffer<StorageEntry>(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),
@@ -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();
@@ -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<VisualElement> _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<CoreIntegrity>(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);
}
@@ -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<PendingSaveLedgerRow>(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<GoalProgress>(dir) ? em.GetComponentData<GoalProgress>(dir) : default;
var core = em.HasComponent<CoreIntegrity>(dir) ? em.GetComponentData<CoreIntegrity>(dir) : default; // END-1
var buffer = em.GetBuffer<StorageEntry>(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,
@@ -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;
@@ -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)
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
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)
};
/// <summary>Clamp a knob to its safe floor: tick knobs &gt;= 1, value knobs &gt;= 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,
};
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
@@ -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;
/// <summary>Knob count (overlay iteration bound).</summary>
public const byte Count = 20;
public const byte Count = 23;
}
/// <summary>
@@ -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;
}
}
@@ -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;
}
/// <summary>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 &lt;= 0.</summary>
public static void DrainFraction(DynamicBuffer<StorageEntry> 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;
}
}
}
}
@@ -15,6 +15,9 @@ namespace ProjectM.Simulation
public int GoalCharge;
public int GoalTarget;
/// <summary>END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max).</summary>
public int CoreCurrent;
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
public byte HasData;
}
@@ -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)
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
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<LedgerRow>();
public StructureSave[] Structures = Array.Empty<StructureSave>();
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
@@ -0,0 +1,34 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// END-1 — the losable Engine Core. An aggregate base-integrity meter that rides the GLOBAL CycleDirector
/// ghost (the untagged ghost already carrying <see cref="CycleState"/>/<see cref="GoalProgress"/>/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; <c>SetIsIrrelevant</c> would hide it cross-region).
/// <para>
/// A Husk that breaches to the Core radius drains <see cref="Current"/> and despawns (server-only
/// <c>CoreDamageSystem</c>); in Calm the Core regenerates toward <see cref="Max"/> (<c>CoreRestoreSystem</c>) so a
/// chipped-but-survived base reads as "we got hurt but we're okay." When <see cref="Current"/> reaches 0 during a
/// Siege the SOFT-loss edge fires once in <c>CyclePhaseSystem</c> (the sole Phase writer): the siege ends, the
/// shared ledger is drained, the base persists wounded (no rollback — the locked DR-029 fork). <see cref="Max"/> is
/// baked from <c>CycleDirectorAuthoring</c>; <see cref="Current"/> is born-correct at spawn (full, or the persisted
/// wounded value from a Continue save).
/// </para>
/// </summary>
public struct CoreIntegrity : IComponentData
{
/// <summary>Current integrity (0 = breached/overrun). Server-authoritative; replicated for the HUD bar.</summary>
[GhostField] public int Current;
/// <summary>Integrity ceiling (baked from authoring; not persisted — a restored Core caps at the baked Max).</summary>
[GhostField] public int Max;
/// <summary>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).</summary>
[GhostField] public uint OverrunTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a933c59d7c550d844b615e3672b333f6