- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions) - Custom warlord creation (8 origins, banner, starting city) or historical factions - City management: 7 buildings, 5 dev tiers, recruitment from levies - Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession - Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay - Sieges: assault, starvation, bribery, infiltration - Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal - Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events - AI factions with distinct personalities; prisoners (execute/release/recruit/ransom) - Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens - View-relative camera controls; headless test suites (smoke, stress, map validator)
1319 lines
55 KiB
JavaScript
1319 lines
55 KiB
JavaScript
// ============================================================
|
||
// SIMULATION — turn engine, economy, AI, war & conquest
|
||
// ============================================================
|
||
import * as D from "./data.js";
|
||
import {
|
||
G, F, GEN, CITY, rand, randInt, pick, chance, clamp, shuffle,
|
||
playerFaction, isPlayerFaction, factionCities, factionGenerals, factionArmies,
|
||
atWar, warKey, declareWar, makePeace, trustBump, getTrust,
|
||
log, chronicle, assignCity, uid, totalTroops, armyPower, makeGenericGeneral, findGen,
|
||
makeGeneral, saveGame, factionTroopCount,
|
||
} from "./state.js";
|
||
import { provNeighbors, adjacentProvinces } from "./world.js";
|
||
import { simulateBattle } from "./battlesim.js";
|
||
import { rollEvents } from "./events.js";
|
||
|
||
// ---------------- ECONOMY ----------------
|
||
export function orderFactor(c) { return 0.4 + c.order / 150; }
|
||
|
||
export function cityTax(c) {
|
||
const b = c.buildings;
|
||
let tax = c.pop * 0.6 * (c.commerceBase / 60) * (1 + 0.3 * b.market) * orderFactor(c);
|
||
tax *= F(c.owner)?.taxRate ?? 0.55;
|
||
// administrator stationed here?
|
||
const adminGen = factionGenerals(c.owner).find(g => g.location === c.id && g.traits.includes("admin"));
|
||
if (adminGen) tax *= 1.18;
|
||
const fac = F(c.owner);
|
||
if (fac?.origin === "merchant") tax *= 1.25;
|
||
if (fac?.origin === "governor") tax *= 1.05;
|
||
return Math.round(tax);
|
||
}
|
||
|
||
export function cityFoodDelta(c) {
|
||
const season = seasonFoodFactor();
|
||
let food = c.fertility * 0.5 * (1 + 0.42 * c.buildings.farm) * season;
|
||
food -= c.pop * 0.03;
|
||
if (c.unrest > 40) food *= 0.8;
|
||
return Math.round(food);
|
||
}
|
||
function seasonFoodFactor() {
|
||
if (G.month >= 2 && G.month <= 4) return 0.9; // spring planting
|
||
if (G.month >= 5 && G.month <= 7) return 1.05;
|
||
if (G.month >= 8 && G.month <= 9) return 1.45; // harvest
|
||
return 0.55; // winter
|
||
}
|
||
|
||
export function armyUpkeep(a) {
|
||
let gold = 0, food = 0;
|
||
for (const [t, n] of Object.entries(a.troops)) {
|
||
const ut = D.UNIT_TYPES[t]; if (!ut || !n) continue;
|
||
gold += (n / 100) * ut.cost * 0.04;
|
||
food += (n / 100) * ut.food;
|
||
}
|
||
return { gold: Math.round(gold), food: Math.round(food) };
|
||
}
|
||
|
||
export function garrisonUpkeep(c) {
|
||
let gold = 0, food = 0;
|
||
for (const [t, n] of Object.entries(c.garrison)) {
|
||
const ut = D.UNIT_TYPES[t]; if (!ut || !n) continue;
|
||
gold += (n / 100) * ut.cost * 0.02;
|
||
food += (n / 100) * ut.food * 0.35;
|
||
}
|
||
return { gold: Math.round(gold), food: Math.round(food) };
|
||
}
|
||
|
||
export function salariesDue(fac) { return G.month % 3 === 0; }
|
||
|
||
export function cityDevTier(c) {
|
||
const score = c.pop / 100 + c.dev / 20 + Object.values(c.buildings).reduce((s, v) => s + v, 0) * 0.5 + (c.capitalOf ? 1 : 0);
|
||
if (score >= 16) return 5;
|
||
if (score >= 12) return 4;
|
||
if (score >= 8) return 3;
|
||
if (score >= 5) return 2;
|
||
return 1;
|
||
}
|
||
|
||
// ---------------- HOSTILITY / MOVEMENT ----------------
|
||
export function areHostile(a, b) {
|
||
if (!a || !b || a === b) return false;
|
||
if (a === "huangjin" || b === "huangjin") return true; // zealots war on all
|
||
return atWar(a, b);
|
||
}
|
||
|
||
export function enterCost() { return null; } // movement is one step per turn
|
||
|
||
export function isProvinceBarred(factionId, provLetter) {
|
||
// cannot enter provinces owned by allies/NAP partners (not at war)
|
||
const owners = new Set(G.provinces[provLetter].cities.map(id => CITY(id).owner).filter(Boolean));
|
||
for (const o of owners) {
|
||
if (o !== factionId && !areHostile(factionId, o)) {
|
||
if (o === "neutral") continue; // neutrals don't bar roads, only resist sieges
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------- BATTLE ORCHESTRATION ----------------
|
||
function terrainOfProv(provLetter) {
|
||
const hexesHere = G.provinces[provLetter];
|
||
const P = D.PROVINCES[provLetter];
|
||
if (P.mount > 0.8) return "mountain";
|
||
if (["n", "h", "g"].includes(provLetter)) return chance(0.4) ? "river" : "plains";
|
||
return chance(0.3) ? "hills" : "plains";
|
||
}
|
||
|
||
export function armiesInProv(provLetter, factionId = null) {
|
||
return Object.values(G.armies).filter(a => a.prov === provLetter && (!factionId || a.faction === factionId));
|
||
}
|
||
|
||
// Resolve an attacking army entering a province (player-invoked or AI-invoked)
|
||
export function resolveProvinceEntry(atkArmy, provLetter, opts = {}) {
|
||
const owners = [...new Set(G.provinces[provLetter].cities.map(id => CITY(id).owner).filter(o => o && o !== atkArmy.faction))];
|
||
const defenders = armiesInProv(provLetter, null).filter(a => areHostile(atkArmy.faction, a.faction));
|
||
|
||
if (defenders.length) {
|
||
const defArmy = defenders.sort((a, b) => totalTroops(b.troops) - totalTroops(a.troops))[0];
|
||
return runFieldBattle(atkArmy, defArmy, opts);
|
||
}
|
||
const city =
|
||
G.provinces[provLetter].cities.map(id => CITY(id))
|
||
.sort((a, b) => totalTroops(b.garrison) - totalTroops(a.garrison))
|
||
.find(c => c.owner && c.owner !== atkArmy.faction &&
|
||
(areHostile(atkArmy.faction, c.owner) || (c.owner === "neutral" && opts.attackNeutral)));
|
||
if (city) {
|
||
return assaultCity(atkArmy, city, opts);
|
||
}
|
||
return { moved: true };
|
||
}
|
||
|
||
export function runFieldBattle(atkArmy, defArmy, opts = {}) {
|
||
const terrain = terrainOfProv(atkArmy.prov);
|
||
const rec = simulateBattle({
|
||
atk: { gen: GEN(atkArmy.genId), troops: { ...atkArmy.troops }, formation: opts.formation || "balanced", stance: opts.stance || "assault", name: `${GEN(atkArmy.genId)?.name ?? "?"}'s host`, faction: atkArmy.faction },
|
||
def: { gen: GEN(defArmy.genId), troops: { ...defArmy.troops }, formation: defArmy.formation || pick(Object.keys(D.FORMATIONS)), stance: "balanced", name: `${GEN(defArmy.genId)?.name ?? "?"}'s host`, faction: defArmy.faction },
|
||
terrain,
|
||
});
|
||
rec.kind = "field"; rec.prov = atkArmy.prov;
|
||
rec.initialAtkTroops = { ...atkArmy.troops };
|
||
rec.initialDefTroops = { ...defArmy.troops };
|
||
rec.initialAtkTotal = totalTroops(atkArmy.troops);
|
||
rec.initialDefTotal = totalTroops(defArmy.troops);
|
||
return applyBattleRecord(rec, atkArmy, defArmy, null);
|
||
}
|
||
|
||
// helper wrapper so runSiege passes def properly
|
||
export function assaultCity(atkArmy, city, opts = {}) {
|
||
const walls0 = city.buildings.wall;
|
||
let wallBonus = walls0;
|
||
if (opts.infiltrated) wallBonus = 0;
|
||
const cmdr = GEN(atkArmy.genId);
|
||
if (cmdr?.traits.includes("engineer")) wallBonus = Math.max(0, wallBonus - 1);
|
||
const defGen = garrisonCommander(city);
|
||
const rec = simulateBattle({
|
||
atk: { gen: cmdr, troops: { ...atkArmy.troops }, formation: opts.formation || "balanced", stance: opts.stance || "assault", name: `${cmdr?.name ?? "?"}'s host`, faction: atkArmy.faction },
|
||
def: { gen: defGen, troops: { ...city.garrison }, formation: "turtle", stance: "defensive", name: `Garrison of ${city.name}`, faction: city.owner, walls: wallBonus },
|
||
terrain: "city",
|
||
});
|
||
rec.kind = "siege"; rec.city = city.id; rec.walls = walls0;
|
||
rec.initialAtkTroops = { ...atkArmy.troops };
|
||
rec.initialDefTroops = { ...city.garrison };
|
||
rec.initialAtkTotal = totalTroops(atkArmy.troops);
|
||
rec.initialDefTotal = totalTroops(city.garrison);
|
||
return applyBattleRecord(rec, atkArmy, null, city);
|
||
}
|
||
|
||
export function garrisonCommander(city) {
|
||
// best defending general located here, else null (militia captain)
|
||
const gens = factionGenerals(city.owner).filter(g => g.location === city.id && !g.isLeader);
|
||
const lead = factionGenerals(city.owner).find(g => g.location === city.id && g.isLeader);
|
||
const pool = [...gens, ...(lead ? [lead] : [])];
|
||
if (!pool.length) return null;
|
||
return pool.sort((a, b) => (b.st.ldr + b.st.war) - (a.st.ldr + a.st.war))[0];
|
||
}
|
||
|
||
export function applyBattleRecord(rec, atkArmy, defArmy, city) {
|
||
const atkFac = rec.meta.atkFaction, defFac = rec.meta.defFaction;
|
||
const winnerIsAtk = rec.winner === "atk";
|
||
const winnerFac = winnerIsAtk ? atkFac : defFac;
|
||
const loserFac = winnerIsAtk ? defFac : atkFac;
|
||
|
||
// apply troop losses
|
||
if (atkArmy) {
|
||
atkArmy.troops = rec.atkTroopsLeft;
|
||
atkArmy.morale = rec.rounds.at(-1)?.attMorale ?? atkArmy.morale;
|
||
atkArmy.moved = true;
|
||
}
|
||
if (defArmy) {
|
||
defArmy.troops = rec.defTroopsLeft;
|
||
defArmy.morale = rec.rounds.at(-1)?.defMorale ?? defArmy.morale;
|
||
}
|
||
if (city) {
|
||
city.garrison = winnerIsAtk ? {} : rec.defTroopsLeft;
|
||
}
|
||
|
||
// hero skill duels may kill
|
||
const duelKills = rec.eventsAll.filter(e => e.duelResult === "win" && e.effect?.kind === "duel");
|
||
|
||
// commander fates
|
||
const loserGen = winnerIsAtk ? (defArmy ? GEN(defArmy.genId) : city ? garrisonCommander(city) : null) : GEN(atkArmy.genId);
|
||
handlePostBattleFates(rec, loserGen, winnerFac);
|
||
|
||
// XP & morale for winners
|
||
const winGen = winnerIsAtk ? GEN(atkArmy.genId) : (defArmy ? GEN(defArmy.genId) : garrisonCommander(city));
|
||
if (winGen) { winGen.xp += 40 + Math.round(rec.defLost / 50); winGen.morale = clamp(winGen.morale + 8, 0, 100); winGen.renown += 2; }
|
||
const loseGenObj = loserGen;
|
||
if (loseGenObj && loseGenObj.alive) loseGenObj.morale = clamp(loseGenObj.morale - 12, 0, 100);
|
||
|
||
// stats
|
||
if (isPlayerFaction(winnerFac)) G.stats.battlesWon++;
|
||
if (isPlayerFaction(loserFac)) G.stats.battlesLost++;
|
||
|
||
// city capture
|
||
if (rec.kind === "siege" && winnerIsAtk && city) {
|
||
captureCity(city, atkFac);
|
||
rec.captured = city.id;
|
||
}
|
||
|
||
// cleanup dead armies
|
||
for (const a of [atkArmy, defArmy]) {
|
||
if (a && totalTroops(a.troops) <= 0) destroyArmy(a.id);
|
||
}
|
||
|
||
// records for replay & logs
|
||
rec.title = rec.kind === "siege"
|
||
? `Siege of ${CITY(rec.city)?.name ?? "?"}`
|
||
: `Battle of ${D.PROVINCES[rec.prov]?.name ?? "?"}`;
|
||
rec.playerInvolved = isPlayerFaction(atkFac) || isPlayerFaction(defFac);
|
||
G.replays.push(rec);
|
||
|
||
const atkName = F(atkFac)?.name ?? "?", defName = F(defFac)?.name ?? "?";
|
||
log(`${rec.title}: ${F(winnerFac)?.name} triumphed. ${F(atkFac)?.name} lost ${rec.atkLost}, ${F(defFac)?.name} lost ${rec.defLost}.`,
|
||
rec.playerInvolved ? (isPlayerFaction(winnerFac) ? "good" : "bad") : "war");
|
||
|
||
if (rec.captured) {
|
||
chronicle(`${CITY(rec.captured).name} falls to ${F(winnerFac)?.name}.`, isPlayerFaction(winnerFac) ? "epic" : "war");
|
||
if (isPlayerFaction(winnerFac)) { playerFaction().fame += 6; playerFaction().fear += 4; }
|
||
}
|
||
// war weariness memory
|
||
trustBump(atkFac, defFac, -6);
|
||
|
||
return rec;
|
||
}
|
||
|
||
function handlePostBattleFates(rec, loserGen, winnerFac) {
|
||
if (!loserGen || !loserGen.alive) return;
|
||
if (chance(rec.killChance * (loserGen.traits.includes("ironwill") ? 0.5 : 1))) {
|
||
killGeneral(loserGen, "fell in battle");
|
||
rec.genKilled = loserGen.id;
|
||
return;
|
||
}
|
||
if (chance(rec.captureChance)) {
|
||
captureGeneral(loserGen, winnerFac);
|
||
rec.genCaptured = loserGen.id;
|
||
} else {
|
||
loserGen.location = null; // fled into the countryside
|
||
loserGen.escaped = true;
|
||
}
|
||
}
|
||
|
||
export function killGeneral(gen, cause) {
|
||
if (!gen.alive) return;
|
||
gen.alive = false; gen.location = null;
|
||
G.stats.lostGenerals++;
|
||
const fac = F(gen.faction);
|
||
if (fac) fac.fame -= 1;
|
||
log(`${gen.name} ${cause}.`, gen.faction === G.playerFaction ? "bad" : "war");
|
||
// loyalty shock to friends/sworn
|
||
for (const other of Object.values(G.generals)) {
|
||
if (!other.alive) continue;
|
||
if ((other.sworn || []).includes(gen.id) || (other.family || []).includes(gen.id)) other.loyalty = clamp(other.loyalty - 12, 0, 100);
|
||
else if ((other.friends || []).includes(gen.id)) other.loyalty = clamp(other.loyalty - 6, 0, 100);
|
||
else if ((other.rivals || []).includes(gen.id)) other.loyalty = clamp(other.loyalty + 4, 0, 100);
|
||
}
|
||
if (gen.isLeader) handleRulerDeath(gen);
|
||
}
|
||
|
||
export function captureGeneral(gen, captorFaction) {
|
||
gen.location = null;
|
||
G.prisoners.push({ gen: gen.id, heldBy: captorFaction, since: `${G.year}-${G.month}` });
|
||
log(`${gen.name} was captured by ${F(captorFaction)?.name}.`, isPlayerFaction(captorFaction) ? "good" : isPlayerFaction(gen.faction) ? "bad" : "war");
|
||
}
|
||
|
||
export function handleRulerDeath(ruler) {
|
||
const fac = F(ruler.faction);
|
||
if (!fac) return;
|
||
const heir = fac.heir ? GEN(fac.heir) : null;
|
||
const lordOf = fac.name && fac.name !== ruler.name ? `, lord of ${fac.name}` : "";
|
||
chronicle(`${ruler.name}${lordOf} is dead.`, "epic");
|
||
if (heir && heir.alive && heir.age >= 15) {
|
||
fac.leader = heir.id; heir.isLeader = true;
|
||
heir.location = fac.capital;
|
||
log(`${heir.name} inherits leadership of ${fac.name}.`, "war");
|
||
// loyalty shake
|
||
for (const g of factionGenerals(fac.id)) g.loyalty = clamp(g.loyalty - randInt(3, 12), 0, 100);
|
||
if (fac.isPlayer) G.flags.succession = true;
|
||
return;
|
||
}
|
||
// no designated heir: strongest surviving general takes the banner
|
||
const candidates = factionGenerals(fac.id).filter(g => g.alive && g.id !== ruler.id);
|
||
if (candidates.length) {
|
||
const succ = candidates.sort((a, b) => (b.loyalty + b.st.ldr / 2 + b.st.war / 3) - (a.loyalty + a.st.ldr / 2 + a.st.war / 3))[0];
|
||
fac.leader = succ.id; succ.isLeader = true;
|
||
succ.location = fac.capital || succ.location;
|
||
log(`${succ.name} seizes command of ${fac.name} amid the mourning.`, "war");
|
||
for (const g of factionGenerals(fac.id)) if (g.id !== succ.id) g.loyalty = clamp(g.loyalty - randInt(4, 14), 0, 100);
|
||
if (fac.isPlayer) G.flags.succession = true;
|
||
} else {
|
||
collapseFaction(fac.id, "without heir");
|
||
}
|
||
}
|
||
|
||
export function destroyArmy(id) {
|
||
const a = G.armies[id];
|
||
if (!a) return;
|
||
delete G.armies[id];
|
||
}
|
||
|
||
export function createArmy(factionId, genId, provLetter, troops, name) {
|
||
const id = uid("army");
|
||
G.armies[id] = { id, name: name || `${GEN(genId)?.name}'s Army`, faction: factionId, genId, prov: provLetter, troops, morale: 75, moved: false, formation: "balanced" };
|
||
if (GEN(genId)) GEN(genId).location = id;
|
||
return G.armies[id];
|
||
}
|
||
|
||
// ---------------- CITY CAPTURE ----------------
|
||
export function captureCity(city, newOwner) {
|
||
const old = city.owner;
|
||
assignCity(city.id, newOwner);
|
||
// loot
|
||
const loot = Math.round(200 + city.pop * rand(0.3, 1.2) * 0.5);
|
||
if (F(newOwner)) F(newOwner).gold += loot;
|
||
// prisoners among located generals
|
||
for (const g of factionGenerals(old)) {
|
||
if (g.location === city.id) {
|
||
if (chance(0.6)) captureGeneral(g, newOwner);
|
||
else { g.location = null; g.escaped = true; }
|
||
}
|
||
}
|
||
if (F(old)?.capital === city.id) {
|
||
const remaining = F(old).cities;
|
||
F(old).capital = remaining[0] || null;
|
||
if (remaining[0]) G.cities[remaining[0]].capitalOf = old;
|
||
log(`The capital of ${F(old)?.name} has fallen!`, "war");
|
||
panic(old, 12);
|
||
}
|
||
// cruelty effects
|
||
const cruel = factionGenerals(newOwner).some(g => g.traits.includes("cruel"));
|
||
if (cruel) { city.pop = Math.round(city.pop * 0.92); playerOrLog(newOwner, () => { playerFaction().fear += 3; playerFaction().honor -= 2; }); }
|
||
panic(old, 8);
|
||
checkFactionDeath(old);
|
||
}
|
||
|
||
function playerOrLog(fid, fn) { if (isPlayerFaction(fid)) fn(); }
|
||
|
||
function panic(fid, amt) {
|
||
if (!F(fid)) return;
|
||
for (const g of factionGenerals(fid)) g.loyalty = clamp(g.loyalty - amt, 0, 100);
|
||
F(fid).legitimacy = clamp(F(fid).legitimacy - 3, 0, 100);
|
||
}
|
||
|
||
export function collapseFaction(fid, reason) {
|
||
const fac = F(fid);
|
||
if (!fac || !fac.alive) return;
|
||
fac.alive = false;
|
||
for (const cid of [...fac.cities]) {
|
||
const c = CITY(cid);
|
||
// cities go independent-ish: nearest neutral handling -> become 'neutral'
|
||
assignCity(cid, "neutral");
|
||
c.garrison = { spear: Math.round(c.pop / 5) };
|
||
}
|
||
for (const g of factionGenerals(fid)) {
|
||
if (g.isLeader) killGeneral(g, `perished as ${fac.name} collapsed (${reason})`);
|
||
else { g.freeAgent = true; g.faction = "neutral"; g.location = null; g.loyalty = 50; }
|
||
}
|
||
for (const a of factionArmies(fid)) destroyArmy(a.id);
|
||
fac.cities = [];
|
||
// remove from wars
|
||
G.wars = G.wars.filter(k => !k.split("|").includes(fid));
|
||
chronicle(`${fac.name} is no more — its lands scatter ${reason}.`, "epic");
|
||
}
|
||
|
||
export function checkFactionDeath(fid) {
|
||
const fac = F(fid);
|
||
if (!fac || !fac.alive) return;
|
||
if (fid === "neutral") return;
|
||
if (!fac.cities.length) collapseFaction(fid, "with nothing left");
|
||
}
|
||
|
||
// ---------------- TURN PROCESSING ----------------
|
||
export function endTurn() {
|
||
G.replays = [];
|
||
G.turnCount++;
|
||
advanceDate();
|
||
|
||
processEconomyAll();
|
||
processArmiesSupply();
|
||
aiAllFactions();
|
||
processSieges();
|
||
rollEvents(); // queues player-facing events + applies world events
|
||
processGeneralsMonth();
|
||
checkVictory();
|
||
autosave();
|
||
return G.replays;
|
||
}
|
||
|
||
function advanceDate() {
|
||
G.month++;
|
||
if (G.month > 12) { G.month = 1; G.year++; ageGenerals(); }
|
||
if (G.mode === "challenge") G.challengeDaysLeft--;
|
||
}
|
||
|
||
function ageGenerals() {
|
||
for (const g of Object.values(G.generals)) {
|
||
if (!g.alive) continue;
|
||
g.age++;
|
||
if (g.age >= 60 && chance((g.age - 58) * 0.03)) {
|
||
killGeneral(g, "died of age and old wounds");
|
||
continue;
|
||
}
|
||
if (g.age >= 50) g.st.war = Math.max(40, g.st.war - 1);
|
||
if (g.age <= 30 && chance(0.3)) g.st.war = Math.min(99, g.st.war + 1);
|
||
}
|
||
}
|
||
|
||
function processEconomyAll() {
|
||
for (const fac of Object.values(G.factions)) {
|
||
if (!fac.alive) continue;
|
||
const diffMul = fac.isPlayer ? D.DIFFICULTIES[G.difficulty].playerTax : D.DIFFICULTIES[G.difficulty].aiIncome;
|
||
let goldIn = 0, foodIn = 0;
|
||
for (const cid of fac.cities) {
|
||
const c = CITY(cid);
|
||
goldIn += cityTax(c) * diffMul;
|
||
foodIn += cityFoodDelta(c);
|
||
// growth
|
||
c.pop = Math.max(20, Math.round(c.pop * (1 + (c.order > 50 ? 0.004 : 0.001) - (c.unrest > 40 ? 0.004 : 0))));
|
||
c.dev = clamp(c.dev + 0.4 + Object.values(c.buildings).reduce((s, v) => s + v, 0) * 0.08, 0, 100);
|
||
// unrest decays
|
||
c.unrest = Math.max(0, c.unrest - 2.5 - (factionGenerals(fac.id).some(g => g.location === cid && g.traits.includes("kind")) ? 1.5 : 0));
|
||
c.order = clamp(c.order + (c.unrest > 0 ? -1 : 1.2) + (c.buildings.academy > 0 ? 0.3 : 0), 5, 100);
|
||
// levies regenerate
|
||
const cap = Math.round(c.pop / 2.5);
|
||
c.levies = Math.min(cap, c.levies + Math.max(30, Math.round(c.pop * 0.028 * orderFactor(c))));
|
||
// garrison upkeep
|
||
const gu = garrisonUpkeep(c);
|
||
goldIn -= gu.gold; foodIn -= gu.food;
|
||
}
|
||
// army upkeep
|
||
for (const a of factionArmies(fac.id)) {
|
||
const au = armyUpkeep(a);
|
||
goldIn -= au.gold; foodIn -= au.food;
|
||
}
|
||
// salaries quarterly
|
||
if (salariesDue(fac)) {
|
||
const s = factionGenerals(fac.id).reduce((sum, g) => sum + g.salary, 0);
|
||
goldIn -= s;
|
||
fac.flagsLastSalary = s;
|
||
if (goldIn + fac.gold < 0) {
|
||
// unpaid!
|
||
for (const g of factionGenerals(fac.id)) if (!g.isLeader) g.loyalty = clamp(g.loyalty - 10, 0, 100);
|
||
if (fac.isPlayer) G.flags.unpaid = true;
|
||
}
|
||
}
|
||
goldIn += 60; // base administration
|
||
fac.gold = Math.max(0, Math.round(fac.gold + goldIn));
|
||
fac.food = Math.max(0, Math.round(fac.food + foodIn));
|
||
fac.lastIncome = Math.round(goldIn);
|
||
fac.lastFood = Math.round(foodIn);
|
||
if (fac.food <= 0) famine(fac.id);
|
||
|
||
// legitimacy drift
|
||
let leg = 0;
|
||
leg += rankLevel(fac.id) * 0.3;
|
||
leg += fac.honor > 60 ? 0.4 : fac.honor < 35 ? -0.4 : 0;
|
||
leg -= fac.corruption / 25;
|
||
if (G.flags.emperorProtectedBy === fac.id) leg += 1.2;
|
||
if (fac.id === G.flags.emperorClaimedBy) leg += 1.0;
|
||
fac.legitimacy = clamp(Math.round(fac.legitimacy + leg), 0, 100);
|
||
fac.fame = clamp(fac.fame, 0, 999);
|
||
fac.corruption = clamp(fac.corruption + (fac.gold > 6000 ? 0.4 : -0.2), 0, 100);
|
||
}
|
||
}
|
||
|
||
function famine(fid) {
|
||
const fac = F(fid);
|
||
for (const a of factionArmies(fid)) {
|
||
attritionArmy(a, 0.08);
|
||
a.morale = clamp(a.morale - 6, 0, 100);
|
||
}
|
||
for (const cid of fac.cities) {
|
||
const c = CITY(cid);
|
||
c.order = clamp(c.order - 5, 5, 100);
|
||
c.pop = Math.round(c.pop * 0.985);
|
||
}
|
||
if (isPlayerFaction(fid)) log("Famine gnaws at your realm — granaries are empty!", "bad");
|
||
}
|
||
|
||
function attritionArmy(a, pct) {
|
||
for (const t of Object.keys(a.troops)) a.troops[t] = Math.max(0, Math.round(a.troops[t] * (1 - pct)));
|
||
if (totalTroops(a.troops) <= 0) destroyArmy(a.id);
|
||
}
|
||
|
||
function processArmiesSupply() {
|
||
for (const a of Object.values(G.armies)) {
|
||
if (!a) continue;
|
||
a.moved = false;
|
||
const fac = F(a.faction);
|
||
if (fac && fac.food <= 300 && chance(0.5)) attritionArmy(a, 0.04);
|
||
}
|
||
}
|
||
|
||
function processSieges() {
|
||
for (const c of Object.values(G.cities)) {
|
||
if (!c.siegedBy) continue;
|
||
const army = G.armies[c.siegedBy];
|
||
if (!army || army.prov !== c.prov || !areHostile(army.faction, c.owner)) { c.siegedBy = null; continue; }
|
||
// starvation progress
|
||
c.siegeProgress = (c.siegeProgress || 0) + 1;
|
||
const defenders = totalTroops(c.garrison);
|
||
for (const t of Object.keys(c.garrison)) c.garrison[t] = Math.max(0, Math.round(c.garrison[t] * 0.94));
|
||
c.order = clamp(c.order - 3, 5, 100);
|
||
if (c.siegeProgress >= 3 && (totalTroops(c.garrison) < defenders * 0.85 || chance(0.3))) {
|
||
// surrender
|
||
const defOwner = c.owner;
|
||
const cmdr = garrisonCommander(c);
|
||
if (cmdr) captureGeneral(cmdr, army.faction);
|
||
captureCity(c, army.faction);
|
||
c.siegedBy = null; c.siegeProgress = 0;
|
||
log(`${c.name} starves into surrender.`, isPlayerFaction(army.faction) ? "good" : "war");
|
||
G.replays.push({ kind: "surrender", title: `${c.name} surrenders`, city: c.id, winner: "atk", meta: { atkFaction: army.faction, defFaction: defOwner }, rounds: [], eventsAll: [], playerInvolved: isPlayerFaction(army.faction), captured: c.id });
|
||
}
|
||
}
|
||
}
|
||
|
||
function processGeneralsMonth() {
|
||
for (const g of Object.values(G.generals)) {
|
||
if (!g.alive || g.isLeader || g.freeAgent || g.hidden) continue;
|
||
const fac = F(g.faction);
|
||
if (!fac || !fac.alive) continue;
|
||
const leader = fac.leader ? GEN(fac.leader) : null;
|
||
let desired = 55;
|
||
desired += (leader?.st.cha ?? 50) / 8;
|
||
desired += Math.min(10, fac.fame / 8);
|
||
desired += fac.gold > 2500 ? 4 : fac.gold < 500 ? -8 : 0;
|
||
if (g.traits.includes("loyalheart")) desired += 22;
|
||
if (g.traits.includes("ambitious")) desired -= 12;
|
||
if (g.traits.includes("greedy")) desired -= 4;
|
||
if (fac.legitimacy < 30) desired -= 6;
|
||
if ((g.rivals || []).some(r => GEN(r)?.alive && GEN(r)?.faction === g.faction && GEN(r).location === g.location)) desired -= 4;
|
||
// promotion glow fades
|
||
if (g.promotedTurn != null) desired += Math.max(0, 14 - (G.turnCount - g.promotedTurn));
|
||
if (g.victories) { desired += Math.min(8, g.victories * 2); }
|
||
g.loyalty = clamp(Math.round(g.loyalty + (desired - g.loyalty) * 0.12 + randInt(-2, 2)), 0, 100);
|
||
g.victories = Math.max(0, (g.victories || 0) - 0.1);
|
||
|
||
// defection
|
||
if (g.loyalty < 22 && chance(0.12) && !g.isLeader) {
|
||
attemptDefection(g);
|
||
}
|
||
// wounds heal
|
||
if (g.wounded > 0) { g.wounded--; if (g.wounded === 0) log(`${g.name} has recovered from his wounds.`, "info"); }
|
||
}
|
||
// prisoners escape chance
|
||
for (const p of [...G.prisoners]) {
|
||
const gen = GEN(p.gen);
|
||
if (!gen || !gen.alive) { G.prisoners = G.prisoners.filter(x => x !== p); continue; }
|
||
if (chance(0.04)) {
|
||
G.prisoners = G.prisoners.filter(x => x !== p);
|
||
gen.freeAgent = true; gen.faction = "neutral"; gen.location = null;
|
||
log(`${gen.name} has escaped captivity!`, "war");
|
||
}
|
||
}
|
||
}
|
||
|
||
function attemptDefection(g) {
|
||
const fac = F(g.faction);
|
||
// find richest neighboring faction
|
||
const curProvs = new Set(fac.cities.map(id => CITY(id).prov));
|
||
const neighFacs = new Set();
|
||
for (const p of curProvs) for (const n of provNeighbors(p)) {
|
||
for (const cid of G.provinces[n].cities) {
|
||
const o = CITY(cid).owner;
|
||
if (o && o !== g.faction && o !== "neutral") neighFacs.add(o);
|
||
}
|
||
}
|
||
const options = [...neighFacs].map(id => ({ id, w: F(id).gold + F(id).cities.length * 500 })).sort((a, b) => b.w - a.w);
|
||
const dest = options.length ? options[0].id : null;
|
||
if (!dest) return;
|
||
if (isPlayerFaction(g.faction)) {
|
||
G.pendingEvents.push(makeEvent({
|
||
kind: "court", art: "🗡",
|
||
title: `${g.name} demands to leave your service!`,
|
||
text: `${g.name} (Loyalty ${Math.round(g.loyalty)}) no longer believes in your cause.\nWhispers say he has received letters from ${F(dest)?.name}...`,
|
||
choices: [
|
||
{ label: "Double his salary (−600 gold)", hint: "Costly, but loyalty is priceless", effect: { fn: "defect_bribe", gen: g.id, cost: 600, boost: 30 } },
|
||
{ label: "Grant him a title", hint: "+Loyalty, +his ambition satisfied", effect: { fn: "defect_promote", gen: g.id } },
|
||
{ label: "Let him go", hint: "He joins another banner", effect: { fn: "defect_release", gen: g.id, dest } },
|
||
{ label: "Imprison him", hint: "−Honor, he is removed from play (prisoner)", effect: { fn: "defect_imprison", gen: g.id } },
|
||
],
|
||
}));
|
||
} else if (chance(0.7)) {
|
||
transferGeneral(g, dest);
|
||
log(`${g.name} defects from ${fac.name} to ${F(dest).name}.`, "war");
|
||
}
|
||
}
|
||
|
||
export function transferGeneral(g, newFac) {
|
||
const old = g.faction;
|
||
g.faction = newFac;
|
||
g.loyalty = 55;
|
||
const cap = F(newFac)?.capital;
|
||
g.location = cap || F(newFac)?.cities?.[0] || null;
|
||
if (isPlayerFaction(old)) G.stats.lostGenerals++;
|
||
if (isPlayerFaction(newFac)) G.stats.recruited++;
|
||
if (old && F(old)) {
|
||
// grudge!
|
||
if (g.sworn) { /* sworn brothers follow their heart, not logic */ }
|
||
}
|
||
}
|
||
|
||
export function makeEvent({ kind, title, text, art, choices }) {
|
||
return { id: uid("ev"), kind, title, text, art: art || "📜", choices };
|
||
}
|
||
|
||
// ---------------- RANKS ----------------
|
||
export function rankLevel(fid) {
|
||
const fac = F(fid);
|
||
if (!fac) return 0;
|
||
const n = fac.cities.length;
|
||
if (fac.rank === "Emperor" || G.flags.emperorClaimedBy === fid) return 5;
|
||
if (n >= 14 && fac.legitimacy >= 70) return 4;
|
||
if (n >= 10 && fac.legitimacy >= 60) return 3;
|
||
if (n >= 6) return 2;
|
||
if (n >= 3) return 1;
|
||
return 0;
|
||
}
|
||
export const RANK_NAMES = ["Governor", "Inspector", "General", "Duke", "King", "Emperor"];
|
||
export function updateRanks() {
|
||
for (const fac of Object.values(G.factions)) {
|
||
if (!fac.alive || fac.id === "neutral") continue;
|
||
const lvl = Math.min(rankLevel(fac.id), 4);
|
||
if (RANK_NAMES[lvl] !== fac.rank) {
|
||
fac.rank = RANK_NAMES[lvl];
|
||
if (fac.isPlayer) { chronicle(`You are raised to ${fac.rank}.`, "epic"); playerFaction().fame += 5; }
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------- PLAYER ACTIONS ----------------
|
||
export function recruitToGarrison(city, type, count) {
|
||
const fac = F(city.owner);
|
||
const ut = D.UNIT_TYPES[type];
|
||
const discount = fac.origin === "rebel" ? 0.5 : fac.origin === "soldier" ? 0.8 : 1;
|
||
const cost = Math.round(count / 100 * ut.cost * discount);
|
||
const maxByLevies = city.levies;
|
||
const n = Math.min(count, maxByLevies);
|
||
if (fac.gold < cost || n <= 0) return { ok: false, why: fac.gold < cost ? "Not enough gold" : "Not enough available levies" };
|
||
fac.gold -= cost;
|
||
city.levies -= n;
|
||
city.garrison[type] = (city.garrison[type] || 0) + n;
|
||
return { ok: true, cost, n };
|
||
}
|
||
|
||
export function raiseArmyFromCity(city, genId, troops, name) {
|
||
const fac = F(city.owner);
|
||
// verify availability
|
||
let cost = 0;
|
||
for (const [t, n] of Object.entries(troops)) {
|
||
if ((city.garrison[t] || 0) < n) return { ok: false, why: `Not enough ${D.UNIT_TYPES[t].name} in garrison` };
|
||
cost += n / 100 * D.UNIT_TYPES[t].cost * 0.1; // mustering fee
|
||
}
|
||
cost = Math.round(cost);
|
||
if (fac.gold < cost) return { ok: false, why: `Mustering costs ${cost} gold` };
|
||
if (!genId) return { ok: false, why: "Choose a commander" };
|
||
const gen = GEN(genId);
|
||
if (!gen || gen.location !== city.id) return { ok: false, why: "Commander not in this city" };
|
||
if (Object.values(G.armies).some(a => a.genId === genId)) return { ok: false, why: "Commander already leads an army" };
|
||
fac.gold -= cost;
|
||
for (const [t, n] of Object.entries(troops)) {
|
||
city.garrison[t] -= n;
|
||
if (city.garrison[t] <= 0) delete city.garrison[t];
|
||
}
|
||
const army = createArmy(city.owner, genId, city.prov, troops, name || `${gen.name}'s Army`);
|
||
gen.victories = gen.victories || 0;
|
||
return { ok: true, army };
|
||
}
|
||
|
||
export function disbandArmy(armyId) {
|
||
const a = G.armies[armyId];
|
||
if (!a) return;
|
||
// return survivors to nearest own city garrison
|
||
const home = G.provinces[a.prov].cities.map(id => CITY(id)).find(c => c.owner === a.faction);
|
||
if (home) {
|
||
for (const [t, n] of Object.entries(a.troops)) home.garrison[t] = (home.garrison[t] || 0) + Math.round(n * 0.9);
|
||
}
|
||
const gen = GEN(a.genId);
|
||
if (gen) { gen.location = home?.id || null; gen.morale = clamp(gen.morale - 5, 0, 100); }
|
||
destroyArmy(armyId);
|
||
}
|
||
|
||
export function mergeArmies(ids) {
|
||
const armies = ids.map(id => G.armies[id]).filter(Boolean);
|
||
if (armies.length < 2) return;
|
||
armies.sort((a, b) => totalTroops(b.troops) - totalTroops(a.troops));
|
||
const main = armies[0];
|
||
for (const other of armies.slice(1)) {
|
||
for (const [t, n] of Object.entries(other.troops)) main.troops[t] = (main.troops[t] || 0) + n;
|
||
const g = GEN(other.genId);
|
||
if (g) { g.location = G.provinces[main.prov].cities.find(id => CITY(id).owner === main.faction) || g.location; g.morale = clamp(g.morale - 3, 0, 100); }
|
||
destroyArmy(other.id);
|
||
}
|
||
main.morale = clamp(main.morale - 5, 0, 100);
|
||
}
|
||
|
||
export function moveArmy(army, targetProv, opts = {}) {
|
||
if (army.moved) return { ok: false, why: "This army has already marched this month." };
|
||
if (!adjacentProvinces(army.prov, targetProv)) return { ok: false, why: "Too far — provinces must be adjacent." };
|
||
if (isProvinceBarred(army.faction, targetProv)) return { ok: false, why: "You cannot march into the lands of those not at war with you." };
|
||
const hostileThere = Object.values(G.armies).some(a => a.prov === targetProv && areHostile(army.faction, a.faction))
|
||
|| G.provinces[targetProv].cities.some(id => { const o = CITY(id).owner; return o && o !== army.faction && (areHostile(army.faction, o) || (o === "neutral" && opts.attackNeutral)); });
|
||
army.prov = targetProv;
|
||
army.moved = true;
|
||
if (hostileThere) {
|
||
return { ok: true, battle: true, result: resolveProvinceEntry(army, targetProv, opts) };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
// ---------------- VICTORY ----------------
|
||
export function checkVictory() {
|
||
updateRanks();
|
||
if (G.gameOver) return;
|
||
// challenge mode
|
||
if (G.mode === "challenge") {
|
||
if (G.challengeDaysLeft <= 0) { G.gameOver = true; G.victory = "challenge_survived"; return; }
|
||
if (!playerFaction().alive) { G.gameOver = true; G.victory = "dead"; return; }
|
||
return;
|
||
}
|
||
const pf = playerFaction();
|
||
if (!pf.alive) { G.gameOver = true; G.victory = "dead"; return; }
|
||
const others = Object.values(G.factions).filter(f => f.alive && f.id !== "neutral" && f.id !== G.playerFaction);
|
||
if (pf.cities.length >= 20) { G.gameOver = true; G.victory = "conquest"; return; }
|
||
if (!others.length) { G.gameOver = true; G.victory = "dominion"; return; }
|
||
if (pf.cities.includes("changan") && pf.cities.includes("luoyang") && pf.legitimacy >= 80 && rankLevel(pf.id) >= 4) {
|
||
G.gameOver = true; G.victory = "emperor"; return;
|
||
}
|
||
}
|
||
|
||
export function proclaimEmperor() {
|
||
const pf = playerFaction();
|
||
pf.rank = "Emperor";
|
||
G.flags.emperorClaimedBy = pf.id;
|
||
chronicle(`${pf.leader ? GEN(pf.leader).name : "The ruler"} proclaims a new dynasty! Heaven trembles.`, "epic");
|
||
// everyone hates you
|
||
for (const f of Object.values(G.factions)) {
|
||
if (f.alive && f.id !== pf.id && f.id !== "neutral") {
|
||
trustBump(pf.id, f.id, -30);
|
||
if (chance(0.65)) { declareWar(f.id, pf.id, "usurpation"); log(`${f.name} declares war on the pretender!`, "bad"); }
|
||
}
|
||
}
|
||
pf.legitimacy = clamp(pf.legitimacy + 15, 0, 100);
|
||
pf.fear += 15;
|
||
}
|
||
|
||
export function protectEmperor() {
|
||
G.flags.emperorProtectedBy = G.playerFaction;
|
||
const pf = playerFaction();
|
||
chronicle(`${pf.name} takes the Han Emperor under its protection.`, "epic");
|
||
pf.legitimacy = clamp(pf.legitimacy + 12, 0, 100);
|
||
pf.fame += 8;
|
||
for (const f of Object.values(G.factions)) if (f.alive && f.id !== pf.id && f.id !== "neutral") trustBump(pf.id, f.id, 6);
|
||
}
|
||
|
||
// ---------------- AI ----------------
|
||
export function aiAllFactions() {
|
||
const order = shuffle(Object.values(G.factions).filter(f => f.alive && !f.isPlayer && f.id !== "neutral"));
|
||
for (const fac of order) aiFaction(fac);
|
||
}
|
||
|
||
function personalityParams(fac) {
|
||
const base = { hawk: { aggr: 1.25, expand: 0.8, build: 0.5, diplo: 0.3, pactBreak: 0.05 },
|
||
diplomat: { aggr: 0.7, expand: 0.4, build: 0.6, diplo: 0.8, pactBreak: 0.01 },
|
||
turtle: { aggr: 0.5, expand: 0.25, build: 0.9, diplo: 0.5, pactBreak: 0.0 },
|
||
merchant: { aggr: 0.55, expand: 0.35, build: 0.95, diplo: 0.7, pactBreak: 0.02 },
|
||
opportunist: { aggr: 1.0, expand: 0.7, build: 0.55, diplo: 0.4, pactBreak: 0.2 },
|
||
fanatic: { aggr: 1.45, expand: 0.9, build: 0.3, diplo: 0.05, pactBreak: 0.4 } }[fac.personality] || {};
|
||
const dm = D.DIFFICULTIES[G.difficulty];
|
||
return { ...base, aggr: base.aggr * dm.aiAggro, expand: base.expand * dm.aiAggro };
|
||
}
|
||
|
||
function aiFaction(fac) {
|
||
const P = personalityParams(fac);
|
||
// 1) develop
|
||
if (fac.cities.length && chance(P.build)) {
|
||
const c = CITY(pick(fac.cities));
|
||
aiBuild(c, fac);
|
||
}
|
||
// 2) recruit garrisons
|
||
for (const cid of fac.cities) {
|
||
const c = CITY(cid);
|
||
const wantSpears = Math.min(c.levies, 500);
|
||
if (wantSpears >= 100 && fac.gold > 700 && chance(0.6)) {
|
||
const res = recruitToGarrison(c, "spear", wantSpears);
|
||
if (res.ok && fac.gold > 900 && c.levies > 200) recruitToGarrison(c, "bow", Math.min(c.levies, 250));
|
||
if (res.ok && fac.gold > 1600 && c.levies > 300) recruitToGarrison(c, "cav", Math.min(c.levies, 200));
|
||
}
|
||
}
|
||
// 3) raise armies
|
||
const myArmies = factionArmies(fac.id);
|
||
const maxArmies = Math.max(1, Math.floor(fac.cities.length * 0.8));
|
||
if (myArmies.length < maxArmies) {
|
||
const idleGens = factionGenerals(fac.id).filter(g => !g.isLeader && !g.hidden && (typeof g.location === "string" && G.cities[g.location]?.owner === fac.id) && !Object.values(G.armies).some(a => a.genId === g.id));
|
||
for (const gen of idleGens) {
|
||
const city = CITY(gen.location);
|
||
if (!city || city.owner !== fac.id) continue;
|
||
const total = totalTroops(city.garrison);
|
||
if (total >= 900 && fac.gold > 700) {
|
||
const troops = {};
|
||
let remain = Math.floor(total * (P.aggr > 1 ? 0.8 : 0.65));
|
||
for (const t of ["hcav", "cav", "xb", "bow", "sword", "spear"]) {
|
||
const avail = city.garrison[t] || 0;
|
||
const take = Math.min(avail, remain);
|
||
if (take > 0) { troops[t] = take; remain -= take; }
|
||
}
|
||
if (totalTroops(troops) >= 500) {
|
||
const r = raiseArmyFromCity(city, gen.id, troops);
|
||
if (r.ok) break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 4) army moves
|
||
for (const army of factionArmies(fac.id)) {
|
||
if (army.moved || totalTroops(army.troops) < 200) continue;
|
||
aiMoveArmy(army, fac, P);
|
||
}
|
||
// 5) diplomacy
|
||
if (chance(P.diplo)) aiDiplomacy(fac, P);
|
||
// 6) seek peace if losing
|
||
aiSeekPeace(fac);
|
||
// 7) huangjin raid behavior
|
||
if (fac.id === "huangjin") aiRaid(fac);
|
||
}
|
||
|
||
function aiBuild(c, fac) {
|
||
const prefs = fac.id === "huangjin" ? ["barracks"] :
|
||
["farm", "market", "farm", "wall", "market", "barracks", "academy"];
|
||
for (const key of shuffle(prefs)) {
|
||
const bd = D.BUILDINGS[key];
|
||
const lv = c.buildings[key];
|
||
if (lv >= bd.maxLv) continue;
|
||
const cost = bd.cost(lv);
|
||
if (fac.gold > cost + 500) {
|
||
fac.gold -= cost;
|
||
c.buildings[key] = lv + 1;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function aiMoveArmy(army, fac, P) {
|
||
const cur = army.prov;
|
||
const neighbors = provNeighbors(cur);
|
||
// defense: enemy army threatening own nearby city?
|
||
for (const n of neighbors) {
|
||
const threats = armiesInProv(n).filter(a => areHostile(fac.id, a.faction));
|
||
const myCitiesNear = G.provinces[cur].cities.some(id => CITY(id).owner === fac.id);
|
||
if (threats.length && myCitiesNear) {
|
||
const threatPow = threats.reduce((s, a) => s + armyPower(a.troops, GEN(a.genId)), 0);
|
||
const myPow = armyPower(army.troops, GEN(army.genId));
|
||
if (myPow > threatPow * 0.8) { doAiMove(army, n); return; }
|
||
}
|
||
}
|
||
// attack: weakest adjacent hostile province
|
||
let bestTarget = null, bestScore = 0;
|
||
for (const n of neighbors) {
|
||
const cityIds = G.provinces[n].cities;
|
||
const enemyCity = cityIds.map(id => CITY(id)).find(c => c.owner && c.owner !== fac.id && (areHostile(fac.id, c.owner) || c.owner === "neutral"));
|
||
if (!enemyCity) continue;
|
||
if (enemyCity.owner !== "neutral" && !areHostile(fac.id, enemyCity.owner)) continue;
|
||
if (enemyCity.owner !== "neutral" && enemyCity.owner !== "huangjin") {
|
||
// respect NAP mostly
|
||
const k = warKey(fac.id, enemyCity.owner);
|
||
if (G.naps.includes(k) && !(fac.personality === "opportunist" && getTrust(fac.id, enemyCity.owner) < -20)) continue;
|
||
}
|
||
const defPow = totalTroops(enemyCity.garrison) * (1 + enemyCity.buildings.wall * 0.25) +
|
||
armiesInProv(n).filter(a => areHostile(fac.id, a.faction) || a.faction === "neutral").reduce((s, a) => s + armyPower(a.troops, GEN(a.genId)), 0);
|
||
const myPow = armyPower(army.troops, GEN(army.genId)) * P.aggr;
|
||
if (myPow > defPow * 1.25) {
|
||
const score = myPow / (defPow + 100) * (enemyCity.owner === "neutral" ? 1.3 : 1) * (enemyCity.capitalOf ? 1.4 : 1);
|
||
if (score > bestScore) { bestScore = score; bestTarget = n; }
|
||
}
|
||
}
|
||
if (bestTarget) { doAiMove(army, bestTarget); return; }
|
||
// consolidate toward frontier / capital rotation
|
||
const ownBorderProvs = new Set();
|
||
for (const cid of fac.cities) for (const n of provNeighbors(CITY(cid).prov)) {
|
||
if (G.provinces[n].cities.some(id => { const o = CITY(id).owner; return o && o !== fac.id; })) ownBorderProvs.add(n);
|
||
}
|
||
const reachableOwn = [...ownBorderProvs].filter(p => adjacentProvinces(cur, p) && !isProvinceBarred(fac.id, p));
|
||
if (reachableOwn.length && chance(0.5)) doAiMove(army, pick(reachableOwn));
|
||
}
|
||
|
||
function doAiMove(army, target) {
|
||
army.prov = target;
|
||
army.moved = true;
|
||
// trigger combat if needed
|
||
const defenders = armiesInProv(target).filter(a => areHostile(army.faction, a.faction));
|
||
if (defenders.length) {
|
||
const defArmy = defenders.sort((a, b) => totalTroops(b.troops) - totalTroops(a.troops))[0];
|
||
runFieldBattle(army, defArmy, {});
|
||
return;
|
||
}
|
||
const enemyCity = G.provinces[target].cities.map(id => CITY(id)).find(c => c.owner && c.owner !== army.faction && (areHostile(army.faction, c.owner) || c.owner === "neutral"));
|
||
if (enemyCity && totalTroops(enemyCity.garrison) > 0) {
|
||
assaultCity(army, enemyCity, {});
|
||
} else if (enemyCity) {
|
||
captureCity(enemyCity, army.faction);
|
||
}
|
||
}
|
||
|
||
function aiDiplomacy(fac, P) {
|
||
const others = Object.values(G.factions).filter(f => f.alive && f.id !== fac.id && f.id !== "neutral");
|
||
if (!others.length) return;
|
||
const target = pick(others);
|
||
const trust = getTrust(fac.id, target.id);
|
||
// alliance against the biggest power
|
||
const biggest = others.sort((a, b) => b.cities.length - a.cities.length)[0];
|
||
if (biggest.id !== fac.id && biggest.cities.length > fac.cities.length * 1.6 && !alliedWith(fac.id, target.id) && chance(0.4)) {
|
||
G.alliances.push(warKey(fac.id, target.id));
|
||
trustBump(fac.id, target.id, 15);
|
||
log(`${fac.name} and ${target.name} form an alliance.`, "war");
|
||
return;
|
||
}
|
||
if (trust < -10 && chance(0.4) && !atWar(fac.id, target.id)) {
|
||
// bribe
|
||
if (fac.gold > 800) { fac.gold -= 300; target.gold += 300; trustBump(fac.id, target.id, 12); }
|
||
return;
|
||
}
|
||
// declare war on weakest neighbor
|
||
if (chance(P.aggr * 0.25)) {
|
||
const neighbors = new Set();
|
||
for (const cid of fac.cities) for (const n of provNeighbors(CITY(cid).prov)) {
|
||
for (const c2 of G.provinces[n].cities) { const o = CITY(c2).owner; if (o && o !== fac.id && o !== "neutral") neighbors.add(o); }
|
||
}
|
||
const cands = [...neighbors].map(id => ({ id, pow: factionTroopCount(id) }))
|
||
.filter(x => !atWar(fac.id, x.id) && x.id !== "huangjin")
|
||
.sort((a, b) => a.pow - b.pow);
|
||
if (cands.length) {
|
||
const victim = cands[0];
|
||
const myPow = factionTroopCount(fac.id);
|
||
const napKey = warKey(fac.id, victim.id);
|
||
if ((!G.naps.includes(napKey) || chance(P.pactBreak)) && myPow > victim.pow * 1.15) {
|
||
declareWar(fac.id, victim.id, "ambition");
|
||
log(`${fac.name} declares war on ${F(victim.id).name}!`, "war");
|
||
if (isPlayerFaction(victim.id)) chronicle(`${fac.name} declares war upon you!`, "war");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function alliedWith(a, b) { return G.alliances.includes(warKey(a, b)); }
|
||
|
||
function aiSeekPeace(fac) {
|
||
const myWars = G.wars.filter(k => k.split("|").includes(fac.id));
|
||
if (!myWars.length) return;
|
||
const losing = fac.cities.length === 0 ? true :
|
||
fac.gold < 200 && fac.food < 300 && chance(0.5);
|
||
if (!losing) return;
|
||
for (const k of myWars) {
|
||
const other = k.split("|").find(x => x !== fac.id);
|
||
if (other === G.playerFaction) {
|
||
if (chance(0.4)) {
|
||
G.pendingEvents.push(makeEvent({
|
||
kind: "diplomacy", art: "🕊",
|
||
title: `${fac.name} sues for peace`,
|
||
text: `Their envoys kneel outside your gate bearing tribute. "${WeHaveLostEnough()}"`,
|
||
choices: [
|
||
{ label: "Accept peace (+tribute)", hint: "Gain 800 gold, end the war", effect: { fn: "peace_accept", fac: fac.id, tribute: 800 } },
|
||
{ label: "Demand a city", hint: "They cede a city if they have one", effect: { fn: "peace_demand_city", fac: fac.id } },
|
||
{ label: "Refuse — total victory", hint: "War continues", effect: { fn: "peace_refuse", fac: fac.id } },
|
||
],
|
||
}));
|
||
}
|
||
} else if (chance(0.5)) {
|
||
makePeace(fac.id, other);
|
||
log(`${fac.name} and ${F(other)?.name} make peace.`, "war");
|
||
}
|
||
}
|
||
}
|
||
function WeHaveLostEnough() {
|
||
return pick([
|
||
"Our fields burn and our sons are ash. Let there be peace between us.",
|
||
"Heaven is tired of blood. Name your price.",
|
||
"We were fools to cross your banner. Mercy, and we pay.",
|
||
]);
|
||
}
|
||
|
||
function aiRaid(fac) {
|
||
// yellow turbans automatically raid a random neighbor
|
||
const targets = new Set();
|
||
for (const cid of fac.cities) for (const n of provNeighbors(CITY(cid).prov)) {
|
||
for (const c2 of G.provinces[n].cities) { const o = CITY(c2).owner; if (o && o !== "huangjin" && o !== "neutral") targets.add(o); }
|
||
}
|
||
if (targets.size && chance(0.3)) {
|
||
const victim = pick([...targets]);
|
||
if (!atWar("huangjin", victim)) {
|
||
declareWar("huangjin", victim, "zealotry");
|
||
log(`The Yellow Turbans rise against ${F(victim).name}!`, "war");
|
||
}
|
||
}
|
||
}
|
||
|
||
function autosave() {
|
||
try { saveGame("autosave"); } catch { }
|
||
}
|
||
|
||
// ---------------- DIPLOMACY ACTIONS (player-invoked) ----------------
|
||
export function diploAction(action, targetId) {
|
||
const me = G.playerFaction, they = targetId;
|
||
const mf = F(me), tf = F(they);
|
||
const trust = getTrust(me, they);
|
||
const originBonus = mf.origin === "merchant" ? 10 : mf.origin === "noble" ? 8 : 0;
|
||
|
||
switch (action) {
|
||
case "gift": {
|
||
const amt = 500;
|
||
if (mf.gold < amt) return { ok: false, msg: "Not enough gold." };
|
||
mf.gold -= amt; tf.gold += amt;
|
||
trustBump(me, they, 14 + originBonus / 2);
|
||
log(`You send a gift of ${amt} gold to ${tf.name}.`);
|
||
return { ok: true, msg: `${tf.name} accepts your gift warmly.` };
|
||
}
|
||
case "nap": {
|
||
if (atWar(me, they)) return { ok: false, msg: "Make peace first." };
|
||
const need = 5 - originBonus;
|
||
if (trust >= need || (trust >= -10 && chance(0.4 + trust / 200 + originBonus / 100))) {
|
||
G.naps.push(warKey(me, they)); trustBump(me, they, 8);
|
||
return { ok: true, msg: `Non-aggression pact sealed with ${tf.name}.` };
|
||
}
|
||
return { ok: false, msg: `${tf.name} declines — trust too low (${Math.round(trust)}).` };
|
||
}
|
||
case "trade": {
|
||
const bonus = alliedWith(me, they) || G.naps.includes(warKey(me, they)) ? 15 : 0;
|
||
if (trust + bonus >= 10 || chance(0.35 + (trust + bonus) / 150)) {
|
||
G.trade.push(warKey(me, they));
|
||
trustBump(me, they, 6);
|
||
return { ok: true, msg: `Trade agreement signed with ${tf.name}. Both treasuries will grow.` };
|
||
}
|
||
return { ok: false, msg: `${tf.name} sees no profit in trade with you yet.` };
|
||
}
|
||
case "alliance": {
|
||
if (atWar(me, they)) return { ok: false, msg: "Allies do not wage war upon each other." };
|
||
const legitBonus = mf.legitimacy / 10;
|
||
if (trust + legitBonus + originBonus >= 30 || chance(0.2 + (trust + legitBonus) / 120)) {
|
||
G.alliances.push(warKey(me, they));
|
||
trustBump(me, they, 15);
|
||
chronicle(`${mf.name} and ${tf.name} swear alliance.`);
|
||
return { ok: true, msg: `${tf.name} swears friendship with you!` };
|
||
}
|
||
return { ok: false, msg: `${tf.name} does not consider you an equal yet.` };
|
||
}
|
||
case "marriage": {
|
||
const unmarried = factionGenerals(me).find(g => !g.isLeader && g.age >= 17 && g.age <= 45 && !g.married);
|
||
if (!unmarried && !mf.heir) return { ok: false, msg: "No suitable member of your house to wed." };
|
||
const need = 20;
|
||
if (trust + originBonus >= need || chance(0.3 + trust / 130)) {
|
||
if (unmarried) unmarried.married = true;
|
||
trustBump(me, they, 30);
|
||
G.alliances.push(warKey(me, they));
|
||
chronicle(`A marriage binds ${mf.name} and ${tf.name}.`);
|
||
return { ok: true, msg: `Wedding bells! A daughter of ${tf.name} joins your house.` };
|
||
}
|
||
return { ok: false, msg: `${tf.name} politely declines the match.` };
|
||
}
|
||
case "demand": {
|
||
const myPow = factionTroopCount(me), theirPow = factionTroopCount(they);
|
||
const fearFactor = mf.fear / 50 + (myPow / Math.max(1, theirPow)) * 0.4 - 0.5;
|
||
if (fearFactor > 0.4 && chance(0.35 + fearFactor * 0.4)) {
|
||
const amt = Math.min(tf.gold, 600);
|
||
tf.gold -= amt; mf.gold += amt;
|
||
trustBump(me, they, -18); tf.fear += 5; mf.honor -= 2;
|
||
return { ok: true, msg: `${tf.name} yields ${amt} gold to avoid your wrath.` };
|
||
}
|
||
trustBump(me, they, -12);
|
||
return { ok: false, msg: `${tf.name} refuses your demand with contempt.` };
|
||
}
|
||
case "declare-war": {
|
||
declareWar(me, they, "aggression");
|
||
G.stats.warsDeclared++;
|
||
mf.honor -= 3; mf.fear += 4;
|
||
if (G.alliances.includes(warKey(me, they))) {
|
||
// betrayal!
|
||
G.stats.betrayals++;
|
||
mf.honor -= 12;
|
||
for (const f of Object.values(G.factions)) if (f.alive && f.id !== me && f.id !== they) trustBump(me, f.id, -15);
|
||
chronicle(`You broke your oath to ${tf.name}. The realm remembers betrayal.`, "war");
|
||
}
|
||
log(`You declare war on ${tf.name}!`, "war");
|
||
return { ok: true, msg: `War is declared upon ${tf.name}!` };
|
||
}
|
||
case "peace": {
|
||
const myAdvantage = factionTroopCount(me) > factionTroopCount(they) * 1.3;
|
||
if (myAdvantage && chance(0.55 + trust / 200)) {
|
||
makePeace(me, they);
|
||
const reparations = Math.min(tf.gold, 500);
|
||
tf.gold -= reparations; mf.gold += reparations;
|
||
return { ok: true, msg: `Peace accepted${reparations ? ` with ${reparations} gold in reparations` : ""}.` };
|
||
}
|
||
if (chance(0.3 + trust / 160)) {
|
||
makePeace(me, they);
|
||
return { ok: true, msg: `${tf.name} accepts an end to the war.` };
|
||
}
|
||
return { ok: false, msg: `${tf.name} fights on.` };
|
||
}
|
||
case "spy": {
|
||
const spymaster = factionGenerals(me).sort((a, b) => (b.st.int + (b.traits.includes("spymaster") ? 30 : 0)) - (a.st.int + (a.traits.includes("spymaster") ? 30 : 0)))[0];
|
||
if (!spymaster) return { ok: false, msg: "You need an officer to run your spies." };
|
||
const success = chance(0.45 + spymaster.st.int / 220 + (spymaster.traits.includes("spymaster") ? 0.2 : 0));
|
||
if (success) {
|
||
const intel = [];
|
||
for (const cid of tf.cities) { const c = CITY(cid); intel.push(`${c.name}: ${totalTroops(c.garrison)} men, walls lv${c.buildings.wall}`); }
|
||
// discover discontent general
|
||
const unhappy = factionGenerals(they).filter(g => g.loyalty < 45 && !g.isLeader);
|
||
if (unhappy.length && chance(0.5)) {
|
||
const tgt = pick(unhappy);
|
||
G.flags.spyTarget = { gen: tgt.id, fac: they };
|
||
return { ok: true, spy: true, intel, unhappyGen: tgt, msg: `Your spies report: ${tgt.name} of ${tf.name} is DISGRUNTLED (loyalty ${Math.round(tgt.loyalty)}).` };
|
||
}
|
||
return { ok: true, spy: true, intel, msg: `Your spies return with maps and rosters.` };
|
||
}
|
||
trustBump(me, they, -8);
|
||
return { ok: false, spyFail: true, msg: `Your spy was caught! ${tf.name} is insulted (trust −8).` };
|
||
}
|
||
case "bribe-gen": {
|
||
const tgtId = G.flags.spyTarget?.gen;
|
||
if (!tgtId) return { ok: false, msg: "No disgruntled general known. Send spies first." };
|
||
const tg = GEN(tgtId);
|
||
const cost = 800;
|
||
if (mf.gold < cost) return { ok: false, msg: "You cannot afford the bribe." };
|
||
const greedy = tg.traits.includes("greedy") ? 0.25 : 0;
|
||
const loyalHeart = tg.traits.includes("loyalheart") ? -0.3 : 0;
|
||
if (mf.gold >= cost && chance(0.4 + (60 - tg.loyalty) / 90 + greedy + loyalHeart + mf.fame / 200)) {
|
||
mf.gold -= cost;
|
||
transferGeneral(tg, me);
|
||
tg.location = mf.capital;
|
||
chronicle(`${tg.name} abandons ${tf.name} and joins your banner!`, "epic");
|
||
G.stats.recruited++;
|
||
G.flags.spyTarget = null;
|
||
return { ok: true, msg: `${tg.name} arrives at your court!` };
|
||
}
|
||
mf.gold -= 300; // partial spend, suspicion rises
|
||
tg.loyalty = clamp(tg.loyalty + 8, 0, 100);
|
||
trustBump(me, they, -10);
|
||
return { ok: false, msg: `${tg.name} reports the bribe to his lord! (−300 gold)` };
|
||
}
|
||
}
|
||
return { ok: false, msg: "Unknown action." };
|
||
}
|
||
|
||
// ---------------- COURT ACTIONS ----------------
|
||
export function courtAction(action, genId) {
|
||
const g = GEN(genId);
|
||
const pf = playerFaction();
|
||
if (!g) return { ok: false, msg: "No such officer." };
|
||
switch (action) {
|
||
case "promote": {
|
||
const cost = 400;
|
||
if (pf.gold < cost) return { ok: false, msg: "Need 400 gold for the ceremony." };
|
||
pf.gold -= cost;
|
||
g.loyalty = clamp(g.loyalty + 18, 0, 100);
|
||
g.promotedTurn = G.turnCount;
|
||
g.salary = Math.round(g.salary * 1.15);
|
||
log(`${g.name} is promoted at court. (+18 loyalty)`, "good");
|
||
return { ok: true, msg: `${g.name} bows deeply. "I will not forget this grace."` };
|
||
}
|
||
case "gift": {
|
||
if (pf.gold < 300) return { ok: false, msg: "Need 300 gold." };
|
||
pf.gold -= 300;
|
||
g.loyalty = clamp(g.loyalty + 10, 0, 100);
|
||
g.bondPlayer = (g.bondPlayer || 0) + 1;
|
||
return { ok: true, msg: `${g.name} receives jade and silk. (+10 loyalty)` };
|
||
}
|
||
case "banquet": {
|
||
if (pf.gold < 600) return { ok: false, msg: "Need 600 gold." };
|
||
pf.gold -= 600;
|
||
for (const mine of factionGenerals(pf.id)) mine.loyalty = clamp(mine.loyalty + 6, 0, 100);
|
||
pf.corruption = clamp(pf.corruption + 1, 0, 100);
|
||
return { ok: true, msg: "Wine flows at your banquet. The hall roars your name." };
|
||
}
|
||
case "search": {
|
||
// discover hidden talents / wanderers in this city
|
||
const city = typeof g.location === "string" ? CITY(g.location) : null;
|
||
const scholarBonus = pf.origin === "scholar" ? 0.15 : 0;
|
||
if (!city) return { ok: false, msg: "Officer must be in a city." };
|
||
city.lastSearch = G.turnCount;
|
||
const hiddenHere = HIDDEN_HERE(city);
|
||
if (hiddenHere && chance(0.35 + scholarBonus + g.st.int / 300)) {
|
||
hiddenHere.hidden = false;
|
||
hiddenHere.freeAgent = true;
|
||
G.stats.discoveries++;
|
||
chronicle(`In ${city.name}, your people find ${hiddenHere.name}, ${hiddenHere.title || "a talent unknown"}!`, "epic");
|
||
return { ok: true, discovered: hiddenHere, msg: `You have found ${hiddenHere.name}!` };
|
||
}
|
||
if (chance(0.25)) {
|
||
const fresh = makeGenericGeneral("neutral", 55);
|
||
fresh.freeAgent = true; fresh.location = city.id;
|
||
G.stats.discoveries++;
|
||
return { ok: true, discovered: fresh, msg: `A wandering warrior seeks a lord: ${fresh.name} (War ${fresh.st.war}).` };
|
||
}
|
||
return { ok: true, msg: `No notable talent found in ${city.name} this month.` };
|
||
}
|
||
case "execute-prisoner":
|
||
case "release-prisoner":
|
||
case "recruit-prisoner":
|
||
case "ransom-prisoner": {
|
||
return prisonerAction(action, genId);
|
||
}
|
||
}
|
||
return { ok: false, msg: "Unknown action." };
|
||
}
|
||
|
||
function HIDDEN_HERE(city) {
|
||
// famous hidden talents tied to cities
|
||
const table = {
|
||
changsha: ["Huang Zhong", "Wei Yan"],
|
||
jiangxia: ["Gan Ning"],
|
||
};
|
||
const names = table[city.id] || [];
|
||
for (const n of names) {
|
||
const gg = findGen(n);
|
||
if (gg && gg.alive && gg.hidden) return gg;
|
||
}
|
||
// dynamic hidden talents (Zhuge Liang etc.)
|
||
for (const ht of D.HIDDEN_TALENTS) {
|
||
if (G.year < ht.minYear) continue;
|
||
if (G.flags["found_" + ht.n.toLowerCase().replace(/[^a-z]/g, "")]) continue;
|
||
if (ht.findCity !== city.id) continue;
|
||
if (findGen(ht.n)?.alive) continue;
|
||
const ng = makeGeneral(ht.n, "neutral", ht.b, ht.st, ht.tr, ht.sk, ht.t);
|
||
ng.hidden = false; ng.freeAgent = true; ng.location = city.id;
|
||
return ng;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function prisonerAction(action, genId) {
|
||
const g = GEN(genId);
|
||
const pf = playerFaction();
|
||
const idx = G.prisoners.findIndex(p => p.gen === genId && p.heldBy === pf.id);
|
||
if (!g || idx < 0) return { ok: false, msg: "Not your prisoner." };
|
||
G.prisoners.splice(idx, 1);
|
||
switch (action) {
|
||
case "execute-prisoner": {
|
||
killGeneral(g, "was executed by order of the court");
|
||
G.stats.executed++;
|
||
pf.honor -= 8; pf.fear += 8;
|
||
if (g.traits.includes("honorable")) pf.honor -= 4;
|
||
chronicle(`${g.name} is executed.`, "war");
|
||
return { ok: true, msg: `${g.name} faces the executioner. The realm watches in silence. (−Honor, +Fear)` };
|
||
}
|
||
case "release-prisoner": {
|
||
g.freeAgent = true; g.faction = "neutral"; g.location = null;
|
||
pf.honor += 6; pf.fame += 2;
|
||
return { ok: true, msg: `${g.name} is freed. Men speak of your magnanimity. (+Honor)` };
|
||
}
|
||
case "recruit-prisoner": {
|
||
const base = 0.35 + (60 - g.loyalty) / 120 + pf.fame / 150 + (g.traits.includes("greedy") ? 0.2 : 0) - (g.traits.includes("loyalheart") ? 0.25 : 0) - (g.sworn ? 0.3 : 0);
|
||
if (chance(base)) {
|
||
transferGeneral(g, pf.id);
|
||
g.location = pf.capital;
|
||
G.stats.recruited++;
|
||
chronicle(`${g.name} bends the knee and joins your banner!`, "epic");
|
||
return { ok: true, msg: `${g.name} swears service to you!` };
|
||
}
|
||
g.freeAgent = true; g.faction = "neutral"; g.location = null;
|
||
return { ok: false, msg: `${g.name} refuses and is released, unbroken.` };
|
||
}
|
||
case "ransom-prisoner": {
|
||
const ownerFac = g.faction;
|
||
const amt = Math.min(F(ownerFac)?.gold ?? 0, Math.round((g.st.ldr + g.st.war) * 6));
|
||
if (amt < 100) {
|
||
g.freeAgent = true; g.faction = "neutral"; g.location = null;
|
||
return { ok: true, msg: `${F(ownerFac)?.name ?? "His former lord"} cannot pay — ${g.name} is released penniless.` };
|
||
}
|
||
F(ownerFac).gold -= amt; pf.gold += amt;
|
||
g.location = F(ownerFac).capital; // returned
|
||
trustBump(pf.id, ownerFac, 5);
|
||
return { ok: true, msg: `${F(ownerFac).name} pays ${amt} gold in ransom for ${g.name}.` };
|
||
}
|
||
}
|
||
return { ok: false, msg: "" };
|
||
}
|
||
|
||
// city building
|
||
export function buildBuilding(cityId, key) {
|
||
const c = CITY(cityId);
|
||
const fac = F(c.owner);
|
||
if (!fac || !fac.isPlayer) return { ok: false, msg: "Not yours." };
|
||
const bd = D.BUILDINGS[key];
|
||
const lv = c.buildings[key];
|
||
if (lv >= bd.maxLv) return { ok: false, msg: "Already at maximum level." };
|
||
let cost = bd.cost(lv);
|
||
if (fac.origin === "governor") cost = Math.round(cost * 0.85);
|
||
if (key === "academy" && fac.origin === "scholar") cost = Math.round(cost * 0.7);
|
||
if (fac.gold < cost) return { ok: false, msg: `Need ${cost} gold.` };
|
||
fac.gold -= cost;
|
||
c.buildings[key] = lv + 1;
|
||
log(`${bd.name} improved in ${c.name} (level ${lv + 1}).`, "good");
|
||
return { ok: true, msg: `${bd.name} raised to level ${lv + 1}.` };
|
||
}
|
||
|
||
export function cityDetail(cid) {
|
||
const c = CITY(cid);
|
||
return {
|
||
tax: cityTax(c), food: cityFoodDelta(c), tier: cityDevTier(c),
|
||
garrison: totalTroops(c.garrison), upkeep: garrisonUpkeep(c),
|
||
};
|
||
}
|