Files
warlords-fate/js/sim.js
T
deepseek f040bb6be0 Three Kingdoms: Warlord's Fate — complete playable game
- 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)
2026-08-23 06:59:40 +00:00

1319 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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),
};
}