1760 lines
66 KiB
JavaScript
1760 lines
66 KiB
JavaScript
// 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);
|
|
}
|