// Headless verification for Civilization (Civ II-lite). // node tools/verifyCivilization.js [--quick] // Exits non-zero on any failure. // // 1. Rules integrity: ids unique, prereqs resolve, tech DAG acyclic & fully // reachable, every tech gates something or feeds a later tech, frames sane. // 2. Worldgen: determinism, land fraction, continents, start quality/spacing. // 3. City fixtures: growth, despotism penalty, corruption, auto-assign, buy math. // 4. Combat: deterministic fixtures + Monte Carlo vs analytic model. // 5. Research pacing per difficulty. // 6. Trade + diplomacy state machine (incl. fuzz). // 7. Spaceship: launch gating, countdown, capital-capture loss. // 8. Serialization round-trip. // 9. AI self-play soak: full games end in victory, invariants hold, perf budget. import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { compileRules, techCost, turnToYear } from '../src/games/civilization/CivilizationRules.js'; import { generateWorld, siteQuality, shieldGrassAt } from '../src/games/civilization/CivilizationWorldGen.js'; import * as Logic from '../src/games/civilization/CivilizationLogic.js'; import * as AI from '../src/games/civilization/CivilizationAI.js'; const QUICK = process.argv.includes('--quick'); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const rulesJson = JSON.parse(readFileSync(join(root, 'data/civilization-rules.json'), 'utf8')); let failures = 0; let passes = 0; function check(name, cond, detail = '') { if (cond) { passes += 1; return; } failures += 1; console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); } function section(name) { console.log(`\n== ${name}`); } // --------------------------------------------------------------------------- section('1. rules integrity'); let RULES = null; try { RULES = compileRules(rulesJson); } catch (err) { check('rules compile', false, err.message); } if (RULES) { check('rules compile', true); const techIds = Object.keys(RULES.techs); check('tech count sane (80+ incl future tech)', techIds.length >= 80, `${techIds.length}`); check('future tech present & repeatable', RULES.techs.futuretech?.repeatable === true); // Cut techs must NOT be present (wonders/happiness/espionage systems removed). for (const cut of ['theology', 'espionage', 'fundamentalism', 'environmentalism', 'geneticengineering', 'recycling']) { check(`cut tech absent: ${cut}`, !RULES.techs[cut]); } // Every tech is reachable (compileRules ranks all) and matters: it gates a // unit/building/government/improvement or is a prereq of another tech. for (const t of RULES.techList) { check(`tech ranked: ${t.id}`, RULES.techRank[t.id] !== undefined); const g = RULES.techGates[t.id]; const matters = g.units.length + g.buildings.length + g.governments.length + g.improvements.length + g.prereqOf.length > 0 || t.repeatable; check(`tech matters: ${t.id}`, matters); } // Rank must respect prereqs. for (const t of RULES.techList) { for (const p of t.prereqs) { check(`rank order ${p} < ${t.id}`, RULES.techRank[p] < RULES.techRank[t.id]); } } // Roots: the classic 8 starting techs. const roots = RULES.techList.filter((t) => t.prereqs.length === 0).map((t) => t.id).sort(); check('8 root techs', roots.length === 8, roots.join(',')); // Units. check('unit count 51', RULES.unitList.length === 51, `${RULES.unitList.length}`); const unitFrames = new Set(); for (const u of RULES.unitList) { check(`unit stats sane: ${u.id}`, u.attack >= 0 && u.attack <= 99 && u.defense >= 0 && u.move >= 0 && u.hp >= 1 && u.fp >= 1 && u.cost >= 10 && u.cost <= 320); check(`unit frame unique: ${u.id}`, !unitFrames.has(u.frame), `${u.frame}`); unitFrames.add(u.frame); check(`unit frame in sheet: ${u.id}`, u.frame >= 0 && u.frame < 56); check(`unit abbr: ${u.id}`, typeof u.abbr === 'string' && u.abbr.length === 2); if (u.domain === 'project') check(`ss unit flagged: ${u.id}`, u.flags.includes('spaceship')); } const ssUnits = RULES.unitList.filter((u) => u.domain === 'project'); check('3 spaceship parts', ssUnits.length === 3); check('spaceship config', RULES.spaceship.structuralNeeded === 8 && RULES.spaceship.componentsNeeded === 4 && RULES.spaceship.modulesNeeded === 3 && RULES.spaceship.travelTurns > 0); // Terrain + specials. check('11 terrains', RULES.terrainList.length === 11); check('exactly one water terrain', RULES.terrainList.filter((t) => t.water).length === 1); const terrFrames = new Set(); for (const t of RULES.terrainList) { check(`terrain frame unique: ${t.id}`, !terrFrames.has(t.frame)); terrFrames.add(t.frame); check(`terrain frame in sheet: ${t.id}`, t.frame >= 0 && t.frame < 12); check(`terrain yields sane: ${t.id}`, t.food >= 0 && t.shield >= 0 && t.trade >= 0 && t.move >= 1 && t.defense >= 1); check(`terrain color: ${t.id}`, /^#[0-9a-f]{6}$/i.test(t.color)); } check('grassland shield frame distinct', !terrFrames.has(RULES.grasslandShieldFrame) && RULES.grasslandShieldFrame >= 0 && RULES.grasslandShieldFrame < 12); check('20 specials', RULES.specialList.length === 20); const specFrames = new Set(); for (const s of RULES.specialList) { check(`special frame unique: ${s.id}`, !specFrames.has(s.frame)); specFrames.add(s.frame); check(`special frame in sheet: ${s.id}`, s.frame >= 0 && s.frame < 20); } const specTerrains = Object.keys(RULES.specialsByTerrain); check('specials cover 10 terrains (all but grassland)', specTerrains.length === 10 && !specTerrains.includes('grassland')); for (const terr of specTerrains) { check(`2 specials on ${terr}`, RULES.specialsByTerrain[terr].length === 2); } // Buildings. check('26 buildings', RULES.buildingList.length === 26, `${RULES.buildingList.length}`); for (const cut of ['temple', 'colosseum', 'cathedral', 'policestation', 'masstransit', 'recyclingcenter', 'solarplant']) { check(`cut building absent: ${cut}`, !RULES.buildings[cut]); } const powerPlants = RULES.buildingList.filter((b) => b.effect === 'power'); check('3 mutually exclusive power plants', powerPlants.length === 3); // Governments & difficulties. check('6 governments', RULES.governmentList.length === 6); check('despotism/anarchy need no tech', !RULES.governments.despotism.prereq && !RULES.governments.anarchy.prereq); check('5 difficulties', RULES.difficultyList.length === 5); check('difficulty ordering', RULES.difficultyList[0].aiProdBonus < RULES.difficultyList[4].aiProdBonus); // World sizes / colors / names. check('3 world sizes', RULES.worldSizeList.length === 3); for (const w of RULES.worldSizeList) check(`world ${w.id} dims`, w.cols >= 32 && w.rows >= 24); check('8 player colors', RULES.playerColors.length === 8 && RULES.playerColors.every((c) => /^#[0-9a-f]{6}$/i.test(c))); check('city name pool 64', RULES.cityNames.length === 64 && new Set(RULES.cityNames).size === 64); // Tech cost + year curve helpers. check('tech cost grows', techCost(0) < techCost(10) && techCost(10) < techCost(50)); check('tech cost difficulty factor', techCost(10, 1.2) > techCost(10, 1.0)); check('year starts 4000 BC', turnToYear(0, RULES.yearCurve) === -4000); const y60 = turnToYear(60, RULES.yearCurve); check('year curve reaches ~1000 BC by turn 60', y60 === -1000, `${y60}`); let prev = -4000; let monotonic = true; for (let i = 1; i <= 500; i += 1) { const y = turnToYear(i, RULES.yearCurve); if (y <= prev) { monotonic = false; break; } prev = y; } check('year curve strictly increasing over 500 turns', monotonic); // Artwork JSON contract <-> assetManifest fields (checked once artwork file exists). try { const art = JSON.parse(readFileSync(join(root, 'data/civilization-artwork.json'), 'utf8')); for (const field of ['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet']) { check(`artwork field ${field}`, !!art[field] && 'path' in art[field] && art[field].frameWidth > 0 && art[field].frameHeight > 0); } check('artwork citySheets map', !!art.citySheets && !!art.citySheets.classic && 'path' in art.citySheets.classic); } catch { console.log(' (data/civilization-artwork.json not present yet — skipping contract checks)'); } } // --------------------------------------------------------------------------- section('2. world generation'); if (RULES) { const hashWorld = (w) => JSON.stringify([w.terrain, w.special, w.huts, w.starts]); for (const sizeId of ['small', 'medium', 'large']) { const size = RULES.worldSizes[sizeId]; let landOk = 0; let contOk = 0; let startsOk = 0; let detOk = 0; let qualityOk = 0; const seeds = QUICK ? 6 : 20; for (let s = 1; s <= seeds; s += 1) { const numCivs = 3 + (s % 5); // 3..7 const w = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); const w2 = generateWorld(RULES, { sizeId, seed: s * 31, numCivs }); if (hashWorld(w) === hashWorld(w2)) detOk += 1; if (w.landFraction >= 0.25 && w.landFraction <= 0.38) landOk += 1; if (w.largestContinentFrac >= 0.15) contOk += 1; const T = {}; RULES.terrainList.forEach((t, i) => { T[t.id] = i; }); let good = w.starts.length === numCivs; for (let a = 0; a < w.starts.length && good; a += 1) { const [x, y] = w.starts[a]; const terr = RULES.terrainList[w.terrain[y * size.cols + x]]; if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') good = false; for (let b = a + 1; b < w.starts.length; b += 1) { const [x2, y2] = w.starts[b]; if (Math.max(Math.abs(x - x2), Math.abs(y - y2)) < 4) good = false; } } if (good) startsOk += 1; const minQ = Math.min(...w.starts.map(([x, y]) => siteQuality(RULES, w, x, y))); if (minQ >= 12) qualityOk += 1; } check(`${sizeId}: deterministic (same seed => same world)`, detOk === seeds, `${detOk}/${seeds}`); check(`${sizeId}: land fraction 25-38%`, landOk === seeds, `${landOk}/${seeds}`); check(`${sizeId}: largest continent >= 15% of land`, contOk === seeds, `${contOk}/${seeds}`); check(`${sizeId}: starts valid & spaced`, startsOk === seeds, `${startsOk}/${seeds}`); check(`${sizeId}: start quality floor`, qualityOk === seeds, `${qualityOk}/${seeds}`); } // Density checks on one representative map. const w = generateWorld(RULES, { sizeId: 'medium', seed: 42, numCivs: 5 }); const land = w.terrain.filter((t) => !RULES.terrainList[t].water).length; const hutCount = w.huts.reduce((a, b) => a + b, 0); check('hut density ~1/40 land', hutCount >= Math.floor(land / 40) * 0.6 && hutCount <= Math.ceil(land / 40), `${hutCount} huts, ${land} land`); const specCount = w.special.filter((s) => s >= 0).length; check('specials density 1/64..1/8 of tiles', specCount >= w.terrain.length / 64 && specCount <= w.terrain.length / 8, `${specCount}`); let specMatch = true; for (let i = 0; i < w.terrain.length; i += 1) { if (w.special[i] < 0) continue; const spec = RULES.specialList[w.special[i]]; if (spec.terrain !== RULES.terrainList[w.terrain[i]].id) { specMatch = false; break; } } check('every special sits on its terrain', specMatch); let hutsOnLand = true; for (let i = 0; i < w.terrain.length; i += 1) { if (w.huts[i] && (RULES.terrainList[w.terrain[i]].water || RULES.terrainList[w.terrain[i]].id === 'glacier')) hutsOnLand = false; } check('huts on land (not glacier)', hutsOnLand); check('shield-grass lattice ~50%', (() => { let c = 0; for (let y = 0; y < 20; y += 1) for (let x = 0; x < 20; x += 1) c += shieldGrassAt(x, y) ? 1 : 0; return c === 200; })()); } // --------------------------------------------------------------------------- // Fixture helpers: hand-built flat worlds for deterministic engine tests. function T(id) { return RULES.terrainList.findIndex((t) => t.id === id); } function mkCiv(i, total, human = false) { const relations = {}; const attitude = {}; for (let j = 0; j < total; j += 1) if (j !== i) { relations[j] = 'contact'; attitude[j] = 0; } return { id: i, leaderId: `test${i}`, name: `Civ${i}`, color: '#ffffff', human, alive: true, government: 'despotism', revolutionTurns: 0, pendingGovernment: null, gold: 100, beakers: 0, researching: null, known: {}, futureCount: 0, relations, attitude, reputation: 0, spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, nameCursor: i, nameOrder: Array.from({ length: RULES.cityNames.length }, (_, k) => k), score: 0, }; } function makeFlatState({ cols = 16, rows = 16, civs = 2, terrain = 'grassland' } = {}) { const n = cols * rows; const world = { cols, rows, terrain: new Array(n).fill(T(terrain)), special: new Array(n).fill(-1), improvements: new Array(n).fill(0), huts: new Array(n).fill(0), continent: new Array(n).fill(0), starts: [], landFraction: 1, largestContinentFrac: 1, sizeId: 'small', seed: 1, }; const state = { version: 1, seed: 1, rngState: 987654321, sizeId: 'small', difficultyId: 'prince', turn: 1, current: 0, humanIndex: 0, world, civs: Array.from({ length: civs }, (_, i) => mkCiv(i, civs, i === 0)), cities: [], units: [], nextUnitId: 1, nextCityId: 1, explored: Array.from({ length: civs }, () => new Array(n).fill(1)), over: null, events: [], }; return state; } function setWar(state, a, b) { state.civs[a].relations[b] = 'war'; state.civs[b].relations[a] = 'war'; } // --------------------------------------------------------------------------- section('3. city fixtures'); if (RULES) { // Founding: settler consumed, size 1, first city gets palace + leader name. { const st = makeFlatState(); const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); const city = Logic.foundCity(RULES, st, settler); check('city founded', !!city && city.size === 1); check('settler consumed', st.units.length === 0); check('first city has palace', !!city.buildings.palace); check('capital named for leader', city.name === 'Civ0 City'); check('min distance blocks adjacent city', !Logic.canFoundCity(RULES, st, 6, 5)); check('distance 2 allowed', Logic.canFoundCity(RULES, st, 7, 5)); } // Growth on flat grassland: foodbox fills, granary keeps half. { const st = makeFlatState(); const settler = Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null); const city = Logic.foundCity(RULES, st, settler); city.build = { type: 'building', id: 'granary' }; let grewAt = -1; for (let t = 0; t < 30 && grewAt < 0; t += 1) { Logic.beginCivTurn(RULES, st, 0); if (city.size >= 2) grewAt = t; } check('city grows on grassland within 30 turns', grewAt >= 0); check('foodbox reset after growth', city.foodBox < (city.size + 1) * Logic.FOODBOX_PER_SIZE); // Force a granary growth and check the half-box carryover. city.buildings.granary = true; city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; const sizeBefore = city.size; Logic.beginCivTurn(RULES, st, 0); check('granary growth', city.size === sizeBefore + 1); check('granary keeps half box', city.foodBox >= Math.floor(((sizeBefore + 1) * Logic.FOODBOX_PER_SIZE) / 2)); } // Despotism penalty & government trade bonus on tile yields. { const st = makeFlatState({ terrain: 'plains' }); const i = 5 * 16 + 5; st.world.special[i] = RULES.specialList.findIndex((s) => s.id === 'wheat'); // 3/1/0 let y = Logic.tileYield(RULES, st, 0, null, 5, 5); check('despotism -1 on 3-food wheat', y.food === 2, `${y.food}`); st.civs[0].government = 'monarchy'; y = Logic.tileYield(RULES, st, 0, null, 5, 5); check('monarchy full wheat', y.food === 3); st.civs[0].government = 'republic'; const j = 6 * 16 + 6; st.world.improvements[j] = 1; // road on plains: +1 trade, republic: +1 more y = Logic.tileYield(RULES, st, 0, null, 6, 6); check('road + republic trade', y.trade === 2, `${y.trade}`); st.civs[0].government = 'despotism'; y = Logic.tileYield(RULES, st, 0, null, 6, 6); check('road under despotism trade 1', y.trade === 1, `${y.trade}`); } // Irrigation / mine / railroad effects. { const st = makeFlatState({ terrain: 'hills' }); st.civs[0].government = 'monarchy'; const i = 5 * 16 + 5; st.world.improvements[i] = 16; // mine on hills: +3 shields let y = Logic.tileYield(RULES, st, 0, null, 5, 5); check('hills mine +3 shields', y.shield === 3, `${y.shield}`); st.world.improvements[i] |= 2; // railroad: +1 when shields >= 1 y = Logic.tileYield(RULES, st, 0, null, 5, 5); check('railroad +1 shield', y.shield === 4); } // Corruption: distance, courthouse, democracy, communism-flat. { const st = makeFlatState({ cols: 32, rows: 8 }); st.civs[0].government = 'monarchy'; const cap = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 28, 4, null)); near.size = 6; far.size = 6; // Give both cities identical trade via routes so corruption math is isolated. near.routes = [{ cityId: cap.id, amount: 20 }]; far.routes = [{ cityId: cap.id, amount: 20 }]; const yNear = Logic.cityYields(RULES, st, near); const yFar = Logic.cityYields(RULES, st, far); check('corruption grows with distance', yFar.corruption > yNear.corruption, `${yNear.corruption} vs ${yFar.corruption}`); far.buildings.courthouse = true; const yFarCourt = Logic.cityYields(RULES, st, far); check('courthouse halves corruption', yFarCourt.corruption === Math.floor(yFar.corruption / 2)); delete far.buildings.courthouse; st.civs[0].government = 'democracy'; check('democracy zero corruption', Logic.cityYields(RULES, st, far).corruption === 0); st.civs[0].government = 'communism'; const cNear = Logic.cityYields(RULES, st, near).corruption; const cFar = Logic.cityYields(RULES, st, far).corruption; check('communism flat corruption', cNear === cFar, `${cNear} vs ${cFar}`); } // Auto-assign is optimal for the linear emphasis objective. { const st = makeFlatState(); // Scatter mixed terrain around a city site. const kinds = ['plains', 'forest', 'hills', 'mountains', 'desert', 'ocean', 'swamp']; let k = 0; for (let y = 3; y <= 7; y += 1) { for (let x = 3; x <= 7; x += 1) { st.world.terrain[y * 16 + x] = T(kinds[k % kinds.length]); k += 3; } } st.world.terrain[5 * 16 + 5] = T('grassland'); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); city.size = 5; for (const emphasis of ['balanced', 'food', 'production', 'trade']) { city.emphasis = emphasis; Logic.autoAssignTiles(RULES, st, city); const weights = { balanced: [3, 2, 1], food: [6, 1, 1], production: [1, 5, 1], trade: [1, 1, 5] }[emphasis]; const wOf = (idx) => { const yld = Logic.tileYield(RULES, st, 0, city, idx % 16, (idx / 16) | 0); return yld.food * weights[0] + yld.shield * weights[1] + yld.trade * weights[2]; }; const chosen = city.worked.reduce((s, idx) => s + wOf(idx), 0); // Brute force: weights of every candidate tile, top-5 sum. const cand = []; for (const [dx, dy] of Logic.CITY_RADIUS) { if (dx === 0 && dy === 0) continue; cand.push(wOf((5 + dy) * 16 + (5 + dx))); } cand.sort((a, b) => b - a); const best = cand.slice(0, 5).reduce((a, b) => a + b, 0); check(`auto-assign optimal (${emphasis})`, chosen === best, `${chosen} vs ${best}`); } } // Buy math + class-switch penalty + building multipliers. { const st = makeFlatState(); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); city.build = { type: 'unit', id: 'warriors' }; check('warriors buy cost 25', Logic.buyCost(RULES, city) === 25); Logic.setBuild(RULES, st, city, 'building', 'granary'); check('granary buy cost 120', Logic.buyCost(RULES, city) === 120); city.shieldBox = 40; check('partial buy cost', Logic.buyCost(RULES, city) === 40); // (60-40)*2 Logic.setBuild(RULES, st, city, 'unit', 'warriors'); check('class switch halves shields', city.shieldBox === 20); st.civs[0].gold = 200; city.build = { type: 'building', id: 'granary' }; city.shieldBox = 0; check('buy succeeds', Logic.buyBuild(RULES, st, city) && st.civs[0].gold === 80); check('no double buy same turn', !Logic.buyBuild(RULES, st, city)); // Science multiplier: library +50%. city.size = 4; city.routes = [{ cityId: 99, amount: 12 }]; st.civs[0].government = 'monarchy'; const sBefore = Logic.cityYields(RULES, st, city).science; city.buildings.library = true; const sAfter = Logic.cityYields(RULES, st, city).science; check('library +50% science', sAfter === Math.floor(sBefore * 1.5), `${sBefore}->${sAfter}`); } // Size caps: 8 without aqueduct, 12 without sewer. { const st = makeFlatState(); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); city.size = 8; city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE + 5; Logic.beginCivTurn(RULES, st, 0); check('size capped at 8 without aqueduct', city.size === 8); city.buildings.aqueduct = true; city.foodBox = (city.size + 1) * Logic.FOODBOX_PER_SIZE; Logic.beginCivTurn(RULES, st, 0); check('aqueduct unlocks growth', city.size === 9); } // Settler build waits for size 2 and costs a citizen. { const st = makeFlatState(); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); city.build = { type: 'unit', id: 'settlers' }; city.shieldBox = 200; Logic.beginCivTurn(RULES, st, 0); check('settler waits at size 1', st.units.length === 0 && city.size === 1); city.size = 3; Logic.beginCivTurn(RULES, st, 0); check('settler built at size>=2 costs pop', st.units.length === 1 && city.size === 2); } // Research: cost, completion, prereq gating. { const st = makeFlatState(); const civ = st.civs[0]; check('cannot research gated tech', !Logic.setResearch(RULES, st, civ, 'monarchy')); check('can research root tech', Logic.setResearch(RULES, st, civ, 'alphabet')); civ.beakers = 1000; Logic.beginCivTurn(RULES, st, 0); check('tech completes', !!civ.known.alphabet); civ.known.codeoflaws = true; civ.known.ceremonialburial = true; check('monarchy now available', Logic.setResearch(RULES, st, civ, 'monarchy')); const cost3 = Logic.currentResearchCost(RULES, st, civ); civ.known.pottery = true; check('cost rises with known count', Logic.currentResearchCost(RULES, st, civ) > cost3); } // Revolution: anarchy interlude then target government. { const st = makeFlatState(); const civ = st.civs[0]; civ.known.monarchy = true; check('revolution starts', Logic.startRevolution(RULES, st, civ, 'monarchy')); check('in anarchy', civ.government === 'anarchy'); for (let i = 0; i < 6; i += 1) Logic.beginCivTurn(RULES, st, 0); check('revolution completes', civ.government === 'monarchy'); check('cannot switch to unknown gov', !Logic.startRevolution(RULES, st, civ, 'democracy')); } } // --------------------------------------------------------------------------- section('4. combat'); if (RULES) { // Deterministic strength modifiers. { const st = makeFlatState({ terrain: 'mountains' }); setWar(st, 0, 1); st.world.terrain[5 * 16 + 4] = T('grassland'); const attacker = Logic.spawnUnit(RULES, st, 0, 'warriors', 4, 5, null); const defender = Logic.spawnUnit(RULES, st, 1, 'phalanx', 5, 5, null); let d = Logic.defenderStrength(RULES, st, defender, attacker); check('mountain phalanx D=6', Math.abs(d - 6) < 1e-9, `${d}`); defender.fortified = true; d = Logic.defenderStrength(RULES, st, defender, attacker); check('fortified adds x1.5', Math.abs(d - 9) < 1e-9, `${d}`); } { // Walls x3 vs land; howitzer ignores them. const st = makeFlatState(); setWar(st, 0, 1); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 8, 8, null)); city.buildings.citywalls = true; const defender = Logic.spawnUnit(RULES, st, 1, 'musketeers', 8, 8, null); const rifle = Logic.spawnUnit(RULES, st, 0, 'riflemen', 7, 8, null); const how = Logic.spawnUnit(RULES, st, 0, 'howitzer', 7, 8, null); const dWalls = Logic.defenderStrength(RULES, st, defender, rifle); const dHow = Logic.defenderStrength(RULES, st, defender, how); check('walls triple defense vs land', Math.abs(dWalls - 9) < 1e-9, `${dWalls}`); check('howitzer ignores walls (city base applies)', Math.abs(dHow - 4.5) < 1e-9, `${dHow}`); } { // Pikemen double vs mounted only. const st = makeFlatState(); setWar(st, 0, 1); const pike = Logic.spawnUnit(RULES, st, 1, 'pikemen', 5, 5, null); const knight = Logic.spawnUnit(RULES, st, 0, 'knights', 4, 5, null); const legion = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); const dVsKnight = Logic.defenderStrength(RULES, st, pike, knight); const dVsLegion = Logic.defenderStrength(RULES, st, pike, legion); check('pikemen x2 vs mounted', Math.abs(dVsKnight - 2 * dVsLegion) < 1e-9); } { // Veteran and hp scaling on attack. const st = makeFlatState(); const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); const a0 = Logic.attackerStrength(RULES, st, u); u.vet = true; check('vet x1.5 attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5) < 1e-9); u.hp = 5; check('hp halves attack', Math.abs(Logic.attackerStrength(RULES, st, u) - a0 * 1.5 * 0.5) < 1e-9); } // Monte Carlo: duel win rates track the analytic single-round model. { const st = makeFlatState(); const trials = QUICK ? 2000 : 10000; // Equal units: expect ~50%. let wins = 0; for (let i = 0; i < trials; i += 1) { if (Logic.simulateDuel(st, 4, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; } const even = wins / trials; check('equal duel ~50%', even > 0.46 && even < 0.54, `${even.toFixed(3)}`); // 2:1 attacker: p(round)=2/3; 10-round-to-kill race strongly favours attacker. wins = 0; for (let i = 0; i < trials; i += 1) { if (Logic.simulateDuel(st, 8, 20, 1, 4, 20, 1, 20).attackerWon) wins += 1; } const strong = wins / trials; check('2:1 duel > 85%', strong > 0.85, `${strong.toFixed(3)}`); // Higher firepower shortens the race for the attacker. wins = 0; for (let i = 0; i < trials; i += 1) { if (Logic.simulateDuel(st, 4, 20, 2, 4, 20, 1, 20).attackerWon) wins += 1; } const fp = wins / trials; check('fp advantage wins > 65%', fp > 0.65, `${fp.toFixed(3)}`); check('duel ordering sane', strong > fp && fp > even); } // Stack death outside cities/fortresses, survival inside. { const st = makeFlatState(); setWar(st, 0, 1); // Cityless civs with no settler are auto-eliminated by checkVictory (run // at the end of resolveAttack) — give each side a settler elsewhere so // wiping out the other's stack doesn't also eliminate the attacker's own // civ (and remove the tank) as a side effect of this fixture. Logic.spawnUnit(RULES, st, 0, 'settlers', 0, 0, null); Logic.spawnUnit(RULES, st, 1, 'settlers', 15, 15, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); tank.vet = true; let out = { result: '' }; for (let i = 0; i < 10 && Logic.unitsAt(st, 5, 5).length; i += 1) { tank.mp = 9; tank.hp = 30; out = Logic.resolveAttack(RULES, st, tank, 5, 5); if (out.won) break; } // Winner advances onto an open-ground tile it just cleared entirely // (matches the victory-glide the UI plays for an unopposed win). check('stack dies on open ground', out.won === true); check('attacker advances onto the now-empty tile', tank.x === 5 && tank.y === 5 && Logic.unitsAt(st, 5, 5).length === 1 && Logic.unitsAt(st, 5, 5)[0].id === tank.id); const combatEvt = st.events.filter((e) => e.type === 'combat').pop(); check('combat event carries attacker/defender snapshot for the UI', !!combatEvt && combatEvt.ax === 4 && combatEvt.ay === 5 && combatEvt.x === 5 && combatEvt.y === 5 && combatEvt.attackerId === tank.id && combatEvt.attackerCiv === 0 && combatEvt.attackerType === 'armor' && combatEvt.defenderCiv === 1 && combatEvt.defenderType === 'warriors' && combatEvt.attackerWon === true && combatEvt.advanced === true); const st2 = makeFlatState(); setWar(st2, 0, 1); const city = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st2, 1, 'warriors', 5, 5, null); const tank2 = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); tank2.vet = true; let won2 = false; for (let i = 0; i < 10 && !won2; i += 1) { tank2.mp = 9; tank2.hp = 30; const o = Logic.resolveAttack(RULES, st2, tank2, 5, 5); won2 = o.won === true; } check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1, `${Logic.unitsAt(st2, 5, 5).length} left, city ${!!city}`); // A win against a protected (city) stack that still has defenders left // does NOT advance the attacker onto the tile. check('attacker does not advance into a still-defended city', !(tank2.x === 5 && tank2.y === 5)); const combatEvt2 = st2.events.filter((e) => e.type === 'combat').pop(); check('non-advancing combat event says so', !!combatEvt2 && combatEvt2.advanced === false); } // City capture: pop loss, loot, palace relocation, elimination. { const st = makeFlatState(); setWar(st, 0, 1); const cityA = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 5, 5, null)); const cityB = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 10, null)); cityA.size = 4; st.civs[1].gold = 200; const tank = Logic.spawnUnit(RULES, st, 0, 'armor', 4, 5, null); const out = Logic.tryMove(RULES, st, tank, 1, 0); check('undefended city captured', out.result === 'captured'); check('captured city loses a pop', cityA.size === 3); check('capturer moves in', tank.x === 5 && tank.y === 5); check('loot transferred', st.civs[0].gold > 100); check('palace relocates', !!cityB.buildings.palace && !cityA.buildings.palace); check('civ still alive with one city', st.civs[1].alive); const tank2 = Logic.spawnUnit(RULES, st, 0, 'armor', 9, 10, null); Logic.tryMove(RULES, st, tank2, 1, 0); check('last city falls => civ eliminated', !st.civs[1].alive); check('conquest victory declared', st.over?.type === 'conquest' && st.over.winner === 0); } // Attacks require war; nukes and SDI. { const st = makeFlatState({ civs: 3 }); const u0 = Logic.spawnUnit(RULES, st, 0, 'legion', 4, 5, null); Logic.spawnUnit(RULES, st, 1, 'legion', 5, 5, null); const blocked = Logic.tryMove(RULES, st, u0, 1, 0); check('attack blocked without war', blocked.result === 'blocked' && blocked.needsWar === 1); setWar(st, 0, 1); const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 1, 'settlers', 10, 5, null)); city.size = 8; Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); const nuke = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); const out = Logic.resolveAttack(RULES, st, nuke, 10, 5); check('nuke clears stack', out.result === 'nuked' && Logic.unitsAt(st, 10, 5).length === 0); check('nuke halves city pop', city.size === 4); check('nuke consumed', !st.units.some((u) => u.type === 'nuclearmsl')); city.buildings.sdidefense = true; Logic.spawnUnit(RULES, st, 1, 'riflemen', 10, 5, null); const nuke2 = Logic.spawnUnit(RULES, st, 0, 'nuclearmsl', 9, 5, null); const out2 = Logic.resolveAttack(RULES, st, nuke2, 10, 5); check('SDI blocks nuke', out2.result === 'nukeBlocked' && Logic.unitsAt(st, 10, 5).length === 1 && city.size === 4); } // Missiles are consumed even on a won conventional attack. { const st = makeFlatState(); setWar(st, 0, 1); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); const cm = Logic.spawnUnit(RULES, st, 0, 'cruisemsl', 4, 5, null); const out = Logic.resolveAttack(RULES, st, cm, 5, 5); check('cruise missile consumed', !st.units.some((u) => u.type === 'cruisemsl'), out.result); } // Movement: roads, rails, boarding, disembark, trireme coast rule. { const st = makeFlatState(); const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 5, 5, null); check('grass step costs full move', (() => { Logic.tryMove(RULES, st, u, 1, 0); return u.mp === 0; })()); const i1 = 5 * 16 + 6; const i2 = 5 * 16 + 7; st.world.improvements[i1] = 1; st.world.improvements[i2] = 1; u.mp = 3; Logic.tryMove(RULES, st, u, 1, 0); check('road step costs 1/3', u.mp === 2, `${u.mp}`); st.world.improvements[i2] |= 2; const i3 = 5 * 16 + 8; st.world.improvements[i3] = 3; Logic.tryMove(RULES, st, u, 1, 0); check('rail step free', u.mp === 2, `${u.mp}`); // Boarding & disembark. const st2 = makeFlatState(); for (let y = 0; y < 16; y += 1) st2.world.terrain[y * 16 + 8] = T('ocean'); const boat = Logic.spawnUnit(RULES, st2, 0, 'transport', 8, 5, null); const inf = Logic.spawnUnit(RULES, st2, 0, 'riflemen', 7, 5, null); const bOut = Logic.tryMove(RULES, st2, inf, 1, 0); check('boards transport', bOut.result === 'boarded' && inf.carriedBy === boat.id); boat.mp = 15; Logic.tryMove(RULES, st2, boat, 0, 1); check('cargo rides along', inf.x === 8 && inf.y === 6); inf.mp = 3; const dOut = Logic.disembark(RULES, st2, inf, 1, 0); check('disembarks ashore', dOut.result === 'moved' && inf.carriedBy === null && inf.x === 9); // Trireme must hug the coast. const st3 = makeFlatState(); for (let y = 0; y < 16; y += 1) { for (let x = 6; x < 16; x += 1) st3.world.terrain[y * 16 + x] = T('ocean'); } const tri = Logic.spawnUnit(RULES, st3, 0, 'trireme', 6, 5, null); check('trireme coast tile ok', Logic.canOccupy(RULES, st3, tri, 6, 8)); check('trireme open sea blocked', !Logic.canOccupy(RULES, st3, tri, 12, 8)); const dd = Logic.spawnUnit(RULES, st3, 0, 'destroyer', 8, 5, null); check('destroyer open sea ok', Logic.canOccupy(RULES, st3, dd, 12, 8)); check('land unit cannot walk on water', !Logic.canOccupy(RULES, st3, Logic.spawnUnit(RULES, st3, 0, 'warriors', 3, 3, null), 8, 8)); } // Pathfinding: prefers roads, avoids blocked tiles, respects domains. { const st = makeFlatState(); for (let x = 3; x <= 12; x += 1) st.world.improvements[7 * 16 + x] |= 1; const u = Logic.spawnUnit(RULES, st, 0, 'warriors', 3, 7, null); const path = Logic.findPath(RULES, st, u, 12, 7); check('path found', !!path && path.length === 9); check('path follows road', path.every(([, y]) => y === 7)); const sea = Logic.findPath(RULES, st, u, 12, 7); check('path deterministic', JSON.stringify(sea) === JSON.stringify(path)); } // Air crash rule at end of turn. { const st = makeFlatState(); Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null)); const f1 = Logic.spawnUnit(RULES, st, 0, 'fighter', 5, 5, null); const f2 = Logic.spawnUnit(RULES, st, 0, 'fighter', 10, 10, null); Logic.endCivTurn(RULES, st, 0); check('fighter in city survives', st.units.includes(f1)); check('fighter in the field crashes', !st.units.includes(f2)); } // Work orders: road, irrigation water rule, mine/irrigation exclusivity, transform. { const st = makeFlatState(); const eng = Logic.spawnUnit(RULES, st, 0, 'engineers', 5, 5, null); st.civs[0].known.explosives = true; check('can start road', Logic.startWork(RULES, st, eng, 'road')); Logic.beginCivTurn(RULES, st, 0); // 2 work points (engineer) = road done const idx = 5 * 16 + 5; check('road built', (st.world.improvements[idx] & 1) === 1); check('irrigation needs water', !Logic.canWork(RULES, st, eng, 'irrigation')); st.world.terrain[5 * 16 + 6] = T('ocean'); check('irrigation ok next to ocean', Logic.canWork(RULES, st, eng, 'irrigation')); Logic.startWork(RULES, st, eng, 'irrigation'); Logic.beginCivTurn(RULES, st, 0); Logic.beginCivTurn(RULES, st, 0); check('irrigation built', (st.world.improvements[idx] & 4) === 4); // Mining hills clears irrigation. const st2 = makeFlatState({ terrain: 'hills' }); const eng2 = Logic.spawnUnit(RULES, st2, 0, 'engineers', 5, 5, null); st2.world.improvements[idx] = 4; Logic.startWork(RULES, st2, eng2, 'mine'); Logic.beginCivTurn(RULES, st2, 0); Logic.beginCivTurn(RULES, st2, 0); check('mine replaces irrigation', (st2.world.improvements[idx] & (16 | 4)) === 16); // Transform swamp -> grassland (engineer only). const st3 = makeFlatState({ terrain: 'swamp' }); st3.civs[0].known.explosives = true; const sett = Logic.spawnUnit(RULES, st3, 0, 'settlers', 4, 4, null); check('settler cannot transform', !Logic.canWork(RULES, st3, sett, 'transform')); const eng3 = Logic.spawnUnit(RULES, st3, 0, 'engineers', 5, 5, null); Logic.startWork(RULES, st3, eng3, 'transform'); for (let i = 0; i < 5; i += 1) Logic.beginCivTurn(RULES, st3, 0); check('swamp transformed to grassland', st3.world.terrain[idx] === T('grassland')); } // Huts: all outcomes reachable, ambush can kill. { const st = makeFlatState(); const seen = new Set(); for (let i = 0; i < 200; i += 1) { st.world.huts[5 * 16 + 6] = 1; const u = Logic.spawnUnit(RULES, st, 0, 'legion', 5, 5, null); const out = Logic.tryMove(RULES, st, u, 1, 0); if (out.hut) seen.add(out.hut.outcome); for (const un of [...st.units]) Logic.removeUnit(st, un); } check('hut outcomes cover gold/tech/unit/ambush', seen.has('gold') && seen.has('tech') && seen.has('unit') && (seen.has('ambushWon') || seen.has('ambushLost')), [...seen].join(',')); } } // --------------------------------------------------------------------------- section('6. trade & diplomacy'); if (RULES) { // Trade routes: distance gate, bonus math, max-3 replacement. { const st = makeFlatState({ cols: 32, rows: 8 }); const home = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 4, null)); const near = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 6, 4, null)); const far = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 20, 4, null)); const cv1 = Logic.spawnUnit(RULES, st, 0, 'caravan', 6, 4, home.id); check('route needs 8+ distance', Logic.canEstablishRoute(RULES, st, cv1) === null); const cv2 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); const goldBefore = st.civs[0].gold; const out = Logic.establishTradeRoute(RULES, st, cv2); check('route established', !!out && out.bonus > 0); check('caravan consumed', !st.units.some((u) => u.type === 'caravan' && u.x === 20)); check('bonus paid in gold+beakers', st.civs[0].gold === goldBefore + out.bonus && st.civs[0].beakers >= out.bonus); check('both cities got the route', home.routes.length === 1 && far.routes.length === 1 && home.routes[0].cityId === far.id); check('route trade feeds yields', Logic.cityYields(RULES, st, far).routeTrade === out.amount); // Max 3: pile on routes, weakest is dropped. home.routes = [{ cityId: 90, amount: 2 }, { cityId: 91, amount: 3 }, { cityId: 92, amount: 4 }]; const cv3 = Logic.spawnUnit(RULES, st, 0, 'caravan', 20, 4, home.id); Logic.establishTradeRoute(RULES, st, cv3); check('max 3 routes, worst replaced', home.routes.length === 3 && !home.routes.some((r) => r.cityId === 90)); // Foreign routes pay double. const st2 = makeFlatState({ cols: 32, rows: 8, civs: 2 }); const h2 = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 0, 'settlers', 2, 4, null)); Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 20, 4, null)); const cvF = Logic.spawnUnit(RULES, st2, 0, 'caravan', 20, 4, h2.id); const outF = Logic.establishTradeRoute(RULES, st2, cvF); check('foreign route pays roughly double', !!outF && outF.bonus >= out.bonus * 1.5, `${out.bonus} vs ${outF?.bonus}`); } // Diplomacy state machine: legal steps only. { const st = makeFlatState({ civs: 3 }); check('contact->peace legal', Logic.canPropose(st, 0, 1, 'peace')); check('contact->alliance illegal', !Logic.canPropose(st, 0, 1, 'alliance')); check('contact->ceasefire illegal', !Logic.canPropose(st, 0, 1, 'ceasefire')); check('apply peace', Logic.applyTreaty(st, 0, 1, 'peace') && st.civs[1].relations[0] === 'peace'); check('peace->alliance legal', Logic.applyTreaty(st, 0, 1, 'alliance')); check('sneak attack scars reputation', (() => { const rep = st.civs[0].reputation; Logic.declareWar(RULES, st, 0, 1); return st.civs[0].reputation < rep && st.civs[0].relations[1] === 'war'; })()); check('war->peace illegal (need ceasefire)', !Logic.canPropose(st, 0, 1, 'peace')); check('war->ceasefire->peace', Logic.applyTreaty(st, 0, 1, 'ceasefire') && Logic.applyTreaty(st, 0, 1, 'peace')); check('third party attitude fell on sneak attack', st.civs[2].attitude[0] < 0); check('cancel treaty back to contact', Logic.cancelTreaty(st, 0, 1) && st.civs[0].relations[1] === 'contact'); check('war on nocontact illegal', (() => { st.civs[0].relations[2] = 'nocontact'; return !Logic.declareWar(RULES, st, 0, 2); })()); } // Gifts, exchanges, tribute. { const st = makeFlatState({ civs: 2 }); st.civs[0].gold = 100; check('gift gold', Logic.giftGold(st, 0, 1, 50) && st.civs[1].gold === 150); check('gift beyond means fails', !Logic.giftGold(st, 0, 1, 500)); check('gift raises attitude', st.civs[1].attitude[0] > 0); st.civs[0].known.alphabet = true; st.civs[1].known.pottery = true; check('tech exchange', Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery') && st.civs[0].known.pottery && st.civs[1].known.alphabet); check('re-exchange fails', !Logic.exchangeTechs(RULES, st, 0, 1, 'alphabet', 'pottery')); const paid = Logic.payTribute(st, 1, 0, 75); check('tribute paid', paid === 75 && st.civs[1].attitude[0] < 0); } // Fuzz: random diplomacy actions never reach an illegal state and // attitudes stay bounded. { const st = makeFlatState({ civs: 4 }); st.rngState = 424242; const legalStates = new Set(['nocontact', 'contact', 'war', 'ceasefire', 'peace', 'alliance']); let legal = true; let bounded = true; const actions = QUICK ? 300 : 1000; for (let i = 0; i < actions; i += 1) { const a = Logic.randInt(st, 4); let b = Logic.randInt(st, 4); if (a === b) b = (b + 1) % 4; const roll = Logic.rand(st); if (roll < 0.25) Logic.declareWar(RULES, st, a, b); else if (roll < 0.5) { const kinds = ['ceasefire', 'peace', 'alliance']; Logic.applyTreaty(st, a, b, kinds[Logic.randInt(st, 3)]); } else if (roll < 0.65) Logic.cancelTreaty(st, a, b); else if (roll < 0.8) { st.civs[a].gold = 50; Logic.giftGold(st, a, b, 25); } else Logic.updateAttitudes(RULES, st, a); for (const civ of st.civs) { for (const [other, rel] of Object.entries(civ.relations)) { if (!legalStates.has(rel)) legal = false; if (st.civs[other].relations[civ.id] !== rel) legal = false; // symmetry } for (const v of Object.values(civ.attitude)) { if (v < -100 || v > 100 || Number.isNaN(v)) bounded = false; } } if (!legal || !bounded) break; } check('fuzz: relations stay legal & symmetric', legal); check('fuzz: attitudes bounded', bounded); } // Attitude -> portrait mood mapping. check('mood mapping', Logic.attitudeMood(-60) === 'upset' && Logic.attitudeMood(0) === 'idle' && Logic.attitudeMood(60) === 'happy'); } // --------------------------------------------------------------------------- section('7. spaceship'); if (RULES) { const st = makeFlatState({ civs: 2 }); const civ = st.civs[0]; check('launch blocked without parts', !Logic.launchSpaceship(RULES, st, civ)); civ.spaceship.structural = 8; civ.spaceship.component = 4; civ.spaceship.module = 2; check('launch blocked missing modules', !Logic.launchSpaceship(RULES, st, civ)); civ.spaceship.module = 3; check('launch succeeds with full parts', Logic.launchSpaceship(RULES, st, civ)); check('arrival scheduled', civ.spaceship.arrivalTurn === st.turn + RULES.spaceship.travelTurns); check('double launch blocked', !Logic.launchSpaceship(RULES, st, civ)); // Countdown to victory via endCivTurn wrapping. for (let i = 0; i < RULES.spaceship.travelTurns + 1 && !st.over; i += 1) { Logic.endCivTurn(RULES, st, 0); Logic.endCivTurn(RULES, st, 1); } check('spaceship arrival wins', st.over?.type === 'spaceship' && st.over.winner === 0); // Capital capture kills the ship. { const st2 = makeFlatState({ civs: 2 }); setWar(st2, 0, 1); const cap = Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 5, 5, null)); Logic.foundCity(RULES, st2, Logic.spawnUnit(RULES, st2, 1, 'settlers', 10, 10, null)); const civ1 = st2.civs[1]; civ1.spaceship = { structural: 8, component: 4, module: 3, launched: false, arrivalTurn: 0 }; Logic.launchSpaceship(RULES, st2, civ1); const tank = Logic.spawnUnit(RULES, st2, 0, 'armor', 4, 5, null); Logic.tryMove(RULES, st2, tank, 1, 0); check('capital captured', cap.civ === 0); check('spaceship lost with capital', !civ1.spaceship.launched && civ1.spaceship.structural === 0); check('game continues (civ lives on)', civ1.alive && !st2.over); } // Spaceship parts respect their caps in the build list. { const st3 = makeFlatState(); const civ0 = st3.civs[0]; civ0.known.spaceflight = true; const city = Logic.foundCity(RULES, st3, Logic.spawnUnit(RULES, st3, 0, 'settlers', 5, 5, null)); let avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); check('structural buildable with tech', avail.includes('ssstructural')); check('component gated on plastics', !avail.includes('sscomponent')); civ0.spaceship.structural = 8; avail = Logic.availableUnits(RULES, st3, civ0, city).map((u) => u.id); check('structural capped at 8', !avail.includes('ssstructural')); } } // --------------------------------------------------------------------------- section('8. serialization'); if (RULES) { const leaders = [{ id: 'steve', name: 'Steve' }, { id: 'gerome', name: 'Gerome' }, { id: 'jerry', name: 'Jerry' }]; const st = Logic.createGame(RULES, { sizeId: 'small', seed: 11, difficultyId: 'prince', leaders }); // Play a few scripted turns. for (let t = 0; t < 5; t += 1) { for (let c = 0; c < st.civs.length; c += 1) { Logic.beginCivTurn(RULES, st, c); for (const u of Logic.civUnits(st, c)) { if (u.type === 'settlers' && Logic.canFoundCity(RULES, st, u.x, u.y)) { Logic.foundCity(RULES, st, u); } else if (u.mp > 0) { Logic.tryMove(RULES, st, u, (t + c) % 3 - 1, (t * c) % 3 - 1); } } Logic.endCivTurn(RULES, st, c); } } const json = Logic.serialize(st); const st2 = Logic.deserialize(json); check('round trip parses', !!st2); check('round trip identical', Logic.serialize(st2) === json); check('hash stable', Logic.hashState(st) === Logic.hashState(st2)); check('version mismatch rejected', Logic.deserialize(JSON.stringify({ version: 99 })) === null); // Determinism: same seed + same script => same hash. const stA = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); const stB = Logic.createGame(RULES, { sizeId: 'small', seed: 77, difficultyId: 'king', leaders }); check('createGame deterministic', Logic.hashState(stA) === Logic.hashState(stB)); } // --------------------------------------------------------------------------- section('5+9. AI self-play soak (+ research pacing)'); if (RULES) { const LEADER_POOL = ['steve', 'gerome', 'jerry', 'aiko', 'natasha', 'brad', 'cybro'] .map((id) => ({ id, name: id[0].toUpperCase() + id.slice(1) })); function checkInvariants(st, label) { for (const civ of st.civs) { if (civ.gold < 0 || Number.isNaN(civ.gold)) return `${label}: negative/NaN gold civ ${civ.id}`; if (civ.beakers < 0) return `${label}: negative beakers`; } for (const c of st.cities) { if (c.size < 1) return `${label}: city size ${c.size}`; if (!st.civs[c.civ].alive) return `${label}: city owned by dead civ`; const seen = new Set(); for (const t of c.worked) { if (seen.has(t)) return `${label}: duplicate worked tile`; seen.add(t); } } for (const u of st.units) { if (!Logic.inBounds(st.world, u.x, u.y)) return `${label}: unit off map`; if (u.hp <= 0) return `${label}: dead unit alive`; const def = RULES.units[u.type]; const terr = Logic.terrainAt(RULES, st.world, u.x, u.y); if (def.domain === 'land' && terr.water && !u.carriedBy) return `${label}: land unit swimming`; if (def.domain === 'sea' && !terr.water && !Logic.cityAt(st, u.x, u.y)) return `${label}: ship aground`; if (!st.civs[u.civ].alive) return `${label}: unit of dead civ`; } // Worked tiles disjoint across cities. const workedAll = new Set(); for (const c of st.cities) { for (const t of c.worked) { if (workedAll.has(t)) return `${label}: worked tile shared between cities`; workedAll.add(t); } } return null; } function runGame(gameIdx, { sizeId, numCivs, difficultyId, seed, turnCap = 600 }) { const leaders = LEADER_POOL.slice(0, numCivs); const st = Logic.createGame(RULES, { sizeId, seed, difficultyId, leaders, humanIndex: -1 }); let invariantErr = null; let aiTime = 0; let aiTurns = 0; let firstSpaceflight = null; while (!st.over && st.turn < turnCap) { const c = st.current; Logic.beginCivTurn(RULES, st, c); const t0 = performance.now(); AI.runAITurn(RULES, st, c); aiTime += performance.now() - t0; aiTurns += 1; Logic.endCivTurn(RULES, st, c); if (!firstSpaceflight && st.civs.some((cv) => cv.known.spaceflight)) { firstSpaceflight = st.turn; } if (st.turn % 50 === 0 && !invariantErr) { invariantErr = checkInvariants(st, `game ${gameIdx} turn ${st.turn}`); } } if (!invariantErr) invariantErr = checkInvariants(st, `game ${gameIdx} final`); return { st, invariantErr, avgAiMs: aiTime / Math.max(1, aiTurns), firstSpaceflight }; } const games = []; const N = QUICK ? 6 : 28; const sizes = ['small', 'medium', 'small', 'medium']; const diffs = ['chieftain', 'warlord', 'prince', 'king', 'emperor']; const configs = []; for (let g = 0; g < N; g += 1) { configs.push({ sizeId: sizes[g % sizes.length], numCivs: 3 + (g % 3), difficultyId: diffs[g % diffs.length], seed: 1000 + g * 17, }); } // Pinned seeds known to end in conquest (deterministic engine), so the // victory-mix coverage below cannot flake on an all-peaceful draw. configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'emperor', seed: 7077 }); configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'king', seed: 8088 }); configs.forEach((cfg, g) => { const out = runGame(g, cfg); games.push({ cfg, ...out }); check(`game ${g} (${cfg.sizeId}/${cfg.numCivs}civ/${cfg.difficultyId}) no invariant breaks`, out.invariantErr === null, out.invariantErr ?? ''); }); const finished = games.filter((g) => g.st.over); const conquest = finished.filter((g) => g.st.over.type === 'conquest'); const space = finished.filter((g) => g.st.over.type === 'spaceship'); console.log(` ${finished.length}/${games.length} games decided ` + `(${conquest.length} conquest, ${space.length} spaceship); ` + `avg AI turn ${(games.reduce((a, g) => a + g.avgAiMs, 0) / games.length).toFixed(2)}ms`); check('a healthy share of games reach a victory (>=30%)', finished.length >= Math.ceil(games.length * 0.3), `${finished.length}/${games.length}`); check('conquest victories occur', conquest.length > 0); if (QUICK) { console.log(' (--quick: spaceship coverage check runs in the full suite only)'); } else { check('spaceship victories occur', space.length > 0, 'no spaceship win in suite'); } check('AI turn time budget (<=50ms avg)', games.every((g) => g.avgAiMs <= 50), `worst ${Math.max(...games.map((g) => g.avgAiMs)).toFixed(1)}ms`); // Research pacing: someone should reach Space Flight in a sensible window. const sfTurns = games.map((g) => g.firstSpaceflight).filter((t) => t !== null); check('space flight reached in some games', sfTurns.length > 0); if (sfTurns.length) { const median = sfTurns.sort((a, b) => a - b)[Math.floor(sfTurns.length / 2)]; check('space flight window turn 150-600', median >= 150 && median <= 600, `median ${median}`); } // Determinism: replaying the same seed produces the same final hash. { const cfg = { sizeId: 'small', numCivs: 3, difficultyId: 'prince', seed: 4242, turnCap: 120 }; const a = runGame(-1, cfg); const b = runGame(-2, cfg); check('soak replay deterministic', Logic.hashState(a.st) === Logic.hashState(b.st)); } } // --------------------------------------------------------------------------- console.log(`\n${passes} passed, ${failures} failed`); if (failures > 0) process.exit(1);