Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -1,12 +0,0 @@
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Client -&gt; server request to upgrade the sender's ability damage one tier, spending Aether from the
/// shared ledger. A one-off RPC. The server grows a single damage <see cref="StatModifier"/> on the
/// player (replace-by-SourceId so the buffer stays bounded), which StatRecomputeSystem folds into
/// EffectiveAbilityStats.Damage on both worlds — no new replicated component.
/// </summary>
public struct AbilityUpgradeRequest : IRpcCommand { }
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1236d3751a5740741a4a10e0a653565f
@@ -0,0 +1,180 @@
using Unity.Collections;
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// One authored boon in the catalog blob: a thin wrapper over the existing stat pipeline —
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row
/// (bytes, never enums, on the baked path). <see cref="Id"/> is the stable APPEND-ONLY key the replicated
/// <c>BoonOffer</c> options and pick RPC carry. <see cref="Weight"/> is the rarity draw weight
/// (common 100 / rare 30 / epic 10). <see cref="ClassMask"/> gates by class: bit0 = Warrior (classId 0),
/// bit1 = Ranger (classId 1), 3 = both.
/// </summary>
public struct BoonDefBlob
{
public byte Id;
public byte Target; // StatTarget as byte
public byte Op; // ModOp as byte
public float Value;
public byte Weight;
public byte ClassMask;
public FixedString64Bytes Name;
public FixedString128Bytes Desc;
}
/// <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 (<see cref="RunMapMath.Hash(uint,uint)"/> chain,
/// no RNG state), so an offer is a reproducible function of (runSeed, room, player). 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 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
/// <paramref name="offerSeed"/>. If the class-legal pool has fewer than 3 entries the tail repeats the
/// last-drawn candidates (a catalog authoring smell, not a crash). Returns the number of distinct ids.
/// </summary>
public static int PickBoons(uint offerSeed, byte classId, ref BoonCatalogBlob pool,
out byte o0, out byte o1, out byte o2)
{
byte classBit = MaskFor(classId);
// Class-legal candidate indices + the total weight.
var candidates = new FixedList128Bytes<byte>();
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;
candidates.Add((byte)i);
totalWeight += pool.Defs[i].Weight;
}
o0 = o1 = o2 = 0;
if (candidates.Length == 0)
return 0;
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
uint salt = 0;
while (picked.Length < 3 && picked.Length < candidates.Length)
{
// Weighted draw with rejection on duplicates (bounded; falls through to a linear fill).
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
byte drawn = candidates[candidates.Length - 1];
int acc = 0;
for (int c = 0; c < candidates.Length; c++)
{
acc += pool.Defs[candidates[c]].Weight;
if (roll < (uint)acc) { drawn = candidates[c]; break; }
}
bool dup = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == drawn) { dup = true; break; }
if (!dup)
{
picked.Add(drawn);
salt = 0;
}
else if (++salt > 16)
{
// Rejection budget spent — take the first unpicked candidate (still deterministic).
for (int c = 0; c < candidates.Length; c++)
{
bool used = false;
for (int p = 0; p < picked.Length; p++)
if (picked[p] == candidates[c]) { used = true; break; }
if (!used) { picked.Add(candidates[c]); break; }
}
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>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 v1 boon table + the blob builder the baker AND EditMode tests share (single source — the
/// authoring bakes this table verbatim when its designer-row list is empty). Append-only ids.
/// </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;
// id, target, op, value, weight, mask(1=Warrior,2=Ranger,3=both), name, desc
defs[i++] = Make(1, StatTarget.Damage, ModOp.PercentAdd, 0.20f, 100, 3, "Honed Edge", "+20% ability damage");
defs[i++] = Make(2, StatTarget.CooldownTicks, ModOp.PercentMult, -0.15f, 100, 3, "Swift Hands", "-15% ability cooldown");
defs[i++] = Make(3, StatTarget.Range, ModOp.PercentAdd, 0.25f, 100, 2, "Long Reach", "+25% projectile range");
defs[i++] = Make(4, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.12f, 100, 3, "Fleet Foot", "+12% move speed");
defs[i++] = Make(5, StatTarget.MaxHealth, ModOp.Flat, 25f, 100, 3, "Iron Constitution", "+25 max health");
defs[i++] = Make(6, StatTarget.MeleeDamage, ModOp.PercentAdd, 0.25f, 100, 1, "Heavy Blows", "+25% melee damage");
defs[i++] = Make(7, StatTarget.MeleeRange, ModOp.PercentAdd, 0.20f, 60, 1, "Extended Haft", "+20% melee reach");
defs[i++] = Make(8, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 0.25f, 60, 2, "Swift Bolts", "+25% projectile speed");
defs[i++] = Make(9, StatTarget.AutoTargetRange, ModOp.PercentAdd, 0.20f, 60, 3, "Keen Instinct", "+20% auto-target range");
defs[i++] = Make(10, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 30, 3, "Berserker's Pace", "-25% ability cooldown");
defs[i++] = Make(11, StatTarget.MaxHealth, ModOp.Flat, 60f, 30, 3, "Titan's Vigor", "+60 max health");
defs[i++] = Make(12, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 10, 3, "Executioner", "+50% ability damage");
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
builder.Dispose();
return blob;
}
static BoonDefBlob Make(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask,
string name, string desc)
{
return new BoonDefBlob
{
Id = id,
Target = (byte)target,
Op = (byte)op,
Value = value,
Weight = weight,
ClassMask = mask,
Name = new FixedString64Bytes(name),
Desc = new FixedString128Bytes(desc),
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 00909311d983d7a43afc195595aff217
@@ -0,0 +1,17 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc9680d4a4c9a334396b60bb97d75b3b
@@ -39,6 +39,12 @@ namespace ProjectM.Simulation
public static byte AbilityFor(byte classId)
=> classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone;
/// <summary>The class a Fire-slot ability id implies — the exact inverse of <see cref="AbilityFor"/> (Ranger
/// iff Primary). Lets the CLIENT derive the local class from the replicated <see cref="AbilityRef"/> (tracks
/// the dev class-switch, unlike the menu's ClassSelection static; PlayerClass itself is server-only).</summary>
public static byte ClassForAbility(byte abilityId)
=> abilityId == (byte)AbilityId.Primary ? RangerClass : WarriorClass;
/// <summary>True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).</summary>
public static bool IsClassSeed(uint sourceId)
=> sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount;
@@ -16,7 +16,9 @@ namespace ProjectM.Simulation
/// enum-codegen hazard that already de-Bursted ProjectileClassificationSystem.
/// </summary>
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
[InternalBufferCapacity(8)]
// Capacity 32 (was 8): class seeds + permanent meta rows + equip + up to ~10 run boons must stay chunk-internal
// (a chunk-layout hint only — NOT ghost serializer metadata, so this is NOT a re-bake; overflow spills to heap).
[InternalBufferCapacity(32)]
public struct StatModifier : IBufferElementData
{
/// <summary>The <see cref="StatTarget"/> this modifier applies to (stored as a byte).</summary>
@@ -31,5 +31,16 @@ namespace ProjectM.Simulation
if (mods[j].SourceId == sourceId) { mods.RemoveAtSwapBack(j); removed++; }
return removed;
}
/// <summary>Remove every <see cref="StatModifier"/> row whose SourceId lies in [lo, hiExclusive) — the
/// run-scoped BOON strip (per-pick distinct ids share the band; one call clears the whole run's boons while
/// class/meta/equip bands survive untouched). Idempotent; RemoveAtSwapBack. Returns the count removed.</summary>
public static int RemoveBySourceIdRange(DynamicBuffer<StatModifier> mods, uint lo, uint hiExclusive)
{
int removed = 0;
for (int j = mods.Length - 1; j >= 0; j--)
if (mods[j].SourceId >= lo && mods[j].SourceId < hiExclusive) { mods.RemoveAtSwapBack(j); removed++; }
return removed;
}
}
}
@@ -1,46 +0,0 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// Baked singleton describing the HOME-BASE mining field: a ring of harvestable resource nodes scattered
/// around the base so the gather -> build -> survive loop lives on ONE screen (no expedition trip required).
/// DISTINCT from <see cref="ResourceFieldSpawner"/> (the expedition field) so the two never collide on a
/// GetSingleton. <see cref="ProjectM.Server.BaseFieldSpawnSystem"/> keeps the live RegionTag{Base} node count
/// topped up to <see cref="TargetCount"/> on a tick cadence; nodes scatter UNIFORMLY-IN-RADIUS in the annulus
/// [<see cref="InnerRadius"/>, <see cref="OuterRadius"/>] around BaseGridMath.PlotCenter (inner clears the
/// square build plot + spawn ring, outer stays inside the walkable boundary ring) and are overridden to
/// RegionTag{Base} + ResourceId.Ore (the sole build currency, kept legible — never the expedition's
/// Aether/Ore/Biomass round-robin). Place ONE authoring in the gameplay subscene.
/// </summary>
public struct BaseFieldSpawner : IComponentData
{
/// <summary>Baked resource-node ghost prefab to instantiate (reuses the expedition node prefab).</summary>
public Entity Prefab;
/// <summary>Desired live base-node count; the system refills toward this each respawn pass.</summary>
public int TargetCount;
/// <summary>Inner scatter radius (world units) from the plot center — must clear the build plot + spawn ring.</summary>
public float InnerRadius;
/// <summary>Outer scatter radius (world units) — must stay inside the boundary ring so nodes are reachable.</summary>
public float OuterRadius;
/// <summary>Server ticks between top-up passes (a depleted field refills toward TargetCount on this cadence).</summary>
public int RespawnIntervalTicks;
}
/// <summary>
/// Server-only runtime state for <see cref="ProjectM.Server.BaseFieldSpawnSystem"/>, baked beside
/// <see cref="BaseFieldSpawner"/>. NOT replicated. <see cref="Epoch"/> seeds the per-node scatter RNG
/// (monotonic, so a top-up never repeats a layout); <see cref="NextSpawnTick"/> gates the cadence (wrap-safe
/// via TickUtil.NonZero + NetworkTick.IsNewerThan, never raw uint). NextSpawnTick == 0 means "fire now" so the
/// first pass seeds the field without waiting for a tick that never comes.
/// </summary>
public struct BaseFieldRuntime : IComponentData
{
public int Epoch;
public uint NextSpawnTick;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7a18d4457f559ef49bcd3122dcdb6d82
@@ -18,4 +18,14 @@ namespace ProjectM.Simulation
/// <summary>Scatter radius (world units) around the expedition region origin.</summary>
public float Radius;
}
/// <summary>
/// Server-only bookkeeping for <see cref="ProjectM.Server.RoomFieldSystem"/>, attached at runtime beside the
/// baked spawner singleton (the BaseFieldRuntime idiom): the <c>RunRuntime.RoomEpoch</c> this system last seeded,
/// int-equality-compared so each room's field scatters exactly once. NOT replicated; session-scoped.
/// </summary>
public struct RoomFieldState : IComponentData
{
public int LastSpawnedRoomEpoch;
}
}
Binary file not shown.
@@ -0,0 +1,116 @@
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 CharacterId 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),
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ee5e173c41773dc4189279241bf322b7
@@ -0,0 +1,63 @@
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 CharacterId 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 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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 307374d6819017f4da4bbfe64f11e7c5
@@ -0,0 +1,18 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71ddd683487e8704197b8eeddcb2c339
@@ -0,0 +1,38 @@
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;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 83b313e94b9f4304993358deca12e6e2
@@ -22,6 +22,10 @@ namespace ProjectM.Simulation
/// 2 = Loss -> the run loads finished + halted, no re-arm). Born-correct at director spawn.</summary>
public byte RunOutcome;
/// <summary>v6: persisted run counters to restore into MetaCounters + the born-correct RunInfo HUD mirror.</summary>
public int RunsCompleted;
public int MaxDepthReached;
/// <summary>0 = nothing staged (New Game); non-zero = apply the staged slice at director spawn.</summary>
public byte HasData;
}
@@ -32,6 +36,17 @@ namespace ProjectM.Simulation
public ushort ItemId;
public int Count;
}
/// <summary>One staged PERMANENT meta tier for a Continue session (v6) — copied VERBATIM into the director's
/// replicated MetaTierState buffer at spawn (unknown ids preserved for round-trip; clamping happens only at
/// seed/shop/spend). The buffer is added UNCONDITIONALLY at staging (empty OK) — the Bursted spawn system
/// GetBuffers it inside the HasData block and a missing buffer would throw on any v&lt;=5 Continue.</summary>
public struct PendingMetaRow : IBufferElementData
{
public byte ClassId;
public byte UpgradeId;
public byte Tier;
}
/// <summary>One staged player-built structure row for a Continue session (M7); BaseRestoreSystem replays it
/// charge-free into the freshly-streamed base. Mirrors <see cref="StructureSave"/> but as an unmanaged ECS
/// buffer element (staged in the ServerWorld before the subscene streams).</summary>
@@ -9,6 +9,18 @@ namespace ProjectM.Simulation
public int ItemId;
public int Count;
}
/// <summary>One persisted per-class PERMANENT meta-upgrade tier (v6). ClassId = the normalized CharacterId
/// (Warrior=2/Ranger=3); UpgradeId = the append-only meta-catalog key (unknown ids round-trip preserved and are
/// skipped live); Tier clamps to the catalog's MaxTier at seed/shop/spend, never at rest.</summary>
[Serializable]
public struct MetaUpgradeSave
{
public byte ClassId;
public byte UpgradeId;
public byte Tier;
}
/// <summary>
/// One serialized player-built structure (M7). Flat scalars (JsonUtility has no int2). The production
/// cooldown is stored as REMAINING ticks (epoch-independent) so it survives the server-tick origin reset on a
@@ -51,7 +63,7 @@ namespace ProjectM.Simulation
[Serializable]
public class SaveData
{
public const int CurrentVersion = 5; // END-2: v5 adds RunOutcome (a won/lost run loads finished); v4 added CoreCurrent
public const int CurrentVersion = 6; // v6: permanent META (per-class upgrade tiers + run counters); v5 added RunOutcomeoreCurrent
/// <summary>Oldest save schema the loader accepts (additive); a v2 save loads with structures at full HP.</summary>
public const int MinLoadableVersion = 2;
@@ -62,6 +74,11 @@ namespace ProjectM.Simulation
public int CoreCurrent; // END-1: Engine Core integrity at save time (0 from a pre-v4 save -> restored to baked Max)
public int RunOutcome; // END-2: 0=InProgress (also any pre-v5 save) / 1=Victory / 2=Loss -> a finished run loads finished
// v6 — permanent meta-progression (0/empty-defaults on any v<=5 save):
public int RunsCompleted; // boss-cleared runs (the HUD counter + the HostSalt fold at restore)
public int MaxDepthReached; // deepest room actually CLEARED across all runs (honest depth, never planned)
public MetaUpgradeSave[] MetaUpgrades = Array.Empty<MetaUpgradeSave>(); // sparse per-class tiers
public LedgerRow[] Ledger = Array.Empty<LedgerRow>();
public StructureSave[] Structures = Array.Empty<StructureSave>();
public StructureIoRow[] StructureIo = Array.Empty<StructureIoRow>();
@@ -27,6 +27,7 @@ namespace ProjectM.Simulation
// field 0-defaults and the restore guard maps 0 -> baked Max); v0/v1 garbage is still rejected.
if (data == null || data.Version < SaveData.MinLoadableVersion || data.Version > SaveData.CurrentVersion) return null;
data.Ledger ??= Array.Empty<LedgerRow>();
data.MetaUpgrades ??= Array.Empty<MetaUpgradeSave>(); // v6: null on any v<=5 file();
return data;
}
catch (Exception e)
@@ -0,0 +1,28 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c3c9cb8a1b819fd4b8474206e766076e
@@ -0,0 +1,20 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5423c043f8023a246b4244eb34e5fbdd
+36 -1
View File
@@ -74,6 +74,22 @@ namespace ProjectM.Simulation
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
public const int StartingOre = 50;
// ---- Expedition run economy (RoomFieldSystem / RunDirectorSystem) ----
/// <summary>Run-wide resource-node allotment: RunDirectorSystem stages it into RunRuntime.NodeBudgetRemaining
/// at the launch edge; RoomFieldSystem floors each room's scatter to what remains and spends it down. THE
/// scarcity knob — ~1.5 nodes/room over an 8-room run (dense Reward rooms drain it fastest; late rooms can
/// run dry, which is the intended tension).</summary>
public const int ExpeditionNodeBudget = 12;
/// <summary>Boss-room health multiplier over the base enemy prefab (RoomEnemyDirectorSystem spawns ONE
/// boss per Boss room; v1 = a beefed Charger — a real boss kit is a later rung).</summary>
public const float BossHealthMultiplier = 8f;
/// <summary>Boss-room visual scale multiplier (LocalTransform.Scale is a replicated [GhostField] — set once
/// at spawn on the baked value, legible at a glance).</summary>
public const float BossScaleMultiplier = 1.6f;
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
@@ -87,7 +103,26 @@ namespace ProjectM.Simulation
// inline mods share that one id and are stripped target-agnostically via
// TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT):
// 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command;
// 0x00E91000.. = equipment (4 slots); 0x00C1A550.. = class traits (Slice 2, permanent).
// 0x00B00000..0x00B10000 = run-scoped BOONS (stripped on return); 0x00C1A550.. = class traits (permanent);
// 0x00E7A000..0x00E7A100 = permanent META upgrades (Step 12a); 0x00E91000.. = equipment (4 slots).
/// <summary>Base of the run-scoped BOON SourceId band: each applied pick draws BoonSourceIdBase +
/// (BoonPickCounter++ % BoonSourceIdSpan), so boons stack as distinct rows and ONE range-strip on the
/// Returning edge clears the whole run's boons (the two-channel model — boons never persist).</summary>
public const uint BoonSourceIdBase = 0x00B00000u;
/// <summary>Width of the boon band [Base, Base+Span) — far above any realistic per-run pick count.</summary>
public const uint BoonSourceIdSpan = 0x10000u;
/// <summary>Base of the PERMANENT meta-upgrade SourceId band: a purchased tier's live StatModifier is
/// keyed MetaSourceIdBase + UpgradeId (absolute-value UPSERT — one row per owned upgrade, set to
/// ValuePerTier*tier). Persisted via MetaTierState (SaveData v6) and re-applied born-correct at spawn.
/// DISJOINT from every other band; NEVER stripped (the permanent channel of the two-channel model).</summary>
public const uint MetaSourceIdBase = 0x00E7A000u;
/// <summary>Width of the meta band [Base, Base+Span) — bounds UpgradeId to a byte-sized catalog.</summary>
public const uint MetaSourceIdSpan = 0x100u;
/// <summary>Base for per-slot equipment SourceIds; slot i tags its mods with <c>EquipSourceIdBase + i</c>.</summary>
public const uint EquipSourceIdBase = 0x00E91000u;
@@ -1,28 +0,0 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// A walk-in travel gate between world regions. A baked entity (visible mesh + this component) at a fixed
/// position; the server <c>ExpeditionGateSystem</c> transits a player who walks within <see cref="Radius"/>
/// and whose region matches <see cref="FromRegion"/> to <see cref="ToRegion"/>, placing them at
/// <see cref="ArrivalPos"/> (offset from the destination gate so they do not immediately re-trigger).
/// Returning to the base during the Expedition phase also starts Defend early (the "timer cap + early
/// return" pacing).
/// </summary>
public struct ExpeditionGate : IComponentData
{
/// <summary>Region a player must currently be in for this gate to act on them (see <see cref="RegionId"/>).</summary>
public byte FromRegion;
/// <summary>Region the player is transited to.</summary>
public byte ToRegion;
/// <summary>Planar (XZ) trigger radius in world units.</summary>
public float Radius;
/// <summary>World position the player arrives at in the destination region.</summary>
public float3 ArrivalPos;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ed28d6b4a4f0b0844b851cecaadeb93f
@@ -0,0 +1,16 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7489e354b7c8d0a46a155fa1d2c22bcc
@@ -35,14 +35,31 @@ namespace ProjectM.Simulation
/// </summary>
public static class RegionMath
{
/// <summary>World-space X offset of the expedition region from the base region.</summary>
/// <summary>World-space X offset of the expedition region (room sub-slot 0) from the base region.</summary>
public const float ExpeditionOffsetX = 1000f;
/// <summary>World-space origin of <paramref name="region"/>, given the base center (BaseGridMath.PlotCenter).</summary>
/// <summary>X stride between the two ping-pong room sub-slots — kept >= any sweep/AI/aggro range so two
/// transiently-coexisting arenas can never interact in the shared PhysicsWorld.</summary>
public const float RoomStrideX = 500f;
/// <summary>
/// World-space origin of expedition room sub-slot <paramref name="subSlot"/> (0 or 1 — the run FSM
/// ping-pongs consecutive rooms between two offsets so the next room spawns at the idle slot while the
/// cleared one is torn down). THE single expedition coordinate authority: every expedition placement
/// (field scatter, enemy ring, party teleport) resolves through here.
/// </summary>
public static float3 ExpeditionRoomOrigin(float3 baseCenter, byte subSlot)
{
return baseCenter + new float3(ExpeditionOffsetX + subSlot * RoomStrideX, 0f, 0f);
}
/// <summary>World-space origin of <paramref name="region"/>, given the base center (BaseGridMath.PlotCenter).
/// The expedition resolves to room sub-slot 0 (legacy call sites; room-aware systems pass the ACTIVE
/// sub-slot to <see cref="ExpeditionRoomOrigin"/> directly).</summary>
public static float3 RegionOrigin(byte region, float3 baseCenter)
{
return region == RegionId.Expedition
? baseCenter + new float3(ExpeditionOffsetX, 0f, 0f)
? ExpeditionRoomOrigin(baseCenter, 0)
: baseCenter;
}
}
@@ -0,0 +1,139 @@
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);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7979eb74587ba004885f89b140b15f00
@@ -0,0 +1,24 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc7dbc4b8c341e746b1cbe11f3a9ad86
@@ -0,0 +1,44 @@
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7fd0f5749da96e14db98fc4e6ada65b0
@@ -0,0 +1,27 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24000ef6fe52691408d4247341cbb189
@@ -0,0 +1,80 @@
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>
/// 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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71c6d427dd2c6aa4789f0ab94833ac71
@@ -0,0 +1,108 @@
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);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59ab777c8e8ab794895a7236f5212758
@@ -0,0 +1,225 @@
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f2e10313b5eeac468bef027c5c7be62
@@ -0,0 +1,55 @@
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;
// ---- boons ----
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
public uint BoonPickCounter;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6936c1ec155a69a45a957a2f2dac1c3f