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)
This commit is contained in:
deepseek
2026-08-23 06:59:40 +00:00
commit f040bb6be0
29 changed files with 60362 additions and 0 deletions
+570
View File
@@ -0,0 +1,570 @@
// ============================================================
// EVENTS — historical drama, crises, court intrigue.
// Events are queued for the player as modal cards; AI-side
// history unfolds silently and is written to the chronicle.
// ============================================================
import * as D from "./data.js";
import {
G, F, GEN, CITY, rand, randInt, pick, chance, clamp,
playerFaction, isPlayerFaction, factionCities, factionGenerals, assignCity,
atWar, warKey, declareWar, makePeace, trustBump, getTrust,
log, chronicle, uid, totalTroops, makeGeneral, findGen,
} from "./state.js";
import { provNeighbors } from "./world.js";
import {
transferGeneral, captureGeneral, killGeneral, collapseFaction,
proclaimEmperor, protectEmperor, makeEvent, recruitToGarrison,
} from "./sim.js";
// ---------------- MAIN ROLL ----------------
export function rollEvents() {
scriptedHistory();
worldCrisis();
playerCourtEvents();
}
// ---------------- SCRIPTED HISTORY ----------------
function scriptedHistory() {
const y = G.year, m = G.month;
// --- Dong Zhuo's fall (Wang Yun's plot) ---
if (!G.flags.dongzhuoDead && F("dong").alive && findGen("Dong Zhuo")?.alive && (y > 193 || m >= 5)) {
if (chance(0.45)) {
G.flags.dongzhuoDead = true;
const dong = findGen("Dong Zhuo");
killGeneral(dong, "was slain by Lü Bu in the palace courtyard — Wang Yun's plot");
// Lü Bu briefly holds Chang'an? Historically he flees; Li Jue takes over.
const lijue = findGen("Li Jue");
if (lijue && lijue.alive) {
F("dong").leader = lijue.id;
lijue.isLeader = true;
lijue.location = F("dong").capital;
for (const g of factionGenerals("dong")) g.loyalty = clamp(g.loyalty + randInt(-8, 8), 0, 100);
}
panicAll("dong", 10);
chronicle(`Dong Zhuo dies under his adopted son's halberd. The tyrant's coalition scatters.`, "epic");
if (isPlayerNeighborOf("dong")) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🔥",
title: "The Tyrant Falls",
text: "Word gallops across the passes: Dong Zhuo is dead, cut down in the palace gate by the Flying General himself. Chang'an burns. His officers scatter like startled crows.\n\nEvery warlord in China redraws their maps tonight.",
choices: [
{ label: "Watch, and sharpen blades", hint: "The chaos continues", effect: { fn: "noop" } },
],
}));
}
}
}
// --- Tao Qian yields Xuzhou ---
const taoqian = findGen("Tao Qian");
if (!G.flags.taoOffered && taoqian?.alive && y >= 194 && chance(0.4)) {
G.flags.taoOffered = true;
const candidates = Object.values(G.factions).filter(f =>
f.alive && f.id !== "tao" && f.id !== "neutral" && f.id !== "huangjin" &&
f.cities.some(cid => provNeighbors(CITY(cid).prov).includes("x")));
// Liu Bei preferred, then highest honor
let heirFac = candidates.find(f => f.id === "liu");
if (!heirFac) heirFac = candidates.sort((a, b) => b.honor - a.honor)[0];
const xzCities = factionCities("tao").map(c => c.id);
// his officers follow the chosen successor
if (heirFac) for (const g of factionGenerals("tao").filter(g2 => g2.id !== taoqian.id)) transferGeneral(g, heirFac.id);
killGeneral(taoqian, "dies of illness, old and honored"); // collapses tao; cities go neutral
if (heirFac) {
if (isPlayerFaction(heirFac.id)) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🗺",
title: "Tao Qian's Legacy",
text: `On his deathbed, Governor Tao Qian of Xuzhou names YOU — not his sons — as the man to shelter his people.\n\n"The people of Xu have suffered enough. Lead them."`,
choices: [
{ label: "Accept Xuzhou with bowed head", hint: `Gain ${xzCities.length} city/cities, +legitimacy, +fame`, effect: { fn: "inherit_cities", from: "tao", legit: 8 } },
{ label: "Refuse — it must be a Liu", hint: "+Honor greatly", effect: { fn: "noop_honor" } },
],
}));
} else {
for (const cid of xzCities) if (CITY(cid).owner === "neutral" || !CITY(cid).owner) assignCity(cid, heirFac.id);
chronicle(`Tao Qian dies, willing Xuzhou to ${F(heirFac.id).name}. The realm is astonished.`, "epic");
}
}
}
// --- Yuan Shu proclaims himself Emperor ---
if (!G.flags.yuanshuEmperor && F("yuanshu").alive && y >= 196 && F("yuanshu").cities.length >= 2 && chance(0.35)) {
G.flags.yuanshuEmperor = true;
F("yuanshu").legitimacy = clamp(F("yuanshu").legitimacy - 25, 0, 100);
F("yuanshu").rank = "Emperor";
chronicle(`Yuan Shu proclaims HIMSELF Son of Heaven in Shouchun. All China spits at the news.`, "epic");
for (const f of Object.values(G.factions)) {
if (f.alive && f.id !== "yuanshu" && f.id !== "neutral") {
trustBump(f.id, "yuanshu", -50);
if (chance(0.4)) declareWar(f.id, "yuanshu", "usurpation");
}
}
if (!isPlayerFaction("yuanshu")) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "👑",
title: "A False Emperor",
text: "Yuan Shu has crowned himself Emperor with a forged seal. Even his own officers laugh behind sleeves.\n\nThe coalition gates are opening...",
choices: [{ label: "Let the dogs hunt him", hint: "Everyone may now war on Yuan Shu", effect: { fn: "noop" } }],
}));
}
}
// --- Protecting the Han Emperor (whoever holds Luoyang) ---
if (!G.flags.emperorProtectedBy && CITY("luoyang").owner && CITY("luoyang").owner !== "neutral" && CITY("luoyang").owner !== "huangjin") {
const owner = CITY("luoyang").owner;
G.flags.emperorProtectedBy = owner;
if (isPlayerFaction(owner)) {
G.pendingEvents.push(makeEvent({
kind: "court", art: "🏯",
title: "The Emperor in Your Care",
text: "Your soldiers found the young Emperor in a ruined farmhouse, eating coarse grain off a broken table.\n\nHe is yours to guard... or to use.",
choices: [
{ label: "Protect the Emperor", hint: "+Legitimacy each month, all factions respect you more", effect: { fn: "protect_emperor" } },
{ label: "Merely house him quietly", hint: "Small legitimacy gain now", effect: { fn: "emperor_quiet", legit: 5 } },
],
}));
} else {
log(`${F(owner).name} takes custody of the Han Emperor.`, "war");
}
}
// --- Three Visits (Zhuge Liang) ---
if (!G.flags.threeVisits && y >= 200 && findGen("Liu Bei")?.alive && !findGen("Zhuge Liang")) {
const liuFac = findGen("Liu Bei").faction;
if (F(liuFac)?.alive && F(liuFac).cities.length >= 1) {
G.flags.threeVisits = true;
const zl = makeGeneral("Zhuge Liang", liuFac, 181, [88, 62, 100, 96, 92], ["genius", "loyalheart", "scholar"], "eightform", "Sleeping Dragon");
zl.loyalty = 95; zl.location = F(liuFac).capital;
chronicle(`${findGen("Liu Bei").name} calls three times on a thatched hut at Longzhong. Zhuge Liang rises, and sees the whole empire at a glance.`, "epic");
if (isPlayerFaction(liuFac)) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🐉",
title: "The Sleeping Dragon Wakes",
text: "Three times you climbed the winding path to Longzhong. The third time, the young scholar was home.\n\nHe unrolled a map of the realm on his knee and said: \"The House of Han cannot be restored by force alone. But stand where three kingdoms meet, and wait for the change of winds...\"",
choices: [
{ label: "Bow twice, and beg him to come down the mountain", hint: "Zhuge Liang joins you!", effect: { fn: "noop_good" } },
],
}));
}
}
}
// --- Guandu-style clash ---
if (!G.flags.guanduDone && F("cao").alive && F("yuan").alive && atWar("cao", "yuan") && y >= 198 && chance(0.3)) {
const caoPow = factionTroopCountLocal("cao"), yuanPow = factionTroopCountLocal("yuan");
G.flags.guanduDone = true;
const winner = caoPow * 1.15 > yuanPow ? "cao" : "yuan"; // Cao Cao's genius edge
const loserId = winner === "cao" ? "yuan" : "cao";
const xuYou = findGen("Xu You");
let flavor = "";
if (winner === "cao") {
flavor = xuYou?.alive ? " A defector's whisper sends flames through the granaries at Wuchao." : " A midnight raid burns the great granaries.";
panicAll("yuan", 15);
} else {
panicAll("cao", 15);
}
chronicle(`The Two Rivers clash north of the Yellow River.${flavor} ${F(winner).name} carries the day.`, "epic");
for (const g of factionGenerals(loserId)) if (g.traits.includes("cautious")) g.loyalty = clamp(g.loyalty - 10, 0, 100);
}
// --- Red Cliffs-style southern fire ---
if (!G.flags.redcliffsDone && y >= 200) {
const southIds = ["sun", "liu", "liubiao"];
for (const nid of southIds) {
if (!F(nid).alive) continue;
for (const enemyId of ["cao", "yuan", "dong"]) {
if (!atWar(nid, enemyId)) continue;
const invaders = Object.values(G.armies).filter(a => a.faction === enemyId && ["n", "h"].includes(a.prov));
if (invaders.length >= 2) {
G.flags.redcliffsDone = true;
const defenderGen = factionGenerals(nid).sort((a, b) => b.st.int - a.st.int)[0];
chronicle(`Great ships crowd the Yangtze. ${defenderGen?.name ?? "The defenders"} read the east wind, loose fire ships, and the northern host is ash on the water.`, "epic");
// devastating losses to invaders
for (const inv of invaders) {
for (const t of Object.keys(inv.troops)) inv.troops[t] = Math.round(inv.troops[t] * (0.3 + rand() * 0.25));
}
const aliveSouth = southIds.filter(x => F(x).alive && x !== nid);
if (aliveSouth.length) trustBump(nid, aliveSouth[0], 15);
break;
}
}
if (G.flags.redcliffsDone) break;
}
}
// --- Lü Bu betrays again ---
const lubu = findGen("Lü Bu");
if (lubu?.alive && F("lubu").alive && G.turnCount > 6 && chance(0.06)) {
// finds a new host or attacks a neighbor
if (lubu.faction !== "lubu") {
const dest = pick(Object.values(G.factions).filter(f => f.alive && f.id !== lubu.faction && f.id !== "neutral"));
transferGeneral(lubu, dest.id);
chronicle(`${lubu.name} abandons his third lord without blinking. "Whoever pays best," he shrugs.`, "war");
}
}
// --- Yellow Turban resurgence waves ---
if (y <= 196 && F("huangjin").alive && chance(0.25)) {
const cityId = pick(F("huangjin").cities.length ? F("huangjin").cities : ["linzi"]);
const c = CITY(cityId);
recruitToGarrison(c, "spear", 400);
recruitToGarrison(c, "bow", 150);
if (chance(0.4)) log("Yellow scarves stream in from the hills — the sect still burns.", "war");
}
// --- Hua Tuo the wandering physician ---
if (chance(0.08)) {
const wounded = Object.values(G.generals).filter(g => g.alive && g.wounded > 0);
if (wounded.length) {
const w = pick(wounded);
w.wounded = 0;
if (isPlayerFaction(w.faction)) log(`Hua Tuo, the wandering physician, heals ${w.name}.`, "good");
}
}
}
function factionTroopCountLocal(fid) {
let n = 0;
for (const cid of F(fid).cities) n += totalTroops(CITY(cid).garrison);
return n;
}
function panicAll(fid, amt) {
for (const g of factionGenerals(fid)) g.loyalty = clamp(g.loyalty - amt, 0, 100);
}
function isPlayerNeighborOf(fid) {
const pf = playerFaction();
for (const cid of pf.cities) {
const prov = CITY(cid).prov;
for (const n of provNeighbors(prov)) {
if (G.provinces[n].cities.some(id => CITY(id).owner === fid)) return true;
}
}
return false;
}
// ---------------- WORLD CRISIS ----------------
function worldCrisis() {
const r = rand();
if (r < 0.06) {
// plague
const allCities = Object.values(G.cities).filter(c => c.owner !== null);
if (!allCities.length) return;
const c = pick(allCities);
c.pop = Math.round(c.pop * 0.93); c.order = clamp(c.order - 10, 5, 100);
log(`Plague sweeps ${c.name}.`, c.owner === G.playerFaction ? "bad" : "war");
if (c.owner === G.playerFaction) {
G.pendingEvents.push(makeEvent({
kind: "crisis", art: "☠",
title: "Plague in " + c.name,
text: "Carts of the dead pass through the market at dusk. The physicians demand action; the merchants demand trade.",
choices: [
{ label: "Quarantine the quarters (300 gold)", hint: "Order recovers faster", effect: { fn: "plague_quarantine", city: c.id } },
{ label: "Trust in Heaven", hint: "Free, but risky", effect: { fn: "noop" } },
],
}));
}
} else if (r < 0.11) {
// locusts / flood
const facs = Object.values(G.factions).filter(f => f.alive);
const f = pick(facs);
if (!f.cities.length) return;
const c = CITY(pick(f.cities));
f.food = Math.max(0, f.food - Math.round(500 + c.pop * 2));
if (f.isPlayer) log(`Locusts strip the fields near ${c.name}. Food stores suffer.`, "bad");
} else if (r < 0.16) {
// bandit uprising
const f = pick(Object.values(G.factions).filter(x => x.alive && x.cities.length));
if (!f) return;
const c = CITY(pick(f.cities));
c.order = clamp(c.order - 12, 5, 100);
if (f.isPlayer) log(`Bandits grow bold in the hills above ${c.name}.`, "bad");
} else if (r < 0.22) {
// merchant caravan offers deal
const pf = playerFaction();
if (pf.alive) {
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🐫",
title: "A Silk Road Caravan",
text: "Traders from the Western Regions arrive with jade, horses, and gossip about every court in China.",
choices: [
{ label: "Buy war horses (800 gold, +300 cavalry in capital)", effect: { fn: "caravan_horses", cost: 800 }, hint: "Requires gold" },
{ label: "Sell them grain (+600 gold)", hint: "800 food", effect: { fn: "caravan_grain" } },
{ label: "Send them away", effect: { fn: "noop" } },
],
}));
}
} else if (r < 0.27) {
// refugees
const pf = playerFaction();
if (pf.alive && pf.cities.length) {
const c = CITY(pick(pf.cities));
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🏕",
title: "Refugees at the Gate",
text: `Thousands flee the wars, arriving starving before ${c.name}. They could till your fields — or fill them with graves.`,
choices: [
{ label: "Open the gates (700 food)", hint: "+population, +order, +honor", effect: { fn: "refugees_accept", city: c.id } },
{ label: "Turn them away", hint: "They will remember", effect: { fn: "noop" } },
],
}));
}
}
}
// ---------------- PLAYER COURT ----------------
function playerCourtEvents() {
const pf = playerFaction();
if (!pf.alive) return;
// ambitious general demands command
if (chance(0.18)) {
const gens = factionGenerals(pf.id).filter(g => !g.isLeader && g.traits.includes("ambitious"));
if (gens.length) {
const g = pick(gens);
if (g.loyalty < 60 || chance(0.5)) {
G.pendingEvents.push(makeEvent({
kind: "court", art: "🏔",
title: `${g.name}'s Ambition`,
text: `${g.name} stands in your hall, helmet under arm. His victories have made him famous — perhaps more famous than his lord.\n\n"My lord, give me a province worthy of my sword. Or watch what ambition denied becomes."`,
choices: [
{ label: "Grant him a title and honors (500 gold)", hint: "+20 loyalty, feeds ambition safely", effect: { fn: "ambition_grant", gen: g.id, cost: 500 } },
{ label: "Promote another over him", hint: "Dangerous — he will not forget this insult", effect: { fn: "ambition_insult", gen: g.id } },
{ label: "Send spies into his household", hint: "Learn his heart… probably", effect: { fn: "ambition_spy", gen: g.id } },
{ label: "Ignore the request", hint: "Loyalty falls", effect: { fn: "ambition_ignore", gen: g.id } },
],
}));
}
}
}
// marriage offer
if (chance(0.1) && pf.cities.length >= 2) {
const others = Object.values(G.factions).filter(f => f.alive && f.id !== pf.id && f.id !== "neutral" && f.id !== "huangjin" && !atWar(pf.id, f.id));
if (others.length) {
const other = pick(others);
const trust = getTrust(pf.id, other.id);
if (trust > -20) {
G.pendingEvents.push(makeEvent({
kind: "diplomacy", art: "🏮",
title: `A Marriage Proposal from ${other.name}`,
text: `An envoy arrives with red silk and a genealogy. ${other.name} offers a daughter of their house in marriage — binding two banners with one ceremony.`,
choices: [
{ label: "Accept the match", hint: "Strong alliance + trust", effect: { fn: "marry", fac: other.id } },
{ label: "Decline politely", hint: "Slight trust loss", effect: { fn: "marry_decline", fac: other.id } },
],
}));
}
}
}
// wandering hero
if (chance(0.08)) {
const freeAgents = Object.values(G.generals).filter(g => g.freeAgent && g.alive && !g.hidden);
const cap = pf.capital ? CITY(pf.capital) : null;
if (cap) {
if (freeAgents.length && chance(0.6)) {
const unoffered = freeAgents.filter(x => !x.offeredOnce);
const hero = pick(unoffered.length ? unoffered : freeAgents);
if (hero) hero.offeredOnce = true;
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🍶",
title: `${hero.name} Comes to Court`,
text: `${hero.title || "A wanderer"} named ${hero.name} drinks in your hall and praises your name — loudly enough that refusing would be an insult.\n\n(LDR ${hero.st.ldr} · WAR ${hero.st.war} · INT ${hero.st.int})\nTraits: ${hero.traits.map(t => D.TRAITS[t]?.name).join(", ")}`,
choices: [
{ label: `Recruit ${hero.name}`, hint: "Welcome him to your banner", effect: { fn: "recruit_hero", gen: hero.id } },
{ label: "Turn him away", hint: "He will serve someone else", effect: { fn: "hero_leave", gen: hero.id } },
],
}));
} else {
// unknown talent
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🌾",
title: "An Unknown Talent",
text: `In the markets below ${cap.name}, your steward notices a commoner correcting generals' battle maps with a charcoal stick.`,
choices: [
{ label: "Summon and test them (200 gold)", hint: "Might be nobody. Might be everything.", effect: { fn: "test_talent", cost: 200 } },
{ label: "Not worth a lord's time", effect: { fn: "noop" } },
],
}));
}
}
}
}
// ---------------- EFFECT RESOLVER ----------------
export function applyEffect(effect) {
if (!effect || !effect.fn) return null;
const pf = playerFaction();
switch (effect.fn) {
case "noop": case "noop_good": case "noop_honor": {
if (effect.fn === "noop_honor") { pf.honor += 8; log("You decline with grace. Men approve.", "good"); }
return { msg: "" };
}
case "defect_bribe": {
if (pf.gold < effect.cost) return { msg: "You lack the gold — he leaves anyway." , fail:true };
pf.gold -= effect.cost;
const g = GEN(effect.gen);
if (g) { g.loyalty = clamp(g.loyalty + effect.boost, 0, 100); }
return { msg: `${g?.name} stays, bought dearly.` };
}
case "defect_promote": {
const g = GEN(effect.gen);
if (g) { g.loyalty = clamp(g.loyalty + 22, 0, 100); g.promotedTurn = G.turnCount; g.title = g.title || "General of the Household"; }
return { msg: `${g?.name} accepts a new title — for now.` };
}
case "defect_release": {
const g = GEN(effect.gen);
if (g) {
transferGeneral(g, effect.dest);
G.stats.betrayals++;
chronicle(`${g.name} leaves your banner for ${F(effect.dest)?.name}.`, "war");
}
return { msg: `${g?.name} rides for ${F(effect.dest)?.name}.` };
}
case "defect_imprison": {
const g = GEN(effect.gen);
if (g) {
captureGeneral(g, pf.id);
pf.honor -= 5; pf.fear += 5;
}
return { msg: `${g?.name} is taken to the dungeons. The court watches in silence.` };
}
case "peace_accept": {
makePeace(pf.id, effect.fac);
pf.gold += effect.tribute;
return { msg: `Peace signed. Tribute received.` };
}
case "peace_demand_city": {
const fac = F(effect.fac);
const borderCity = fac.cities.map(id => CITY(id)).find(c => provNeighbors(c.prov).some(p => G.provinces[p].cities.some(x => CITY(x).owner === pf.id)));
if (borderCity) {
assignCity(borderCity.id, pf.id);
makePeace(pf.id, effect.fac);
chronicle(`${borderCity.name} is ceded to you for peace.`, "epic");
return { msg: `${borderCity.name} is yours.` };
}
makePeace(pf.id, effect.fac);
return { msg: "They had no cities to spare — peace signed anyway." };
}
case "peace_refuse":
return { msg: "The envoys leave empty-handed. The war goes on." };
case "plague_quarantine": {
if (pf.gold >= 300) {
pf.gold -= 300;
CITY(effect.city).unrest = Math.max(0, CITY(effect.city).unrest - 10);
return { msg: "Guards seal the sick streets. It may be enough." };
}
return { msg: "No gold for quarantine." };
}
case "caravan_horses": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
const cap = CITY(pf.capital);
cap.garrison.cav = (cap.garrison.cav || 0) + 300;
return { msg: "300 western horses join your capital garrison." };
}
case "caravan_grain": {
if (pf.food < 800) return { msg: "Not enough food to sell.", fail: true };
pf.food -= 800; pf.gold += 600;
return { msg: "Grain sold at a handsome price." };
}
case "refugees_accept": {
if (pf.food < 700) return { msg: "Your granaries cannot feed them.", fail: true };
pf.food -= 700;
const c = CITY(effect.city);
c.pop += 40; c.order = clamp(c.order + 8, 5, 100); c.levies += 60;
pf.honor += 5;
return { msg: "They kneel at your gate, weeping. Your fame grows." };
}
case "ambition_grant": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty + 20, 0, 100); g.promotedTurn = G.turnCount;
return { msg: `${g.name}, satisfied — for now.` };
}
case "ambition_insult": {
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty - 15, 0, 100);
g.insulted = true;
return { msg: `${g.name} bows — too smoothly. Something cold enters his eyes.` };
}
case "ambition_spy": {
const g = GEN(effect.gen);
if (chance(0.5 + (g.traits.includes("spymaster") ? -0.3 : 0))) {
if (g.loyalty < 45) return { msg: `Your spies confirm it: ${g.name} exchanges letters with your enemies! (You may imprison him from his panel.)` };
return { msg: `Spies find nothing but ledgers and poetry. Perhaps he is honest.` };
}
g.loyalty = clamp(g.loyalty - 5, 0, 100);
return { msg: `Your spy was discovered snooping. ${g.name} is offended.` };
}
case "ambition_ignore": {
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty - 12, 0, 100);
g.morale = clamp(g.morale - 5, 0, 100);
return { msg: `${g.name} says nothing. That is worse.` };
}
case "marry": {
const unmarried = factionGenerals(pf.id).find(g => !g.isLeader && g.age >= 17 && g.age <= 45 && !g.married);
if (unmarried) unmarried.married = true;
trustBump(pf.id, effect.fac, 30);
if (!G.alliances.includes(warKey(pf.id, effect.fac))) G.alliances.push(warKey(pf.id, effect.fac));
pf.legitimacy = clamp(pf.legitimacy + 5, 0, 100);
chronicle(`A wedding joins your house to ${F(effect.fac).name}.`, "good");
return { msg: "Red lanterns hang from every gate. Two houses become kin." };
}
case "marry_decline": {
trustBump(pf.id, effect.fac, -8);
return { msg: "The envoy departs with stiff courtesy." };
}
case "recruit_hero": {
const g = GEN(effect.gen);
if (g) {
transferGeneral(g, pf.id);
g.location = pf.capital;
G.stats.recruited++;
chronicle(`${g.name} joins your cause.`, "good");
return { msg: `${g.name} kneels: "My sword is yours, my lord."` };
}
return { msg: "He vanished along the road." };
}
case "hero_leave": {
const g = GEN(effect.gen);
if (g) g.location = null;
return { msg: "He salutes and vanishes into the crowd. Someone else will find him." };
}
case "test_talent": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
if (chance(0.55)) {
const fresh = spawnGenericTalent();
fresh.location = pf.capital;
transferGeneral(fresh, pf.id);
G.stats.recruited++; G.stats.discoveries++;
chronicle(`From nowhere, ${fresh.name} joins you — remembered by history or not, that is up to fate.`, "good");
return { msg: `${fresh.name} (LDR ${fresh.st.ldr} WAR ${fresh.st.war} INT ${fresh.st.int}) proves remarkable! He enters your service.` };
}
return { msg: "The 'genius' turns out to be a grain merchant with opinions." };
}
case "protect_emperor": protectEmperor(); return { msg: "You take the Emperor under your protection." };
case "emperor_quiet": pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100); return { msg: "The Emperor rests quietly in your care." };
case "inherit_cities": {
const from = effect.from;
for (const cid of [...F(from).cities]) assignCity(cid, pf.id);
pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100);
pf.fame += 8;
for (const g of factionGenerals(from)) transferGeneral(g, pf.id);
collapseFaction(from, "by inheritance");
return { msg: "Xuzhou accepts your banner." };
}
default:
console.warn("unknown effect", effect.fn);
return { msg: "" };
}
}
function spawnGenericTalent() {
return stateModule.makeGenericGeneral("neutral", 58);
}
import * as stateModule from "./state.js";