- run_tests.js discovers and runs each *_test.js in its own worker process (--jobs N to cap the pool, --file <name> for a single suite) - GameState caches the deterministic terrain, city sites and road network by (seed, mapConfig, civ count), rebuilding only the mutable per-game state - the snapshot ships mapConfig and the client rebuilds terrain from it, so the DOM tests can use the small map fixture and a non-default config renders right - shortestPaths can stop once every target city is settled
219 lines
7.5 KiB
JavaScript
219 lines
7.5 KiB
JavaScript
// Pre-generated road network. The network is built once, after the cities are
|
|
// placed but before any player controls them, and never changes afterwards.
|
|
//
|
|
// The steps mirror the brief:
|
|
// 1. weigh every possible city-to-city route with the same hex pathfinding
|
|
// units use, on a cost that grows exponentially with the terrain;
|
|
// 2. order those candidate links by cost and keep a greedy geometric spanner
|
|
// of stretch `ROADS.spannerStretch`, so the road network stays sparse
|
|
// while no two cities are more than that factor worse off than direct;
|
|
// 3. return the set of tiles the chosen routes cross.
|
|
|
|
import { key, parseKey } from "./hex.js";
|
|
import { ROADS } from "./data/roads.js";
|
|
|
|
// Cost of running a road through a tile. Non-land tiles are impassable, and a
|
|
// city tile is free because cities are founded with a road already in place.
|
|
export function tileRoadCost(tile, isCity = false) {
|
|
if (isCity) return ROADS.cityCost;
|
|
if (!tile || tile.terrainClass !== "Land") return Infinity;
|
|
return Math.pow(ROADS.costBase, tile.movementCostMultiplier || 1);
|
|
}
|
|
|
|
class MinHeap {
|
|
constructor() {
|
|
this.keys = [];
|
|
this.values = [];
|
|
}
|
|
|
|
isEmpty() {
|
|
return this.keys.length === 0;
|
|
}
|
|
|
|
push(priority, value) {
|
|
this.keys.push(priority);
|
|
this.values.push(value);
|
|
let index = this.keys.length - 1;
|
|
while (index > 0) {
|
|
const parent = (index - 1) >> 1;
|
|
if (this.keys[parent] <= this.keys[index]) break;
|
|
this._swap(parent, index);
|
|
index = parent;
|
|
}
|
|
}
|
|
|
|
pop() {
|
|
const value = this.values[0];
|
|
const last = this.keys.length - 1;
|
|
this.keys[0] = this.keys[last];
|
|
this.values[0] = this.values[last];
|
|
this.keys.pop();
|
|
this.values.pop();
|
|
let index = 0;
|
|
while (true) {
|
|
let smallest = index;
|
|
const left = (index << 1) + 1;
|
|
const right = left + 1;
|
|
if (left < this.keys.length && this.keys[left] < this.keys[smallest]) smallest = left;
|
|
if (right < this.keys.length && this.keys[right] < this.keys[smallest]) smallest = right;
|
|
if (smallest === index) break;
|
|
this._swap(index, smallest);
|
|
index = smallest;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
_swap(a, b) {
|
|
const k = this.keys[a];
|
|
this.keys[a] = this.keys[b];
|
|
this.keys[b] = k;
|
|
const v = this.values[a];
|
|
this.values[a] = this.values[b];
|
|
this.values[b] = v;
|
|
}
|
|
}
|
|
|
|
// Dijkstra over the land tiles from `source`, costing each step through
|
|
// `stepCost(coords, tile)`. Returns the distance to every reachable tile and
|
|
// the parent needed to rebuild the route.
|
|
//
|
|
// `targets` (optional) is a set of canonical keys the caller actually needs:
|
|
// once every one of them is settled the search stops, which keeps the
|
|
// road-network build from flooding the whole continent from each city.
|
|
export function shortestPaths(source, topology, tiles, stepCost, targets = null) {
|
|
const sourceKey = key(source.x, source.y);
|
|
const distance = new Map([[sourceKey, 0]]);
|
|
const parent = new Map();
|
|
const settled = new Set();
|
|
const remaining = targets ? new Set(targets) : null;
|
|
if (remaining) remaining.delete(sourceKey);
|
|
const heap = new MinHeap();
|
|
heap.push(0, source);
|
|
while (!heap.isEmpty()) {
|
|
const current = heap.pop();
|
|
const currentKey = key(current.x, current.y);
|
|
if (settled.has(currentKey)) continue;
|
|
settled.add(currentKey);
|
|
if (remaining) {
|
|
remaining.delete(currentKey);
|
|
if (remaining.size === 0 && distance.has(currentKey)) break;
|
|
}
|
|
for (const neighbour of topology.neighbours(current.x, current.y)) {
|
|
const nk = key(neighbour.x, neighbour.y);
|
|
const tile = tiles[nk];
|
|
if (!tile || tile.terrainClass !== "Land") continue;
|
|
const cost = stepCost(neighbour, tile);
|
|
if (!Number.isFinite(cost)) continue;
|
|
const candidate = distance.get(currentKey) + cost;
|
|
if (candidate < (distance.has(nk) ? distance.get(nk) : Infinity)) {
|
|
distance.set(nk, candidate);
|
|
parent.set(nk, current);
|
|
heap.push(candidate, neighbour);
|
|
}
|
|
}
|
|
}
|
|
return { distance, parent };
|
|
}
|
|
|
|
function reconstruct(parent, sourceKey, goalKey) {
|
|
const path = [];
|
|
let cursor = parseKey(goalKey);
|
|
while (cursor && key(cursor.x, cursor.y) !== sourceKey) {
|
|
path.push(cursor);
|
|
cursor = parent.get(key(cursor.x, cursor.y));
|
|
if (path.length > 100000) return [];
|
|
}
|
|
path.push(parseKey(sourceKey));
|
|
return path.reverse();
|
|
}
|
|
|
|
// Distance between two city indices in a weighted adjacency map, or Infinity
|
|
// when no spanner path connects them yet.
|
|
function spannerDistance(adjacency, from, to) {
|
|
if (from === to) return 0;
|
|
const distance = new Map([[from, 0]]);
|
|
const settled = new Set();
|
|
const heap = new MinHeap();
|
|
heap.push(0, from);
|
|
while (!heap.isEmpty()) {
|
|
const current = heap.pop();
|
|
if (settled.has(current)) continue;
|
|
settled.add(current);
|
|
if (current === to) return distance.get(current);
|
|
for (const [next, weight] of adjacency.get(current) || []) {
|
|
const candidate = distance.get(current) + weight;
|
|
if (candidate < (distance.has(next) ? distance.get(next) : Infinity)) {
|
|
distance.set(next, candidate);
|
|
heap.push(candidate, next);
|
|
}
|
|
}
|
|
}
|
|
return Infinity;
|
|
}
|
|
|
|
// Builds the road tile set connecting `cities`. `cities` is a list of objects
|
|
// with a `coords` field; the returned Set holds canonical "x,y" keys, cities
|
|
// included.
|
|
export function buildRoadNetwork(cities, topology, tiles, options = {}) {
|
|
const stretch = options.spannerStretch || ROADS.spannerStretch;
|
|
const roads = new Set();
|
|
if (!cities || cities.length === 0) return roads;
|
|
const cityKeys = cities.map((city) => key(city.coords.x, city.coords.y));
|
|
const isCity = new Set(cityKeys);
|
|
const stepCost = (coords, tile) => tileRoadCost(tile, isCity.has(key(coords.x, coords.y)));
|
|
for (const k of cityKeys) roads.add(k);
|
|
if (cities.length < 2) return roads;
|
|
|
|
// Every possible link, with the cheapest route and its cost. Each search may
|
|
// stop as soon as it has reached every other city, instead of flooding the
|
|
// whole continent.
|
|
const searches = cities.map((city, index) =>
|
|
shortestPaths(
|
|
city.coords,
|
|
topology,
|
|
tiles,
|
|
stepCost,
|
|
cityKeys.filter((_, other) => other !== index)
|
|
)
|
|
);
|
|
const edges = [];
|
|
for (let a = 0; a < cities.length; a++) {
|
|
for (let b = a + 1; b < cities.length; b++) {
|
|
if (!searches[a].distance.has(cityKeys[b])) continue;
|
|
edges.push({
|
|
a,
|
|
b,
|
|
cost: searches[a].distance.get(cityKeys[b]),
|
|
path: reconstruct(searches[a].parent, cityKeys[a], cityKeys[b]),
|
|
});
|
|
}
|
|
}
|
|
edges.sort((a, b) => a.cost - b.cost || a.a - b.a || a.b - b.b);
|
|
for (const edge of greedySpanner(edges, stretch)) {
|
|
for (const coords of edge.path) roads.add(key(coords.x, coords.y));
|
|
}
|
|
return roads;
|
|
}
|
|
|
|
// Greedy geometric spanner: walk the candidate links cheapest first and keep a
|
|
// link only when the links kept so far do not already connect its ends within
|
|
// `stretch` times its cost. Exported so the spanner property can be tested on a
|
|
// synthetic edge list without a hex map.
|
|
export function greedySpanner(edges, stretch = ROADS.spannerStretch) {
|
|
const adjacency = new Map();
|
|
const chosen = [];
|
|
const addEdge = (a, b, cost) => {
|
|
if (!adjacency.has(a)) adjacency.set(a, []);
|
|
if (!adjacency.has(b)) adjacency.set(b, []);
|
|
adjacency.get(a).push([b, cost]);
|
|
adjacency.get(b).push([a, cost]);
|
|
};
|
|
for (const edge of edges) {
|
|
if (spannerDistance(adjacency, edge.a, edge.b) > stretch * edge.cost) {
|
|
addEdge(edge.a, edge.b, edge.cost);
|
|
chosen.push(edge);
|
|
}
|
|
}
|
|
return chosen;
|
|
}
|