END-2: final siege + latching win/lose (SL-3)
At GoalProgress.Charge>=Target a new server-only GoalReachedSystem arms a larger final siege (x live FinalSiegeMultiplier) and flips RunPhase=FinalDefense; CyclePhaseSystem latches a REPLICATED RunOutcome (Victory on clear / Loss on Core breach) and halts the director. RunOutcome is a [GhostField] byte on the global CycleDirector ghost (the client banner observes it); RunPhase stays server-only. ThreatDirector/CoreRestore/CoreDamage halt once decided; SiegeTimeout is off during the final siege. SaveData v5 persists the outcome so a won/lost run loads finished. GoalProgress.Target 10->4. Completes Path A's spine. See DR-036. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ namespace ProjectM.Authoring
|
|||||||
});
|
});
|
||||||
AddComponent<ResourceLedger>(entity);
|
AddComponent<ResourceLedger>(entity);
|
||||||
AddBuffer<StorageEntry>(entity);
|
AddBuffer<StorageEntry>(entity);
|
||||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 10 });
|
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th)
|
||||||
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
|
// 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.
|
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
|
||||||
AddComponent(entity, new CoreIntegrity
|
AddComponent(entity, new CoreIntegrity
|
||||||
@@ -68,6 +68,11 @@ namespace ProjectM.Authoring
|
|||||||
Max = authoring.CoreIntegrityMax,
|
Max = authoring.CoreIntegrityMax,
|
||||||
OverrunTick = 0u,
|
OverrunTick = 0u,
|
||||||
});
|
});
|
||||||
|
// END-2: the terminal run outcome is REPLICATED ([GhostField]) so the client HUD shows the win/loss
|
||||||
|
// banner by observing it. Baked here -> part of the ghost serializer (one re-bake). Born InProgress;
|
||||||
|
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
|
||||||
|
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
|
||||||
|
|
||||||
|
|
||||||
AddComponent(entity, new ThreatConfig
|
AddComponent(entity, new ThreatConfig
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ namespace ProjectM.Client
|
|||||||
Label _coreText;
|
Label _coreText;
|
||||||
uint _lastOverrunTick;
|
uint _lastOverrunTick;
|
||||||
float _overrunFlashLeft;
|
float _overrunFlashLeft;
|
||||||
|
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
||||||
|
VisualElement _runBanner;
|
||||||
|
Label _runBannerText, _runBannerSub;
|
||||||
|
|
||||||
|
|
||||||
readonly List<VisualElement> _pips = new();
|
readonly List<VisualElement> _pips = new();
|
||||||
|
|
||||||
@@ -250,6 +254,21 @@ namespace ProjectM.Client
|
|||||||
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
|
_locationText.text = "BASE OVERRUN - resources lost; the Core will recover";
|
||||||
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
|
_locationText.style.color = new Color(1f, 0.3f, 0.25f);
|
||||||
}
|
}
|
||||||
|
// ---- END-2: terminal run banner (Victory / Loss), observed from the replicated RunOutcome ----
|
||||||
|
if (SystemAPI.TryGetSingleton<RunOutcome>(out var runOutcome) && runOutcome.Value != RunOutcomeId.InProgress)
|
||||||
|
{
|
||||||
|
bool win = runOutcome.Value == RunOutcomeId.Victory;
|
||||||
|
_runBanner.style.display = DisplayStyle.Flex;
|
||||||
|
_runBannerText.text = win ? "THE ENGINE HOLDS" : "OVERRUN";
|
||||||
|
_runBannerText.style.color = win ? new Color(0.45f, 0.95f, 1f) : new Color(1f, 0.35f, 0.3f);
|
||||||
|
_runBannerSub.text = win ? "VICTORY - the final siege is broken" : "THE FINAL STAND FELL";
|
||||||
|
_runBannerSub.style.color = win ? new Color(0.7f, 0.95f, 1f) : new Color(1f, 0.6f, 0.5f);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_runBanner.style.display = DisplayStyle.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
|
// ---- Threat readout (top-right) — hidden entirely at base with zero husks; its reappearance is the cue ----
|
||||||
@@ -565,6 +584,7 @@ namespace ProjectM.Client
|
|||||||
BuildHintBar(root);
|
BuildHintBar(root);
|
||||||
BuildDowned(root);
|
BuildDowned(root);
|
||||||
BuildInventory(root);
|
BuildInventory(root);
|
||||||
|
BuildRunBanner(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
void BuildVignette(VisualElement root)
|
void BuildVignette(VisualElement root)
|
||||||
@@ -839,6 +859,29 @@ namespace ProjectM.Client
|
|||||||
_downed.style.display = DisplayStyle.None;
|
_downed.style.display = DisplayStyle.None;
|
||||||
root.Add(_downed);
|
root.Add(_downed);
|
||||||
}
|
}
|
||||||
|
void BuildRunBanner(VisualElement root)
|
||||||
|
{
|
||||||
|
_runBanner = new VisualElement();
|
||||||
|
_runBanner.style.position = Position.Absolute;
|
||||||
|
_runBanner.style.left = 0; _runBanner.style.right = 0; _runBanner.style.top = 0; _runBanner.style.bottom = 0;
|
||||||
|
_runBanner.style.alignItems = Align.Center;
|
||||||
|
_runBanner.style.justifyContent = Justify.Center;
|
||||||
|
_runBanner.pickingMode = PickingMode.Ignore;
|
||||||
|
_runBanner.style.backgroundColor = new Color(0.02f, 0.03f, 0.05f, 0.55f);
|
||||||
|
var col = HudUi.Group(Align.Center);
|
||||||
|
_runBannerText = HudUi.Display("", 72, Color.white, TextAnchor.MiddleCenter);
|
||||||
|
col.Add(_runBannerText);
|
||||||
|
_runBannerSub = HudUi.Text("", 22, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||||
|
_runBannerSub.style.marginTop = 8;
|
||||||
|
col.Add(_runBannerSub);
|
||||||
|
var hint = HudUi.Text("Esc - menu", 15, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||||
|
hint.style.marginTop = 24;
|
||||||
|
col.Add(hint);
|
||||||
|
_runBanner.Add(col);
|
||||||
|
_runBanner.style.display = DisplayStyle.None;
|
||||||
|
root.Add(_runBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void BuildInventory(VisualElement root)
|
void BuildInventory(VisualElement root)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ namespace ProjectM.Client
|
|||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
var em = server.EntityManager;
|
var em = server.EntityManager;
|
||||||
var e = em.CreateEntity();
|
var e = em.CreateEntity();
|
||||||
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, HasData = 1 });
|
em.AddComponentData(e, new PendingSave { GoalCharge = data.GoalCharge, GoalTarget = data.GoalTarget, CoreCurrent = data.CoreCurrent, RunOutcome = (byte)data.RunOutcome, HasData = 1 });
|
||||||
var buf = em.AddBuffer<PendingSaveLedgerRow>(e);
|
var buf = em.AddBuffer<PendingSaveLedgerRow>(e);
|
||||||
if (data.Ledger != null)
|
if (data.Ledger != null)
|
||||||
foreach (var row in data.Ledger)
|
foreach (var row in data.Ledger)
|
||||||
@@ -139,6 +139,7 @@ namespace ProjectM.Client
|
|||||||
var dir = q.GetSingletonEntity();
|
var dir = q.GetSingletonEntity();
|
||||||
var goal = em.HasComponent<GoalProgress>(dir) ? em.GetComponentData<GoalProgress>(dir) : default;
|
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 core = em.HasComponent<CoreIntegrity>(dir) ? em.GetComponentData<CoreIntegrity>(dir) : default; // END-1
|
||||||
|
var outcome = em.HasComponent<RunOutcome>(dir) ? em.GetComponentData<RunOutcome>(dir) : default; // END-2
|
||||||
|
|
||||||
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
var buffer = em.GetBuffer<StorageEntry>(dir, true);
|
||||||
var rows = new LedgerRow[buffer.Length];
|
var rows = new LedgerRow[buffer.Length];
|
||||||
@@ -160,6 +161,7 @@ namespace ProjectM.Client
|
|||||||
GoalCharge = goal.Charge,
|
GoalCharge = goal.Charge,
|
||||||
GoalTarget = goal.Target,
|
GoalTarget = goal.Target,
|
||||||
CoreCurrent = core.Current,
|
CoreCurrent = core.Current,
|
||||||
|
RunOutcome = outcome.Value,
|
||||||
|
|
||||||
Ledger = rows,
|
Ledger = rows,
|
||||||
Structures = structures,
|
Structures = structures,
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ namespace ProjectM.Server
|
|||||||
var core = SystemAPI.HasComponent<CoreIntegrity>(dir)
|
var core = SystemAPI.HasComponent<CoreIntegrity>(dir)
|
||||||
? SystemAPI.GetComponent<CoreIntegrity>(dir)
|
? SystemAPI.GetComponent<CoreIntegrity>(dir)
|
||||||
: default;
|
: default;
|
||||||
|
// END-2: persist the terminal run outcome so a won/lost run loads finished (no re-arm on Continue).
|
||||||
|
var outcome = SystemAPI.HasComponent<RunOutcome>(dir)
|
||||||
|
? SystemAPI.GetComponent<RunOutcome>(dir)
|
||||||
|
: default;
|
||||||
|
|
||||||
|
|
||||||
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
|
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
|
||||||
@@ -57,6 +61,7 @@ namespace ProjectM.Server
|
|||||||
GoalCharge = goal.Charge,
|
GoalCharge = goal.Charge,
|
||||||
GoalTarget = goal.Target,
|
GoalTarget = goal.Target,
|
||||||
CoreCurrent = core.Current,
|
CoreCurrent = core.Current,
|
||||||
|
RunOutcome = outcome.Value,
|
||||||
|
|
||||||
Ledger = rows,
|
Ledger = rows,
|
||||||
Structures = structures,
|
Structures = structures,
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ namespace ProjectM.Server
|
|||||||
if (core.Current <= 0)
|
if (core.Current <= 0)
|
||||||
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
|
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
|
||||||
|
|
||||||
|
// END-2: once the run is decided (Victory/Loss latched) the Core takes no more damage. Defensive — the
|
||||||
|
// siege already despawned its Husks on resolution; this mirrors the CoreRestoreSystem terminal-halt guard.
|
||||||
|
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
||||||
|
return;
|
||||||
|
|
||||||
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
|
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
|
||||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||||
int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk);
|
int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ namespace ProjectM.Server
|
|||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnUpdate(ref SystemState state)
|
public void OnUpdate(ref SystemState state)
|
||||||
{
|
{
|
||||||
|
// END-2: once the run is decided (Victory/Loss latched), the Core freezes at its terminal value (no regen).
|
||||||
|
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
||||||
|
return;
|
||||||
|
|
||||||
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
|
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
|
||||||
return; // heal only between sieges
|
return; // heal only between sieges
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ namespace ProjectM.Server
|
|||||||
});
|
});
|
||||||
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
|
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
|
||||||
ecb.AddComponent(director, new ThreatState());
|
ecb.AddComponent(director, new ThreatState());
|
||||||
|
// END-2: server-only run-phase marker (Normal until the goal cap arms the final siege). Added at
|
||||||
|
// spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab.
|
||||||
|
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
|
||||||
|
|
||||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||||
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
||||||
@@ -66,7 +69,13 @@ namespace ProjectM.Server
|
|||||||
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
|
var pending = SystemAPI.GetComponent<PendingSave>(pendingEntity);
|
||||||
if (pending.HasData != 0)
|
if (pending.HasData != 0)
|
||||||
{
|
{
|
||||||
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = pending.GoalTarget });
|
// END-2: clamp the restored Target to the baked run-length so a pre-v5 save carrying the old
|
||||||
|
// Target=10 still honours the slice's baked Target=4 (the final siege stays reachable).
|
||||||
|
int bakedTarget = SystemAPI.HasComponent<GoalProgress>(spawner.Prefab)
|
||||||
|
? SystemAPI.GetComponent<GoalProgress>(spawner.Prefab).Target : pending.GoalTarget;
|
||||||
|
int restoredTarget = pending.GoalTarget > 0 && pending.GoalTarget < bakedTarget
|
||||||
|
? pending.GoalTarget : bakedTarget;
|
||||||
|
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = restoredTarget });
|
||||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||||
@@ -82,6 +91,10 @@ namespace ProjectM.Server
|
|||||||
ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u });
|
ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// END-2: born-correct the terminal run outcome (a won/lost run loads finished + halted; a pre-v5
|
||||||
|
// save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard.
|
||||||
|
ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome });
|
||||||
|
|
||||||
}
|
}
|
||||||
ecb.DestroyEntity(pendingEntity);
|
ecb.DestroyEntity(pendingEntity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,13 @@ namespace ProjectM.Server
|
|||||||
}
|
}
|
||||||
else if (cycle.Phase == CyclePhase.Siege)
|
else if (cycle.Phase == CyclePhase.Siege)
|
||||||
{
|
{
|
||||||
// END-1 soft-loss edge (checked BEFORE survival): the Engine Core breached to 0 -> the siege ENDS
|
// END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent-
|
||||||
// overrun, the shared ledger is drained, the base persists wounded. No rollback, NO goal reward
|
// guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths.
|
||||||
// (you lost) — the locked DR-029 soft fork. CyclePhaseSystem stays the sole Phase/WaveState writer.
|
bool isFinal = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
||||||
|
&& SystemAPI.GetComponent<RunPhase>(cycleEntity).Value == RunPhaseId.FinalDefense;
|
||||||
|
|
||||||
|
// The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the
|
||||||
|
// sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer).
|
||||||
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
|
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
|
||||||
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
|
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
|
||||||
if (overrun)
|
if (overrun)
|
||||||
@@ -100,23 +104,8 @@ namespace ProjectM.Server
|
|||||||
cycle.Phase = CyclePhase.Calm;
|
cycle.Phase = CyclePhase.Calm;
|
||||||
cycle.PhaseEndTick = 0;
|
cycle.PhaseEndTick = 0;
|
||||||
|
|
||||||
// Penalty: drain a fraction of the shared ledger (the ResourceLedger StorageEntry buffer on
|
// The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the wave
|
||||||
// THIS director ghost). The drain pct is the live tuning knob with the baked fallback.
|
// so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||||
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 husks = m_AliveHusks.ToEntityArray(Allocator.Temp);
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
for (int hi = 0; hi < husks.Length; hi++)
|
for (int hi = 0; hi < husks.Length; hi++)
|
||||||
@@ -133,7 +122,29 @@ namespace ProjectM.Server
|
|||||||
SystemAPI.SetComponent(waveLost, wl);
|
SystemAPI.SetComponent(waveLost, wl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autosave the wounded state (a breach is a meaningful checkpoint).
|
if (isFinal)
|
||||||
|
{
|
||||||
|
// END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO
|
||||||
|
// ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner
|
||||||
|
// (from the replicated RunOutcome), not the soft "the Core will recover" flash.
|
||||||
|
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient
|
||||||
|
// overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
var coreL = SystemAPI.GetComponent<CoreIntegrity>(cycleEntity);
|
||||||
|
coreL.OverrunTick = TickUtil.NonZero(now);
|
||||||
|
SystemAPI.SetComponent(cycleEntity, coreL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autosave the checkpoint (a breach / final loss is a meaningful save point).
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||||
}
|
}
|
||||||
@@ -141,11 +152,21 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
cycle.Phase = CyclePhase.Calm;
|
cycle.Phase = CyclePhase.Calm;
|
||||||
cycle.PhaseEndTick = 0;
|
cycle.PhaseEndTick = 0;
|
||||||
// Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle).
|
if (isFinal)
|
||||||
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
|
||||||
{
|
{
|
||||||
|
// END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt;
|
||||||
|
// do NOT increment the (already-capped) goal.
|
||||||
|
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory });
|
||||||
|
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||||
|
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||||
|
}
|
||||||
|
else if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||||
|
{
|
||||||
|
// Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the
|
||||||
|
// increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem
|
||||||
|
// only READS this edge to arm the final siege.
|
||||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||||
goal.Charge += 1;
|
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
||||||
SystemAPI.SetComponent(cycleEntity, goal);
|
SystemAPI.SetComponent(cycleEntity, goal);
|
||||||
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
|
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
|
||||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Burst;
|
||||||
|
using Unity.Entities;
|
||||||
|
using Unity.Mathematics;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Server
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// END-2 — arms the FINAL siege when the long-arc goal meter fills. Server-only, plain
|
||||||
|
/// <see cref="SimulationSystemGroup"/>, <c>[UpdateAfter(CyclePhaseSystem)]</c> so it reads
|
||||||
|
/// <see cref="GoalProgress.Charge"/> AFTER the survived-siege increment that may have just reached Target.
|
||||||
|
/// On the <c>Charge >= Target</c> rising edge — guarded by <see cref="RunPhaseId.Normal"/> +
|
||||||
|
/// <see cref="RunOutcomeId.InProgress"/> so it fires EXACTLY once — it:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>arms a bigger siege through the existing single entry point <see cref="ThreatState.PendingSiegeSize"/>:
|
||||||
|
/// the would-be-next normal siege size (<c>SizeBase + ScheduleSizePerWave*wave</c>) times the live
|
||||||
|
/// <see cref="TuningConfig.FinalSiegeMultiplier"/> (floored at 1 so the final siege is never smaller), telegraphed
|
||||||
|
/// via <see cref="ThreatState.ArmTick"/> (wrap-safe <see cref="TickUtil.NonZero"/>);</item>
|
||||||
|
/// <item>flips <see cref="RunPhase"/> to <see cref="RunPhaseId.FinalDefense"/>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// It NEVER writes <see cref="CycleState"/>.Phase / <c>WaveState</c> (CyclePhaseSystem stays the sole writer) nor
|
||||||
|
/// <see cref="GoalProgress"/>.Charge (CyclePhaseSystem clamps it at the increment site) — it only READS the edge.
|
||||||
|
/// CyclePhaseSystem then consumes <see cref="ThreatState.PendingSiegeSize"/> the next tick exactly like any other
|
||||||
|
/// armed siege; <c>ThreatDirectorSystem</c> stops arming once <see cref="RunPhase"/> leaves Normal, so no normal
|
||||||
|
/// siege can stomp the final one. Plain server group => one run per tick, no rollback/predicted exposure.
|
||||||
|
/// Bytes, never enums (Burst-safe).
|
||||||
|
/// </summary>
|
||||||
|
[BurstCompile]
|
||||||
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||||
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
|
[UpdateAfter(typeof(CyclePhaseSystem))]
|
||||||
|
public partial struct GoalReachedSystem : ISystem
|
||||||
|
{
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnCreate(ref SystemState state)
|
||||||
|
{
|
||||||
|
state.RequireForUpdate<NetworkTime>();
|
||||||
|
state.RequireForUpdate<CycleState>();
|
||||||
|
state.RequireForUpdate<RunPhase>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||||
|
if (!serverTick.IsValid)
|
||||||
|
return;
|
||||||
|
uint now = serverTick.TickIndexForValidTick;
|
||||||
|
|
||||||
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||||
|
|
||||||
|
// Exactly-once guards: a decided run, or one already in the final siege, arms nothing.
|
||||||
|
if (SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
||||||
|
&& SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value != RunOutcomeId.InProgress)
|
||||||
|
return;
|
||||||
|
var runPhase = SystemAPI.GetComponent<RunPhase>(cycleEntity);
|
||||||
|
if (runPhase.Value != RunPhaseId.Normal)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Goal cap reached? (Charge is clamped to Target at the CyclePhaseSystem increment site.)
|
||||||
|
if (!SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||||
|
return;
|
||||||
|
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||||
|
if (goal.Target <= 0 || goal.Charge < goal.Target)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!SystemAPI.HasComponent<ThreatState>(cycleEntity) || !SystemAPI.HasComponent<ThreatConfig>(cycleEntity))
|
||||||
|
return;
|
||||||
|
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||||
|
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
||||||
|
|
||||||
|
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
||||||
|
float mult = math.max(1f, SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg)
|
||||||
|
? tcfg.FinalSiegeMultiplier
|
||||||
|
: TuningConfig.Defaults().FinalSiegeMultiplier);
|
||||||
|
int normalSize = config.SizeBase + config.ScheduleSizePerWave * wave;
|
||||||
|
int finalSize = math.max(1, (int)(normalSize * mult));
|
||||||
|
|
||||||
|
// Arm the final siege (overwrites any pending normal siege — the final supersedes; at the goal-reach tick
|
||||||
|
// PendingSiegeSize is 0 anyway, the just-cleared siege having consumed it). CyclePhaseSystem consumes it.
|
||||||
|
threat.PendingSiegeSize = finalSize;
|
||||||
|
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
||||||
|
SystemAPI.SetComponent(cycleEntity, threat);
|
||||||
|
|
||||||
|
runPhase.Value = RunPhaseId.FinalDefense;
|
||||||
|
SystemAPI.SetComponent(cycleEntity, runPhase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 472c137c49b85e141b0ee00b1d1fa076
|
||||||
@@ -52,12 +52,21 @@ namespace ProjectM.Server
|
|||||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||||
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
||||||
|
// END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The
|
||||||
|
// SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with
|
||||||
|
// HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour.
|
||||||
|
byte runPhase = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
||||||
|
? SystemAPI.GetComponent<RunPhase>(cycleEntity).Value : RunPhaseId.Normal;
|
||||||
|
byte runOutcome = SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
||||||
|
? SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value : RunOutcomeId.InProgress;
|
||||||
|
bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress;
|
||||||
|
|
||||||
|
|
||||||
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
|
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
|
||||||
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
|
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
|
||||||
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
|
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
|
||||||
{
|
{
|
||||||
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0)
|
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm)
|
||||||
{
|
{
|
||||||
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
|
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
|
||||||
threat.PendingSiegeSize = math.max(1, size);
|
threat.PendingSiegeSize = math.max(1, size);
|
||||||
@@ -77,7 +86,7 @@ namespace ProjectM.Server
|
|||||||
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
|
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
|
||||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
||||||
}
|
}
|
||||||
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0
|
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm
|
||||||
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
|
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
|
||||||
{
|
{
|
||||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
||||||
@@ -95,7 +104,7 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
threat.SiegeStartTick = TickUtil.NonZero(now);
|
threat.SiegeStartTick = TickUtil.NonZero(now);
|
||||||
}
|
}
|
||||||
else if (config.SiegeTimeoutTicks > 0)
|
else if (config.SiegeTimeoutTicks > 0 && runPhase != RunPhaseId.FinalDefense)
|
||||||
{
|
{
|
||||||
var start = new NetworkTick(threat.SiegeStartTick);
|
var start = new NetworkTick(threat.SiegeStartTick);
|
||||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ namespace ProjectM.Simulation
|
|||||||
public float CoreRegenIntervalTicks; // ticks between +1 regen in Calm (18 -> +1/0.3s -> ~full over one short Calm)
|
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)
|
public float CoreOverrunDrainPct; // fraction (0..1) of the shared ledger lost on a breach (soft-loss penalty)
|
||||||
|
|
||||||
|
// END-2 final siege: the would-be-next normal siege size is multiplied by this for the FINAL siege so the
|
||||||
|
// climax reads visibly larger. Floors at 1 (the default ClampKnob bucket) — a final siege is never smaller
|
||||||
|
// than a normal one; GoalReachedSystem also math.max(1, ...) at the use-site.
|
||||||
|
public float FinalSiegeMultiplier;
|
||||||
|
|
||||||
/// <summary>The baked feel defaults == the pre-MC-0 consts. Single source of truth for the fallback path.</summary>
|
/// <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
|
public static TuningConfig Defaults() => new TuningConfig
|
||||||
{
|
{
|
||||||
@@ -82,6 +87,7 @@ namespace ProjectM.Simulation
|
|||||||
CoreDamagePerHusk = 10f, // END-1: 10 breaching Husks = full loss; ~5 = a serious dent
|
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)
|
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)
|
CoreOverrunDrainPct = 0.5f, // END-1: a breach costs half the shared ledger (soft-loss penalty)
|
||||||
|
FinalSiegeMultiplier = 2.5f, // END-2: the final siege is ~2.5x the would-be-next normal siege
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path
|
/// <summary>Clamp a knob to its safe floor: tick knobs >= 1, value knobs >= 0. Used by every write path
|
||||||
@@ -104,7 +110,8 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.CoreDamagePerHusk:
|
case TuningKnob.CoreDamagePerHusk:
|
||||||
case TuningKnob.CoreOverrunDrainPct:
|
case TuningKnob.CoreOverrunDrainPct:
|
||||||
return math.max(0f, value);
|
return math.max(0f, value);
|
||||||
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem)
|
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
|
||||||
|
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
|
||||||
default:
|
default:
|
||||||
return math.max(1f, value);
|
return math.max(1f, value);
|
||||||
}
|
}
|
||||||
@@ -139,6 +146,7 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.CoreDamagePerHusk: c.CoreDamagePerHusk = value; break;
|
case TuningKnob.CoreDamagePerHusk: c.CoreDamagePerHusk = value; break;
|
||||||
case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break;
|
case TuningKnob.CoreRegenIntervalTicks: c.CoreRegenIntervalTicks = value; break;
|
||||||
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
|
case TuningKnob.CoreOverrunDrainPct: c.CoreOverrunDrainPct = value; break;
|
||||||
|
case TuningKnob.FinalSiegeMultiplier: c.FinalSiegeMultiplier = value; break;
|
||||||
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +179,7 @@ namespace ProjectM.Simulation
|
|||||||
case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk;
|
case TuningKnob.CoreDamagePerHusk: return c.CoreDamagePerHusk;
|
||||||
case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks;
|
case TuningKnob.CoreRegenIntervalTicks: return c.CoreRegenIntervalTicks;
|
||||||
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
|
case TuningKnob.CoreOverrunDrainPct: return c.CoreOverrunDrainPct;
|
||||||
|
case TuningKnob.FinalSiegeMultiplier: return c.FinalSiegeMultiplier;
|
||||||
default: return 0f;
|
default: return 0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +210,7 @@ namespace ProjectM.Simulation
|
|||||||
CoreDamagePerHusk = c.CoreDamagePerHusk,
|
CoreDamagePerHusk = c.CoreDamagePerHusk,
|
||||||
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
|
CoreRegenIntervalTicks = c.CoreRegenIntervalTicks,
|
||||||
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
|
CoreOverrunDrainPct = c.CoreOverrunDrainPct,
|
||||||
|
FinalSiegeMultiplier = c.FinalSiegeMultiplier,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
|
/// <summary>Reconstruct the full config from a wire snapshot (FULL state, not a delta).</summary>
|
||||||
@@ -229,6 +239,7 @@ namespace ProjectM.Simulation
|
|||||||
CoreDamagePerHusk = r.CoreDamagePerHusk,
|
CoreDamagePerHusk = r.CoreDamagePerHusk,
|
||||||
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
|
CoreRegenIntervalTicks = r.CoreRegenIntervalTicks,
|
||||||
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
|
CoreOverrunDrainPct = r.CoreOverrunDrainPct,
|
||||||
|
FinalSiegeMultiplier = r.FinalSiegeMultiplier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,9 +269,10 @@ namespace ProjectM.Simulation
|
|||||||
public const byte CoreDamagePerHusk = 20;
|
public const byte CoreDamagePerHusk = 20;
|
||||||
public const byte CoreRegenIntervalTicks = 21;
|
public const byte CoreRegenIntervalTicks = 21;
|
||||||
public const byte CoreOverrunDrainPct = 22;
|
public const byte CoreOverrunDrainPct = 22;
|
||||||
|
public const byte FinalSiegeMultiplier = 23;
|
||||||
|
|
||||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||||
public const byte Count = 23;
|
public const byte Count = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -294,5 +306,6 @@ namespace ProjectM.Simulation
|
|||||||
public float CoreDamagePerHusk;
|
public float CoreDamagePerHusk;
|
||||||
public float CoreRegenIntervalTicks;
|
public float CoreRegenIntervalTicks;
|
||||||
public float CoreOverrunDrainPct;
|
public float CoreOverrunDrainPct;
|
||||||
|
public float FinalSiegeMultiplier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ namespace ProjectM.Simulation
|
|||||||
/// <summary>END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max).</summary>
|
/// <summary>END-1: Engine Core integrity to restore (0 = pre-v4 save / New Game -> born full at baked Max).</summary>
|
||||||
public int CoreCurrent;
|
public int CoreCurrent;
|
||||||
|
|
||||||
|
/// <summary>END-2: terminal run outcome to restore (0 = InProgress / pre-v5 save / New Game; 1 = Victory,
|
||||||
|
/// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn.</summary>
|
||||||
|
public byte RunOutcome;
|
||||||
|
|
||||||
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
|
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
|
||||||
public byte HasData;
|
public byte HasData;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ namespace ProjectM.Simulation
|
|||||||
[Serializable]
|
[Serializable]
|
||||||
public class SaveData
|
public class SaveData
|
||||||
{
|
{
|
||||||
public const int CurrentVersion = 4; // END-1: v4 adds CoreCurrent (a wounded base persists)
|
public const int CurrentVersion = 5; // END-2: v5 adds RunOutcome (a won/lost run loads finished); v4 added CoreCurrent
|
||||||
|
|
||||||
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
|
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
|
||||||
public const int MinLoadableVersion = 2;
|
public const int MinLoadableVersion = 2;
|
||||||
@@ -60,6 +60,7 @@ namespace ProjectM.Simulation
|
|||||||
public int GoalCharge;
|
public int GoalCharge;
|
||||||
public int GoalTarget;
|
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 int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max)
|
||||||
|
public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished
|
||||||
|
|
||||||
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
|
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
|
||||||
public StructureSave[] Structures = Array.Empty<StructureSave>();
|
public StructureSave[] Structures = Array.Empty<StructureSave>();
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
using Unity.NetCode;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// END-2 — server-only marker of which run-phase the macro loop is in. Lives on the GLOBAL CycleDirector
|
||||||
|
/// entity beside <see cref="CycleState"/>/<see cref="CycleRuntime"/>/<see cref="ThreatState"/>; NOT replicated
|
||||||
|
/// (the client never needs to distinguish "the final siege is armed" — the larger wave + telegraph already read
|
||||||
|
/// as escalation; the client shows the terminal banner from the replicated <see cref="RunOutcome"/> instead).
|
||||||
|
/// SINGLE writer: <c>GoalReachedSystem</c> flips <see cref="RunPhaseId.Normal"/> ->
|
||||||
|
/// <see cref="RunPhaseId.FinalDefense"/> exactly once when <see cref="GoalProgress.Charge"/> reaches Target.
|
||||||
|
/// Added at spawn by <c>CycleDirectorSpawnSystem</c> (like CycleRuntime/ThreatState), so it is server-world-only
|
||||||
|
/// and never on the ghost serializer (no re-hash). A <c>byte</c> (never an enum) so a Bursted reader can't trip
|
||||||
|
/// the cross-assembly-enum Burst ICE.
|
||||||
|
/// </summary>
|
||||||
|
public struct RunPhase : IComponentData
|
||||||
|
{
|
||||||
|
public byte Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Phase constants for <see cref="RunPhase.Value"/> (bytes — never an enum on a Bursted path).</summary>
|
||||||
|
public static class RunPhaseId
|
||||||
|
{
|
||||||
|
/// <summary>Normal play: scheduled / post-expedition sieges arm; the goal meter climbs +1 per survived siege.</summary>
|
||||||
|
public const byte Normal = 0;
|
||||||
|
|
||||||
|
/// <summary>The goal cap was reached: the larger FINAL siege is armed/running. No further sieges arm.</summary>
|
||||||
|
public const byte FinalDefense = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// END-2 — the terminal run outcome: the LATCHING win/lose state, REPLICATED so the client HUD shows the
|
||||||
|
/// victory/loss banner by simply observing it (no fragile client-side reconstruction). Rides the GLOBAL
|
||||||
|
/// untagged CycleDirector ghost (relevant to every connection in every region — it must NEVER be region-tagged;
|
||||||
|
/// the shared-global-state rule), one <c>[GhostField] byte</c> alongside <see cref="CoreIntegrity"/>/
|
||||||
|
/// <see cref="GoalProgress"/>. SINGLE writer: <c>CyclePhaseSystem</c> latches <see cref="RunOutcomeId.Victory"/>
|
||||||
|
/// (final siege cleared) or <see cref="RunOutcomeId.Loss"/> (Core breached during the final siege). Once it is
|
||||||
|
/// non-<see cref="RunOutcomeId.InProgress"/> the run HALTS (GoalReachedSystem + ThreatDirectorSystem stop arming;
|
||||||
|
/// CoreRestoreSystem stops regen). Baked onto the prefab so it is part of the ghost (adding this <c>[GhostField]</c>
|
||||||
|
/// re-hashes the CycleDirector ghost -> one re-bake); born-correct at spawn (InProgress for a New Game, or the
|
||||||
|
/// persisted value on Continue — SaveData v5).
|
||||||
|
/// </summary>
|
||||||
|
public struct RunOutcome : IComponentData
|
||||||
|
{
|
||||||
|
[GhostField] public byte Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome constants for <see cref="RunOutcome.Value"/> (bytes — never an enum on a Bursted/serialized path).</summary>
|
||||||
|
public static class RunOutcomeId
|
||||||
|
{
|
||||||
|
/// <summary>The run is live (no terminal result yet).</summary>
|
||||||
|
public const byte InProgress = 0;
|
||||||
|
|
||||||
|
/// <summary>The final siege was survived — the Engine holds. Terminal; the run halts.</summary>
|
||||||
|
public const byte Victory = 1;
|
||||||
|
|
||||||
|
/// <summary>The Core was breached during the final siege — overrun. Terminal; the run halts.</summary>
|
||||||
|
public const byte Loss = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8ce481dc9a135834fa6d59882895b0f5
|
||||||
Reference in New Issue
Block a user