LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)

The 2026-08-06 audit found the shipping scene was still the abandoned
co-op-Hades game with LANTERN combat bolted on, and that a third of the
codebase was live code for a direction abandoned on 2026-07-13. Operator
chose deletion over freezing: "everything is saved in source control if
needed. I want the project to be clean."

DELETED (~140 source files, Scripts 335->231, Tests 77->43):
- Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring /
  SwarmerAuthoring were attached to ZERO prefabs, so LungeState /
  SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI
  passes, BossAISystem (261 lines) and the whole MixBands escalation
  curve could not match a single chunk at runtime, while 734 lines of
  green tests certified them. Both shipping enemy prefabs were already
  byte-identical in stats.
- Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag,
  route select, portal interact, ready-check, room field/teardown.
- Meta shop, prep loadout, boons (incl. KillRewardSystem and
  DashTrailDamageSystem, which existed only to serve boon flags).
- Build palette + structures, shared storage, inventory/equipment
  (already recorded PAUSED in CLAUDE.md).
- The HUD panels driving all of the above (HudSystem 1168 -> 610).

KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter
for spawn rings, respawn and dynamic light), the resource ledger +
StorageMath, the save system, region/relevancy. Three of these were in
the delete set until I checked their consumers — worth remembering that
the file-level manifest was wrong about them.

Also folds in audit finding M5: PlayerClass was a second, server-only
copy of the byte FrameId already replicates. It existed for the meta
shop; with that gone, FrameId is the single frame identity.

Harvest is now single-sink (ledger). HarvestMath keeps its shape so
LANTERN's carried-vs-banked cargo split lands in one place, not two.

