Files
Project-M/Assets/_Project/Scripts/Simulation/World/RunMapMath.cs
T
2026-07-02 20:41:43 -07:00

226 lines
9.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}