Run Re-Do
This commit is contained in:
@@ -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 & 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 < 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 1–3).</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 2–3, 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 2–3.
|
||||
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 & 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
|
||||
Reference in New Issue
Block a user