// ============================================================ // 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), }; }