226 lines
9.8 KiB
C#
226 lines
9.8 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|