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:
@@ -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 >= 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,
|
||||
};
|
||||
|
||||
/// <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 <= 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
|
||||
Reference in New Issue
Block a user