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
@@ -1,19 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to build a structure of <see cref="StructureType"/> at grid cell
/// (<see cref="CellX"/>, <see cref="CellZ"/>). A one-off action, so an RPC (mirrors StorageOpRequest).
/// StructureType is a byte; the cell is two int scalars (NOT an int2) to stay
/// within the project's scalar-only RPC payload precedent (avoids first-of-its-kind composite-math-in-RPC
/// codegen risk on Netcode 1.x). The server re-validates legality + cost authoritatively.
/// </summary>
public struct BuildPlaceRequest : IRpcCommand
{
public byte StructureType;
public int CellX;
public int CellZ;
public byte Direction;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dbcc491dc3dd853459cd8cfad2458b17
@@ -1,24 +0,0 @@
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic build-placement helpers (unit-tested like <see cref="BaseGridMath"/> /
/// <c>StorageMath</c>). Occupancy is DERIVED from the live structure set each placement (the structure
/// ghosts are the source of truth — restart- and replay-order-safe), never cached on the immutable
/// baked <see cref="BaseAnchor"/>. The server passes a Temp <see cref="NativeHashSet{T}"/> of occupied
/// cells built by scanning live <see cref="PlacedStructure"/> ghosts.
/// </summary>
public static class BuildPlacementMath
{
/// <summary>True if <paramref name="cell"/> is occupied in the derived set.</summary>
public static bool IsOccupied(in NativeHashSet<int2> occupied, int2 cell) => occupied.Contains(cell);
/// <summary>Full server placement legality: the cell is in-plot (half-open, negative-safe) AND not occupied.</summary>
public static bool CanPlace(in BaseAnchor anchor, in NativeHashSet<int2> occupied, int2 cell)
{
return BaseGridMath.IsCellInPlot(anchor, cell) && !occupied.Contains(cell);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 203dcbd4f9cc089408633b0bb6ccb2c1
@@ -1,31 +0,0 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure validity check for the client build-placement PREVIEW (the ground-ghost colour) — the same legality
/// the server re-validates authoritatively in BuildPlaceSystem, computed client-side so the ghost can read
/// green (valid) vs red (why-not). No managed types / RNG / wall-clock → unit-testable. The caller supplies
/// the live occupancy result + the affordability inputs (it owns the structure scan + the ledger read).
/// </summary>
public static class BuildPreviewMath
{
public const byte Valid = 0;
public const byte OutOfPlot = 1;
public const byte Occupied = 2;
public const byte Unaffordable = 3;
/// <summary>
/// Evaluate placement at <paramref name="cell"/>: must be in-plot, unoccupied, and affordable.
/// <paramref name="occupied"/> = the caller's live-structure cell check; <paramref name="have"/>/<paramref name="cost"/>
/// the resource on hand vs the catalog cost. Returns the first failing reason, else <see cref="Valid"/>.
/// </summary>
public static byte Evaluate(in BaseAnchor anchor, int2 cell, bool occupied, int have, int cost)
{
if (!BaseGridMath.IsCellInPlot(anchor, cell)) return OutOfPlot;
if (occupied) return Occupied;
if (have < cost) return Unaffordable;
return Valid;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4deb41f803f65bd4b946354e4a2adcf2
@@ -1,72 +0,0 @@
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Structure type ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). Ids 1-4 are
/// RETIRED (EB-2 turret defense + M7 automation machines, deleted in the LANTERN purge) and stay reserved
/// so PlacedStructure.Type's [GhostField] serializer + old save bytes never re-mean.
/// </summary>
public static class StructureType
{
public const byte None = 0;
// RETIRED ids — reserved, do not reuse:
public const byte Turret = 1;
public const byte Harvester = 2;
public const byte Fabricator = 3;
public const byte Conveyor = 4;
// Live buildables:
public const byte Wall = 5;
public const byte Pylon = 6;
}
/// <summary>
/// A built base structure occupying one grid cell. An ownerless INTERPOLATED ghost (RegionTag{Base},
/// world-owned, runtime-spawned by BuildPlaceSystem). <see cref="Type"/> is the only replicated field
/// (a cheap byte for client visual branching); <see cref="Cell"/> is server-only (clients derive it from
/// the replicated LocalTransform via <see cref="BaseGridMath.WorldToCell"/>, so it stays off the wire).
/// <see cref="NextTick"/> / <see cref="LastProcessedTick"/> are server-only raw NetworkTick values
/// (<see cref="TickUtil.NonZero"/>-guarded; 0 = inactive), kept for future timed structures (the turret
/// cooldown + production catch-up that used them are retired — LANTERN purge).
/// </summary>
public struct PlacedStructure : IComponentData
{
/// <summary>Structure type (see <see cref="StructureType"/>); the only replicated field.</summary>
[GhostField] public byte Type;
/// <summary>Occupied grid cell (server-only; clients derive it from LocalTransform).</summary>
public int2 Cell;
/// <summary>Next action tick (server-only). 0 = inactive.</summary>
public uint NextTick;
/// <summary>Last tick this structure was processed (server-only). Stamped at spawn.</summary>
public uint LastProcessedTick;
}
/// <summary>
/// One row of the build catalog: cost + prefab per structure type. Modeled on AbilityPrefabElement
/// (prefab baked via GetEntity, NEVER inside a blob — blobs don't remap entity refs).
/// </summary>
public struct StructureCatalogEntry : IBufferElementData
{
public byte Type;
public Entity Prefab;
public byte CostResourceId;
public int CostAmount;
}
/// <summary>Tag on the baked singleton carrying the <see cref="StructureCatalogEntry"/> buffer (the build cost/prefab table).</summary>
public struct StructureCatalog : IComponentData { }
/// <summary>
/// Marks a structure PLACED by a player at runtime (BuildPlaceSystem) or restored from a save — i.e. the
/// persistable set, as opposed to anything baked into the subscene. SaveWriteSystem scans only these and
/// BaseRestoreSystem re-adds the tag, so save/restore is the single source of truth for player builds.
/// Server-only (not replicated). (Re-homed here from the retired automation components — LANTERN purge.)
/// </summary>
public struct RuntimePlacedTag : IComponentData { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 00d3379caf4807d4ebd97432848dd5d5
@@ -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));
}
}
}
@@ -1,4 +1,3 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
@@ -6,49 +5,29 @@ namespace ProjectM.Simulation
/// <summary>
/// Shared harvest-yield deposit routing used by BOTH the projectile-sweep harvest (ResourceHarvestSystem) and
/// the melee-cone harvest (MeleeComboSystem), so the two can't drift. (They previously did: the melee path
/// hard-coded <see cref="Tuning.DefaultStackMax"/> and silently ignored per-item stack caps.) Base-region yield
/// credits the shared ledger DIRECTLY (the build-currency pool); expedition / un-tagged yield routes to the
/// harvesting player's PERSONAL inventory — per-item StackMax from the item catalog, fallback DefaultStackMax —
/// and spills any overflow to the ledger (the no-loss valve). Pure + Burst-friendly.
/// hard-coded a stack cap and silently ignored per-item limits.) All yield credits the shared
/// <see cref="ResourceLedger"/> directly.
///
/// HISTORY (2026-08-07 audit purge): yield used to route to a PERSONAL InventorySlot bag for expedition-region
/// targets and spill to the ledger. The inventory/equipment layer was already marked PAUSED in CLAUDE.md and
/// belonged to the superseded base/expedition direction, so it was deleted along with the shell; harvest is now
/// single-sink. When LANTERN's carried-vs-banked cargo distinction lands (Phase 2), reintroduce the second sink
/// here rather than at the two call sites — that is the whole point of this class.
/// </summary>
public static class HarvestMath
{
/// <summary>
/// Routes one harvested yield to its sink. Returns true if the yield landed somewhere (inventory or ledger);
/// callers use this to avoid consuming a target for zero credit (e.g. no ledger singleton present).
/// <paramref name="player"/> may be <see cref="Entity.Null"/> (unresolvable owner) — the yield then falls
/// through to the ledger. <paramref name="ledger"/> is only touched when <paramref name="haveLedger"/> is true.
/// Routes one harvested yield to the shared ledger. Returns true if the yield landed somewhere; callers use
/// this to avoid consuming a target for zero credit (e.g. no ledger singleton present).
/// <paramref name="ledger"/> is only touched when <paramref name="haveLedger"/> is true.
/// </summary>
public static bool DepositYield(
byte yieldId, int amount, bool toLedger, Entity player,
BufferLookup<InventorySlot> invLookup,
DynamicBuffer<StorageEntry> ledger, bool haveLedger,
bool haveDb, in ItemDatabase itemDb)
public static bool DepositYield(byte yieldId, int amount, DynamicBuffer<StorageEntry> ledger, bool haveLedger)
{
int remainder = amount;
bool deposited = false;
if (amount <= 0 || !haveLedger)
return false;
if (!toLedger && player != Entity.Null && invLookup.HasBuffer(player))
{
int stackMax = Tuning.DefaultStackMax;
if (haveDb && itemDb.Value.IsCreated)
{
ref var blob = ref itemDb.Value.Value;
if (blob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
stackMax = def.StackMax;
}
var inv = invLookup[player];
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
deposited = true;
}
if (remainder > 0 && haveLedger)
{
StorageMath.Deposit(ledger, yieldId, remainder);
deposited = true;
}
return deposited;
StorageMath.Deposit(ledger, yieldId, amount);
return true;
}
}
}
@@ -1,12 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Tag marking the shared home-base storage container. All state lives in the entity's
/// <see cref="StorageEntry"/> buffer. In M5 there is exactly one (server-spawned at a fixed base
/// cell), so server systems resolve it as a singleton. Server-authoritative and world-resident, so
/// its contents survive a player disconnect (no disk persistence yet).
/// </summary>
public struct SharedStorageContainer : IComponentData { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 8de8d91f5d8f0a64c87b0847ae85c564
@@ -1,33 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to deposit into or withdraw from the shared storage container. A one-off
/// action, so it is an RPC (not a per-tick predicted input). Op is stored as a byte (see
/// <see cref="StorageOp"/>) rather than an enum to keep the generated serializer trivial and avoid the
/// cross-assembly enum-codegen hazard. No target entity is carried: M5 has a single shared container,
/// which the server resolves as a singleton (entity refs are not stable across worlds).
/// </summary>
public struct StorageOpRequest : IRpcCommand
{
/// <summary>Operation code (see <see cref="StorageOp"/>): 0 = deposit, 1 = withdraw.</summary>
public byte Op;
/// <summary>Item to deposit/withdraw.</summary>
public ushort ItemId;
/// <summary>Quantity to deposit/withdraw (server clamps withdraw to available).</summary>
public int Count;
}
/// <summary>Operation codes for <see cref="StorageOpRequest.Op"/> (byte to keep RPC serialization trivial).</summary>
public static class StorageOp
{
/// <summary>Add items to the shared container.</summary>
public const byte Deposit = 0;
/// <summary>Remove items from the shared container.</summary>
public const byte Withdraw = 1;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dc9ec88867d746e45b9204331b5bab51
@@ -1,20 +0,0 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton baked into the gameplay subscene, holding the baked storage-container ghost prefab and
/// the base-grid cell to spawn it at. A one-shot server system instantiates the prefab at
/// BaseGridMath.CellToWorld(anchor, Cell) and then destroys this singleton. Mirrors the
/// UpgradePickupSpawner / PlayerSpawner pattern.
/// </summary>
public struct StorageSpawner : IComponentData
{
/// <summary>Baked storage-container ghost prefab to instantiate.</summary>
public Entity Prefab;
/// <summary>Base-grid cell at which to place the container (cell center, on the base plane).</summary>
public int2 Cell;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6a2e4fad83fa03b4890d736b388a9917
@@ -1,27 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to equip an item from the sender's personal inventory into the slot the
/// catalog assigns it (<see cref="ItemDefBlob.EquipSlot"/>). A one-off action, so it is an RPC (not a
/// per-tick predicted input); applied exactly once server-only in the plain SimulationSystemGroup. Carries
/// only the ItemId — the server derives the target slot from the catalog, so a client can't force a weapon
/// into the armor slot. Unconditional wire type (no #if); the server resolves the sender via SourceConnection.
/// </summary>
public struct EquipRequest : IRpcCommand
{
/// <summary>The inventory item to equip; the server resolves its slot + effects from the catalog.</summary>
public ushort ItemId;
}
/// <summary>
/// Client -&gt; server request to unequip whatever occupies <see cref="Slot"/> (an <see cref="EquipSlotId"/>),
/// returning the item to the personal inventory and stripping its effects. Unconditional wire type.
/// </summary>
public struct UnequipRequest : IRpcCommand
{
/// <summary>The <see cref="EquipSlotId"/> to clear.</summary>
public byte Slot;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ad5475188aa05de45a231f937b19f069
@@ -1,31 +0,0 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Equipment-slot ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). The player's
/// <see cref="EquipmentSlot"/> buffer holds one row PER slot in this fixed order (the buffer index IS the
/// slot), so these double as both the catalog's <see cref="ItemDefBlob.EquipSlot"/> value and the buffer
/// index. The Weapon slot grants its item's <see cref="ItemDefBlob.GrantedAbilityId"/> into AbilityRef;
/// every slot grants the item's inline stat mods. <see cref="Tool"/> is reserved for Phase 2 (tool-gated
/// harvesting). 255 = not equippable.
/// </summary>
public static class EquipSlotId
{
/// <summary>Weapon: grants the item's ability (AbilityRef.Id) + its stat mods.</summary>
public const byte Weapon = 0;
/// <summary>Armor: grants the item's stat mods.</summary>
public const byte Armor = 1;
/// <summary>Trinket: grants the item's stat mods.</summary>
public const byte Trinket = 2;
/// <summary>Tool (axe/pickaxe) — reserved for Phase 2 tool-gated harvesting.</summary>
public const byte Tool = 3;
/// <summary>Number of equipment slots (the baked <see cref="EquipmentSlot"/> buffer length).</summary>
public const byte Count = 4;
/// <summary>Sentinel: this item is not equippable.</summary>
public const byte None = 255;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ce8addc5ae6832a449d1b6ad459ab21a
@@ -1,26 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One equipment slot on the player. The per-player buffer holds exactly <see cref="EquipSlotId.Count"/>
/// rows in fixed slot order (the buffer INDEX is the slot — Weapon=0/Armor=1/Trinket=2/Tool=3), so only the
/// equipped item id needs to replicate; there is no separate Slot field to desync. A [GhostField]
/// <see cref="SendToOwnerType.All"/> buffer (a <see cref="StatModifier"/>/<see cref="InventorySlot"/> twin)
/// so the owning client's HUD can show its loadout; the server is the SOLE writer (EquipSystem).
///
/// The actual effects — AbilityRef.Id from the Weapon slot + StatModifiers per slot — are applied
/// EVENT-DRIVEN by EquipSystem (once per equip/unequip), NOT re-derived from this buffer each tick; this
/// buffer is the replicated record of WHAT is equipped (HUD-facing + persistence-ready), not the effect.
/// NOTE: adding this [GhostField] buffer changes the player ghost serialization hash → the player
/// prefab/subscene MUST be re-baked consistently in both worlds (see <see cref="InventorySlot"/>).
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(4)]
public struct EquipmentSlot : IBufferElementData
{
/// <summary>Item equipped in this slot (0 = empty). The buffer INDEX is the <see cref="EquipSlotId"/>.</summary>
[GhostField] public ushort ItemId;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ac0f8812307d3ef43bbed63d1e3fb737
@@ -1,23 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to move items from the sender's PERSONAL inventory into the shared base
/// stockpile (the global <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from).
/// A one-off action, so it is an RPC (not a per-tick predicted input), and the server applies it exactly
/// once in the plain SimulationSystemGroup (no rollback double-apply). Payload is plain blittable scalars
/// (no entity refs, no enum): the server resolves the sender's player from the RPC's SourceConnection. The
/// wire type is UNCONDITIONAL (never #if-gated) so the RpcCollection hash matches across release/dev peers;
/// only the send/receive SYSTEMS may be #if-gated.
/// </summary>
public struct InventoryDepositRequest : IRpcCommand
{
/// <summary>Item to deposit, or 0 to deposit EVERYTHING the player is carrying. The server branches on
/// 0 BEFORE any per-item withdraw and never writes a 0-id row.</summary>
public ushort ItemId;
/// <summary>Quantity to deposit; &lt;= 0 means "all of that item" (ignored when ItemId is 0).</summary>
public int Count;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a815da9a948230e46bc4f7154887613e
@@ -1,110 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic stacking logic for a player's <see cref="InventorySlot"/> buffer (no RNG /
/// wall-clock / singleton access, so server and any future prediction agree). Parallels
/// <see cref="StorageMath"/> in spirit but does NOT collapse into it: StorageMath is an unbounded
/// single-row merge, whereas this enforces a per-item stack cap and a max slot count and supports multiple
/// stacks of the same item once a stack fills. DynamicBuffer is a handle, so mutations apply to the
/// underlying entity buffer; growing it (buffer.Add) is a resize, NOT a structural change, so it is safe to
/// call while iterating a different query. Unit-tested in EditMode via a plain Entities world.
/// </summary>
public static class InventoryMath
{
/// <summary>
/// Add <paramref name="count"/> of <paramref name="itemId"/>: first tops up existing non-full stacks
/// of that item, then appends new stacks (each capped at <paramref name="stackMax"/>) while a free slot
/// remains (buffer length &lt; <paramref name="maxSlots"/>). Returns the REMAINDER that did not fit
/// (0 if everything was deposited). No-op (returns 0) for count &lt;= 0; a positive count of itemId 0
/// returns the full count (nothing deposited — never writes a 0-id row). stackMax &lt; 1 = unbounded.
/// </summary>
public static int Deposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
{
if (count <= 0) return 0;
if (itemId == 0) return count;
if (stackMax < 1) stackMax = int.MaxValue;
// Top up existing stacks of this item.
for (int i = 0; i < buffer.Length && count > 0; i++)
{
if (buffer[i].ItemId != itemId) continue;
var e = buffer[i];
int space = stackMax - e.Count;
if (space <= 0) continue;
int add = space < count ? space : count;
e.Count += add;
buffer[i] = e;
count -= add;
}
// Append new stacks while a slot is free.
while (count > 0 && buffer.Length < maxSlots)
{
int add = stackMax < count ? stackMax : count;
buffer.Add(new InventorySlot { ItemId = itemId, Count = add });
count -= add;
}
return count;
}
/// <summary>
/// Remove up to <paramref name="count"/> of <paramref name="itemId"/> across all its stacks, clamped to
/// what is available; drops a stack that reaches zero. Returns the amount actually withdrawn (0 if none).
/// Iterates back-to-front so RemoveAt does not skip a stack. No-op for count &lt;= 0 or itemId 0.
/// </summary>
public static int Withdraw(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count)
{
if (count <= 0 || itemId == 0) return 0;
int taken = 0;
for (int i = buffer.Length - 1; i >= 0 && count > 0; i--)
{
if (buffer[i].ItemId != itemId) continue;
var e = buffer[i];
int t = e.Count < count ? e.Count : count;
e.Count -= t;
taken += t;
count -= t;
if (e.Count <= 0)
buffer.RemoveAt(i);
else
buffer[i] = e;
}
return taken;
}
/// <summary>
/// Non-mutating check: would depositing <paramref name="count"/> of <paramref name="itemId"/> FULLY fit
/// (top-up existing stacks + new stacks within <paramref name="maxSlots"/>)? Used by the equip swap to
/// guarantee the swapped-out item has room BEFORE any withdrawal (no item loss). Mirrors Deposit's space math.
/// </summary>
public static bool CanDeposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
{
if (count <= 0) return true;
if (itemId == 0) return false;
if (stackMax < 1) stackMax = int.MaxValue;
long space = 0;
for (int i = 0; i < buffer.Length; i++)
if (buffer[i].ItemId == itemId)
space += stackMax - buffer[i].Count;
int freeSlots = maxSlots - buffer.Length;
if (freeSlots > 0)
space += (long)freeSlots * stackMax;
return space >= count;
}
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
{
int total = 0;
for (int i = 0; i < buffer.Length; i++)
if (buffer[i].ItemId == itemId)
total += buffer[i].Count;
return total;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c8560f6c2e717b943bed78d40ea87404
@@ -1,35 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One (item, count) row in a player's PERSONAL inventory. The per-player DynamicBuffer of these is the
/// server-authoritative source of what that player is carrying. A structural twin of
/// <see cref="StatModifier"/>: a [GhostField] buffer with <see cref="SendToOwnerType.All"/> so the owning
/// (predicting) client receives its own inventory — without it the owner, being the owner, would not get
/// the owner-typed buffer at all and the HUD would read empty. BOTH fields carry [GhostField]; the
/// [GhostComponent] attribute alone does NOT auto-replicate fields (an un-annotated field ships as a
/// silent zero), so the annotations mirror <see cref="StorageEntry"/> field-for-field.
///
/// REPLICATION DISCIPLINE — the ONLY writers are server-only: <see cref="ProjectM.Server.ResourceHarvestSystem"/>
/// (harvest yield) and the deposit-to-base RPC handler, both in the plain server SimulationSystemGroup. So
/// there is no predicted-loop double-apply and the owner never mispredicts its inventory — it is a pure
/// server-authored snapshot. NEVER mutate this from a client predicted system (that would reintroduce a
/// double-apply / mispredict path). ItemId is the same opaque ushort id space as <see cref="StorageEntry"/>
/// and the <see cref="ItemDatabase"/> catalog.
///
/// NOTE: adding this [GhostField] buffer CHANGES the player ghost serialization hash — the player prefab /
/// subscene MUST be re-baked (consistently in both worlds) or the connect handshake desyncs.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(Tuning.InventoryMaxSlots)]
public struct InventorySlot : IBufferElementData
{
/// <summary>Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op).</summary>
[GhostField] public ushort ItemId;
/// <summary>Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath).</summary>
[GhostField] public int Count;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f151c780df9917d4089b2944f3ffb12d
@@ -1,26 +0,0 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Broad item-category ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard that
/// already de-Bursted ProjectileClassificationSystem). The category lets systems and UI treat an item
/// generically (a resource stacks and is spendable at the base; a tool/weapon is equippable; a
/// consumable is used) without a per-id switch. Stored in the ItemDatabase blob's <see cref="ItemDefBlob"/>.
/// </summary>
public static class ItemCategory
{
/// <summary>Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting.</summary>
public const byte Resource = 0;
/// <summary>Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2).</summary>
public const byte Tool = 1;
/// <summary>Weapon. Equipping it grants its ability + stat modifiers (Phase 1).</summary>
public const byte Weapon = 2;
/// <summary>Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1).</summary>
public const byte Gear = 3;
/// <summary>One-shot consumable (potion/charge). Used from the inventory (later phase).</summary>
public const byte Consumable = 4;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fa1718754184d2a418b1099ef7e3ae34
@@ -1,17 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Singleton handle to the baked item-definition database (config, not replicated — baked identically
/// into both worlds from the gameplay subscene, exactly like <see cref="AbilityDatabase"/>). A distinct
/// component type, so <c>GetSingleton&lt;ItemDatabase&gt;()</c> resolves independently of the ability
/// database (singleton-ness is per type). Optional at runtime: consumers that read it use
/// <c>TryGetSingleton</c> and fall back to defaults (e.g. <c>Tuning.DefaultStackMax</c>) so the sim still
/// runs before the catalog is authored.
/// </summary>
public struct ItemDatabase : IComponentData
{
public BlobAssetReference<ItemDatabaseBlob> Value;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6b355888562be7349b8754168375db9b
@@ -1,104 +0,0 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored item definition, baked immutable into the <see cref="ItemDatabase"/> blob. This is the
/// single source of truth for everything an item IS — resources, tools, weapons, gear, consumables — so
/// adding game content is an authoring row + re-bake, with no code change. Id space is the SAME
/// <c>ushort</c> space as <see cref="StorageEntry.ItemId"/> / <see cref="InventorySlot.ItemId"/>, and it
/// SUBSUMES the low <see cref="ResourceId"/> byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a
/// low-id item of <see cref="ItemCategory.Resource"/>. KEEP ids 1-3 stable for the existing resources and
/// reserve new item ids &gt; 3; 0 = none. Entity/prefab refs do NOT live here (blobs don't remap entity
/// refs) — a future companion buffer carries those, exactly like AbilityPrefabElement.
///
/// The blob is config (baked identically into both worlds, NOT replicated, NOT in SaveData), so growing
/// this struct later (a granted-ability id, a StatModifier-spec array, a slot id for Phase 1/2/3) is a
/// pure re-bake with zero migration: no SaveData version bump, no ghost-hash change, no desync. <see cref="Tier"/>
/// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a
/// content-only edit.
/// </summary>
/// <summary>One stat-modifier grant on an equippable item, stored INLINE (NOT a nested BlobArray: a nested
/// BlobArray is a relative-offset pointer that corrupts the moment <see cref="ItemDatabaseBlob.TryGetItem"/>
/// returns the containing <see cref="ItemDefBlob"/> BY VALUE — the same copy hazard the class note warns about).
/// Target 255 = unused.</summary>
public struct ItemModSpec
{
/// <summary><see cref="StatTarget"/> as a byte; 255 = unused slot.</summary>
public byte Target;
/// <summary><see cref="ModOp"/> as a byte.</summary>
public byte Op;
/// <summary>Magnitude (flat amount or fractional percent).</summary>
public float Value;
}
public struct ItemDefBlob
{
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
public ushort ItemId;
/// <summary>Broad category (see <see cref="ItemCategory"/>), stored as a byte.</summary>
public byte Category;
/// <summary>Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3).</summary>
public byte Tier;
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
public int StackMax;
/// <summary>Equip slot (see <see cref="EquipSlotId"/>); 255 = not equippable.</summary>
public byte EquipSlot;
/// <summary>Up to <see cref="MaxMods"/> INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray.</summary>
public ItemModSpec Mod0, Mod1, Mod2, Mod3;
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
public FixedString64Bytes Name;
/// <summary>Number of inline mod slots.</summary>
public const int MaxMods = 4;
/// <summary>Indexed access to the inline mod slots (returns a copy — safe, ItemModSpec holds no BlobArray).</summary>
public ItemModSpec GetMod(int i)
{
switch (i)
{
case 0: return Mod0;
case 1: return Mod1;
case 2: return Mod2;
default: return Mod3;
}
}
}
/// <summary>
/// Immutable designer-authored item database, baked from ScriptableObjects to a blob asset and shared by
/// every entity (Burst-fast, zero per-instance cost). Looked up by stable <see cref="ItemDefBlob.ItemId"/>
/// — ID-KEYED, never by array index, so inserting a new item never renumbers existing ids.
///
/// NOTE: the lookup is intentionally NOT a 'readonly' method. A readonly struct method forces a defensive
/// copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its relative-offset
/// pointer, so the array would read as empty. A plain (non-readonly) method accesses the BlobArray in place.
/// Always reach this through 'ref blob.Value' (mirrors <see cref="AbilityDatabaseBlob"/>).
/// </summary>
public struct ItemDatabaseBlob
{
public BlobArray<ItemDefBlob> Items;
/// <summary>Linear lookup by item id (the array is tiny). Returns false if not present.</summary>
public bool TryGetItem(ushort id, out ItemDefBlob def)
{
for (int i = 0; i < Items.Length; i++)
{
if (Items[i].ItemId == id)
{
def = Items[i];
return true;
}
}
def = default;
return false;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0ae0cc6bfb8578c42bff8e300078cf2d
@@ -1,116 +0,0 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored PERMANENT meta upgrade: tiered (buy tier owned+1 up to <see cref="MaxTier"/>), priced in Aether
/// with a linear ramp (<c>cost(owned) = BaseCost + owned*CostGrowth</c>), class-gated by <see cref="ClassMask"/>
/// (bit0 = Warrior, bit1 = Ranger — resolve via <see cref="BoonMath.MaskFor"/>, NEVER a raw <c>1&lt;&lt;ClassId</c>:
/// the stored ClassId is the normalized FrameKind 2/3). <see cref="Id"/> is the stable APPEND-ONLY key
/// persisted in SaveData v6 and keyed into the live StatModifier as <c>Tuning.MetaSourceIdBase + Id</c>.
/// DISTINCT from <see cref="BoonDefBlob"/> — boons are run-scoped single-shots; overloading one catalog would
/// blur the two channels (DR-037). <see cref="PrereqId"/> = 0xFF means no prerequisite (v1 ships a FLAT catalog
/// — the operator default; trees are an authoring change, not a code change).
/// </summary>
public struct MetaUpgradeDefBlob
{
public byte Id;
public byte ClassMask;
public byte Target; // StatTarget as byte
public byte Op; // ModOp as byte
public byte MaxTier;
public float ValuePerTier;
public int BaseCost;
public int CostGrowth;
public byte PrereqId; // 0xFF = none
public byte PrereqTier;
public FixedString64Bytes Name;
public FixedString128Bytes Desc;
}
/// <summary>The baked permanent-upgrade pool (config blob, both worlds, NOT replicated).</summary>
public struct MetaUpgradeCatalogBlob
{
public BlobArray<MetaUpgradeDefBlob> Defs;
}
/// <summary>Singleton carrying the baked meta catalog (ONE MetaCatalogAuthoring in the gameplay subscene).</summary>
public struct MetaUpgradeCatalog : IComponentData
{
public BlobAssetReference<MetaUpgradeCatalogBlob> Value;
}
/// <summary>Pure helpers over the meta catalog + the director's <see cref="MetaTierState"/> record.</summary>
public static class MetaMath
{
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
public static int FindDef(ref MetaUpgradeCatalogBlob pool, byte id)
{
for (int i = 0; i < pool.Defs.Length; i++)
if (pool.Defs[i].Id == id) return i;
return -1;
}
/// <summary>The owned tier of (class, upgrade) in the record buffer (absent row = 0).</summary>
public static byte TierOf(DynamicBuffer<MetaTierState> record, byte classId, byte upgradeId)
{
for (int i = 0; i < record.Length; i++)
if (record[i].ClassId == classId && record[i].UpgradeId == upgradeId) return record[i].Tier;
return 0;
}
/// <summary>Aether cost of buying tier owned+1 (linear ramp; compute from the CLAMPED owned tier).</summary>
public static int CostForTier(in MetaUpgradeDefBlob def, byte ownedClamped)
=> def.BaseCost + ownedClamped * def.CostGrowth;
}
/// <summary>
/// The DEFAULT v1 meta table + the blob builder the baker AND EditMode tests share (the BoonCatalogData
/// pattern; an empty designer-row list on the authoring bakes this verbatim). FLAT catalog — every
/// PrereqId = 0xFF (operator default: validate the economy before prereq trees). Ids append-only.
/// Priced for the 15%-Aether node economy (~2-5 Aether per lucky room).
/// </summary>
public static class MetaCatalogData
{
public static BlobAssetReference<MetaUpgradeCatalogBlob> BuildDefault(Allocator allocator = Allocator.Persistent)
{
var builder = new BlobBuilder(Allocator.Temp);
ref var root = ref builder.ConstructRoot<MetaUpgradeCatalogBlob>();
var defs = builder.Allocate(ref root.Defs, 8);
int i = 0;
// id, mask(1=Warrior,2=Ranger,3=both), target, op, maxTier, valuePerTier, baseCost, growth, name, desc
defs[i++] = Make(1, 3, StatTarget.MaxHealth, ModOp.Flat, 5, 15f, 10, 5, "Reinforced Frame", "+15 max health per tier");
defs[i++] = Make(2, 3, StatTarget.Damage, ModOp.PercentAdd, 5, 0.08f, 12, 6, "Sharpened Arsenal", "+8% ability damage per tier");
defs[i++] = Make(3, 3, StatTarget.CooldownTicks, ModOp.PercentMult, 3, -0.06f, 15, 10, "Swift Recovery", "-6% ability cooldown per tier");
defs[i++] = Make(4, 3, StatTarget.MoveSpeed, ModOp.PercentAdd, 3, 0.05f, 10, 8, "Fleet Stride", "+5% move speed per tier");
defs[i++] = Make(5, 1, StatTarget.MeleeDamage, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Warrior's Might", "+10% melee damage per tier");
defs[i++] = Make(6, 1, StatTarget.MeleeRange, ModOp.PercentAdd, 3, 0.08f, 10, 6, "Warrior's Reach", "+8% melee reach per tier");
defs[i++] = Make(7, 2, StatTarget.Range, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Ranger's Longshot", "+10% projectile range per tier");
defs[i++] = Make(8, 2, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 3, 0.10f, 10, 6, "Ranger's Velocity", "+10% projectile speed per tier");
var blob = builder.CreateBlobAssetReference<MetaUpgradeCatalogBlob>(allocator);
builder.Dispose();
return blob;
}
static MetaUpgradeDefBlob Make(byte id, byte mask, StatTarget target, ModOp op, byte maxTier,
float valuePerTier, int baseCost, int growth, string name, string desc)
{
return new MetaUpgradeDefBlob
{
Id = id,
ClassMask = mask,
Target = (byte)target,
Op = (byte)op,
MaxTier = maxTier,
ValuePerTier = valuePerTier,
BaseCost = baseCost,
CostGrowth = growth,
PrereqId = 0xFF,
PrereqTier = 0,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ee5e173c41773dc4189279241bf322b7
@@ -1,76 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// One owned permanent meta-upgrade tier, keyed by (class, upgrade). The CycleDirector's DynamicBuffer of these is
/// the authoritative per-class meta-progression record. A GLOBAL <c>[GhostField]</c> buffer on the ownerless
/// interpolated director ghost (no OwnerSendType — the party invests together and every client reads it for the
/// shop), mirroring <see cref="StorageEntry"/>. Persisted to disk (SaveData v6) and re-applied born-correct at
/// player spawn as meta-band <see cref="StatModifier"/>s. Bytes only (Burst/serialization safe). Sparse — an
/// absent (class, upgrade) row means tier 0.
/// </summary>
[InternalBufferCapacity(24)]
public struct MetaTierState : IBufferElementData
{
/// <summary>Owning class id (Warrior/Ranger — the FrameKind anchor).</summary>
[GhostField] public byte ClassId;
/// <summary>Upgrade id (append-only key into the meta catalog).</summary>
[GhostField] public byte UpgradeId;
/// <summary>Owned tier (&gt;=1; an absent row = 0).</summary>
[GhostField] public byte Tier;
}
/// <summary>
/// Server-only singleton on the CycleDirector: the FIRST-COMMIT latch for the co-op route choice. Written IN-PLACE
/// (immediate SystemAPI.SetComponent, not a deferred ECB) inside <c>RouteSelectSystem</c>'s drain loop so two
/// same-tick picks cannot both observe <see cref="HasPick"/>==0 (the DR-014 atomicity idiom). NOT replicated.
/// </summary>
public struct RouteCommand : IComponentData
{
/// <summary>1 once a route has been committed for the current (RunEpoch, layer).</summary>
public byte HasPick;
/// <summary>The committed option index (into the replicated RouteOpt* set).</summary>
public byte OptionIndex;
/// <summary>Run epoch the pick is for (stale-reject guard).</summary>
public int ForRunEpoch;
/// <summary>Layer the pick is for (stale-reject guard).</summary>
public int ForLayer;
}
/// <summary>
/// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem
/// sets <see cref="HasInteract"/> when a player interacts the portal during RoomExplore; RunDirectorSystem (the sole
/// RunInfo/RunRuntime writer) reads it to advance the run + tear the room down, then clears it. NOT replicated.
/// Added unconditionally at director spawn (like RouteCommand).
/// </summary>
public struct PortalCommand : IComponentData
{
/// <summary>1 once a participant has interacted the room-exit portal this RoomExplore.</summary>
public byte HasInteract;
}
/// <summary>
/// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for
/// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot
/// has the component the bank block reads; restored values are SetComponent'd only inside the save-present block.
/// NOT replicated.
/// </summary>
public struct MetaCounters : IComponentData
{
public int RunsCompleted;
public int MaxDepthReached;
}
/// <summary>
/// Server-only tag of a player's class id, added at spawn by <c>GoInGameServerSystem</c>. Lets the meta systems
/// resolve which per-class tier record to seed / spend against (class was previously only an <c>AbilityRef</c> /
/// wire concern). NOT replicated.
/// </summary>
public struct PlayerClass : IComponentData
{
public byte ClassId;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 307374d6819017f4da4bbfe64f11e7c5
@@ -1,18 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server permanent meta-upgrade purchase: <see cref="UpgradeId"/> keys the baked meta catalog. The
/// TIER is SERVER-COMPUTED (a purchase always buys owned+1) — putting a tier on the wire would invite
/// desync/cheat. Server-validated (<c>RunInfo.Lifecycle==Staging</c> phase gate, class mask, prereq, MaxTier,
/// Aether affordability) with DR-014 in-loop ledger atomicity so two same-tick purchases on barely-enough Aether
/// cannot both pass. UNCONDITIONAL wire type. Declared at Step 3 (wire front-load); consumed by
/// <c>MetaSpendSystem</c> from Step 13.
/// </summary>
public struct MetaSpendRequest : IRpcCommand
{
/// <summary>Meta-catalog upgrade id (append-only key).</summary>
public byte UpgradeId;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 71ddd683487e8704197b8eeddcb2c339
@@ -1,38 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// The ONE collector of the permanent-meta save slice (the SaveStructureScan idiom): reads the director's
/// replicated <see cref="MetaTierState"/> buffer + server-only <see cref="MetaCounters"/> into the
/// <see cref="SaveData"/> v6 fields. Shared by BOTH save writers — <c>SaveWriteSystem</c> (autosave) AND
/// <c>WorldLauncher.TrySaveFromServer</c> (quit-to-menu) — so the writers can never drift; the quit path
/// silently omitting these fields would WIPE all permanent progression on the most common exit (the meta
/// review's top blocker). Rows are copied VERBATIM (unknown ids round-trip).
/// </summary>
public static class MetaSaveScan
{
public static void Collect(EntityManager em, Entity director,
out MetaUpgradeSave[] rows, out int runsCompleted, out int maxDepthReached)
{
rows = System.Array.Empty<MetaUpgradeSave>();
runsCompleted = 0;
maxDepthReached = 0;
if (em.HasBuffer<MetaTierState>(director))
{
var buf = em.GetBuffer<MetaTierState>(director, true);
rows = new MetaUpgradeSave[buf.Length];
for (int i = 0; i < buf.Length; i++)
rows[i] = new MetaUpgradeSave { ClassId = buf[i].ClassId, UpgradeId = buf[i].UpgradeId, Tier = buf[i].Tier };
}
if (em.HasComponent<MetaCounters>(director))
{
var counters = em.GetComponentData<MetaCounters>(director);
runsCompleted = counters.RunsCompleted;
maxDepthReached = counters.MaxDepthReached;
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 83b313e94b9f4304993358deca12e6e2
@@ -1,43 +0,0 @@
using System.Collections.Generic;
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>)
/// into the flat SaveData arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
/// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
/// anything baked into the subscene is the subscene's source of truth, not the save's). Managed (List/array) —
/// runs only on a save, never in the hot loop.
/// </summary>
public static class SaveStructureScan
{
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures)
{
var structs = new List<StructureSave>();
using var q = em.CreateEntityQuery(
ComponentType.ReadOnly<PlacedStructure>(),
ComponentType.ReadOnly<RuntimePlacedTag>());
using var entities = q.ToEntityArray(Allocator.Temp);
for (int k = 0; k < entities.Length; k++)
{
var e = entities[k];
var ps = em.GetComponentData<PlacedStructure>(e);
structs.Add(new StructureSave
{
Type = ps.Type,
CellX = ps.Cell.x,
CellZ = ps.Cell.y,
// EB-1: guarded so structures without Health don't crash the autosave path (no try/catch).
HP = em.HasComponent<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f,
});
}
structures = structs.ToArray();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 47b137b2d90c6154d8c195f8c491f0d8
@@ -1,28 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// A player's private choice-of-3 boon offer for the just-cleared room. OWNER-ONLY replication
/// (<see cref="SendToOwnerType.SendToOwner"/>): the offer is an observe-only HUD read of the LOCAL player — no
/// prediction, no teammate read — so the traffic-minimal owner-only path is correct (Play-validated at Step 9;
/// the proven fallback is <see cref="SendToOwnerType.All"/>, which for HUD purposes still only surfaces each
/// player's component on the client that owns that ghost). Written by <c>BoonOfferSystem</c> on the RoomReward
/// entry edge (options drawn deterministically from Hash(RunSeed, room, NetworkId)); <see cref="Pending"/> is
/// cleared by <c>BoonApplySystem</c> on a valid pick and zeroed by the Returning-edge strip. INERT until Step 9 —
/// baked at Step 3 so the player ghost re-bakes exactly ONCE for the whole redesign.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
public struct BoonOffer : IComponentData
{
/// <summary>1 = awaiting this player's pick.</summary>
[GhostField] public byte Pending;
/// <summary>Boon catalog id of option 0.</summary>
[GhostField] public byte Option0;
/// <summary>Boon catalog id of option 1.</summary>
[GhostField] public byte Option1;
/// <summary>Boon catalog id of option 2.</summary>
[GhostField] public byte Option2;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c3c9cb8a1b819fd4b8474206e766076e
@@ -1,21 +0,0 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a <c>[GhostField]</c>, so no ghost-hash impact;
/// it piggybacks the <see cref="BoonEffects"/> player re-bake). Keys the per-dash "hit once" dedup to
/// <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and cannot be relied
/// upon to reset) rather than to any DashState clear edge: <c>DashTrailDamageSystem</c> clears <see cref="Hit"/>
/// whenever the current StartTick differs from <see cref="LastStartTick"/>. Server-only (no rollback) so the
/// accumulator is safe to persist across ticks.
/// </summary>
public struct DashTrailState : IComponentData
{
/// <summary>The <see cref="DashState.StartTick"/> the <see cref="Hit"/> set currently belongs to.</summary>
public uint LastStartTick;
/// <summary>Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash).</summary>
public FixedList64Bytes<Entity> Hit;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 248bd87d96cfc5b43b4681a203756c5c
@@ -21,8 +21,6 @@ namespace ProjectM.Simulation
public uint Stamp;
public uint KnockUntil;
public bool IsFinisher; // Phase 1.7: this swing is the combo finisher
public bool Detonate; // Phase 1.7: attacker has the FinisherDetonate boon
public bool Pull; // Phase 1.7: attacker has the KnockToPull boon
}
/// <summary>
@@ -52,12 +50,8 @@ namespace ProjectM.Simulation
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<BossState> m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams)
ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup;
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
@@ -69,7 +63,7 @@ namespace ProjectM.Simulation
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
float2 facingDir, int ownerId, byte bflags, bool hasMods, DynamicBuffer<StatModifier> mods)
float2 facingDir, int ownerId, bool hasMods, DynamicBuffer<StatModifier> mods)
{
bool fin = step >= comboLen;
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
@@ -85,8 +79,7 @@ namespace ProjectM.Simulation
Stamp = stamp,
KnockUntil = knockUntil,
IsFinisher = fin,
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
Pull = (bflags & BoonFlag.KnockToPull) != 0,
// Detonate/Pull came from boon flags (deleted 2026-08-07 audit purge).
};
}
@@ -94,12 +87,8 @@ namespace ProjectM.Simulation
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
}
@@ -134,7 +123,6 @@ namespace ProjectM.Simulation
// when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
m_StatModLookup.Update(ref state);
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
@@ -164,7 +152,6 @@ namespace ProjectM.Simulation
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = default;
}
@@ -237,7 +224,6 @@ namespace ProjectM.Simulation
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
}
@@ -246,7 +232,6 @@ namespace ProjectM.Simulation
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
}
}
@@ -273,14 +258,8 @@ namespace ProjectM.Simulation
// pool) just like a base projectile hit. SERVER-ONLY (this whole block) — interpolated node ghosts
// are never rolled back, so the deposit + destroy fire exactly once per swing.
bool haveLedger = SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerEntity);
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
DynamicBuffer<StorageEntry> ledger = default;
if (haveLedger) ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
m_RegionLookup.Update(ref state);
m_InvLookup.Update(ref state);
var meleePlayerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (po, pe) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
meleePlayerByConn[po.ValueRO.NetworkId] = pe;
var harvEntity = new NativeList<Entity>(Allocator.Temp);
var harvPos = new NativeList<float3>(Allocator.Temp);
var harvRemaining = new NativeList<int>(Allocator.Temp);
@@ -288,7 +267,6 @@ namespace ProjectM.Simulation
var harvPerHit = new NativeList<float>(Allocator.Temp);
var harvIsClutter = new NativeList<bool>(Allocator.Temp);
var harvVariant = new NativeList<byte>(Allocator.Temp);
var harvToLedger = new NativeList<bool>(Allocator.Temp);
foreach (var (hx, node, he) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().WithEntityAccess())
{
@@ -301,7 +279,6 @@ namespace ProjectM.Simulation
harvPerHit.Add(node.ValueRO.HarvestPerHit);
harvIsClutter.Add(false);
harvVariant.Add(0);
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
}
foreach (var (hx, clutter, he) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<BlightClutter>>().WithEntityAccess())
@@ -315,13 +292,11 @@ namespace ProjectM.Simulation
harvPerHit.Add(clutter.ValueRO.ScrapPerHit);
harvIsClutter.Add(true);
harvVariant.Add(clutter.ValueRO.Variant);
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
}
var harvDestroyed = new NativeArray<bool>(harvEntity.Length, Allocator.Temp);
m_KnockbackLookup.Update(ref state);
m_BossLookup.Update(ref state);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int s = 0; s < cleaves.Length; s++)
@@ -339,29 +314,8 @@ namespace ProjectM.Simulation
SourceTick = c.Stamp,
});
if (c.KnockSpeed > 0f)
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target,
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil, c.Pull);
}
}
// Phase 1.7 Detonating Finisher: a finisher swing with the boon blasts a planar AoE around its
// origin (mirrors HazardExplosionSystem). Cone+blast overlap is the normal DamageEvent-summation.
for (int s = 0; s < cleaves.Length; s++)
{
var dc = cleaves[s];
if (!dc.IsFinisher || !dc.Detonate)
continue;
float detRadSq = k_DetonateRadius * k_DetonateRadius;
for (int i = 0; i < enemyEntities.Length; i++)
{
float2 dd = new float2(enemyPositions[i].x - dc.From.x, enemyPositions[i].z - dc.From.z);
if (math.lengthsq(dd) > detRadSq)
continue;
ecb.AppendToBuffer(enemyEntities[i], new DamageEvent
{
Amount = dc.Damage,
SourceNetworkId = dc.OwnerId,
SourceTick = dc.Stamp,
});
KnockbackUtil.Stamp(ref m_KnockbackLookup, target,
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil);
}
}
@@ -383,17 +337,12 @@ namespace ProjectM.Simulation
// zero means ZERO (cover); any POSITIVE yield still credits >= 1 (the fractional-yield guard).
int deposit = harvPerHit[i] > 0f ? amount : 0;
byte yieldId = harvYieldId[i];
// Route by region: Base nodes credit the shared ledger DIRECTLY (the build pool); an
// expedition / un-tagged target goes to the swinging player's PERSONAL inventory (spill to
// ledger), mirroring ResourceHarvestSystem. Only deplete if the yield landed somewhere —
// never consume a node for zero credit (e.g. no ledger singleton present).
Entity meleeHarvester = Entity.Null;
if (meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer))
meleeHarvester = meleePlayer;
// All yield credits the shared ledger — the PERSONAL inventory sink was deleted with the
// superseded shell (2026-08-07 audit). Only deplete if the yield landed somewhere; never
// consume a node for zero credit (e.g. no ledger singleton present).
if (deposit > 0)
{
bool deposited = HarvestMath.DepositYield(yieldId, deposit, harvToLedger[i], meleeHarvester,
m_InvLookup, ledger, haveLedger, haveDb, itemDb);
bool deposited = HarvestMath.DepositYield(yieldId, deposit, ledger, haveLedger);
if (!deposited)
continue; // never consume a YIELDING target for zero credit
}
@@ -455,8 +404,6 @@ namespace ProjectM.Simulation
harvIsClutter.Dispose();
harvVariant.Dispose();
harvDestroyed.Dispose();
harvToLedger.Dispose();
meleePlayerByConn.Dispose();
}
if (cleaves.IsCreated)
@@ -1,20 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// The player's expedition ready-check flag — a plain send-to-all <c>[GhostField]</c> byte on the player ghost so
/// EVERY client can render the "N/M READY" staging panel (owner-only would hide teammates' readiness). Written
/// server-only by <c>ReadyToggleSystem</c> from the <see cref="ReadyToggleRequest"/> RPC — honored during Staging
/// AND the Launching countdown (an un-ready during the countdown is the launch-abort escape hatch); cleared for
/// all players by <c>RunDirectorSystem</c> on the Returning edge. The N/M derivation counts live PlayerTag ghosts,
/// which is valid ONLY because the party is co-located at base while Staging (the co-location invariant — N7);
/// ready toggles are refused in every other lifecycle state.
/// </summary>
public struct PlayerReady : IComponentData
{
/// <summary>1 = ready to launch; 0 = not ready.</summary>
[GhostField] public byte Value;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 5423c043f8023a246b4244eb34e5fbdd
@@ -1,35 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
// NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED.
// ExpeditionObjective below is the surviving replicated room-objective readout (live consumers:
// RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it).
/// <summary>
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so
/// GhostRelevancy.SetIsIrrelevant never hides it
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
/// </summary>
public struct ExpeditionObjective : IComponentData
{
/// <summary>0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim).</summary>
[GhostField] public byte State;
/// <summary>Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared.</summary>
[GhostField] public short Remaining;
}
/// <summary>State constants for <see cref="ExpeditionObjective.State"/> (byte, not enum — Burst/serialization).</summary>
public static class ExpeditionObjectiveState
{
public const byte Idle = 0;
public const byte Active = 1;
public const byte Cleared = 2;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ca714d222c4d2ed48aaaad7bbe6ec8fc
@@ -1,12 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server: interact with the room-exit portal to leave (advance the run). Client-gated on proximity +
/// the RoomExplore lifecycle (both replicated/derivable client-side); the server honors it ONLY in RoomExplore
/// from an expedition player, setting <see cref="PortalCommand"/> for RunDirectorSystem (the sole RunInfo writer)
/// to consume. Empty payload. UNCONDITIONAL wire type.
/// </summary>
public struct PortalInteractRequest : IRpcCommand { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9d527637b76c7a545817f6f388533248
@@ -1,16 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server ready-check toggle — an explicit SET (not a flip), so a duplicated/late RPC is idempotent.
/// UNCONDITIONAL wire type (never #if — the reflection-built RpcCollection hash must match across peers; only
/// send/receive SYSTEMS may be gated). Blittable scalar payload per the project RPC rules. Handled by
/// <c>ReadyToggleSystem</c> (Staging/Launching only).
/// </summary>
public struct ReadyToggleRequest : IRpcCommand
{
/// <summary>1 = ready, 0 = not ready.</summary>
public byte Ready;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7489e354b7c8d0a46a155fa1d2c22bcc
@@ -1,139 +0,0 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic per-room layout math: resolves a map node (<see cref="RunMapNode"/>) into a concrete
/// <see cref="RoomPlan"/> and scatters points within the room's shape. No RNG state (scatter takes a
/// caller-seeded <see cref="Random"/> by ref); no wall-clock — EditMode-unit-testable and save/replay reproducible
/// (mirrors <see cref="ZoneEnemyMath"/> / <see cref="RunMapMath"/>). Archetype numbers (per-shape radius, per-type
/// node counts) are const tables here; an authored <c>RoomArchetype</c> blob can later back these when RoomFieldSystem
/// wants designer-tuned variety, without changing this signature's callers.
/// </summary>
public static class RoomLayoutMath
{
/// <summary>Resolve a map node + its depth into the concrete room spec the server lays out.</summary>
public static RoomPlan Plan(in RunMapNode node, int layer, int roomCount)
{
return new RoomPlan
{
RoomType = node.RoomType,
Biome = node.Biome,
ShapeId = node.ShapeId,
Radius = ShapeRadius(node.ShapeId),
NodeCount = BaseNodeCount(node.RoomType),
DifficultyEpoch = DifficultyEpoch(layer, node.RoomType),
};
}
/// <summary>
/// Depth-based difficulty rung fed to <see cref="ZoneEnemyMath"/>: deeper rooms are harder (layer+1 floor),
/// with Elite/Boss bumps. Lower-bounded at 1. Pure integer.
/// </summary>
public static int DifficultyEpoch(int layer, byte roomType)
{
int d = math.max(1, layer + 1);
if (roomType == RoomTypeId.Elite) d += 2;
if (roomType == RoomTypeId.Boss) d += 3;
return d;
}
/// <summary>Base resource-node count per room type (before the run-wide scarcity budget floors it). Reward
/// rooms are dense; combat/elite lean; the Boss room is minimal.</summary>
public static int BaseNodeCount(byte roomType)
{
switch (roomType)
{
case RoomTypeId.Reward: return 5;
case RoomTypeId.Combat: return 2;
case RoomTypeId.Elite: return 2;
case RoomTypeId.Boss: return 1;
default: return 2;
}
}
/// <summary>Arena scatter radius (world units) for a shape id.</summary>
public static float ShapeRadius(byte shapeId)
{
switch (shapeId)
{
case RoomShapeId.Wide: return 24f;
case RoomShapeId.Long: return 24f;
case RoomShapeId.Cross: return 22f;
case RoomShapeId.Disk:
default: return 18f;
}
}
/// <summary>
/// Deterministic scatter of point <paramref name="index"/> of <paramref name="count"/> within the room's
/// shape around <paramref name="center"/>, using a caller-seeded RNG. Every returned point satisfies
/// <see cref="ContainsPoint"/> for the same shape/center (asserted in tests). Y is preserved from
/// <paramref name="center"/>. <paramref name="index"/>/<paramref name="count"/> are reserved for future
/// even-spacing variants; today the RNG draw is the sole source of position.
/// </summary>
public static float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng)
{
float r = ShapeRadius(shapeId);
switch (shapeId)
{
case RoomShapeId.Wide:
{
float x = rng.NextFloat(-r, r);
float z = rng.NextFloat(-r * 0.5f, r * 0.5f);
return new float3(center.x + x, center.y, center.z + z);
}
case RoomShapeId.Long:
{
float x = rng.NextFloat(-r * 0.5f, r * 0.5f);
float z = rng.NextFloat(-r, r);
return new float3(center.x + x, center.y, center.z + z);
}
case RoomShapeId.Cross:
{
bool horiz = rng.NextInt(0, 2) == 0;
float along = rng.NextFloat(-r, r);
float across = rng.NextFloat(-r * 0.25f, r * 0.25f);
return horiz
? new float3(center.x + along, center.y, center.z + across)
: new float3(center.x + across, center.y, center.z + along);
}
case RoomShapeId.Disk:
default:
{
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = r * math.sqrt(rng.NextFloat(0f, 1f)); // area-uniform
return new float3(center.x + math.cos(ang) * rad, center.y, center.z + math.sin(ang) * rad);
}
}
}
/// <summary>
/// True iff planar point <paramref name="p"/> lies within the shape's footprint around <paramref name="center"/>
/// (the exact bound <see cref="ScatterInShape"/> produces). Used to validate scatter and (later) placement.
/// </summary>
public static bool ContainsPoint(byte shapeId, float3 center, float3 p)
{
const float eps = 1e-3f;
float r = ShapeRadius(shapeId);
float dx = p.x - center.x;
float dz = p.z - center.z;
switch (shapeId)
{
case RoomShapeId.Wide:
return math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.5f + eps;
case RoomShapeId.Long:
return math.abs(dx) <= r * 0.5f + eps && math.abs(dz) <= r + eps;
case RoomShapeId.Cross:
{
bool horizArm = math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.25f + eps;
bool vertArm = math.abs(dz) <= r + eps && math.abs(dx) <= r * 0.25f + eps;
return horizArm || vertArm;
}
case RoomShapeId.Disk:
default:
return dx * dx + dz * dz <= (r + eps) * (r + eps);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7979eb74587ba004885f89b140b15f00
@@ -1,24 +0,0 @@
namespace ProjectM.Simulation
{
/// <summary>
/// The resolved, concrete spec for ONE room the party is about to enter — a pure function of its map node
/// (<see cref="RunMapNode"/>) + its depth, produced by <see cref="RoomLayoutMath.Plan"/>. Consumed server-side by
/// the room field/enemy directors to lay out resources + seed the enemy wave; transient (never replicated —
/// the client only needs the small published <c>RunInfo</c> mirror for the HUD). All-value, unmanaged, Burst-safe.
/// </summary>
public struct RoomPlan
{
/// <summary><see cref="RoomTypeId"/> (drives node/enemy density + the difficulty bump).</summary>
public byte RoomType;
/// <summary><see cref="RoomBiomeId"/> (cosmetic, forwarded to the client HUD/atmosphere).</summary>
public byte Biome;
/// <summary><see cref="RoomShapeId"/> (the arena footprint scatter uses).</summary>
public byte ShapeId;
/// <summary>Arena scatter radius (world units) for this shape.</summary>
public float Radius;
/// <summary>Base number of resource nodes to scatter (before the run-wide scarcity budget floors it).</summary>
public int NodeCount;
/// <summary>Depth-based difficulty rung fed to <c>ZoneEnemyMath</c> (higher = harder; Elite/Boss bump it).</summary>
public int DifficultyEpoch;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: fc7dbc4b8c341e746b1cbe11f3a9ad86
@@ -1,44 +0,0 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Stamps a runtime-spawned expedition ghost as belonging to ONE room of the current run (nodes, clutter, zone
/// enemies — everything the room's directors instantiate). Server-only, NOT a <c>[GhostField]</c> (clients never
/// see rooms, only relevancy-scoped ghosts). Teardown of room <c>i</c> filters on <see cref="Room"/> — the
/// hard-learned DR-031/DR-040 lesson that a shared-tag global cull wipes the OTHER room the moment two rooms
/// transiently coexist (the ping-pong sub-slot handoff). <see cref="Room"/> = <c>CurrentRoom &amp; 0xFF</c>.
/// </summary>
public struct RoomTag : IComponentData
{
/// <summary>The 0-based room index this entity belongs to (low byte).</summary>
public byte Room;
}
/// <summary>
/// The ONE way a room's contents die: a <see cref="RoomTag"/>-filtered destroy. Type-agnostic — every room-scoped
/// ghost carries the tag, so one query covers nodes/clutter/enemies with no per-type sweep and no double-destroy
/// (each entity is visited exactly once). Callers pass their cached all-<see cref="RoomTag"/> query + an ECB
/// (structural changes stay batched). Pure/static so EditMode pins the cross-room-wipe regression directly.
/// </summary>
public static class RoomTeardown
{
/// <summary>Queue destruction of every entity stamped <see cref="RoomTag"/>.Room == <paramref name="room"/>.</summary>
public static int DestroyRoom(EntityQuery allRoomTagged, EntityCommandBuffer ecb, byte room)
{
var entities = allRoomTagged.ToEntityArray(Allocator.Temp);
var tags = allRoomTagged.ToComponentDataArray<RoomTag>(Allocator.Temp);
int destroyed = 0;
for (int i = 0; i < entities.Length; i++)
{
if (tags[i].Room != room) continue;
ecb.DestroyEntity(entities[i]);
destroyed++;
}
entities.Dispose();
tags.Dispose();
return destroyed;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7fd0f5749da96e14db98fc4e6ada65b0
@@ -1,27 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client → server route choice at a RouteSelect gate. <see cref="OptionIndex"/> indexes the REPLICATED
/// <c>RunInfo.RouteOpt*</c> option set (never a raw map column — the server re-validates against its own
/// <c>NextMask</c>, so a divergent client can only send an index the server rejects).
/// <see cref="ForRunEpoch"/>/<see cref="ForLayer"/> stale-reject a pick that arrives after the party already
/// advanced. First ACCEPTED commit wins (the in-place <c>RouteCommand</c> latch — DR-014 atomicity).
/// UNCONDITIONAL wire type, blittable scalars only. Declared at Step 3 (wire front-load, one RpcCollection hash
/// change for the whole redesign); consumed by <c>RouteSelectSystem</c> from Step 8.
/// </summary>
public struct RouteSelectRequest : IRpcCommand
{
/// <summary>Index into the replicated RouteOpt* set (0..RouteOptionCount-1).</summary>
public byte OptionIndex;
/// <summary>RE-MEANED (Step-8 review, zero wire churn): carries <c>(int)RunInfo.RunSeed</c> — the
/// replicated, per-run-unique, never-zero run-identity token — NOT the server-only RunEpoch (which a client
/// cannot know). The server accepts iff <c>(uint)ForRunEpoch == RunRuntime.RunSeed</c>: the full cross-run
/// stale-reject at zero RpcCollection-hash cost (re-mean bytes, don't rename).</summary>
public int ForRunEpoch;
/// <summary>The layer this pick was made for — <c>RunInfo.CurrentRoom</c> VERBATIM (during a gate that is
/// still the just-CLEARED layer; never +1 — the server compares the same un-incremented value).</summary>
public int ForLayer;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 24000ef6fe52691408d4247341cbb189
@@ -1,85 +0,0 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Lifecycle states for a co-op expedition run (<see cref="RunInfo.Lifecycle"/>). A <c>byte</c>, never an enum
/// (Burst/serialization safe), APPEND-ONLY. <see cref="RouteSelect"/> is appended after the original four so no
/// value is re-meaned. The party is at the base hub in <see cref="Staging"/>; a discrete run spans
/// <see cref="Launching"/>→<see cref="InRoom"/>→<see cref="RoomReward"/>→<see cref="RouteSelect"/> (loop) →
/// <see cref="Returning"/>→<see cref="Staging"/>.
/// </summary>
public static class RunLifecycle
{
/// <summary>Party in the base hub; ready-check active; no expedition ghosts exist.</summary>
public const byte Staging = 0;
/// <summary>All-ready launch transient: seed chosen, party teleporting into room 0.</summary>
public const byte Launching = 1;
/// <summary>Active room populated; party fighting/looting.</summary>
public const byte InRoom = 2;
/// <summary>Room cleared; per-player boon offers pending; room torn down.</summary>
public const byte RoomReward = 3;
/// <summary>Run ended (boss cleared / party wiped / all left): teleport home, bank, → Staging.</summary>
public const byte Returning = 4;
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
public const byte RouteSelect = 5;
/// <summary>DR-046: room cleared + boon picked, but the room + resource NODES persist and a portal is up.
/// Party loots; interacting the portal (or a soft-timeout) tears the room down + advances (RouteSelect, or
/// Returning if the boss fell). Append-only byte value — no ghost re-mean.</summary>
public const byte RoomExplore = 6;
}
/// <summary>
/// The REPLICATED run-lifecycle summary the whole party observes — a server-decided, client-observed FSM on the
/// GLOBAL untagged CycleDirector ghost (so it is relevant cross-region for free, like <see cref="CycleState"/>/
/// <see cref="GoalProgress"/>/<see cref="RunOutcome"/>). SOLE writer: <c>RunDirectorSystem</c>. Distinct from
/// <see cref="CycleState.Phase"/> (that stays the BASE Calm↔Siege posture for retaliation/final sieges).
///
/// Fields split three ways: the lifecycle/room readout (HUD "Room i/N", biome cross-fade), the branching-map
/// wire (<see cref="RunSeed"/> so the client regenerates the map for DISPLAY, + <see cref="CurrentCol"/> and the
/// authoritative reachable <c>RouteOpt*</c> the clickable options bind to), and a two-field mirror of the
/// persisted meta counters for the HUD. All integers/bytes → replicate exact (no quantization). Adding this
/// <c>[GhostField]</c> component re-hashes the runtime-spawned director ghost (server + client bake the same
/// prefab → hash matches), exactly like <see cref="CoreIntegrity"/>/<see cref="RunOutcome"/>.
/// </summary>
public struct RunInfo : IComponentData
{
// ---- lifecycle + room readout ----
/// <summary><see cref="RunLifecycle"/>.</summary>
[GhostField] public byte Lifecycle;
/// <summary>0-based depth of the active room (HUD "Room CurrentRoom+1 / RoomCount").</summary>
[GhostField] public int CurrentRoom;
/// <summary>Total rooms this run (== map layer count, seed-varied in [6,10]).</summary>
[GhostField] public int RoomCount;
/// <summary><see cref="RoomTypeId"/> of the active room.</summary>
[GhostField] public byte CurrentRoomType;
/// <summary><see cref="RoomBiomeId"/> of the active room (client atmosphere cross-fade).</summary>
[GhostField] public byte CurrentBiome;
/// <summary>Server tick the launch countdown elapses (0 = none). Via <see cref="TickUtil.NonZero"/>; compared with IsNewerThan.</summary>
[GhostField] public uint LaunchTick;
// ---- branching map wire ----
/// <summary>The run seed — clients regenerate the map layout for DISPLAY via <see cref="RunMapMath.Generate"/> (no gameplay authority).</summary>
[GhostField] public uint RunSeed;
/// <summary>The party's current column in the active layer.</summary>
[GhostField] public byte CurrentCol;
/// <summary>Number of reachable next-room options (0 unless <see cref="RunLifecycle.RouteSelect"/>).</summary>
[GhostField] public byte RouteOptionCount;
/// <summary>Reachable next-layer column for option 0 (authoritative — the clickable button binds to this, not the regen).</summary>
[GhostField] public byte RouteOpt0Col;
[GhostField] public byte RouteOpt1Col;
[GhostField] public byte RouteOpt2Col;
/// <summary><see cref="RoomTypeId"/> of option 0 (so the HUD labels the choice).</summary>
[GhostField] public byte RouteOpt0Type;
[GhostField] public byte RouteOpt1Type;
[GhostField] public byte RouteOpt2Type;
// ---- persisted-meta HUD mirror ----
/// <summary>Runs completed (boss-cleared), mirrored for the HUD from the persisted meta counters.</summary>
[GhostField] public int RunsCompleted;
/// <summary>Deepest room reached across runs, mirrored for the HUD.</summary>
[GhostField] public int MaxDepthReached;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 71c6d427dd2c6aa4789f0ab94833ac71
@@ -1,108 +0,0 @@
using System;
using Unity.Collections;
namespace ProjectM.Simulation
{
/// <summary>
/// Room-TYPE ids for a run-map node. A <c>byte</c>, never a C# enum — kept Burst-safe, serialization/replay-stable,
/// and APPEND-ONLY (a persisted meta / replay reproducibility depends on these values never being re-meaned).
/// </summary>
public static class RoomTypeId
{
public const byte Combat = 0;
public const byte Elite = 1;
public const byte Reward = 2;
public const byte Boss = 3;
public const byte Count = 4;
}
/// <summary>
/// Room SHAPE ids — the arena footprint <see cref="RoomLayoutMath.ScatterInShape"/> places nodes/enemies within.
/// A <c>byte</c> (append-only). Resolved to a concrete radius/footprint by <see cref="RoomLayoutMath"/>.
/// </summary>
public static class RoomShapeId
{
public const byte Disk = 0; // circular arena (area-uniform scatter)
public const byte Wide = 1; // rectangle, wider on X
public const byte Long = 2; // rectangle, longer on Z
public const byte Cross = 3; // plus/cross of two bars
public const byte Count = 4;
}
/// <summary>
/// Cosmetic BIOME ids — resolved to atmosphere/fog/tint by the client presentation layer (WorldAtmosphereSystem)
/// per room. A <c>byte</c> (append-only); purely visual, no gameplay authority.
/// </summary>
public static class RoomBiomeId
{
public const byte Meadow = 0;
public const byte Arid = 1;
public const byte Cavern = 2;
public const byte Blight = 3;
public const byte Count = 4;
}
/// <summary>
/// One node in the branching run-map DAG (Slay-the-Spire style). 4 bytes, unmanaged. <see cref="NextMask"/> is a
/// bit set: bit <c>j</c> ⇒ this node can advance to column <c>j</c> of the NEXT layer (<c>j &lt; RunMap.MaxWidth</c>).
/// A node with <see cref="NextMask"/> == 0 is a terminal (the single Boss node). Generated purely from the run seed
/// by <see cref="RunMapMath.Generate"/>, so it is identical on server + client (client regenerates for display).
/// </summary>
public struct RunMapNode : IEquatable<RunMapNode>
{
/// <summary><see cref="RoomTypeId"/>.</summary>
public byte RoomType;
/// <summary><see cref="RoomBiomeId"/> (cosmetic).</summary>
public byte Biome;
/// <summary><see cref="RoomShapeId"/>.</summary>
public byte ShapeId;
/// <summary>Reachable next-layer columns: bit <c>j</c> ⇒ column <c>j</c> of the next layer. 0 = terminal (Boss).</summary>
public byte NextMask;
public bool Equals(RunMapNode o) =>
RoomType == o.RoomType && Biome == o.Biome && ShapeId == o.ShapeId && NextMask == o.NextMask;
public override bool Equals(object o) => o is RunMapNode n && Equals(n);
public override int GetHashCode() => RoomType | (Biome << 8) | (ShapeId << 16) | (NextMask << 24);
}
/// <summary>
/// A generated branching run map: a layered DAG the party traverses one node per layer. TRANSIENT — regenerated
/// from the run seed via <see cref="RunMapMath.Generate"/> and NEVER a ghost buffer / never persisted (only the
/// seed + the party's current column ride the wire). Fixed stride of <see cref="MaxWidth"/> per layer, so the
/// stable node key <c>nodeId = layer*MaxWidth + col</c> resolves the SAME room regardless of the path taken —
/// which keeps per-room content (layout, boons) deterministic. Bounded to <see cref="MaxNodes"/> so it lives in a
/// <see cref="FixedList512Bytes{T}"/> (30 × 4 B = 120 B).
/// </summary>
public struct RunMap
{
/// <summary>Max layers (run length is seed-varied within [6, <see cref="MaxLayers"/>]).</summary>
public const int MaxLayers = 10;
/// <summary>Max nodes per layer (branch width 13).</summary>
public const int MaxWidth = 3;
/// <summary>Node-buffer capacity (fixed stride): <see cref="MaxLayers"/> × <see cref="MaxWidth"/>.</summary>
public const int MaxNodes = MaxLayers * MaxWidth;
/// <summary>Nodes, fixed stride <see cref="MaxWidth"/> per layer (<c>LayerCount*MaxWidth</c> entries; columns
/// ≥ <see cref="Width"/> are absent/unused).</summary>
public FixedList512Bytes<RunMapNode> Nodes;
/// <summary>Per-layer branch width (<see cref="LayerCount"/> entries, each in [1, <see cref="MaxWidth"/>]).</summary>
public FixedList64Bytes<byte> LayerWidths;
/// <summary>Number of layers this run (== room count, in [6, <see cref="MaxLayers"/>]).</summary>
public byte LayerCount;
/// <summary>Stable node key for a (layer, col) — fixed stride, so the same key is the same room on any path.</summary>
public static int NodeId(int layer, int col) => layer * MaxWidth + col;
/// <summary>Branch width of a layer.</summary>
public int Width(int layer) => LayerWidths[layer];
/// <summary>Node at (layer, col).</summary>
public RunMapNode Node(int layer, int col) => Nodes[NodeId(layer, col)];
/// <summary>Node by stable id.</summary>
public RunMapNode NodeAt(int nodeId) => Nodes[nodeId];
/// <summary>Layer of a node id.</summary>
public int LayerOf(int nodeId) => nodeId / MaxWidth;
/// <summary>Column of a node id.</summary>
public int ColOf(int nodeId) => nodeId % MaxWidth;
/// <summary>The single Boss node id (last layer, column 0).</summary>
public int BossNodeId => NodeId(LayerCount - 1, 0);
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 59ab777c8e8ab794895a7236f5212758
@@ -1,225 +0,0 @@
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure, deterministic generator for the branching run-map DAG — no RNG state, no wall-clock, INTEGER-HASH ONLY
/// (no <see cref="Unity.Mathematics.Random"/>, whose draw order is fragile across the multi-pass edge build and
/// which the client would have to replay bit-identically). A run map is therefore a pure function of the run seed,
/// so server + client regenerate the SAME graph — the server keeps gameplay authority (the party's column + the
/// reachable options ride the wire), the client regenerates only to DRAW the map. Mirrors the
/// <see cref="ZoneEnemyMath"/> pure-math discipline.
///
/// Structure: layer 0 = a single Combat landing node; interior layers width 23, weighted-typed
/// (Combat 60 / Reward 25 / Elite 15, with an all-Reward-layer guard); the second-to-last layer is an all-Elite
/// gate (so every start→boss path passes ≥1 Elite); the last layer = the single Boss terminal. Edges: a primary
/// pass (every source gets ≥1 proportional out-edge, jittered, sometimes widened) + a coverage pass (every target
/// gets ≥1 in-edge), which together guarantee full reachability from the root and exactly one terminal.
/// </summary>
public static class RunMapMath
{
// ---- deterministic integer hashing (order-independent combine + a final avalanche) ----
static uint Mix(uint h)
{
h ^= h >> 16; h *= 0x7feb352du;
h ^= h >> 15; h *= 0x846ca68bu;
h ^= h >> 16;
return h;
}
static uint Combine(uint h, uint v)
{
// boost-style hash_combine
h ^= v + 0x9e3779b9u + (h << 6) + (h >> 2);
return h;
}
/// <summary>Deterministic hash of a salt tuple (integer-only, well-mixed, never dependent on draw order).</summary>
public static uint Hash(uint a) => Mix(Combine(0x811c9dc5u, a));
public static uint Hash(uint a, uint b) => Mix(Combine(Combine(0x811c9dc5u, a), b));
public static uint Hash(uint a, uint b, uint c) => Mix(Combine(Combine(Combine(0x811c9dc5u, a), b), c));
public static uint Hash(uint a, uint b, uint c, uint d) =>
Mix(Combine(Combine(Combine(Combine(0x811c9dc5u, a), b), c), d));
/// <summary>
/// Generate the branching run map for <paramref name="runSeed"/>. Deterministic + identical on both worlds.
/// </summary>
public static RunMap Generate(uint runSeed)
{
uint s = math.max(1u, runSeed);
int L = 6 + (int)(Hash(s, 0x1Au) % 5u); // run length in [6,10]
var map = new RunMap { LayerCount = (byte)L };
// Per-layer branch widths: single landing + single boss, interior 23.
map.LayerWidths = new FixedList64Bytes<byte>();
for (int layer = 0; layer < L; layer++)
{
byte w = (layer == 0 || layer == L - 1)
? (byte)1
: (byte)(2 + (int)(Hash(s, (uint)layer, 0x11u) % 2u)); // 2 or 3
map.LayerWidths.Add(w);
}
// Nodes: fixed stride MaxWidth per layer (absent columns left default).
map.Nodes = new FixedList512Bytes<RunMapNode>();
int slots = L * RunMap.MaxWidth;
for (int i = 0; i < slots; i++) map.Nodes.Add(default);
// Types / biome / shape.
for (int layer = 0; layer < L; layer++)
{
int w = map.LayerWidths[layer];
byte layerBiome = (byte)(Hash(s, (uint)layer, 0xB1u) % RoomBiomeId.Count);
bool anyNonReward = false;
for (int col = 0; col < w; col++)
{
byte type = PickType(s, layer, col, L);
if (type != RoomTypeId.Reward) anyNonReward = true;
byte shape = (byte)(Hash(s, (uint)layer, (uint)col, 0x5Au) % RoomShapeId.Count);
map.Nodes[RunMap.NodeId(layer, col)] = new RunMapNode
{
RoomType = type,
Biome = layerBiome,
ShapeId = shape,
NextMask = 0,
};
}
// Guard: never an entire interior layer of only Reward rooms → force column 0 to Combat.
if (!anyNonReward && w > 0)
{
int id0 = RunMap.NodeId(layer, 0);
var n = map.Nodes[id0];
n.RoomType = RoomTypeId.Combat;
map.Nodes[id0] = n;
}
}
BuildEdges(ref map, s);
return map;
}
static byte PickType(uint s, int layer, int col, int L)
{
if (layer == 0) return RoomTypeId.Combat; // guaranteed landing room
if (layer == L - 1) return RoomTypeId.Boss; // single terminal
if (layer == L - 2) return RoomTypeId.Elite; // all-Elite gate (≥1 Elite on every path)
uint r = Hash(s, (uint)layer, (uint)col, 0xC0u) % 100u; // Combat 60 / Reward 25 / Elite 15
if (r < 60u) return RoomTypeId.Combat;
if (r < 85u) return RoomTypeId.Reward;
return RoomTypeId.Elite;
}
static void BuildEdges(ref RunMap map, uint s)
{
int L = map.LayerCount;
for (int l = 0; l < L - 1; l++)
{
int w = map.LayerWidths[l];
int wn = map.LayerWidths[l + 1];
// Primary: every source gets a proportional out-edge (± jitter), sometimes widened to a neighbor.
for (int c = 0; c < w; c++)
{
int t = ProportionalCol(c, w, wn);
int jitter = (int)(Hash(s, (uint)l, (uint)c, 0xEDu) % 3u) - 1; // -1, 0, +1
t = math.clamp(t + jitter, 0, wn - 1);
SetEdge(ref map, l, c, t);
if (wn > 1 && Hash(s, (uint)l, (uint)c, 0x2Bu) % 100u < 35u)
{
int dir = (Hash(s, (uint)l, (uint)c, 0x2Cu) % 2u) == 0u ? -1 : 1;
int t2 = math.clamp(t + dir, 0, wn - 1);
SetEdge(ref map, l, c, t2);
}
}
// Coverage: every target in the next layer must have ≥1 in-edge (forces convergence on the Boss).
for (int tcol = 0; tcol < wn; tcol++)
{
if (!HasInEdge(ref map, l, tcol))
{
int src = ProportionalCol(tcol, wn, w);
SetEdge(ref map, l, src, tcol);
}
}
}
}
static int ProportionalCol(int from, int fromWidth, int toWidth)
{
if (fromWidth <= 1 || toWidth <= 1) return toWidth / 2;
return (int)math.round((float)from * (toWidth - 1) / (fromWidth - 1));
}
static void SetEdge(ref RunMap map, int layer, int col, int targetCol)
{
int id = RunMap.NodeId(layer, col);
var n = map.Nodes[id];
n.NextMask |= (byte)(1 << targetCol);
map.Nodes[id] = n;
}
static bool HasInEdge(ref RunMap map, int layer, int targetCol)
{
int w = map.LayerWidths[layer];
byte bit = (byte)(1 << targetCol);
for (int c = 0; c < w; c++)
if ((map.Nodes[RunMap.NodeId(layer, c)].NextMask & bit) != 0) return true;
return false;
}
/// <summary>
/// The columns of the NEXT layer reachable from node (<paramref name="layer"/>, <paramref name="col"/>).
/// Empty for the Boss/last layer. This is the authoritative set the route-choice offer is drawn from.
/// </summary>
public static int ReachableOptions(in RunMap map, int layer, int col, out FixedList32Bytes<byte> cols)
{
cols = new FixedList32Bytes<byte>();
if (layer < 0 || layer >= map.LayerCount - 1) return 0;
byte mask = map.Node(layer, col).NextMask;
int wn = map.Width(layer + 1);
for (int j = 0; j < wn; j++)
if ((mask & (1 << j)) != 0) cols.Add((byte)j);
return cols.Length;
}
/// <summary>
/// True iff every PRESENT node is reachable from the root (0,0) via the edges (BFS). Used to assert the
/// generator never strands a node or the Boss. O(nodes).
/// </summary>
public static bool AllNodesReachable(in RunMap map)
{
var visited = new FixedList128Bytes<byte>();
for (int i = 0; i < RunMap.MaxNodes; i++) visited.Add(0);
var stack = new FixedList128Bytes<byte>();
int root = RunMap.NodeId(0, 0);
visited[root] = 1;
stack.Add((byte)root);
while (stack.Length > 0)
{
int id = stack[stack.Length - 1];
stack.RemoveAt(stack.Length - 1);
int layer = map.LayerOf(id);
if (layer >= map.LayerCount - 1) continue;
byte mask = map.NodeAt(id).NextMask;
int wn = map.Width(layer + 1);
for (int j = 0; j < wn; j++)
{
if ((mask & (1 << j)) == 0) continue;
int nid = RunMap.NodeId(layer + 1, j);
if (visited[nid] == 0) { visited[nid] = 1; stack.Add((byte)nid); }
}
}
for (int layer = 0; layer < map.LayerCount; layer++)
for (int col = 0; col < map.Width(layer); col++)
if (visited[RunMap.NodeId(layer, col)] == 0) return false;
return true;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6f2e10313b5eeac468bef027c5c7be62
@@ -1,16 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY roster tag for the players conscripted into the CURRENT run — stamped on every connected
/// player at the launch edge (Launching → room 0), removed on the Returning edge. Room advances teleport
/// ONLY participants: a dead-respawned member (back at base) is re-conscripted on the next advance (the
/// operator-locked default), while a mid-run late JOINER — who never readied — stays safely at base until
/// the next Staging (spec §2.2 closed-party rule; post-impl review, confirmed medium). NOT a [GhostField];
/// disconnect cleanup is free (the tag dies with the player ghost's LinkedEntityGroup despawn).
/// </summary>
public struct RunParticipant : IComponentData
{
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 92795e88279d04e42af70f8ec1032cc7
@@ -1,60 +0,0 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Server-only working state for the run FSM — lives on the CycleDirector beside <see cref="RunInfo"/> but is
/// NOT replicated (adding fields here never re-bakes the ghost). Owned/written by <c>RunDirectorSystem</c>.
/// Determinism: <see cref="RunSeed"/> = max(1, Hash(<see cref="RunEpoch"/>, <see cref="HostSalt"/>)) — monotonic
/// int, never a tick, equality-compared. Tick sentinels (<see cref="RewardGraceTick"/>/<see cref="RouteGraceTick"/>)
/// route through <see cref="TickUtil.NonZero"/> and compare via NetworkTick.IsNewerThan (never raw uint).
/// </summary>
public struct RunRuntime : Unity.Entities.IComponentData
{
// ---- run identity / seed ----
/// <summary>Working copy of the run seed (mirrored to the replicated <see cref="RunInfo.RunSeed"/>).</summary>
public uint RunSeed;
/// <summary>Monotonic run counter; bumped on the Staging→Launching edge so each run reseeds. Equality-compared.</summary>
public int RunEpoch;
/// <summary>Per-playthrough salt folded into <see cref="RunSeed"/> for cross-session map variety (seeded at spawn, non-tick).</summary>
public uint HostSalt;
// ---- room traversal ----
/// <summary>Monotonic room-seed counter; bumped per room advance so the field/enemy directors reseed. Equality-compared.</summary>
public int RoomEpoch;
/// <summary>Which of the two ping-pong sub-arena slots the active room occupies (CurrentRoom &amp; 1).</summary>
public byte ActiveSubSlot;
/// <summary>The active room's stable map node id (single plan authority — field/enemy directors read this, never re-derive).</summary>
public int CurrentNodeId;
/// <summary>The active room's column (mirrors <see cref="RunInfo.CurrentCol"/>).</summary>
public byte CurrentCol;
/// <summary>The active room's <see cref="RoomTypeId"/> (single plan authority).</summary>
public byte CurrentRoomType;
// ---- scarcity / banking latches ----
/// <summary>Run-wide remaining resource-node allotment (floors each room's scatter; decrements per node) → true scarcity.</summary>
public int NodeBudgetRemaining;
/// <summary>The <see cref="RunEpoch"/> the terminal bank last fired for — equality latch so a multi-tick Returning banks once.</summary>
public int LastBankedRunEpoch;
/// <summary>1 iff the run ended by a genuine BOSS clear (gates the win-meter/RunsCompleted credit; 0 on abort/wipe).</summary>
public byte LastTerminalCleared;
/// <summary>Rooms actually CLEARED this run (bumped on each InRoom→RoomReward edge; reset at launch) — the
/// honest depth the terminal bank records into MaxDepthReached (never the planned RoomCount — D-F3).</summary>
public int RoomsClearedThisRun;
// ---- ready / grace ----
/// <summary>Previous-tick all-ready state (rising-edge latch for the Staging→Launching launch).</summary>
public byte WasAllReady;
/// <summary>Server tick the RoomReward boon-pick grace elapses (NonZero; IsNewerThan-compared).</summary>
public uint RewardGraceTick;
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
public uint RouteGraceTick;
/// <summary>DR-046: RoomExplore soft-timeout (NonZero; auto-advance if nobody interacts the portal), so the
/// loot window can never softlock. Set on entering RoomExplore, compared via NetworkTick.IsNewerThan.</summary>
public uint ExploreGraceTick;
// ---- boons ----
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
public uint BoonPickCounter;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6936c1ec155a69a45a957a2f2dac1c3f