1/**2 * Seed-deterministic v2 map generation. Integer math only.3 *4 * Layout: plains base; ocean border ring; arctic caps and steppe bands at the5 * north/south edges; forest / hills / mountain / swamp blobs; 1-2 volcano6 * tiles inside mountains; lakes; 1-2 meandering rivers to the ocean; ore ('O')7 * and rarer gold ('G') veins on hills/mountain tiles.8 *9 * Spawn fairness (terraformed, deterministic): within Chebyshev radius 8 of10 * each founding site every tribe is guaranteed plains >= 20, forest >= 10,11 * water-adjacent land tiles >= 3, hills-or-mountain >= 6 with >= 1 ore vein.12 */13import { CLAIM_COST, HILLS_CLASS, TC, TIER_CLAIM_BUDGET, WATER_CLASS } from './constants.js';14import type { Rng } from './rng.js';1516export interface GeneratedMap {17 terrain: Uint8Array;18 spawns: Array<{ x: number; y: number }>;19}2021function blob(22 terrain: Uint8Array,23 size: number,24 cx: number,25 cy: number,26 r: number,27 t: number,28 onlyOver: number | -1,29): void {30 for (let dy = -r; dy <= r; dy++) {31 for (let dx = -r; dx <= r; dx++) {32 if (dx * dx + dy * dy > r * r) continue;33 const x = cx + dx;34 const y = cy + dy;35 if (x < 0 || y < 0 || x >= size || y >= size) continue;36 const i = y * size + x;37 if (onlyOver === -1 || terrain[i] === onlyOver) terrain[i] = t;38 }39 }40}4142const isLand = (t: number): boolean => t !== TC.WATER && t !== TC.RIVER;4344function waterAdjacentLand(terrain: Uint8Array, size: number, x: number, y: number): boolean {45 const t = terrain[y * size + x]!;46 if (!isLand(t)) return false;47 for (let dy = -1; dy <= 1; dy++) {48 for (let dx = -1; dx <= 1; dx++) {49 if (dx === 0 && dy === 0) continue;50 const nx = x + dx;51 const ny = y + dy;52 if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;53 if (WATER_CLASS.has(terrain[ny * size + nx]!)) return true;54 }55 }56 return false;57}5859/**60 * Deterministic offsets ordered by (Chebyshev distance, dy, dx) out to61 * radius 8 — used to count and terraform spawn surroundings in a fixed order.62 */63function ringOffsets(maxR: number): Array<[number, number]> {64 const offs: Array<[number, number]> = [];65 for (let r = 0; r <= maxR; r++) {66 for (let dy = -r; dy <= r; dy++) {67 for (let dx = -r; dx <= r; dx++) {68 if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue;69 offs.push([dx, dy]);70 }71 }72 }73 return offs;74}75const SPAWN_OFFS = ringOffsets(8);7677export function generateMap(size: number, tribeCount: number, rng: Rng): GeneratedMap {78 const terrain = new Uint8Array(size * size).fill(TC.PLAINS);79 const idx = (x: number, y: number): number => y * size + x;8081 // --- Ocean border: solid 2 deep, ragged out to 4. ---82 for (let y = 0; y < size; y++) {83 for (let x = 0; x < size; x++) {84 const edge = Math.min(x, y, size - 1 - x, size - 1 - y);85 if (edge < 2) terrain[idx(x, y)] = TC.WATER;86 else if (edge === 2) {87 if (rng.int(2) === 0) terrain[idx(x, y)] = TC.WATER;88 } else if (edge === 3) {89 if (rng.int(4) === 0) terrain[idx(x, y)] = TC.WATER;90 }91 }92 }9394 // --- Arctic caps and steppe bands (north & south), scaled to map size95 // (10/6 rows at the original 128; proportional on smaller worlds). ---96 const arcticRows = Math.max(3, Math.trunc((size * 10) / 128));97 const steppeRows = Math.max(2, Math.trunc((size * 6) / 128));98 for (let y = 0; y < size; y++) {99 const fromEdge = Math.min(y, size - 1 - y);100 let t = -1;101 if (fromEdge < arcticRows) t = TC.ARCTIC;102 else if (fromEdge < arcticRows + steppeRows) t = TC.STEPPE;103 if (t === -1) continue;104 for (let x = 0; x < size; x++) {105 const i = idx(x, y);106 if (terrain[i] === TC.PLAINS) terrain[i] = t;107 }108 }109110 // --- Biome blobs (over plains only, so bands/ocean survive). ---111 const scatter = (t: number, count: number, rMin: number, rMax: number, over: number): void => {112 for (let i = 0; i < count; i++) {113 const cx = 4 + rng.int(size - 8);114 const cy = 4 + rng.int(size - 8);115 const r = rMin + rng.int(rMax - rMin + 1);116 blob(terrain, size, cx, cy, r, t, over);117 }118 };119 // Densities were tuned at 128x128; scale counts by area for other sizes.120 const byArea = (n: number, min: number): number =>121 Math.max(min, Math.trunc((n * size * size) / (128 * 128)));122 scatter(TC.FOREST, byArea(40, 8), 2, 4, TC.PLAINS);123 scatter(TC.HILLS, byArea(25, 5), 2, 3, TC.PLAINS);124 // Mountains over hills (cores), plus a few standalone ranges over plains.125 scatter(TC.MOUNTAIN, byArea(12, 3), 1, 2, TC.HILLS);126 scatter(TC.MOUNTAIN, byArea(4, 1), 1, 2, TC.PLAINS);127 scatter(TC.SWAMP, byArea(10, 2), 1, 2, TC.PLAINS);128129 // --- Lakes. ---130 for (let i = 0; i < byArea(5, 1); i++) {131 const cx = 8 + rng.int(size - 16);132 const cy = 8 + rng.int(size - 16);133 blob(terrain, size, cx, cy, 2 + rng.int(2), TC.WATER, -1);134 }135136 // --- Rivers: 1-2, meandering from an interior point to the nearest edge. ---137 const rivers = 1 + rng.int(2);138 for (let i = 0; i < rivers; i++) {139 let x = (size >> 2) + rng.int(size >> 1);140 let y = (size >> 2) + rng.int(size >> 1);141 // Main direction: toward the nearest edge.142 const toLeft = x;143 const toRight = size - 1 - x;144 const toTop = y;145 const toBottom = size - 1 - y;146 const m = Math.min(toLeft, toRight, toTop, toBottom);147 let mdx = 0;148 let mdy = 0;149 if (m === toLeft) mdx = -1;150 else if (m === toRight) mdx = 1;151 else if (m === toTop) mdy = -1;152 else mdy = 1;153 for (let step = 0; step < size * 2; step++) {154 if (x < 0 || y < 0 || x >= size || y >= size) break;155 const t = terrain[idx(x, y)]!;156 if (t === TC.WATER) break; // reached ocean/lake157 terrain[idx(x, y)] = TC.RIVER;158 // Advance: main direction, with lateral jitter every other step.159 const j = rng.int(3) - 1; // -1 | 0 | 1160 if (mdx !== 0) {161 x += mdx;162 y += j;163 } else {164 y += mdy;165 x += j;166 }167 }168 }169170 // --- Volcanoes: up to 2, on mountain tiles. ---171 let volcanoes = 0;172 for (let tries = 0; tries < 60 && volcanoes < 2; tries++) {173 const x = rng.int(size);174 const y = rng.int(size);175 if (terrain[idx(x, y)] === TC.MOUNTAIN) {176 terrain[idx(x, y)] = TC.VOLCANO;177 volcanoes++;178 }179 }180181 // --- Ore & gold veins on hills/mountain. ---182 let ore = 0;183 const oreTarget = Math.max(10, Math.trunc((35 * size * size) / (128 * 128)));184 for (let tries = 0; tries < 200 && ore < oreTarget; tries++) {185 const x = rng.int(size);186 const y = rng.int(size);187 const t = terrain[idx(x, y)]!;188 if (t === TC.HILLS || t === TC.MOUNTAIN) {189 terrain[idx(x, y)] = TC.ORE;190 ore++;191 }192 }193 let gold = 0;194 const goldTarget = Math.max(2, Math.trunc((8 * size * size) / (128 * 128)));195 for (let tries = 0; tries < 100 && gold < goldTarget; tries++) {196 const x = rng.int(size);197 const y = rng.int(size);198 const t = terrain[idx(x, y)]!;199 if (t === TC.HILLS || t === TC.MOUNTAIN) {200 terrain[idx(x, y)] = TC.GOLD;201 gold++;202 }203 }204205 // --- Spawns: seeded best-candidate scatter, then fairness terraforming.206 // No ring, no fixed edge distance: each spawn samples a fixed number of207 // random candidates and keeps the one farthest from everyone already208 // placed (water candidates heavily penalized — terraforming can build a209 // beachhead, but dry land is a better home). Different seeds produce210 // genuinely different arenas; the mutual-distance bias keeps starts fair211 // without making them predictable.212 const margin = Math.max(3, size >> 4); // just clear of the ocean border ring213 const span = size - 2 * margin;214 const CANDIDATES = 24;215 const spawns: Array<{ x: number; y: number }> = [];216 for (let i = 0; i < tribeCount; i++) {217 let bestX = margin;218 let bestY = margin;219 let bestScore = -0x7fffffff;220 for (let k = 0; k < CANDIDATES; k++) {221 const x = margin + rng.int(span);222 const y = margin + rng.int(span);223 let near = size * 2; // first spawn: uniform random among dry candidates224 for (const s of spawns) {225 const d = Math.max(Math.abs(x - s.x), Math.abs(y - s.y));226 if (d < near) near = d;227 }228 const t = terrain[idx(x, y)]!;229 // Separation floor first (two fairness radii, so terraforms never230 // pave each other), then spread — but with a nearest-neighbor CEILING:231 // past ~45% of the map the reward flips to a penalty, so arenas vary232 // without ever stranding tribes beyond each other's horizons.233 const SEP = 18;234 const CAP = Math.max(SEP + 2, Math.trunc((size * 45) / 100));235 const score =236 Math.min(near, SEP) * 1024 +237 Math.min(near, CAP) * 4 -238 Math.max(0, near - CAP) * 8 -239 (t === TC.WATER ? size * 1024 : 0);240 if (score > bestScore) {241 bestScore = score;242 bestX = x;243 bestY = y;244 }245 }246 spawns.push({ x: bestX, y: bestY });247 terraformSpawn(terrain, size, bestX, bestY);248 }249250 return { terrain, spawns };251}252253/** Guarantee the fairness quotas around one spawn. Fixed scan order. */254function terraformSpawn(terrain: Uint8Array, size: number, sx: number, sy: number): void {255 const idx = (x: number, y: number): number => y * size + x;256 const inMap = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < size && y < size;257258 // Clear a 3x3 core to plains (the hall site).259 for (let dy = -1; dy <= 1; dy++) {260 for (let dx = -1; dx <= 1; dx++) {261 if (inMap(sx + dx, sy + dy)) terrain[idx(sx + dx, sy + dy)] = TC.PLAINS;262 }263 }264265 const count = (pred: (t: number, x: number, y: number) => boolean): number => {266 let n = 0;267 for (const [dx, dy] of SPAWN_OFFS) {268 const x = sx + dx;269 const y = sy + dy;270 if (!inMap(x, y)) continue;271 if (pred(terrain[idx(x, y)]!, x, y)) n++;272 }273 return n;274 };275276 // Plains >= 20: convert nearest non-water, non-plains tiles.277 let plains = count((t) => t === TC.PLAINS);278 for (const [dx, dy] of SPAWN_OFFS) {279 if (plains >= 20) break;280 const x = sx + dx;281 const y = sy + dy;282 if (!inMap(x, y)) continue;283 const t = terrain[idx(x, y)]!;284 if (t !== TC.PLAINS && isLand(t)) {285 terrain[idx(x, y)] = TC.PLAINS;286 plains++;287 }288 }289290 // Forest >= 10: convert plains tiles at distance >= 3 (nearest first).291 let forest = count((t) => t === TC.FOREST);292 for (const [dx, dy] of SPAWN_OFFS) {293 if (forest >= 10) break;294 if (Math.max(Math.abs(dx), Math.abs(dy)) < 3) continue;295 const x = sx + dx;296 const y = sy + dy;297 if (!inMap(x, y)) continue;298 if (terrain[idx(x, y)] === TC.PLAINS) {299 terrain[idx(x, y)] = TC.FOREST;300 forest++;301 }302 }303304 // Hills-or-mountain >= 6: convert plains at distance >= 4 (farthest first,305 // to keep the near field farmable).306 let hills = count((t) => HILLS_CLASS.has(t));307 for (let k = SPAWN_OFFS.length - 1; k >= 0 && hills < 6; k--) {308 const [dx, dy] = SPAWN_OFFS[k]!;309 if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) continue;310 const x = sx + dx;311 const y = sy + dy;312 if (!inMap(x, y)) continue;313 if (terrain[idx(x, y)] === TC.PLAINS) {314 terrain[idx(x, y)] = TC.HILLS;315 hills++;316 }317 }318319 // Ore vein >= 1: promote the first hills-class tile (fixed order).320 let hasOre = count((t) => t === TC.ORE || t === TC.GOLD) > 0;321 if (!hasOre) {322 for (const [dx, dy] of SPAWN_OFFS) {323 const x = sx + dx;324 const y = sy + dy;325 if (!inMap(x, y)) continue;326 const t = terrain[idx(x, y)]!;327 if (t === TC.HILLS || t === TC.MOUNTAIN) {328 terrain[idx(x, y)] = TC.ORE;329 hasOre = true;330 break;331 }332 }333 }334335 // CLAIM-REACH quotas: the radius-8 quotas guarantee resources a tribe can336 // SEE — these guarantee resources a tier-0 camp can CLAIM (base budget 5,337 // before any expansion decree scaling). Without them the documented chain338 // (lumber camp -> mine -> smelter -> smithy) is visible but unreachable,339 // extraction buildings silently drop, and naive economies freeze — the340 // exact famine independent agent tournaments reproduced 8-for-8.341 {342 const budget = TIER_CLAIM_BUDGET[0]!;343 const claimCost = (x: number, y: number): number => {344 // Cheapest-cost reach from the spawn over CLAIM_COST, bounded small.345 const dist = new Map<number, number>();346 dist.set(sy * size + sx, 0);347 const frontier: Array<[number, number, number]> = [[sx, sy, 0]];348 let target = 0x7fff;349 while (frontier.length > 0) {350 frontier.sort((a, b) => a[2] - b[2]);351 const [cx, cy, cd] = frontier.shift()!;352 if (cx === x && cy === y) return cd;353 if (cd >= budget) continue;354 for (const [dx2, dy2] of [[0, -1], [-1, 0], [1, 0], [0, 1]] as const) {355 const nx = cx + dx2;356 const ny = cy + dy2;357 if (!inMap(nx, ny)) continue;358 if (Math.max(Math.abs(nx - sx), Math.abs(ny - sy)) > 8) continue;359 const step = CLAIM_COST[terrain[idx(nx, ny)]!]!;360 if (step === 0) continue;361 const nd = cd + step;362 const key = ny * size + nx;363 if (nd < (dist.get(key) ?? 0x7fff)) {364 dist.set(key, nd);365 frontier.push([nx, ny, nd]);366 }367 }368 }369 return target;370 };371 const inReach = (pred: (t: number) => boolean): number => {372 let n2 = 0;373 for (const [dx2, dy2] of SPAWN_OFFS) {374 const x = sx + dx2;375 const y = sy + dy2;376 if (!inMap(x, y)) continue;377 if (!pred(terrain[idx(x, y)]!)) continue;378 if (claimCost(x, y) <= budget) n2++;379 }380 return n2;381 };382 // Convert claimable plains at cheb >= 2 (spare the hall's doorstep) in383 // fixed scan order until the tier-0 economy has raw materials in reach.384 const ensure = (want: number, pred: (t: number) => boolean, to: number): void => {385 let have = inReach(pred);386 for (const [dx2, dy2] of SPAWN_OFFS) {387 if (have >= want) return;388 if (Math.max(Math.abs(dx2), Math.abs(dy2)) < 2) continue;389 const x = sx + dx2;390 const y = sy + dy2;391 if (!inMap(x, y)) continue;392 if (terrain[idx(x, y)] !== TC.PLAINS) continue;393 if (claimCost(x, y) > budget) continue;394 terrain[idx(x, y)] = to;395 have++;396 }397 };398 ensure(3, (t) => t === TC.FOREST, TC.FOREST);399 ensure(2, (t) => HILLS_CLASS.has(t), TC.HILLS);400 if (inReach((t) => t === TC.ORE || t === TC.GOLD) === 0) {401 // Promote the first claim-reachable hills tile to an ore vein.402 for (const [dx2, dy2] of SPAWN_OFFS) {403 const x = sx + dx2;404 const y = sy + dy2;405 if (!inMap(x, y)) continue;406 const t = terrain[idx(x, y)]!;407 if ((t === TC.HILLS || t === TC.MOUNTAIN) && claimCost(x, y) <= budget) {408 terrain[idx(x, y)] = TC.ORE;409 break;410 }411 }412 }413 }414415 // Water-adjacent land >= 3, WITH the water itself inside the radius-8416 // window (rim adjacency to unseen water at r9 satisfies nobody — a tier-1417 // camp's vision is exactly 8).418 const waterAdjInWindow = (x: number, y: number): boolean => {419 if (!isLand(terrain[idx(x, y)]!)) return false;420 for (let dy = -1; dy <= 1; dy++) {421 for (let dx = -1; dx <= 1; dx++) {422 if (dx === 0 && dy === 0) continue;423 const nx = x + dx;424 const ny = y + dy;425 if (!inMap(nx, ny)) continue;426 if (Math.max(Math.abs(nx - sx), Math.abs(ny - sy)) > 8) continue;427 if (WATER_CLASS.has(terrain[idx(nx, ny)]!)) return true;428 }429 }430 return false;431 };432 let waterAdj = count((_, x, y) => waterAdjInWindow(x, y));433 if (waterAdj < 3) {434 const spots: ReadonlyArray<readonly [number, number]> = [435 [6, 6],436 [7, 6],437 [-6, 6],438 [-7, 6],439 [6, -6],440 [7, -6],441 ];442 for (const [dx, dy] of spots) {443 const x = sx + dx;444 const y = sy + dy;445 if (!inMap(x, y)) continue;446 terrain[idx(x, y)] = TC.WATER;447 waterAdj = count((_, cx, cy) => waterAdjInWindow(cx, cy));448 if (waterAdj >= 3) break;449 }450 }451}