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:
@@ -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