// Civilization — headless game engine. No Phaser imports; runs in Node for // tools/verifyCivilization.js and in the browser scene. // // Simplifications vs Civ II (by design, see plan/sprites.md): no happiness or // tax sliders (fixed 50/50 gold/science trade split), no senate, no wonders, // no pollution, no zones of control, no rivers. Movement points are stored in // thirds (road = 1/3, railroad = free). Combat is the Civ II round model: // p(hit) = A/(A+D), loser of a round loses the winner's firepower in hp. import { generateWorld, mulberry32, shieldGrassAt, IMP } from './CivilizationWorldGen.js'; import { techCost } from './CivilizationRules.js'; export { mulberry32, shieldGrassAt, IMP }; export const FOOD_PER_CITIZEN = 2; export const FOODBOX_PER_SIZE = 10; export const VET_BONUS = 1.5; export const FORTIFY_BONUS = 1.5; export const CITY_BASE_DEF = 1.5; // unwalled city acts like fortified ground export const FORTRESS_BONUS = 2; export const HEAL_FIELD = 0.1; export const HEAL_CITY = 1 / 3; export const PATH_EXPANSION_CAP = 400; export const HUT_RESULTS = ['gold', 'tech', 'unit', 'ambush']; // Fat cross: 5x5 Chebyshev block minus the four corners = 21 tiles. export const CITY_RADIUS = []; for (let dy = -2; dy <= 2; dy += 1) { for (let dx = -2; dx <= 2; dx += 1) { if (Math.abs(dx) === 2 && Math.abs(dy) === 2) continue; CITY_RADIUS.push([dx, dy]); } } export function rand(state) { // Explicit-state mulberry32 step so the RNG serializes with the game. let a = state.rngState | 0; a = (a + 0x6d2b79f5) | 0; state.rngState = a; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } export function randInt(state, n) { return Math.floor(rand(state) * n); } export function cheb(x1, y1, x2, y2) { return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2)); } // --------------------------------------------------------------------------- // Game creation export function createGame(rules, opts) { const { sizeId = 'medium', seed = 1, difficultyId = 'prince', leaders, humanIndex = 0, } = opts; const numCivs = leaders.length; const world = generateWorld(rules, { sizeId, seed, numCivs }); const difficulty = rules.difficulties[difficultyId]; const n = world.cols * world.rows; const state = { version: 1, rules: null, // attached transiently via attachRules; never serialized seed, rngState: (seed * 2654435761) | 0, sizeId, difficultyId, turn: 0, current: 0, humanIndex, world, civs: [], cities: [], units: [], nextUnitId: 1, nextCityId: 1, explored: [], over: null, events: [], // AI-initiated diplomacy aimed at the human, kept out of `events` so it // survives the event-log trim in beginCivTurn and can stay pending across // turns until answered (see CivilizationDiplomacy.js). pendingRequests: [], }; const nameOrder = shuffledIndexes(state, rules.cityNames.length); for (let i = 0; i < numCivs; i += 1) { const relations = {}; const attitude = {}; for (let j = 0; j < numCivs; j += 1) { if (j !== i) { relations[j] = 'nocontact'; attitude[j] = 0; } } // Leader starting-condition trait (see data/civilization-rules.json // civTraits) — trait id may be missing/unknown (old saves, setup-screen // dummy-leader fallback), every consumer degrades to neutral via ?./??. const trait = rules.civTraits[leaders[i].trait]; const civ = { id: i, leaderId: leaders[i].id, traitId: leaders[i].trait ?? null, citySheet: leaders[i].citySheet ?? 'classic', name: leaders[i].name, color: rules.playerColors[i % rules.playerColors.length], human: i === humanIndex, alive: true, government: 'despotism', revolutionTurns: 0, pendingGovernment: null, gold: 50 + (trait?.startingGold ?? 0), beakers: 0, researching: null, known: {}, futureCount: 0, relations, attitude, // AI-initiated diplomacy bookkeeping, all keyed by the other civ's id. // frustration (0-100) is grudge built up by refused requests; it decays // each turn and drags `attitude` down while it lasts. requestCooldown is // {otherId: {kind: turnAvailableAgain}}, lastRequestTurn throttles a // single leader, pledges holds promises the other civ made (e.g. to // withdraw units). Consumers must tolerate these being absent — old // saves predate them. frustration: {}, requestCooldown: {}, lastRequestTurn: {}, pledges: {}, reputation: 0, spaceship: { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }, nameCursor: i, // stride through the shared shuffled name pool nameOrder, score: 0, }; // Every leader personally lists 2-3 starting techs (data/opponents.json // startingTechs) independent of their trait — pioneering leaders already // get 2 from their trait above and list none of their own, everyone else // gets their personal pick. Union (not concat) guards against double- // granting if a personal list ever overlaps a trait-granted tech. const startingTechs = new Set([...(trait?.startingTechs ?? []), ...(leaders[i].startingTechs ?? [])]); for (const techId of startingTechs) grantTech(rules, state, civ, techId); state.civs.push(civ); state.explored.push(new Array(n).fill(0)); } // Starting settlers (+1 for AI at higher difficulties). const startSettlers = rules.worldSizes[sizeId].startSettlers; for (let i = 0; i < numCivs; i += 1) { const [sx, sy] = world.starts[i]; const count = startSettlers + (state.civs[i].human ? 0 : difficulty.aiStartUnits); for (let k = 0; k < count; k += 1) { const spot = k === 0 ? [sx, sy] : nearbyLandSpot(rules, state, sx, sy); spawnUnit(rules, state, i, 'settlers', spot[0], spot[1], null); } exploreAround(state, i, sx, sy, 2); } return state; } function shuffledIndexes(state, len) { const arr = Array.from({ length: len }, (_, i) => i); for (let i = arr.length - 1; i > 0; i -= 1) { const j = randInt(state, i + 1); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function nearbyLandSpot(rules, state, x, y) { const { world } = state; for (let r = 1; r <= 3; r += 1) { for (let dy = -r; dy <= r; dy += 1) { for (let dx = -r; dx <= r; dx += 1) { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= world.cols || ny >= world.rows) continue; const terr = rules.terrainList[world.terrain[ny * world.cols + nx]]; if (!terr.water && terr.id !== 'glacier' && terr.id !== 'mountains') return [nx, ny]; } } } return [x, y]; } // --------------------------------------------------------------------------- // Lookups export function tileIndex(world, x, y) { return y * world.cols + x; } export function inBounds(world, x, y) { return x >= 0 && y >= 0 && x < world.cols && y < world.rows; } export function terrainAt(rules, world, x, y) { return rules.terrainList[world.terrain[tileIndex(world, x, y)]]; } export function cityAt(state, x, y) { return state.cities.find((c) => c.x === x && c.y === y) ?? null; } export function unitsAt(state, x, y) { return state.units.filter((u) => u.x === x && u.y === y && !u.carriedBy); } export function unitById(state, id) { return state.units.find((u) => u.id === id) ?? null; } export function cityById(state, id) { return state.cities.find((c) => c.id === id) ?? null; } export function civUnits(state, civ) { return state.units.filter((u) => u.civ === civ); } export function civCities(state, civ) { return state.cities.filter((c) => c.civ === civ); } export function knowsTech(civ, techId) { return !!civ.known[techId]; } export function knownCount(civ) { return Object.keys(civ.known).length + civ.futureCount; } export function availableTechs(rules, civ) { return rules.techList.filter((t) => (t.repeatable || !civ.known[t.id]) && t.prereqs.every((p) => civ.known[p])); } export function availableUnits(rules, state, civ, city) { return rules.unitList.filter((u) => { if (u.prereq && !knowsTech(civ, u.prereq)) return false; if (u.obsoletedBy && knowsTech(civ, rules.units[u.obsoletedBy].prereq)) return false; if (u.domain === 'sea' && !isCoastal(rules, state, city)) return false; if (u.flags.includes('spaceship')) { const ship = state.civs[civ.id].spaceship; const cap = { ssstructural: rules.spaceship.structuralNeeded, sscomponent: rules.spaceship.componentsNeeded, ssmodule: rules.spaceship.modulesNeeded }[u.id]; const have = { ssstructural: ship.structural, sscomponent: ship.component, ssmodule: ship.module }[u.id]; if (ship.launched || have >= cap) return false; } return true; }); } export function availableBuildings(rules, state, civ, city) { return rules.buildingList.filter((b) => { if (city.buildings[b.id]) return false; if (b.prereq && !knowsTech(civ, b.prereq)) return false; if (b.requires && !city.buildings[b.requires]) return false; if (b.effect === 'power' && hasPowerPlant(city)) return false; if ((b.effect === 'oceanfood' || b.effect === 'oceanshield' || b.effect === 'defensesea') && !isCoastal(rules, state, city)) return false; return true; }); } function hasPowerPlant(city) { return !!(city.buildings.powerplant || city.buildings.hydroplant || city.buildings.nuclearplant); } export function isCoastal(rules, state, city) { for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { const x = city.x + dx; const y = city.y + dy; if (inBounds(state.world, x, y) && terrainAt(rules, state.world, x, y).water) return true; } return false; } // --------------------------------------------------------------------------- // Tile yields export function tileYield(rules, state, civIdx, city, x, y) { const { world } = state; const i = tileIndex(world, x, y); const terr = rules.terrainList[world.terrain[i]]; const special = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null; let food = special ? special.food : terr.food; let shield = special ? special.shield : terr.shield; let trade = special ? special.trade : terr.trade; if (!special && terr.id === 'grassland' && shieldGrassAt(x, y)) shield += 1; const imp = world.improvements[i]; if ((imp & IMP.IRRIGATION) && terr.irrigate) food += terr.irrigate; if ((imp & IMP.MINE) && terr.mine) shield += terr.mine; if ((imp & IMP.ROAD) && !terr.water && terr.move === 1) trade += 1; if ((imp & IMP.RAILROAD) && shield >= 1) shield += 1; if ((imp & IMP.FARMLAND) && city && city.buildings.supermarket) { food = Math.floor(food * 1.5); } if (city) { if (terr.water && city.buildings.harbor) food += 1; if (terr.water && city.buildings.offshoreplatform) shield += 1; } const civ = state.civs[civIdx]; const gov = rules.governments[civ.government]; if (gov.tradeBonus && trade >= 1) trade += gov.tradeBonus; if (gov.despotPenalty) { if (food >= 3) food -= 1; if (shield >= 3) shield -= 1; if (trade >= 3) trade -= 1; } return { food, shield, trade }; } const EMPHASIS_WEIGHTS = { balanced: { food: 3, shield: 2, trade: 1 }, food: { food: 6, shield: 1, trade: 1 }, production: { food: 1, shield: 5, trade: 1 }, trade: { food: 1, shield: 1, trade: 5 }, }; export function autoAssignTiles(rules, state, city) { const { world } = state; const weights = EMPHASIS_WEIGHTS[city.emphasis] ?? EMPHASIS_WEIGHTS.balanced; const takenElsewhere = new Set(); for (const other of state.cities) { if (other.id === city.id) continue; for (const t of other.worked) takenElsewhere.add(t); } const options = []; for (const [dx, dy] of CITY_RADIUS) { if (dx === 0 && dy === 0) continue; const x = city.x + dx; const y = city.y + dy; if (!inBounds(world, x, y)) continue; const idx = tileIndex(world, x, y); if (takenElsewhere.has(idx)) continue; const other = cityAt(state, x, y); if (other) continue; const yld = tileYield(rules, state, city.civ, city, x, y); options.push({ idx, w: yld.food * weights.food + yld.shield * weights.shield + yld.trade * weights.trade }); } options.sort((a, b) => b.w - a.w || a.idx - b.idx); city.worked = options.slice(0, city.size).map((o) => o.idx); } export function cityYields(rules, state, city) { const civ = state.civs[city.civ]; const gov = rules.governments[civ.government]; const centre = tileYield(rules, state, city.civ, city, city.x, city.y); let food = centre.food; let shield = Math.max(1, centre.shield); // city tile always makes 1 shield... let trade = Math.max(1, centre.trade); // ...and 1 trade (the market economy floor) for (const idx of city.worked) { const x = idx % state.world.cols; const y = (idx / state.world.cols) | 0; const yld = tileYield(rules, state, city.civ, city, x, y); food += yld.food; shield += yld.shield; trade += yld.trade; } // Trade routes. let routeTrade = 0; for (const r of city.routes) routeTrade += r.amount; trade += routeTrade; // Corruption. const capital = civCities(state, city.civ).find((c) => c.buildings.palace); const dist = gov.flatCorruption ? 10 : (capital ? cheb(city.x, city.y, capital.x, capital.y) : 16); let corruption = Math.floor(trade * gov.corruptionFactor * Math.min(1, dist / 20)); if (city.buildings.courthouse) corruption = Math.floor(corruption * 0.5); corruption = Math.min(corruption, trade); const netTrade = trade - corruption; // Fixed 50/50 tax split (no sliders in this build), then building multipliers. // Science gets the odd arrow: with rivers cut, early cities often make just // 1 trade, and research must never round down to a permanent zero. const anarchy = gov.noScience === true; const baseGold = Math.floor(netTrade / 2); const baseScience = anarchy ? 0 : Math.ceil(netTrade / 2); const trait = rules.civTraits[civ.traitId]; let goldMult = trait?.goldMult ?? 1; let sciMult = trait?.scienceMult ?? 1; let shieldMult = trait?.shieldMult ?? 1; for (const bId of Object.keys(city.buildings)) { const b = rules.buildings[bId]; if (!b) continue; if (b.effect === 'gold') goldMult += b.value; if (b.effect === 'science') sciMult += b.value; if (b.effect === 'shields') shieldMult += b.value; if (b.effect === 'power' && city.buildings.factory) shieldMult += b.value; } // Difficulty production handicap — AI civs only (see rules.difficulties' // aiProdBonus; humanResearchFactor/aiScienceBonus handle research speed, // aiAggression handles war odds, this is the shield-output lever). if (!civ.human) shieldMult *= rules.difficulties[state.difficultyId].aiProdBonus; // Unit support: shields per supported unit beyond the free allowance // (Democracy pays gold instead), settlers also eat food. const supported = state.units.filter((u) => u.homeCity === city.id); let supportShields = 0; let supportGold = 0; let settlerFood = 0; let combatants = 0; for (const u of supported) { const def = rules.units[u.type]; if (def.flags.includes('settler')) settlerFood += gov.settlerFood; if (def.domain === 'project' || def.flags.includes('noncombat')) continue; combatants += 1; if (combatants > gov.freeUnits) { if (gov.unitUpkeep === 'gold') supportGold += 1; else supportShields += 1; } } const grossShield = Math.floor(shield * shieldMult); // Waste — corruption's production sibling: the same capital-distance decay // applied to shields, at half strength (full strength would zero out // distant cities' build boxes entirely under Despotism). Courthouse halves // it, same as corruption. let waste = Math.floor(grossShield * gov.corruptionFactor * Math.min(1, dist / 20) * 0.5); if (city.buildings.courthouse) waste = Math.floor(waste * 0.5); waste = Math.min(waste, grossShield); const netShield = Math.max(0, grossShield - waste - supportShields); const foodNeed = city.size * FOOD_PER_CITIZEN + settlerFood; const upkeep = Object.keys(city.buildings) .reduce((sum, id) => sum + (rules.buildings[id]?.upkeep ?? 0), 0); return { food, foodNeed, foodSurplus: food - foodNeed, shield: netShield, grossShield, waste, supportShields, trade, corruption, netTrade, routeTrade, gold: Math.floor(baseGold * goldMult), science: Math.floor(baseScience * sciMult), upkeep, supportGold, }; } // --------------------------------------------------------------------------- // Cities // Read-only preview of the name a new city would get, without consuming it // from the pool — lets UI show a default before the player confirms. export function peekCityName(rules, state, civ) { const c = state.civs[civ]; if (civCities(state, civ).length === 0) return `${c.name} City`; const pool = rules.cityNames; const idx = c.nameOrder[c.nameCursor % pool.length]; const round = Math.floor(c.nameCursor / pool.length); return round > 0 ? `${pool[idx]} ${'I'.repeat(round + 1)}` : pool[idx]; } function advanceCityNameCursor(state, civ) { if (civCities(state, civ).length === 0) return; // first city never draws from the pool state.civs[civ].nameCursor += state.civs.length; } export function nextCityName(rules, state, civ) { const name = peekCityName(rules, state, civ); advanceCityNameCursor(state, civ); return name; } export function canFoundCity(rules, state, x, y) { const terr = terrainAt(rules, state.world, x, y); if (terr.water || terr.id === 'glacier') return false; for (const c of state.cities) { if (cheb(c.x, c.y, x, y) < 2) return false; } return true; } export function foundCity(rules, state, unit, name) { if (!canFoundCity(rules, state, unit.x, unit.y)) return null; const civ = state.civs[unit.civ]; const cityName = name && name.trim() ? name.trim() : peekCityName(rules, state, unit.civ); advanceCityNameCursor(state, unit.civ); const city = { id: state.nextCityId, civ: unit.civ, x: unit.x, y: unit.y, name: cityName, size: 1, foodBox: 0, shieldBox: 0, build: { type: 'unit', id: 'warriors' }, // safe fallback; pickNextBuild overrides below buildings: {}, worked: [], emphasis: 'balanced', routes: [], boughtThisTurn: false, }; pickNextBuild(rules, state, city); // best available land defender, not always warriors state.nextCityId += 1; if (civCities(state, unit.civ).length === 0) city.buildings.palace = true; // Civ II treats the city square as having a road (its main early trade). state.world.improvements[tileIndex(state.world, city.x, city.y)] |= IMP.ROAD; state.cities.push(city); removeUnit(state, unit); autoAssignTiles(rules, state, city); exploreAround(state, unit.civ, city.x, city.y, 2); state.events.push({ type: 'cityFounded', civ: unit.civ, cityId: city.id, name: city.name }); return city; } // Coinage/Public Works: convert shields straight into gold/food every turn // instead of banking them toward a unit/building. `den` lets food run at a // worse ratio than gold without losing shields — see processCity, which // reuses city.shieldBox as the carry register for the remainder. export const SPECIAL_BUILDS = { gold: { id: 'coinage', name: 'Coinage', num: 1, den: 1 }, food: { id: 'publicworks', name: 'Public Works', num: 1, den: 2 }, }; function isProductionClass(type) { return type === 'unit' || type === 'building'; } export function setBuild(rules, state, city, type, id) { if (city.build && city.build.type !== type && city.shieldBox > 0 && isProductionClass(city.build.type) && isProductionClass(type)) { city.shieldBox = Math.floor(city.shieldBox / 2); // Civ II class-switch penalty } city.build = { type, id }; } export function buildCost(rules, city) { return city.build.type === 'unit' ? rules.units[city.build.id].cost : rules.buildings[city.build.id].cost; } export function buyCost(rules, city) { const remaining = Math.max(0, buildCost(rules, city) - city.shieldBox); return Math.ceil(remaining * (city.build.type === 'unit' ? 2.5 : 2)); } export function buyBuild(rules, state, city) { const civ = state.civs[city.civ]; const cost = buyCost(rules, city); if (city.boughtThisTurn || civ.gold < cost) return false; civ.gold -= cost; city.shieldBox = buildCost(rules, city); city.boughtThisTurn = true; return true; } export function sellBuilding(rules, state, city, buildingId) { if (!city.buildings[buildingId] || buildingId === 'palace') return false; delete city.buildings[buildingId]; state.civs[city.civ].gold += rules.buildings[buildingId].cost; return true; } function completeBuild(rules, state, city) { const civ = state.civs[city.civ]; const { type, id } = city.build; if (type === 'building') { city.buildings[id] = true; city.shieldBox = 0; state.events.push({ type: 'buildingDone', civ: city.civ, cityId: city.id, building: id }); pickNextBuild(rules, state, city); return; } const def = rules.units[id]; if (def.flags.includes('spaceship')) { const ship = civ.spaceship; if (id === 'ssstructural') ship.structural += 1; if (id === 'sscomponent') ship.component += 1; if (id === 'ssmodule') ship.module += 1; city.shieldBox = 0; state.events.push({ type: 'spaceshipPart', civ: city.civ, cityId: city.id, part: id }); pickNextBuild(rules, state, city); return; } if (def.flags.includes('settler')) { if (city.size < 2) return; // hold until the city can spare the population city.size -= 1; autoAssignTiles(rules, state, city); } const unit = spawnUnit(rules, state, city.civ, id, city.x, city.y, city.id); if (city.buildings.barracks && def.domain === 'land') unit.vet = true; city.shieldBox = 0; state.events.push({ type: 'unitDone', civ: city.civ, cityId: city.id, unit: id }); } function pickNextBuild(rules, state, city) { // Fall back to something always buildable after finishing a build. const civ = state.civs[city.civ]; const units = availableUnits(rules, state, civ, city).filter((u) => !u.flags.includes('spaceship')); const best = units.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat')) .sort((a, b) => b.defense - a.defense)[0] ?? units[0]; if (best) city.build = { type: 'unit', id: best.id }; } function processCity(rules, state, city) { const civ = state.civs[city.civ]; autoAssignTiles(rules, state, city); const y = cityYields(rules, state, city); // Food. city.foodBox += y.foodSurplus; const boxSize = (city.size + 1) * FOODBOX_PER_SIZE; if (city.foodBox >= boxSize) { const cap = sizeCap(rules, city); if (city.size < cap) { city.size += 1; city.foodBox = city.buildings.granary ? Math.floor(boxSize / 2) : 0; state.events.push({ type: 'cityGrew', civ: city.civ, cityId: city.id, size: city.size }); } else { city.foodBox = boxSize; // capped until an aqueduct/sewer arrives } } else if (city.foodBox < 0) { // Famine: starve a citizen, or a supported settler first. const settler = state.units.find((u) => u.homeCity === city.id && rules.units[u.type].flags.includes('settler')); if (settler) removeUnit(state, settler); else { city.size -= 1; state.events.push({ type: 'cityShrank', civ: city.civ, cityId: city.id, size: city.size }); } city.foodBox = 0; if (city.size <= 0) { destroyCity(rules, state, city); return; } } // Shields. if (city.build.type === 'gold' || city.build.type === 'food') { // Coinage/Public Works: shieldBox is repurposed as a carry register so // the 2:1 food ratio never loses an odd shield across turns. const spec = SPECIAL_BUILDS[city.build.type]; city.shieldBox += y.shield; const gain = Math.floor(city.shieldBox / spec.den) * spec.num; city.shieldBox -= Math.floor(gain / spec.num) * spec.den; if (city.build.type === 'gold') civ.gold += gain; else city.foodBox += gain; } else { // Capped at cost — a settler build stalled on city.size < 2 // (see completeBuild) would otherwise keep banking shields turn after // turn with nowhere to go, showing e.g. "60/40 shields" and a negative // turn count once shields overshot the cost. city.shieldBox = Math.min(city.shieldBox + y.shield, buildCost(rules, city)); if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city); } city.boughtThisTurn = false; // Economy. civ.gold += y.gold - y.upkeep - y.supportGold; if (!rules.governments[civ.government].noScience) civ.beakers += y.science; // Bankruptcy: auto-sell the cheapest sellable building. if (civ.gold < 0) { const sellable = Object.keys(city.buildings).filter((b) => b !== 'palace'); if (sellable.length) { sellable.sort((a, b) => rules.buildings[a].cost - rules.buildings[b].cost); sellBuilding(rules, state, city, sellable[0]); state.events.push({ type: 'buildingSold', civ: city.civ, cityId: city.id, building: sellable[0] }); } if (civ.gold < 0) civ.gold = 0; } } export function sizeCap(rules, city) { if (city.buildings.sewersystem) return 99; if (city.buildings.aqueduct) return 12; return 8; } function destroyCity(rules, state, city) { state.cities = state.cities.filter((c) => c.id !== city.id); for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; for (const other of state.cities) { other.routes = other.routes.filter((r) => r.cityId !== city.id); } state.events.push({ type: 'cityDestroyed', cityId: city.id, name: city.name }); } // --------------------------------------------------------------------------- // Research & government export function currentResearchCost(rules, state, civ) { const diff = rules.difficulties[state.difficultyId]; const factor = civ.human ? diff.humanResearchFactor : 1 / diff.aiScienceBonus; return techCost(knownCount(civ), factor); } export function setResearch(rules, state, civ, techId) { const tech = rules.techs[techId]; if (!tech) return false; if (!tech.repeatable && civ.known[techId]) return false; if (!tech.prereqs.every((p) => civ.known[p])) return false; civ.researching = techId; return true; } function progressResearch(rules, state, civ) { if (!civ.researching) return; const cost = currentResearchCost(rules, state, civ); if (civ.beakers < cost) return; civ.beakers -= cost; const techId = civ.researching; if (rules.techs[techId].repeatable) civ.futureCount += 1; else civ.known[techId] = true; civ.researching = null; state.events.push({ type: 'techDone', civ: civ.id, tech: techId }); } export function grantTech(rules, state, civ, techId) { if (rules.techs[techId].repeatable) civ.futureCount += 1; else civ.known[techId] = true; if (civ.researching === techId) civ.researching = null; } export function startRevolution(rules, state, civ, targetGovId) { const gov = rules.governments[targetGovId]; if (!gov || (gov.prereq && !civ.known[gov.prereq])) return false; if (targetGovId === civ.government) return false; civ.government = 'anarchy'; civ.pendingGovernment = targetGovId; civ.revolutionTurns = 2 + randInt(state, 3); state.events.push({ type: 'revolution', civ: civ.id, target: targetGovId }); return true; } function progressRevolution(rules, state, civ) { if (civ.government !== 'anarchy' || !civ.pendingGovernment) return; civ.revolutionTurns -= 1; if (civ.revolutionTurns <= 0) { civ.government = civ.pendingGovernment; civ.pendingGovernment = null; state.events.push({ type: 'newGovernment', civ: civ.id, government: civ.government }); } } // --------------------------------------------------------------------------- // Units & movement export function spawnUnit(rules, state, civIdx, type, x, y, homeCity) { const def = rules.units[type]; const unit = { id: state.nextUnitId, civ: civIdx, type, x, y, hp: def.hp, mp: def.move * 3, vet: false, fortified: false, sentry: false, moved: false, homeCity, carriedBy: null, order: null, }; state.nextUnitId += 1; state.units.push(unit); return unit; } export function removeUnit(state, unit) { for (const u of state.units) if (u.carriedBy === unit.id) removeUnit(state, u); state.units = state.units.filter((x) => x.id !== unit.id); } export function moveCost(rules, state, unit, fx, fy, tx, ty) { const def = rules.units[unit.type]; const { world } = state; const toTerr = terrainAt(rules, world, tx, ty); if (def.domain === 'air') return 3; if (def.domain === 'sea') { if (!toTerr.water && !cityAt(state, tx, ty)) return Infinity; return 3; } // Land. if (toTerr.water) return Infinity; // boarding handled in tryMove const fi = tileIndex(world, fx, fy); const ti = tileIndex(world, tx, ty); const bothRail = (world.improvements[fi] & IMP.RAILROAD) && (world.improvements[ti] & IMP.RAILROAD); if (bothRail) return 0; const bothRoad = (world.improvements[fi] & (IMP.ROAD | IMP.RAILROAD)) && (world.improvements[ti] & (IMP.ROAD | IMP.RAILROAD)); if (bothRoad) return 1; if (def.flags.includes('ignoreterrain')) return 1; return toTerr.move * 3; } // Whether `unit` may occupy (x,y) ignoring enemies (domain/terrain check). export function canOccupy(rules, state, unit, x, y) { if (!inBounds(state.world, x, y)) return false; const def = rules.units[unit.type]; const terr = terrainAt(rules, state.world, x, y); const city = cityAt(state, x, y); if (def.domain === 'sea') { if (city) return city.civ === unit.civ; if (!terr.water) return false; if (def.flags.includes('coastal')) { // Triremes hug the coast: some adjacent land required. let coast = false; for (let dy = -1; dy <= 1; dy += 1) { for (let dx = -1; dx <= 1; dx += 1) { const nx = x + dx; const ny = y + dy; if (inBounds(state.world, nx, ny) && !terrainAt(rules, state.world, nx, ny).water) coast = true; } } return coast; } return true; } if (def.domain === 'air') return true; return !terr.water; } // One-step move/attack/board. Returns an outcome object. export function tryMove(rules, state, unit, dx, dy) { if (state.over) return { result: 'invalid' }; if (Math.abs(dx) > 1 || Math.abs(dy) > 1 || (dx === 0 && dy === 0)) return { result: 'invalid' }; if (unit.mp <= 0 || unit.carriedBy) return { result: 'invalid' }; const tx = unit.x + dx; const ty = unit.y + dy; if (!inBounds(state.world, tx, ty)) return { result: 'invalid' }; const def = rules.units[unit.type]; const targetCity = cityAt(state, tx, ty); const targets = unitsAt(state, tx, ty).filter((u) => u.civ !== unit.civ); // Attack? if (targets.length || (targetCity && targetCity.civ !== unit.civ)) { const enemyCiv = targets.length ? targets[0].civ : targetCity.civ; if (state.civs[unit.civ].relations[enemyCiv] !== 'war') { return { result: 'blocked', needsWar: enemyCiv }; } if (def.attack <= 0) return { result: 'invalid' }; if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) return { result: 'invalid' }; if (def.domain === 'sea' && !terrainAt(rules, state.world, tx, ty).water && !targetCity) return { result: 'invalid' }; if (targets.length === 0 && targetCity) { // Undefended city: land units capture, sea/air just raid the walls. if (def.domain !== 'land') return { result: 'invalid' }; spendMove(unit, 3); return captureCity(rules, state, unit, targetCity); } return resolveAttack(rules, state, unit, tx, ty); } // Board a transport? if (def.domain === 'land' && terrainAt(rules, state.world, tx, ty).water) { const boat = unitsAt(state, tx, ty).find((u) => u.civ === unit.civ && (rules.units[u.type].cargo ?? 0) > cargoCount(state, u)); if (boat) { unit.x = tx; unit.y = ty; unit.carriedBy = boat.id; unit.mp = 0; unit.moved = true; return { result: 'boarded', boat: boat.id }; } return { result: 'invalid' }; } if (!canOccupy(rules, state, unit, tx, ty)) return { result: 'invalid' }; if (targetCity && targetCity.civ !== unit.civ) return { result: 'invalid' }; const cost = moveCost(rules, state, unit, unit.x, unit.y, tx, ty); if (!Number.isFinite(cost)) return { result: 'invalid' }; spendMove(unit, cost); unit.x = tx; unit.y = ty; unit.fortified = false; unit.moved = true; dropCarried(rules, state, unit, tx, ty); exploreAround(state, unit.civ, tx, ty, 2); makeContacts(rules, state, unit.civ, tx, ty); const hutIdx = tileIndex(state.world, tx, ty); if (state.world.huts[hutIdx]) { state.world.huts[hutIdx] = 0; return { result: 'moved', hut: resolveHut(rules, state, unit) }; } return { result: 'moved' }; } function spendMove(unit, cost) { unit.mp = Math.max(0, unit.mp - Math.max(0, cost)); unit.moved = true; } export function unitsOnBoat(state, boat) { return state.units.filter((u) => u.carriedBy === boat.id); } function cargoCount(state, boat) { return unitsOnBoat(state, boat).length; } function dropCarried(rules, state, unit, x, y) { // Units riding a transport move with it; disembark handled by their own move. for (const u of state.units) { if (u.carriedBy === unit.id) { u.x = x; u.y = y; } } if (unit.carriedBy) unit.carriedBy = null; } export function disembark(rules, state, unit, dx, dy) { if (!unit.carriedBy || unit.mp <= 0) return { result: 'invalid' }; const boat = unitById(state, unit.carriedBy); if (!boat) { unit.carriedBy = null; return { result: 'invalid' }; } unit.carriedBy = null; unit.mp = 3; // stepping ashore takes the turn's movement const out = tryMove(rules, state, unit, dx, dy); if (out.result === 'invalid' || out.result === 'blocked') { unit.carriedBy = boat.id; unit.mp = 0; } return out; } const ADJACENT_DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; // Land tiles next to `boat` that are clear of any foreign city/units — used // to offer the human player a "Disembark" action (see CivilizationGame.js) // that unloads the whole hold onto a single chosen tile at once. Kept clear // of enemies so it never surprises the player with a fight; attacking off a // transport is still possible unit-by-unit via the AI's own logic, just not // through this bulk convenience command. export function disembarkTiles(rules, state, boat) { const tiles = []; for (const [dx, dy] of ADJACENT_DIRS) { const x = boat.x + dx; const y = boat.y + dy; if (!inBounds(state.world, x, y)) continue; if (terrainAt(rules, state.world, x, y).water) continue; const city = cityAt(state, x, y); if (city && city.civ !== boat.civ) continue; if (unitsAt(state, x, y).some((u) => u.civ !== boat.civ)) continue; tiles.push({ x, y, dx, dy }); } return tiles; } function resolveHut(rules, state, unit) { const civ = state.civs[unit.civ]; // Captured up front: the hut tile is what the view has to repaint, and an // ambush loss removes the unit before the event is pushed. const { x, y } = unit; const roll = rand(state); if (roll < 0.4) { const gold = 25 * (1 + randInt(state, 4)); civ.gold += gold; state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold, x, y }); return { outcome: 'gold', gold }; } if (roll < 0.65) { const options = availableTechs(rules, civ).filter((t) => t.era === 'ancient' && !t.repeatable); if (options.length) { const tech = options[randInt(state, options.length)]; grantTech(rules, state, civ, tech.id); state.events.push({ type: 'hut', civ: civ.id, outcome: 'tech', tech: tech.id, x, y }); return { outcome: 'tech', tech: tech.id }; } civ.gold += 50; state.events.push({ type: 'hut', civ: civ.id, outcome: 'gold', gold: 50, x, y }); return { outcome: 'gold', gold: 50 }; } if (roll < 0.85) { const type = civ.known.chivalry ? 'knights' : (civ.known.ironworking ? 'legion' : 'horsemen'); spawnUnit(rules, state, unit.civ, type, unit.x, unit.y, null); state.events.push({ type: 'hut', civ: civ.id, outcome: 'unit', unit: type, x, y }); return { outcome: 'unit', unit: type }; } // Ambush: fight a phantom era-scaled hostile on the spot. const phantomType = civ.known.conscription ? 'riflemen' : (civ.known.gunpowder ? 'musketeers' : 'legion'); const phantom = rules.units[phantomType]; const def = rules.units[unit.type]; const survived = simulateDuel(state, Math.max(1, def.attack), def.hp, def.fp, phantom.defense, phantom.hp, phantom.fp, unit.hp); if (!survived.attackerWon) { removeUnit(state, unit); state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushLost', x, y }); return { outcome: 'ambushLost' }; } unit.hp = survived.attackerHp; state.events.push({ type: 'hut', civ: civ.id, outcome: 'ambushWon', x, y }); return { outcome: 'ambushWon' }; } // --------------------------------------------------------------------------- // Combat // Easier-difficulty combat handicap: boosts the human side and softens the // AI side on Chieftain/Warlord; neutral (1.0/1.0/0.5) from Prince up, so // Prince stays the unmodified reference difficulty. function combatBonus(rules, state, civIdx) { const diff = rules.difficulties[state.difficultyId]; return state.civs[civIdx].human ? diff.humanCombatBonus : diff.aiCombatBonus; } function vetChance(rules, state, civIdx) { const diff = rules.difficulties[state.difficultyId]; return state.civs[civIdx].human ? diff.humanVetChance : 0.5; } export function defenderStrength(rules, state, defUnit, attacker) { const def = rules.units[defUnit.type]; const attDef = rules.units[attacker.type]; const terr = terrainAt(rules, state.world, defUnit.x, defUnit.y); const city = cityAt(state, defUnit.x, defUnit.y); const idx = tileIndex(state.world, defUnit.x, defUnit.y); let d = def.defense * (defUnit.vet ? VET_BONUS : 1) * (defUnit.hp / def.hp); d *= terr.defense; if (defUnit.fortified) d *= FORTIFY_BONUS; if (state.world.improvements[idx] & IMP.FORTRESS) d *= FORTRESS_BONUS; if (city) { if (city.buildings.citywalls && attDef.domain === 'land' && !attDef.flags.includes('ignorewalls')) { d *= rules.buildings.citywalls.value; } else if (!defUnit.fortified) { d *= CITY_BASE_DEF; } if (attDef.domain === 'sea' && city.buildings.coastalfortress) d *= rules.buildings.coastalfortress.value; if (attDef.domain === 'air' && city.buildings.sambattery) d *= rules.buildings.sambattery.value; } if (def.flags.includes('antimounted') && attDef.flags.includes('mounted')) d *= 2; d *= combatBonus(rules, state, defUnit.civ); return d; } export function attackerStrength(rules, state, unit) { const def = rules.units[unit.type]; return def.attack * (unit.vet ? VET_BONUS : 1) * (unit.hp / def.hp) * combatBonus(rules, state, unit.civ); } export function pickDefender(rules, state, x, y, attacker) { const targets = unitsAt(state, x, y).filter((u) => u.civ !== attacker.civ); if (!targets.length) return null; let best = targets[0]; let bestD = -1; for (const t of targets) { const d = defenderStrength(rules, state, t, attacker); if (d > bestD) { bestD = d; best = t; } } return best; } export function simulateDuel(state, A, aHpMax, aFp, D, dHpMax, dFp, aHpStart) { let aHp = aHpStart; let dHp = dHpMax; const p = (A + D) > 0 ? A / (A + D) : 1; while (aHp > 0 && dHp > 0) { if (rand(state) < p) dHp -= aFp; else aHp -= dFp; } return { attackerWon: dHp <= 0, attackerHp: Math.max(0, aHp), defenderHp: Math.max(0, dHp) }; } export function resolveAttack(rules, state, attacker, tx, ty) { const attDef = rules.units[attacker.type]; const defender = pickDefender(rules, state, tx, ty, attacker); if (!defender) return { result: 'invalid' }; const defDef = rules.units[defender.type]; // Nukes: no combat — obliterate the tile (SDI blocks). if (attDef.flags.includes('nuke')) { const city = cityAt(state, tx, ty); if (city && city.buildings.sdidefense) { removeUnit(state, attacker); state.events.push({ type: 'nukeBlocked', cityId: city.id }); return { result: 'nukeBlocked' }; } for (const u of unitsAt(state, tx, ty)) removeUnit(state, u); if (city) city.size = Math.max(1, Math.ceil(city.size / 2)); removeUnit(state, attacker); state.events.push({ type: 'nuke', x: tx, y: ty, cityId: city?.id ?? null }); checkVictory(rules, state); return { result: 'nuked' }; } const ax = attacker.x; const ay = attacker.y; const attackerId = attacker.id; const attackerCiv = attacker.civ; const attackerType = attacker.type; const defenderId = defender.id; const defenderCiv = defender.civ; const defenderType = defender.type; const A = attackerStrength(rules, state, attacker); const D = defenderStrength(rules, state, defender, attacker); const duel = simulateDuel(state, A, attDef.hp, attDef.fp, D, defDef.hp, defDef.fp, attacker.hp); const city = cityAt(state, tx, ty); const idx = tileIndex(state.world, tx, ty); const protectedStack = !!city || !!(state.world.improvements[idx] & IMP.FORTRESS); // Advance-after-kill: an unopposed winner (not a self-destructing missile) // steps onto the tile it just cleared, matching the victory-glide the UI // plays for it. let advanced = false; if (duel.attackerWon) { removeUnit(state, defender); if (!protectedStack) { for (const u of unitsAt(state, tx, ty).filter((un) => un.civ === defender.civ)) removeUnit(state, u); } attacker.hp = Math.max(1, duel.attackerHp); if (!attacker.vet && rand(state) < vetChance(rules, state, attacker.civ)) attacker.vet = true; // A land winner advancing into a now-empty enemy city captures it on the // way in (same rule as tryMove's undefended-city path). Sea/air winners // stay put instead — they raid the walls but can never take the city. const conquered = city && city.civ !== attacker.civ ? city : null; if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0 && !(conquered && attDef.domain !== 'land')) { attacker.x = tx; attacker.y = ty; dropCarried(rules, state, attacker, tx, ty); exploreAround(state, attacker.civ, tx, ty, 2); makeContacts(rules, state, attacker.civ, tx, ty); if (conquered) captureCity(rules, state, attacker, conquered); advanced = true; } } else { defender.hp = Math.max(1, duel.defenderHp); if (!defender.vet && rand(state) < vetChance(rules, state, defender.civ)) defender.vet = true; removeUnit(state, attacker); } spendMove(attacker, 3); if (attDef.flags.includes('missile') && duel.attackerWon) removeUnit(state, attacker); state.events.push({ type: 'combat', x: tx, y: ty, ax, ay, attackerId, attackerCiv, attackerType, defenderId, defenderCiv, defenderType, attackerWon: duel.attackerWon, advanced, }); checkVictory(rules, state); return { result: 'combat', won: duel.attackerWon }; } export function captureCity(rules, state, unit, city) { const oldCiv = state.civs[city.civ]; const newCivIdx = unit.civ; const loot = 50 + 10 * city.size; const wasCapital = !!city.buildings.palace; state.civs[newCivIdx].gold += Math.min(oldCiv.gold, loot); oldCiv.gold = Math.max(0, oldCiv.gold - loot); delete city.buildings.palace; city.civ = newCivIdx; city.size = Math.max(1, city.size - 1); city.routes = []; city.shieldBox = 0; pickNextBuild(rules, state, city); // best available land defender for the new owner for (const u of state.units.filter((un) => un.homeCity === city.id)) u.homeCity = null; unit.x = city.x; unit.y = city.y; unit.moved = true; exploreAround(state, newCivIdx, city.x, city.y, 2); // A capital lost mid-flight destroys the spaceship. if (wasCapital && oldCiv.spaceship.launched) { oldCiv.spaceship = { structural: 0, component: 0, module: 0, launched: false, arrivalTurn: 0 }; state.events.push({ type: 'spaceshipLost', civ: oldCiv.id }); } // Relocate palace to another city (free) if any remain. const remaining = civCities(state, oldCiv.id); if (wasCapital && remaining.length) remaining[0].buildings.palace = true; state.events.push({ type: 'cityCaptured', cityId: city.id, name: city.name, from: oldCiv.id, to: newCivIdx, attackerCiv: unit.civ, attackerType: unit.type, }); if (!remaining.length) eliminateCiv(rules, state, oldCiv.id); checkVictory(rules, state); return { result: 'captured', cityId: city.id }; } export function eliminateCiv(rules, state, civIdx) { const civ = state.civs[civIdx]; if (!civ.alive) return; civ.alive = false; for (const u of civUnits(state, civIdx)) removeUnit(state, u); state.events.push({ type: 'civEliminated', civ: civIdx }); } // --------------------------------------------------------------------------- // Worked orders (settlers/engineers) export function canWork(rules, state, unit, impId) { const def = rules.units[unit.type]; if (!def.flags.includes('settler')) return false; const imp = rules.improvements[impId]; if (!imp) return false; if (imp.engineerOnly && !def.flags.includes('engineer')) return false; const civ = state.civs[unit.civ]; if (imp.prereq && !civ.known[imp.prereq]) return false; const { world } = state; const idx = tileIndex(world, unit.x, unit.y); const terr = terrainAt(rules, world, unit.x, unit.y); if (terr.water) return false; const bits = world.improvements[idx]; switch (impId) { case 'road': return !(bits & IMP.ROAD); case 'railroad': return !!(bits & IMP.ROAD) && !(bits & IMP.RAILROAD); case 'irrigation': return terr.irrigate !== null && !(bits & IMP.IRRIGATION) && hasWaterAccess(rules, state, unit.x, unit.y); case 'farmland': return !!(bits & IMP.IRRIGATION) && !(bits & IMP.FARMLAND); case 'mine': return terr.mine !== null && !(bits & IMP.MINE); case 'fortress': return !(bits & IMP.FORTRESS) && !cityAt(state, unit.x, unit.y); case 'transform': return terr.transform !== null; default: return false; } } function hasWaterAccess(rules, state, x, y) { for (let dy = -1; dy <= 1; dy += 1) { for (let dx = -1; dx <= 1; dx += 1) { const nx = x + dx; const ny = y + dy; if (!inBounds(state.world, nx, ny)) continue; if (terrainAt(rules, state.world, nx, ny).water) return true; if (state.world.improvements[tileIndex(state.world, nx, ny)] & IMP.IRRIGATION) return true; } } return false; } export function startWork(rules, state, unit, impId) { if (!canWork(rules, state, unit, impId)) return false; unit.order = { kind: 'work', imp: impId, progress: 0 }; unit.mp = 0; return true; } function progressWork(rules, state, unit) { if (!unit.order || unit.order.kind !== 'work') return; const def = rules.units[unit.type]; unit.order.progress += def.flags.includes('engineer') ? 2 : 1; unit.mp = 0; const imp = rules.improvements[unit.order.imp]; if (unit.order.progress < imp.work) return; const { world } = state; const idx = tileIndex(world, unit.x, unit.y); switch (unit.order.imp) { case 'road': world.improvements[idx] |= IMP.ROAD; break; case 'railroad': world.improvements[idx] |= IMP.RAILROAD; break; case 'irrigation': world.improvements[idx] = (world.improvements[idx] | IMP.IRRIGATION) & ~IMP.MINE; break; case 'farmland': world.improvements[idx] |= IMP.FARMLAND; break; case 'mine': world.improvements[idx] = (world.improvements[idx] | IMP.MINE) & ~(IMP.IRRIGATION | IMP.FARMLAND); break; case 'fortress': world.improvements[idx] |= IMP.FORTRESS; break; case 'transform': { const terr = terrainAt(rules, world, unit.x, unit.y); if (terr.transform) { const target = rules.terrainList.findIndex((t) => t.id === terr.transform); world.terrain[idx] = target; world.improvements[idx] = 0; } break; } default: break; } unit.order = null; state.events.push({ type: 'workDone', civ: unit.civ, x: unit.x, y: unit.y }); } // --------------------------------------------------------------------------- // Pathfinding (Dijkstra in move-thirds, capped) export function findPath(rules, state, unit, tx, ty) { const { world } = state; if (!inBounds(world, tx, ty)) return null; const start = tileIndex(world, unit.x, unit.y); const goal = tileIndex(world, tx, ty); if (start === goal) return []; const dist = new Map([[start, 0]]); const prev = new Map(); const frontier = [{ idx: start, d: 0 }]; let expansions = 0; while (frontier.length && expansions < PATH_EXPANSION_CAP) { // Linear min-extract: frontier stays small under the expansion cap. let minI = 0; for (let i = 1; i < frontier.length; i += 1) { if (frontier[i].d < frontier[minI].d) minI = i; } const { idx, d } = frontier[minI]; frontier[minI] = frontier[frontier.length - 1]; frontier.pop(); if (idx === goal) break; if (d > (dist.get(idx) ?? Infinity)) continue; expansions += 1; const x = idx % world.cols; const y = (idx / world.cols) | 0; for (let dy = -1; dy <= 1; dy += 1) { for (let dx = -1; dx <= 1; dx += 1) { if (dx === 0 && dy === 0) continue; const nx = x + dx; const ny = y + dy; if (!inBounds(world, nx, ny)) continue; const nIdx = tileIndex(world, nx, ny); if (nIdx !== goal) { if (!canOccupy(rules, state, unit, nx, ny)) continue; const blockers = unitsAt(state, nx, ny).filter((u) => u.civ !== unit.civ); const enemyCity = cityAt(state, nx, ny); if (blockers.length || (enemyCity && enemyCity.civ !== unit.civ)) continue; } const cost = moveCost(rules, state, unit, x, y, nx, ny); if (!Number.isFinite(cost)) continue; const nd = d + cost + 0.01; // slight step bias keeps rail paths short if (nd < (dist.get(nIdx) ?? Infinity)) { dist.set(nIdx, nd); prev.set(nIdx, idx); frontier.push({ idx: nIdx, d: nd }); } } } } if (!prev.has(goal)) return null; const path = []; let cur = goal; while (cur !== start) { path.unshift([cur % world.cols, (cur / world.cols) | 0]); cur = prev.get(cur); } return path; } // --------------------------------------------------------------------------- // Visibility / exploration / contact export function exploreAround(state, civIdx, x, y, radius) { const grid = state.explored[civIdx]; const { world } = state; for (let dy = -radius; dy <= radius; dy += 1) { for (let dx = -radius; dx <= radius; dx += 1) { const nx = x + dx; const ny = y + dy; if (inBounds(world, nx, ny)) grid[tileIndex(world, nx, ny)] = 1; } } } export function computeVisible(state, civIdx) { const { world } = state; const vis = new Set(); const mark = (x, y, r) => { for (let dy = -r; dy <= r; dy += 1) { for (let dx = -r; dx <= r; dx += 1) { const nx = x + dx; const ny = y + dy; if (inBounds(world, nx, ny)) vis.add(tileIndex(world, nx, ny)); } } }; for (const u of civUnits(state, civIdx)) mark(u.x, u.y, 2); for (const c of civCities(state, civIdx)) mark(c.x, c.y, 2); return vis; } export function isUnitVisibleTo(rules, state, unit, civIdx) { if (unit.civ === civIdx) return true; const def = rules.units[unit.type]; if (def.flags.includes('submarine')) { // Subs only show when something of ours is adjacent. return civUnits(state, civIdx).some((u) => cheb(u.x, u.y, unit.x, unit.y) <= 1) || civCities(state, civIdx).some((c) => cheb(c.x, c.y, unit.x, unit.y) <= 1); } return true; } // Full proximity sweep: any of my units/cities within 3 of theirs = contact. export function contactSweep(rules, state, civIdx) { const civ = state.civs[civIdx]; const minePoints = [ ...civUnits(state, civIdx).map((u) => [u.x, u.y]), ...civCities(state, civIdx).map((c) => [c.x, c.y]), ]; for (const other of state.civs) { if (other.id === civIdx || !other.alive) continue; if (civ.relations[other.id] !== 'nocontact') continue; const theirs = [ ...civUnits(state, other.id).map((u) => [u.x, u.y]), ...civCities(state, other.id).map((c) => [c.x, c.y]), ]; let met = false; for (const [mx, my] of minePoints) { for (const [tx, ty] of theirs) { if (cheb(mx, my, tx, ty) <= 3) { met = true; break; } } if (met) break; } if (met) { civ.relations[other.id] = 'contact'; other.relations[civIdx] = 'contact'; state.events.push({ type: 'contact', a: civIdx, b: other.id }); } } } export function makeContacts(rules, state, civIdx, x, y) { for (const other of state.civs) { if (other.id === civIdx || !other.alive) continue; if (state.civs[civIdx].relations[other.id] !== 'nocontact') continue; const near = civUnits(state, other.id).some((u) => cheb(u.x, u.y, x, y) <= 1) || civCities(state, other.id).some((c) => cheb(c.x, c.y, x, y) <= 1); if (near) { state.civs[civIdx].relations[other.id] = 'contact'; other.relations[civIdx] = 'contact'; state.events.push({ type: 'contact', a: civIdx, b: other.id }); } } } // --------------------------------------------------------------------------- // Trade routes (caravans) export const TRADE_ROUTE_MIN_DIST = 6; export const MAX_ROUTES = 3; export function canEstablishRoute(rules, state, unit) { const def = rules.units[unit.type]; if (!def.flags.includes('caravan')) return null; const here = cityAt(state, unit.x, unit.y); const home = cityById(state, unit.homeCity); if (!here || !home || here.id === home.id) return null; if (here.civ !== unit.civ && state.civs[unit.civ].relations[here.civ] === 'war') return null; if (cheb(here.x, here.y, home.x, home.y) < TRADE_ROUTE_MIN_DIST) return null; return { here, home }; } export function establishTradeRoute(rules, state, unit) { const pair = canEstablishRoute(rules, state, unit); if (!pair) return null; const { here, home } = pair; const civ = state.civs[unit.civ]; const dist = cheb(here.x, here.y, home.x, home.y); const yHome = cityYields(rules, state, home); const yHere = cityYields(rules, state, here); const foreign = here.civ !== unit.civ; const bonus = Math.floor((dist + yHome.netTrade + yHere.netTrade) / 2) * (foreign ? 2 : 1); civ.gold += bonus; civ.beakers += bonus; const amount = Math.max(1, Math.floor(dist / 4)) + (foreign ? 1 : 0); addRoute(home, { cityId: here.id, amount }); addRoute(here, { cityId: home.id, amount }); removeUnit(state, unit); state.events.push({ type: 'tradeRoute', civ: unit.civ, from: home.id, to: here.id, bonus, amount, }); return { bonus, amount, from: home, to: here }; } function addRoute(city, route) { const existing = city.routes.find((r) => r.cityId === route.cityId); if (existing) { existing.amount = Math.max(existing.amount, route.amount); return; } city.routes.push(route); if (city.routes.length > MAX_ROUTES) { city.routes.sort((a, b) => b.amount - a.amount); city.routes.length = MAX_ROUTES; } } // --------------------------------------------------------------------------- // Diplomacy // // Pairwise relation states and the legal proposal steps between them. War can // be declared from any contacted state; breaking peace/alliance that way is a // "sneak attack" and permanently scars the aggressor's reputation. export const DIPLO_PROPOSALS = { war: ['ceasefire'], ceasefire: ['peace'], contact: ['peace'], peace: ['alliance'], alliance: [], nocontact: [], }; export function canPropose(state, a, b, kind) { const rel = state.civs[a].relations[b]; return (DIPLO_PROPOSALS[rel] ?? []).includes(kind); } export function applyTreaty(state, a, b, kind) { if (!canPropose(state, a, b, kind)) return false; state.civs[a].relations[b] = kind; state.civs[b].relations[a] = kind; bumpAttitude(state, a, b, 15); bumpAttitude(state, b, a, 15); state.events.push({ type: 'treaty', a, b, kind }); return true; } export function declareWar(rules, state, a, b) { const civA = state.civs[a]; const rel = civA.relations[b]; if (rel === 'nocontact' || rel === 'war') return false; const sneak = rel === 'peace' || rel === 'alliance'; civA.relations[b] = 'war'; state.civs[b].relations[a] = 'war'; bumpAttitude(state, b, a, -50); if (sneak) { const gov = rules.governments[civA.government]; civA.reputation -= 25 * (gov.warPenalty ?? 1); for (const other of state.civs) { if (other.id !== a && other.alive) bumpAttitude(state, other.id, a, -20); } } state.events.push({ type: 'war', a, b, sneak }); return true; } export function cancelTreaty(state, a, b) { const rel = state.civs[a].relations[b]; if (rel !== 'peace' && rel !== 'alliance' && rel !== 'ceasefire') return false; state.civs[a].relations[b] = 'contact'; state.civs[b].relations[a] = 'contact'; bumpAttitude(state, b, a, rel === 'alliance' ? -20 : -10); state.events.push({ type: 'treatyCancelled', a, b, was: rel }); return true; } export function giftGold(state, a, b, amount) { const civA = state.civs[a]; if (amount <= 0 || civA.gold < amount) return false; civA.gold -= amount; state.civs[b].gold += amount; bumpAttitude(state, b, a, Math.min(20, Math.ceil(amount / 25))); state.events.push({ type: 'gift', a, b, gold: amount }); return true; } export function giftTech(rules, state, a, b, techId) { const civA = state.civs[a]; const civB = state.civs[b]; if (!civA.known[techId] || civB.known[techId]) return false; grantTech(rules, state, civB, techId); bumpAttitude(state, b, a, 15); state.events.push({ type: 'gift', a, b, tech: techId }); return true; } export function exchangeTechs(rules, state, a, b, giveId, getId) { const civA = state.civs[a]; const civB = state.civs[b]; if (!civA.known[giveId] || civB.known[giveId]) return false; if (!civB.known[getId] || civA.known[getId]) return false; grantTech(rules, state, civB, giveId); grantTech(rules, state, civA, getId); bumpAttitude(state, a, b, 5); bumpAttitude(state, b, a, 5); state.events.push({ type: 'techExchange', a, b, giveId, getId }); return true; } export function payTribute(state, a, b, amount) { const civA = state.civs[a]; const paid = Math.min(civA.gold, amount); if (paid <= 0) return 0; civA.gold -= paid; state.civs[b].gold += paid; bumpAttitude(state, a, b, -15); // being shaken down breeds resentment state.events.push({ type: 'tribute', a, b, gold: paid }); return paid; } export function bumpAttitude(state, ofCiv, towardCiv, delta) { const civ = state.civs[ofCiv]; civ.attitude[towardCiv] = clampAttitude((civ.attitude[towardCiv] ?? 0) + delta); } function clampAttitude(v) { return Math.max(-100, Math.min(100, v)); } // Frustration: a grudge counter (0-100) separate from attitude, raised when // `ofCiv`'s requests are refused. It decays every turn (updateAttitudes) and // pulls attitude down while it lasts, so a leader you keep brushing off slides // toward war through the ordinary hostility path rather than a special case. export function frustrationOf(state, ofCiv, towardCiv) { return state.civs[ofCiv].frustration?.[towardCiv] ?? 0; } export function bumpFrustration(state, ofCiv, towardCiv, delta) { const civ = state.civs[ofCiv]; civ.frustration ??= {}; civ.frustration[towardCiv] = Math.max(0, Math.min(100, (civ.frustration[towardCiv] ?? 0) + delta)); return civ.frustration[towardCiv]; } export function civPower(rules, state, civIdx) { let power = 0; for (const u of civUnits(state, civIdx)) { const def = rules.units[u.type]; power += def.attack + def.defense; } for (const c of civCities(state, civIdx)) power += c.size * 2; return power; } // Per-turn attitude drift toward a situational baseline. Reputation scars from // sneak attacks hold the ceiling down permanently. export function updateAttitudes(rules, state, civIdx) { const civ = state.civs[civIdx]; const myPower = civPower(rules, state, civIdx); for (const other of state.civs) { if (other.id === civIdx || !other.alive) continue; if (civ.relations[other.id] === 'nocontact') continue; let baseline = 0; const theirPower = civPower(rules, state, other.id); if (theirPower > myPower * 2) baseline -= 20; // fear the runaway // Shared enemies build friendship. for (const third of state.civs) { if (third.id === civIdx || third.id === other.id || !third.alive) continue; if (civ.relations[third.id] === 'war' && other.relations[third.id] === 'war') baseline += 20; } // Border friction: their combat units close to my cities. let friction = 0; for (const u of civUnits(state, other.id)) { if (rules.units[u.type].flags.includes('noncombat')) continue; if (civCities(state, civIdx).some((c) => cheb(c.x, c.y, u.x, u.y) <= 3)) friction += 1; } baseline -= Math.min(30, friction * 5); if (civ.relations[other.id] === 'war') baseline -= 40; if (civ.relations[other.id] === 'alliance') baseline += 30; baseline += Math.max(-50, other.reputation / 2); // Refused requests sour the baseline for as long as the grudge lasts, and // the grudge itself fades a little every turn. const grudge = bumpFrustration(state, civIdx, other.id, -(rules.diplomacy?.frustrationDecay ?? 2)); baseline -= Math.round(grudge * (rules.diplomacy?.frustrationAttitudeWeight ?? 0.5)); baseline = clampAttitude(baseline); const cur = civ.attitude[other.id] ?? 0; civ.attitude[other.id] = clampAttitude(cur + Math.sign(baseline - cur) * Math.min(3, Math.abs(baseline - cur))); } } export function attitudeMood(value) { if (value <= -25) return 'upset'; if (value >= 25) return 'happy'; return 'idle'; } // --------------------------------------------------------------------------- // Turn structure export function beginCivTurn(rules, state, civIdx) { const civ = state.civs[civIdx]; if (!civ.alive) return; // Trim the event log instead of clearing it: the scene reads events from // AI turns at the start of the human turn (toasts, terrain repaints), and // headless soaks must not grow it unboundedly. if (state.events.length > 400) { state.events = [ ...state.events.filter((e) => e.keep), ...state.events.slice(-200).filter((e) => !e.keep), ]; } progressRevolution(rules, state, civ); updateAttitudes(rules, state, civIdx); // Cities produce/grow/research. for (const city of civCities(state, civIdx)) processCity(rules, state, city); progressResearch(rules, state, civ); if (!civ.researching && !civ.human) { // AIs always keep researching something (module CivilizationAI refines this). const options = availableTechs(rules, civ); if (options.length) civ.researching = options[randInt(state, options.length)].id; } // Units: reset movement, heal stationary ones, progress work orders. for (const unit of civUnits(state, civIdx)) { const def = rules.units[unit.type]; if (!unit.moved && !unit.order) { const city = cityAt(state, unit.x, unit.y); const healFrac = city ? (city.buildings.barracks ? 1 : HEAL_CITY) : HEAL_FIELD; unit.hp = Math.min(def.hp, unit.hp + Math.ceil(def.hp * healFrac)); } unit.mp = def.move * 3; unit.moved = false; progressWork(rules, state, unit); } } export function endCivTurn(rules, state, civIdx) { const civ = state.civs[civIdx]; if (civ.alive) { contactSweep(rules, state, civIdx); // Air units must end the turn on a city or carrier. for (const unit of civUnits(state, civIdx)) { const def = rules.units[unit.type]; if (def.domain !== 'air') continue; const city = cityAt(state, unit.x, unit.y); const carrier = unitsAt(state, unit.x, unit.y) .find((u) => u.civ === civIdx && (rules.units[u.type].cargoAir ?? 0) > 0); if (!city && !carrier) { removeUnit(state, unit); state.events.push({ type: 'airCrash', civ: civIdx, unitType: unit.type }); } } } // Advance to the next living civ; wrap advances the game turn. let next = civIdx; for (let i = 0; i < state.civs.length; i += 1) { next = (next + 1) % state.civs.length; if (next === 0) { state.turn += 1; checkSpaceshipArrivals(rules, state); } if (state.civs[next].alive) break; } state.current = next; return next; } export function launchSpaceship(rules, state, civ) { const ship = civ.spaceship; if (ship.launched) return false; if (ship.structural < rules.spaceship.structuralNeeded || ship.component < rules.spaceship.componentsNeeded || ship.module < rules.spaceship.modulesNeeded) return false; ship.launched = true; ship.arrivalTurn = state.turn + rules.spaceship.travelTurns; state.events.push({ type: 'spaceshipLaunched', civ: civ.id, arrivalTurn: ship.arrivalTurn }); return true; } function checkSpaceshipArrivals(rules, state) { if (state.over) return; for (const civ of state.civs) { if (civ.alive && civ.spaceship.launched && state.turn >= civ.spaceship.arrivalTurn) { state.over = { type: 'spaceship', winner: civ.id }; state.events.push({ type: 'victory', mode: 'spaceship', civ: civ.id, keep: true }); return; } } } export function checkVictory(rules, state) { if (state.over) return state.over; for (const civ of state.civs) { if (civ.alive && civCities(state, civ.id).length === 0 && !civUnits(state, civ.id).some((u) => rules.units[u.type].flags.includes('settler')) && state.turn > 0) { eliminateCiv(rules, state, civ.id); } } const living = state.civs.filter((c) => c.alive); if (living.length === 1) { state.over = { type: 'conquest', winner: living[0].id }; state.events.push({ type: 'victory', mode: 'conquest', civ: living[0].id, keep: true }); } else if (living.length === 0) { state.over = { type: 'conquest', winner: -1 }; } return state.over; } export function civScore(rules, state, civIdx) { const civ = state.civs[civIdx]; let pop = 0; for (const c of civCities(state, civIdx)) pop += c.size; return pop * 2 + knownCount(civ) * 2 + civ.futureCount * 5 + civCities(state, civIdx).length * 3; } // --------------------------------------------------------------------------- // Serialization export function serialize(state) { const { rules, ...rest } = state; return JSON.stringify(rest); } export function deserialize(json) { const state = JSON.parse(json); if (state.version !== 1) return null; return state; } export function hashState(state) { const str = serialize(state); let h = 2166136261; for (let i = 0; i < str.length; i += 1) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16); }