295/295 EditMode green, zero compile errors. Subscene re-bake and Play
validation follow in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:59:39 -07:00
parent 6a412fe3e7
commit 62e48a3b0b
304 changed files with 260 additions and 14591 deletions
@@ -19,7 +19,7 @@ namespace ProjectM.Simulation
/// AbilitySocket (loadout), SocketCooldown (hot per-socket cooldown), EffectiveSocketStats (per-socket
/// folded stats from StatRecomputeSystem). To stay under the 7-type SystemAPI.Query cap, the query holds
/// only PlayerInput/PlayerFacing/LocalTransform/GhostOwner and reads the socket data by entity via
/// BufferLookup/ComponentLookup (mirroring the BoonEffects lookup).
/// BufferLookup/ComponentLookup.okup.
///
/// SpawnId key (owner14 | socket2 | fireCount12 | fork4) reserves socket bits so two sockets firing the
/// same-prefab projectile on one tick classify to DISTINCT ghosts (the review's NP-1/RS-1/DB-3 fix). The
@@ -37,11 +37,9 @@ namespace ProjectM.Simulation
[BurstCompile]
public partial struct AbilityFireSystem : ISystem
{
// Server-only knockback stamp for the Cone (guarded + boss-immune).
// Server-only knockback stamp for the Cone.ss-immune).
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup;
// Owner-replicated mechanic-changer boons, read by the player entity.
ComponentLookup<BoonEffects> m_BoonEffectsLookup;
// Boss knockback-immunity and BoonEffects lookups deleted 2026-08-07 (audit purge).
// LANTERN socket kit, read by the player entity so the fire query stays at 4 type args (7-arg cap).
BufferLookup<AbilitySocket> m_SocketLookup;
ComponentLookup<SocketCooldown> m_SocketCdLookup;
@@ -65,8 +63,7 @@ namespace ProjectM.Simulation
state.RequireForUpdate<AbilityDatabase>();
state.RequireForUpdate<NetworkTime>();
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_SocketLookup = state.GetBufferLookup<AbilitySocket>(isReadOnly: true);
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
@@ -96,8 +93,7 @@ namespace ProjectM.Simulation
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
m_BoonEffectsLookup.Update(ref state);
m_SocketLookup.Update(ref state);
m_SocketCdLookup.Update(ref state);
m_EffSocketLookup.Update(ref state);
@@ -135,8 +131,8 @@ namespace ProjectM.Simulation
var effSockets = m_EffSocketLookup[entity];
var cd = m_SocketCdLookup[entity]; // struct copy; written back after mutation
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
// Boons deleted 2026-08-07 (audit purge): pull was the KnockToPull mechanic-changer.
const bool pull = false;
// 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the
// MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing).
@@ -154,7 +150,7 @@ namespace ProjectM.Simulation
{
float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
}
m_ConePendingLookup[entity] = default; // consume (drop on mismatch)
}
@@ -223,7 +219,7 @@ namespace ProjectM.Simulation
{
float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
}
m_ConePendingLookup[entity] = new ConeContactPending
{
@@ -236,7 +232,7 @@ namespace ProjectM.Simulation
// Legacy immediate (knob 0, or a plain test world without the baked pending slot).
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId,
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
}
}
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
@@ -333,10 +329,10 @@ namespace ProjectM.Simulation
dir = AutoTarget.Resolve(xform.ValueRO.Position, rawAim, es.AutoTargetRange, es.AutoTargetConeRadians, candidates);
}
byte pierce = bfx.Pierce;
byte chain = bfx.Chain;
byte pierce = 0; // boon Pierce deleted 2026-08-07
byte chain = 0; // boon Chain deleted 2026-08-07
byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags);
int shots = 1 + math.min((int)bfx.Fork, 8);
int shots = 1; // boon Fork deleted 2026-08-07
for (int s = 0; s < shots; s++)
{
@@ -391,7 +387,7 @@ namespace ProjectM.Simulation
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
NetworkTick serverTick, bool pull, in NativeList<Entity> coneTargets,
in NativeList<float3> coneTargetPos, ref EntityCommandBuffer ecb,
ref ComponentLookup<KnockbackState> knockbackLookup, in ComponentLookup<BossState> bossLookup)
ref ComponentLookup<KnockbackState> knockbackLookup)
{
float cRange = math.max(0.1f, es.Range);
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
@@ -406,7 +402,7 @@ namespace ProjectM.Simulation
SourceNetworkId = ownerNetId,
SourceTick = cStamp,
});
KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci],
KnockbackUtil.Stamp(ref knockbackLookup, coneTargets[ci],
casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed,
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
}
@@ -1,302 +0,0 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (<see cref="Kind"/>==0 —
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row, the
/// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
/// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
/// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
/// <c>BoonOffer</c> options + pick RPC carry. <see cref="Weight"/> is the rarity draw weight (common 100 /
/// uncommon 60 / rare 30 / epic 10). <see cref="ClassMask"/>: bit0 = Warrior (classId 0), bit1 = Ranger
/// (classId 1), 3 = both. <see cref="Family"/> tags synergy/dedup — no two same-family options in one deal.
/// </summary>
public struct BoonDefBlob
{
public byte Id;
public byte Target; // StatTarget as byte (Kind==0)
public byte Op; // ModOp as byte (Kind==0)
public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
public byte Weight;
public byte ClassMask;
public byte Kind; // 0 = stat, 1 = mechanic-changer (Phase 1.7)
public byte EffectKind; // BoonEffectKind byte (Kind==1)
public byte Family; // BoonFamily byte — dedup/dominated/bias tag
public FixedString64Bytes Name;
public FixedString128Bytes Desc;
}
/// <summary>
/// Synergy/dedup tags for <see cref="BoonDefBlob.Family"/>. Bytes (Burst-safe). No two options of the same
/// family are offered in one deal (dominated-offer protection — kills the "-15% vs -25% cooldown" case); owning
/// a mechanic family biases future offers toward it (light build-bias).
/// </summary>
public static class BoonFamily
{
public const byte None = 0;
public const byte Projectile = 1;
public const byte Melee = 2;
public const byte Mobility = 3;
public const byte OnKill = 4;
public const byte StatDamage = 5;
public const byte StatHealth = 6;
public const byte StatSpeed = 7;
public const byte StatCooldown = 8;
}
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
public struct BoonCatalogBlob
{
public BlobArray<BoonDefBlob> Defs;
}
/// <summary>Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene).</summary>
public struct BoonCatalog : IComponentData
{
public BlobAssetReference<BoonCatalogBlob> Value;
}
/// <summary>
/// Server-only bookkeeping for <c>BoonOfferSystem</c>, attached at runtime beside the catalog singleton (the
/// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room.
/// </summary>
public struct BoonOfferState : IComponentData
{
public int OfferedRoomEpoch;
}
/// <summary>
/// Pure, deterministic boon selection math — integer-hash only (<c>RunMapMath.Hash</c> chain, no RNG state), so
/// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is
/// safe because <c>BoonOfferSystem</c> draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch)
/// — the client never re-runs it. EditMode-tested.
/// </summary>
public static class BoonMath
{
/// <summary>Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger).</summary>
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
/// <summary>
/// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
/// already owns is excluded (dedup); no two options share a <see cref="BoonFamily"/> in one deal
/// (dominated-offer protection); a candidate whose family matches an owned effect's family draws at ×1.5
/// weight (light build-bias). Falls back deterministically when draws collide. Returns the number of
/// distinct ids (tail repeats the last when the legal pool has fewer than 3).
/// </summary>
public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool,
out byte o0, out byte o1, out byte o2)
{
byte classBit = MaskFor(classId);
int ownedFamilies = OwnedFamilyMask(owned);
var candidates = new FixedList128Bytes<byte>(); // catalog indices
var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
int totalWeight = 0;
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++)
{
if ((pool.Defs[i].ClassMask & classBit) == 0) continue;
if (pool.Defs[i].Weight == 0) continue;
if (IsOwnedFlag(pool.Defs[i], owned)) continue; // non-stacking flag already held → dedup
int w = pool.Defs[i].Weight;
byte fam = pool.Defs[i].Family;
if (fam != 0 && (ownedFamilies & (1 << fam)) != 0)
w += w / 2; // ×1.5 build-bias (integer)
if (w > 255) w = 255;
candidates.Add((byte)i);
weights.Add((byte)w);
totalWeight += w;
}
o0 = o1 = o2 = 0;
if (candidates.Length == 0)
return 0;
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
var pickedFamilies = new FixedList32Bytes<byte>(); // families used this deal (fam != 0)
uint salt = 0;
while (picked.Length < 3 && picked.Length < candidates.Length)
{
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
int chosen = candidates.Length - 1;
int acc = 0;
for (int c = 0; c < candidates.Length; c++)
{
acc += weights[c];
if (roll < (uint)acc) { chosen = c; break; }
}
byte drawn = candidates[chosen];
byte fam = pool.Defs[drawn].Family;
bool dup = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == drawn) { dup = true; break; }
bool famClash = false;
if (!dup && fam != 0)
for (int p = 0; p < pickedFamilies.Length; p++)
if (pickedFamilies[p] == fam) { famClash = true; break; }
if (!dup && !famClash)
{
picked.Add(drawn);
if (fam != 0) pickedFamilies.Add(fam);
salt = 0;
}
else if (++salt > 16)
{
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
salt = 0;
}
}
o0 = picked.Length > 0 ? pool.Defs[picked[0]].Id : (byte)0;
o1 = picked.Length > 1 ? pool.Defs[picked[1]].Id : o0;
o2 = picked.Length > 2 ? pool.Defs[picked[2]].Id : o1;
return picked.Length;
}
/// <summary>Deterministic tail-fill when the weighted draw keeps colliding: take the first unused candidate
/// that is family-distinct from the deal; if none, the first unused (family clash tolerated as last resort so
/// the deal never wedges below 3 while candidates remain).</summary>
static void AddFallback(ref FixedList32Bytes<byte> picked, ref FixedList32Bytes<byte> pickedFamilies,
in FixedList128Bytes<byte> candidates, ref BoonCatalogBlob pool)
{
int firstUnused = -1;
for (int c = 0; c < candidates.Length; c++)
{
byte cand = candidates[c];
bool used = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == cand) { used = true; break; }
if (used) continue;
if (firstUnused < 0) firstUnused = cand;
byte cfam = pool.Defs[cand].Family;
bool clash = false;
if (cfam != 0)
for (int p = 0; p < pickedFamilies.Length; p++)
if (pickedFamilies[p] == cfam) { clash = true; break; }
if (clash) continue;
picked.Add(cand);
if (cfam != 0) pickedFamilies.Add(cfam);
return;
}
if (firstUnused >= 0)
picked.Add((byte)firstUnused);
}
/// <summary>True when a candidate is a non-stacking FLAG effect the player already owns (dedup). Pierce/Fork/
/// Chain STACK, so they're never excluded. Byte switch — Burst-safe.</summary>
static bool IsOwnedFlag(in BoonDefBlob d, in BoonEffects owned)
{
if (d.Kind != 1) return false;
switch (d.EffectKind)
{
case BoonEffectKind.DashTrail: return (owned.Flags & BoonFlag.DashTrail) != 0;
case BoonEffectKind.FinisherDetonate: return (owned.Flags & BoonFlag.FinisherDetonate) != 0;
case BoonEffectKind.KnockToPull: return (owned.Flags & BoonFlag.KnockToPull) != 0;
case BoonEffectKind.Siphon: return (owned.Flags & BoonFlag.Siphon) != 0;
case BoonEffectKind.Frenzy: return (owned.Flags & BoonFlag.Frenzy) != 0;
default: return false;
}
}
/// <summary>Bitmask (indexed by <see cref="BoonFamily"/> value) of the MECHANIC families the player owns —
/// drives the ×1.5 build-bias. Stat families are never marked (build-bias is mechanic-synergy only).</summary>
static int OwnedFamilyMask(in BoonEffects owned)
{
int m = 0;
if (owned.Pierce != 0 || owned.Fork != 0 || owned.Chain != 0) m |= 1 << BoonFamily.Projectile;
if ((owned.Flags & (BoonFlag.FinisherDetonate | BoonFlag.KnockToPull)) != 0) m |= 1 << BoonFamily.Melee;
if ((owned.Flags & BoonFlag.DashTrail) != 0) m |= 1 << BoonFamily.Mobility;
if ((owned.Flags & (BoonFlag.Siphon | BoonFlag.Frenzy)) != 0) m |= 1 << BoonFamily.OnKill;
return m;
}
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
public static int FindDef(ref BoonCatalogBlob pool, byte id)
{
for (int i = 0; i < pool.Defs.Length; i++)
if (pool.Defs[i].Id == id) return i;
return -1;
}
}
/// <summary>
/// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
/// (<see cref="BoonDefBlob.Kind"/>==1) + 4 strong flat-stat boons (Kind==0). Ids are within-session stable (both
/// worlds bake the same code; boons never persist across saves — stripped on the Returning edge).
/// </summary>
public static class BoonCatalogData
{
/// <summary>Build the default catalog blob (caller owns/disposes the reference).</summary>
public static BlobAssetReference<BoonCatalogBlob> BuildDefault(Allocator allocator = Allocator.Persistent)
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, 12);
int i = 0;
// ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
// (the Warrior's Fire is a cone, not a projectile). ----
defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
// ---- 4 strong flat-stat boons (Kind=0) ----
defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage");
defs[i++] = Stat(10, StatTarget.MaxHealth, ModOp.Flat, 60f, 100, 3, BoonFamily.StatHealth, "Titan's Vigor", "+60 max health");
defs[i++] = Stat(11, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.18f, 100, 3, BoonFamily.StatSpeed, "Fleet Foot", "+18% move speed");
defs[i++] = Stat(12, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 60, 3, BoonFamily.StatCooldown, "Berserker's Pace", "-25% ability cooldown");
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
builder.Dispose();
return blob;
}
/// <summary>A flat-stat boon row (Kind=0 — appends a <see cref="StatModifier"/>).</summary>
static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = (byte)target,
Op = (byte)op,
Value = value,
Weight = weight,
ClassMask = mask,
Kind = 0,
EffectKind = BoonEffectKind.None,
Family = family,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
/// <summary>A mechanic-changer boon row (Kind=1 — mutates <see cref="BoonEffects"/>). <paramref name="value"/>
/// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects.</summary>
static BoonDefBlob Effect(byte id, byte effectKind, float value, byte weight, byte mask, byte family,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = 0,
Op = 0,
Value = value,
Weight = weight,
ClassMask = mask,
Kind = 1,
EffectKind = effectKind,
Family = family,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 00909311d983d7a43afc195595aff217
@@ -1,61 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat
/// <see cref="StatModifier"/> band. Stackable counts (<see cref="Pierce"/>/<see cref="Fork"/>/<see cref="Chain"/>)
/// and boolean <see cref="Flags"/> (see <see cref="BoonFlag"/>) that combat systems read to alter behaviour.
/// <para>
/// Replicated <see cref="SendToOwnerType.SendToOwner"/> (matching <c>BoonOffer</c>): rollback-correctness is
/// provided by the <c>[GhostField]</c>s themselves — the owner is the sole predicting client and needs the
/// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in <c>AbilityFireSystem</c>, which
/// filters <c>.WithAll&lt;Simulate&gt;()</c>) don't mispredict. Non-owning clients render forked/pierced/chained
/// projectiles as interpolated server ghosts and never read the shooter's effects; every other read is
/// server-only. NOT <see cref="SendToOwnerType.All"/> — the send type is not what enables rollback, the
/// <c>[GhostField]</c> is.
/// </para>
/// Baked INERT (all 0) on the player prefab (the <c>BoonOffer</c> idiom) so a pick is a non-structural mutate;
/// zeroed on the Returning edge in <c>RunDirectorSystem</c> alongside the StatModifier band strips.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
public struct BoonEffects : IComponentData
{
/// <summary>Extra enemy hits a projectile survives before despawning (stacks).</summary>
[GhostField] public byte Pierce;
/// <summary>Extra spread projectiles spawned per shot (stacks).</summary>
[GhostField] public byte Fork;
/// <summary>Targets a projectile chains to after a hit (stacks).</summary>
[GhostField] public byte Chain;
/// <summary>Boolean effect bits — see <see cref="BoonFlag"/>.</summary>
[GhostField] public byte Flags;
}
/// <summary>Bit masks for <see cref="BoonEffects.Flags"/>. Plain byte consts (never an enum compared in Burst).</summary>
public static class BoonFlag
{
public const byte DashTrail = 1; // dashing damages enemies passed through
public const byte FinisherDetonate = 2; // the melee combo finisher blasts an AoE
public const byte KnockToPull = 4; // this player's knockback pulls enemies IN instead of away
public const byte Siphon = 8; // killing an enemy heals this player
public const byte Frenzy = 16; // a kill grants a short cooldown-reduction surge
}
/// <summary>
/// Stable byte discriminator for a <c>BoonDefBlob</c> mechanic-changer effect (0 = a plain stat boon).
/// Bytes only — Burst-safe, never an enum compared inside a Bursted system.
/// </summary>
public static class BoonEffectKind
{
public const byte None = 0;
public const byte Pierce = 1;
public const byte Fork = 2;
public const byte Chain = 3;
public const byte DashTrail = 4;
public const byte FinisherDetonate = 5;
public const byte KnockToPull = 6;
public const byte Siphon = 7;
public const byte Frenzy = 8;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 76c925707efba46478fb9c697d391e0d
@@ -1,17 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server boon pick: <see cref="Index"/> (0/1/2) into the sender's OWN replicated <c>BoonOffer</c>
/// options. Server-validated (<c>Pending==1</c>, index in range, <c>RunInfo.Lifecycle==RoomReward</c> — the
/// D-F4 gate that stops a grace-timeout straggler pick landing after the Returning-edge strip). UNCONDITIONAL
/// wire type, blittable scalar. Declared at Step 3 (wire front-load); consumed by <c>BoonApplySystem</c> from
/// Step 10.
/// </summary>
public struct BoonPickRequest : IRpcCommand
{
/// <summary>The chosen option slot: 0, 1, or 2.</summary>
public byte Index;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: cc9680d4a4c9a334396b60bb97d75b3b
@@ -1,40 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that <see cref="Server"/>'s
/// RoomEnemyDirectorSystem tags at spawn). NOT replicated and NOT baked — added at runtime via ECB on the boss
/// entity, so it needs no ghost-hash change (a runtime-added replicated component would not replicate anyway;
/// this one is deliberately server-only, like <see cref="LungeState"/>/<see cref="KnockbackState"/>).
/// <para>
/// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of
/// <c>.WithAll&lt;EnemyTag, BossState&gt;()</c>, and EnemyAISystem's Charger MOVE pass excludes it via
/// <c>.WithNone&lt;BossState&gt;()</c> so exactly one system writes the boss's Position/AttackWindup. The boss does
/// NOT use LungeState (its signature move is a telegraphed radial SLAM, not a lunge) — so EnemyAISystem's
/// IsLunging derive visits it but sees <c>LungeState.UntilTick==0</c> and derives the bit off (harmless single
/// writer). <see cref="Phase"/> is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule).
/// All tick fields route through <c>TickUtil.NonZero</c> and compare with <see cref="Unity.NetCode.NetworkTick"/>.
/// </para>
/// </summary>
public struct BossState : IComponentData
{
/// <summary>1 = phase one (heavy Charger + slam), 2 = phase two (&lt;50% HP: faster + summons adds). Byte, not enum.</summary>
public byte Phase;
/// <summary>Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready).</summary>
public uint SlamReadyTick;
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
public uint SummonReadyTick;
/// <summary>Earliest raw tick the boss may begin its next LUNGE wind-up (B4; NonZero; 0 = ready).</summary>
public uint LungeReadyTick;
/// <summary>Which attack the live AttackWindup belongs to: 0 = radial slam, 1 = lunge (B4 — slam and lunge
/// share the one replicated windup field; this server-only byte disambiguates the elapse branch). The client
/// distinguishes via the IsLunging ghost bit instead (BossAISystem holds LungeState.UntilTick through the
/// lunge wind-up + travel, so EnemyAISystem's derive turns the bit on).</summary>
public byte PendingAttack;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fdf576e1f07162e43bae89c4ccc06dec
@@ -3,57 +3,25 @@ using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// The ONE in-place class-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing
/// base ClassSelect (Staging). A class swap is much more than re-seeding: the pre-code review (DR-046) confirmed
/// that swapping only the class-seed band leaves the OLD class's PERMANENT META rows on the buffer and omits the
/// NEW class's — so a base swap would drain Aether into the wrong class's record and mis-set Max HP. This helper
/// mirrors the (previously editor-only) full swap: <see cref="ClassTraits.Reapply"/> (class-seed band) + the meta
/// band strip + per-class <see cref="MetaTierState"/> replay. The caller then writes AbilityRef/PlayerClass/
/// AbilityCooldown and calls <see cref="HealClamp"/> (a static can't resolve singletons or SystemAPI.SetComponent,
/// so the caller passes the resolved pieces). Server-authoritative + prediction-correct (StatRecomputeSystem
/// refolds EffectiveCharacterStats next tick).
/// The ONE in-place frame-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing
/// frame select. Re-seeds the frame's stat band via <see cref="ClassTraits.Reapply"/>; the caller then writes
/// FrameId/PlayerClass, re-seeds the socket loadout, and calls <see cref="HealClamp"/> (a static can't resolve
/// singletons or SystemAPI.SetComponent, so the caller passes the resolved pieces). Server-authoritative +
/// prediction-correct (StatRecomputeSystem refolds EffectiveCharacterStats next tick).
///
/// HISTORY (2026-08-07 audit purge): this also used to strip and replay a PERMANENT-META band
/// (MetaUpgradeCatalog + MetaTierState) so an Aether-bought upgrade followed the frame across a swap. The meta
/// shop belonged to the superseded base/expedition direction and was deleted; only the frame band remains.
/// </summary>
public static class ClassSwapUtil
{
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
/// <paramref name="mods"/>. Returns the normalized class + its Fire ability id (the caller sets AbilityRef).
/// <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the strip still runs).</summary>
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
/// <paramref name="mods"/>. Returns the normalized class (the caller writes FrameId/PlayerClass + re-seeds
/// the socket loadout). <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the
/// strip still runs).</summary>
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord,
out byte newClass)
/// <summary>Re-seed the frame stat band for <paramref name="rawClass"/> on <paramref name="mods"/>.
/// Returns the normalized frame id (the caller writes FrameId/PlayerClass + re-seeds the socket
/// loadout).</summary>
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods, out byte newClass)
{
newClass = ClassTraits.Normalize(rawClass);
ClassTraits.Reapply(newClass, mods);
// Strip the OLD class's meta rows (Reapply only touched the class-seed band), then replay the NEW class's
// persisted tiers (the GoInGame skip/clamp rules) so the permanent channel stays correct across the swap.
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.MetaSourceIdBase,
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
if (haveMeta && metaCat.Value.IsCreated && metaRecord.IsCreated)
{
ref var metaPool = ref metaCat.Value.Value;
byte metaBit = BoonMath.MaskFor(newClass);
for (int mi = 0; mi < metaRecord.Length; mi++)
{
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
if (defIdx < 0) continue;
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
mods.Add(new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
});
}
}
}
/// <summary>Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the
@@ -1,53 +0,0 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the
/// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock
/// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO
/// Health, so it is invisible to every WithAll&lt;Health&gt; target loop (player melee/projectile hit-tests can
/// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by
/// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem.
/// </summary>
public struct EnemyProjectile : IComponentData
{
/// <summary>Planar heading (world XZ -> float2 x,y), unit length, locked at spawn.</summary>
public float2 Direction;
/// <summary>Travel speed (world units/second).</summary>
public float Speed;
/// <summary>Damage applied to the first valid same-region target hit.</summary>
public float Damage;
/// <summary>Max travel distance before it expires (world units).</summary>
public float Range;
/// <summary>Accumulated travelled distance (server-only; drives range-expiry).</summary>
public float DistanceTravelled;
/// <summary>Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the
/// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the
/// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling.</summary>
public float LastStep;
/// <summary>Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any
/// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER
/// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard.</summary>
public byte Region;
}
/// <summary>
/// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it
/// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab).
/// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap
/// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern).
/// </summary>
public struct SpitterProjectilePrefab : IComponentData
{
public Entity Prefab;
public int MaxLiveProjectiles;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8a31a7b0c834ae24db480005ffdb6a15
@@ -5,20 +5,23 @@ namespace ProjectM.Simulation
{
/// <summary>
/// Shared knockback stamp for melee/cone hits. Guarded exactly as the two call sites were: the target must own
/// <see cref="KnockbackState"/> (dummies/structures lacking it would throw at ECB playback if written) and must
/// NOT be a boss (<see cref="BossState"/> = knockback-immune, A4). The planar (XZ) heading is
/// normalize(targetPos - sourcePos), falling back to <paramref name="faceFallback"/> when that delta is
/// degenerate. Deduplicates the identical stamps in <see cref="AbilityFireSystem"/> (Warrior cone) and
/// <see cref="MeleeComboSystem"/> (melee cleave). Callers still gate their own speed/window (e.g. the melee
/// <see cref="KnockbackState"/> (dummies/structures lacking it would throw at ECB playback if written). The
/// planar (XZ) heading is normalize(targetPos - sourcePos), falling back to <paramref name="faceFallback"/>
/// when that delta is degenerate. Deduplicates the identical stamps in <see cref="AbilityFireSystem"/> (cone)
/// and <see cref="MeleeComboSystem"/> (melee cleave). Callers still gate their own speed/window (e.g. the melee
/// KnockSpeed &gt; 0 check) before calling.
///
/// The former BossState knockback-immunity gate was removed with the boss purge (2026-08-07 audit): the boss
/// query required LungeState, which no prefab baked, so BossAISystem matched nothing and the immunity branch
/// was unreachable. Reintroduce a per-target immunity flag when the LANTERN shelf-boss lands (Phase 6).
/// </summary>
static class KnockbackUtil
{
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup,
public static void Stamp(ref ComponentLookup<KnockbackState> lookup,
Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick, bool pull = false)
{
if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
return;
if (!lookup.HasComponent(target))
return;;
float3 delta = targetPos - sourcePos;
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback;
@@ -1,49 +0,0 @@
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger
/// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted):
/// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch
/// (which excludes these via <c>.WithNone&lt;LungeState&gt;()</c>). On a wind-up commit the Charger LOCKS
/// <see cref="Dir"/> toward the target and travels at <see cref="Speed"/> until <see cref="UntilTick"/> — dealing
/// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot).
/// NOT a <c>[GhostField]</c> (the lunged position replicates via the stock LocalTransform variant, like
/// KnockbackState). All ticks via <c>TickUtil.NonZero</c>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
/// </summary>
public struct LungeState : IComponentData
{
/// <summary>Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y).</summary>
public float2 Dir;
/// <summary>Lunge speed (world units/s); only meaningful while <see cref="UntilTick"/> is active.</summary>
public float Speed;
/// <summary>Raw tick the lunge ends (NonZero). <c>0</c> = not lunging. Active while .IsNewerThan(serverTick).</summary>
public uint UntilTick;
/// <summary>Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not
/// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit
/// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
public uint StaggerUntilTick;
}
/// <summary>
/// REPLICATED enableable MID-LUNGE flag on a Charger (Slice 1, Feature D). ENABLED for exactly the ticks a
/// Charger is committed to its locked-direction lunge (<see cref="LungeState.UntilTick"/> active), DISABLED
/// otherwise. The ONLY replicated Charger surface beyond the stock LocalTransform — a <c>[GhostEnabledBit]</c>,
/// NOT a [GhostField], because the client needs only on/off: the lunge HEADING is already carried by the
/// replicated LocalTransform.Rotation (EnemyAISystem writes LookRotationSafe(lungeDir) each lunge tick), so the
/// client indicator derives direction via AnimParamMath.PlanarForward like the danger cone already does. Fixes
/// the cue VANISHING at commit (AttackWindup zeroes on commit, so a windup-gated cone disappears exactly when
/// the danger is realest): this bit STAYS on through the committed travel. Server-derived once per tick from
/// LungeState.UntilTick in EnemyAISystem (the sole LungeState writer); BAKE DISABLED (a Charger spawns
/// not-lunging) + visit via .WithPresent&lt;IsLunging&gt;() to write the bit while disabled (the Dead idiom).
/// </summary>
[GhostEnabledBit]
public struct IsLunging : IComponentData, IEnableableComponent { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: cc65446b98bef1040bc5b9beaac094ba
@@ -1,30 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition
/// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic
/// <see cref="ZoneEnemyMath"/>.{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG -&gt;
/// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus
/// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition)
/// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one
/// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old
/// 2-type <see cref="ZoneEnemyMath.WaveSize"/> / <see cref="ZoneEnemyMath.IsChargerSlot"/> exactly (a parity test
/// pins this, so the base-siege size curve is provably unchanged where it must be).
/// </summary>
public struct MixBands : IComponentData
{
public int GruntBase;
public int ChargerBase;
public int SpitterBase;
public int SwarmerSlotBase;
public int ChargerPerEpoch;
public int SpitterPerEpoch;
public int SwarmerSlotPerEpoch;
/// <summary>Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds
/// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size.</summary>
public int SwarmerPackPerEpoch;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 850f904d96b1c7d41959dddbdbf0b4b5
@@ -1,42 +0,0 @@
namespace ProjectM.Simulation
{
/// <summary>One base "prep loadout" option: spend a base resource before launch for a RUN-SCOPED stat buff
/// (stripped on the Returning edge like a boon). Mechanical fields only — the HUD supplies display labels.</summary>
public struct PrepRow
{
public byte Id;
public byte CostResId; // ResourceId.*
public int Cost;
public byte Target; // StatTarget
public byte Op; // ModOp
public float Value;
}
/// <summary>
/// The base PREP-LOADOUT catalog (DR-046): the player funds each run's power from base resources at Staging. A
/// purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep SourceId band
/// (<see cref="Tuning.PrepSourceIdBase"/> + Id), which <see cref="Server"/>'s PrepPurchaseSystem gates once-per-run
/// by that SourceId's PRESENCE (its lifetime == the band, stripped on Returning — so it re-buys next run for free,
/// no separate latch). A plain managed static table (read by the non-Burst receiver + the managed HUD).
/// </summary>
public static class PrepCatalog
{
public static readonly PrepRow[] Rows =
{
new PrepRow { Id = 0, CostResId = ResourceId.Ore, Cost = 30, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f },
new PrepRow { Id = 1, CostResId = ResourceId.Biomass, Cost = 40, Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.12f },
new PrepRow { Id = 2, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
new PrepRow { Id = 3, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
};
public static int Count => Rows.Length;
public static bool TryGet(byte id, out PrepRow row)
{
for (int i = 0; i < Rows.Length; i++)
if (Rows[i].Id == id) { row = Rows[i]; return true; }
row = default;
return false;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ef0c16b1e46d22c42bf38db14b2983b5
@@ -1,16 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server: buy a base PREP-LOADOUT option (<see cref="PrepCatalog"/> id). Honored ONLY in Staging; the
/// server prices it from the catalog (never on the wire), does an in-loop <see cref="StorageMath.TotalOf"/>
/// pre-check BEFORE <see cref="StorageMath.Withdraw"/> (DR-014 atomicity), and appends the run-scoped
/// <see cref="StatModifier"/> once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type.
/// </summary>
public struct PrepPurchaseRequest : IRpcCommand
{
/// <summary>Prep-catalog option id.</summary>
public byte OptionId;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e945968f38977974f926709051f28609
@@ -1,45 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no
/// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant
/// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger
/// branch (the AI partitions Spitter = .WithAll&lt;EnemyTag,SpitterState&gt;().WithNone&lt;LungeState&gt;() so no
/// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too
/// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If
/// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField]
/// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only.
/// </summary>
public struct SpitterState : IComponentData
{
/// <summary>Band centre: the distance the Spitter tries to hold from its target (world units).</summary>
public float PreferredRange;
/// <summary>Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds.</summary>
public float RangeTolerance;
/// <summary>Muzzle speed baked onto the spit projectile (world units/second).</summary>
public float ProjectileSpeed;
/// <summary>If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back.</summary>
public float CorneredRange;
/// <summary>Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not
/// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot.</summary>
public int WindupTicks;
/// <summary>Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its
/// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan.</summary>
public uint NextShotTick;
}
/// <summary>
/// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the
/// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The
/// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps
/// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged.
/// </summary>
public struct SwarmerTag : IComponentData { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: be9404154fd4f964099918079d2da6b8
@@ -3,101 +3,32 @@ using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic composition math for the expedition zone-enemy wave — no RNG state, no wall-clock — so the
/// per-epoch wave is reproducible across restarts/saves and EditMode-unit-testable without an ECS world (mirrors
/// <see cref="EnemyAIMath"/> / <c>ProductionMath</c>). The highest-leverage Slice-3 variety lever: the encounter
/// COMPOSITION shifts grunt-heavy -&gt; charger-heavy as the expedition <c>epoch</c> climbs (grunt count stays
/// fixed; the per-epoch growth is all chargers).
/// Pure, deterministic wave-size math — no RNG state, no wall-clock — so a wave is reproducible across
/// restarts/saves and EditMode-unit-testable without an ECS world (mirrors <see cref="EnemyAIMath"/>).
///
/// HISTORY (2026-08-07 audit purge): this class used to carry a 4-kind weighted composition
/// (Grunt/Charger/Spitter/Swarmer) driven by a MixBands struct. The Charger/Spitter/Swarmer authoring
/// components were on ZERO prefabs, so LungeState/SpitterState/SwarmerTag were never baked and every
/// branch of that math resolved to Grunt at runtime — the escalation curve was inert while 734 lines of
/// green tests certified it. The composition layer was deleted; the LANTERN bestiary (Drowner, Grindylow,
/// Wrecker, Wisp-Choir) will reintroduce variety through the CreatureKit path, not through this file.
/// Recover the old version from git if the weighted-slot model is wanted again.
/// </summary>
public static class ZoneEnemyMath
{
/// <summary>
/// Total enemies in this epoch's wave: the baked <paramref name="gruntsPerWave"/> + <paramref name="chargersPerWave"/>
/// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied
/// expedition always has a fight. <paramref name="epoch"/> is the monotonic sortie counter (&gt;=1 in practice).
/// </summary>
public static int WaveSize(int epoch, int gruntsPerWave, int chargersPerWave)
{
int e = math.max(1, epoch);
int baseCount = math.max(0, gruntsPerWave) + math.max(0, chargersPerWave);
return math.max(1, baseCount + (e - 1));
}
/// <summary>
/// Deterministic grunt/charger pick for spawn <paramref name="slot"/> of this epoch's wave. The charger
/// count is <paramref name="chargersPerWave"/> + (epoch - 1), clamped to the wave size, assigned to the LAST
/// slots; everything earlier is a Grunt. So the grunt count stays fixed at <paramref name="gruntsPerWave"/>
/// and the wave skews charger-heavy as the epoch climbs. Returns true for a Charger slot. Stable per
/// (epoch, slot) — a replayed wave is identical. Pure integer math (Burst-safe; no enum, no RNG).
/// </summary>
public static bool IsChargerSlot(int epoch, int slot, int gruntsPerWave, int chargersPerWave)
{
int e = math.max(1, epoch);
int size = WaveSize(epoch, gruntsPerWave, chargersPerWave);
int chargers = math.clamp(math.max(0, chargersPerWave) + (e - 1), 0, size);
int s = ((slot % size) + size) % size;
return s >= size - chargers;
}
// ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ----
// Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted).
/// <summary>The single enemy kind. Directors index a per-Kind prefab buffer by this byte; kept as a
/// byte (not an enum) because <c>EnemyAISystem</c> is Bursted and cross-assembly enums trip Burst.</summary>
public const byte KindGrunt = 0;
public const byte KindCharger = 1;
public const byte KindSpitter = 2;
public const byte KindSwarmer = 3;
/// <summary>
/// Total SLOTS in this epoch/wave under <paramref name="bands"/>: GruntBase + the per-kind ramped counts
/// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight.
/// A swarmer SLOT expands to a pack at spawn (<see cref="PackSizeForSlot"/>), so this counts packs, not
/// individual swarmers. For the LEGACY band it equals <see cref="WaveSize"/> (parity-tested). Pure integer.
/// Total enemies in this wave: <paramref name="baseCount"/> plus one extra per epoch beyond the first
/// (a gentle ramp). Lower-bounded at 1 so an occupied arena always has a fight. <paramref name="epoch"/>
/// is the monotonic wave counter (&gt;=1 in practice). Pure integer math; Burst-safe.
/// </summary>
public static int WaveSlots(int epoch, in MixBands bands)
public static int WaveSize(int epoch, int baseCount)
{
int e = math.max(1, epoch);
int grunts = math.max(0, bands.GruntBase);
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
return math.max(1, grunts + chargers + spitters + swarmers);
}
/// <summary>
/// Deterministic Kind byte for spawn <paramref name="slot"/> of this epoch/wave. Slots are partitioned in a
/// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy
/// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds
/// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this
/// returns KindCharger on exactly the slots the old <see cref="IsChargerSlot"/> did (parity-tested). Pure.
/// </summary>
public static byte KindForSlot(int epoch, int slot, in MixBands bands)
{
int e = math.max(1, epoch);
int size = WaveSlots(epoch, bands);
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor
int s = ((slot % size) + size) % size;
if (s < grunts) return KindGrunt;
s -= grunts;
if (s < spitters) return KindSpitter;
s -= spitters;
if (s < chargers) return KindCharger;
s -= chargers;
if (s < swarmers) return KindSwarmer;
return KindGrunt; // defensive: unreachable while counts sum to size
}
/// <summary>
/// Swarmer cluster size for a swarmer slot: <paramref name="basePackSize"/> plus the (default-0)
/// <see cref="MixBands.SwarmerPackPerEpoch"/> ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack;
/// the field is exposed for later tuning.
/// </summary>
public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize)
{
int e = math.max(1, epoch);
return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1));
return math.max(1, math.max(0, baseCount) + (e - 1));
}
}
}