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,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