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:
+570
@@ -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";
|
||||
Reference in New Issue
Block a user