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
+631
View File
@@ -0,0 +1,631 @@
// ============================================================
// UI — panels, HUD, modals, toasts
// ============================================================
import * as D from "./data.js";
import { G, F, GEN, CITY, playerFaction, isPlayerFaction } from "./state.js";
const $ = sel => document.querySelector(sel);
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
const fmt = n => n >= 10000 ? (n / 1000).toFixed(1) + "k" : Math.round(n).toLocaleString();
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
export class UI {
constructor(hooks) {
this.hooks = hooks;
this.activeTab = null;
this.selected = null;
this.bindStatic();
}
bindStatic() {
$("#btn-panel-close").onclick = () => this.closePanel();
document.querySelectorAll(".tab-btn").forEach(b => {
b.onclick = () => {
const tab = b.dataset.tab;
if (this.activeTab === tab) this.closePanel();
else this.openTab(tab);
};
});
}
// ---------------- HUD ----------------
refreshHUD() {
const pf = playerFaction();
if (!pf) return;
const season = D.seasonOfMonth(G.month);
$("#hud-season").textContent = season;
$("#hud-date").textContent = `${D.seasonOfMonth(G.month)} ${G.year} · ${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][G.month - 1]}`;
$("#hud-gold").textContent = fmt(pf.gold);
this.setDelta("#hud-gold-d", pf.lastIncome);
$("#hud-food").textContent = fmt(pf.food);
this.setDelta("#hud-food-d", pf.lastFood);
let troops = 0;
for (const cid of pf.cities) troops += Object.values(CITY(cid).garrison).reduce((a, b) => a + b, 0);
for (const a of Object.values(G.armies)) if (a.faction === pf.id) troops += Object.values(a.troops).reduce((x, y) => x + y, 0);
$("#hud-troops").textContent = fmt(troops);
$("#hud-cities").textContent = pf.cities.length;
$("#hud-generals").textContent = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id).length;
$("#hud-legit").textContent = Math.round(pf.legitimacy);
// objective note
const obj = [];
if (G.mode === "challenge") obj.push(`Survive the trial — ${Math.max(0, G.challengeDaysLeft)} turns remain.`);
else {
obj.push(`Victory: hold <b>20 cities</b> (now ${pf.cities.length}), or unite China, or be crowned Emperor.`);
if (!pf.cities.includes("changan") || !pf.cities.includes("luoyang")) obj.push("Hold Chang'an & Luoyang with high legitimacy to claim the Mandate.");
}
$("#turn-objective").innerHTML = obj.join("<br>");
}
setDelta(sel, v) {
const e = $(sel);
if (v > 0) { e.textContent = `+${fmt(v)}`; e.className = "delta"; }
else if (v < 0) { e.textContent = `${fmt(-v)}`; e.className = "delta neg"; }
else { e.textContent = ""; e.className = "delta"; }
}
// ---------------- panel routing ----------------
openTab(tab) {
this.activeTab = tab;
document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
$("#side-panel").classList.remove("hidden");
const content = $("#panel-content");
content.innerHTML = "";
if (tab === "court") this.renderGenerals(content);
else if (tab === "cities") this.renderCities(content);
else if (tab === "armies") this.renderArmies(content);
else if (tab === "diplomacy") this.renderDiplomacy(content);
else if (tab === "court-politics") this.renderCourt(content);
else if (tab === "journal") this.renderJournal(content);
else if (tab === "chronicle") this.renderChronicle(content);
else if (tab === "help") this.renderHelp(content);
}
closePanel() {
this.activeTab = null;
$("#side-panel").classList.add("hidden");
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
}
rerender() {
this.refreshHUD();
if (this.activeTab) this.openTab(this.activeTab);
}
// ---------------- PORTRAITS ----------------
portrait(g, size = 52) {
const fac = F(g.faction);
const col = fac?.color || "#888";
const initial = g.name[0].toUpperCase();
return `<div class="gen-portrait" style="width:${size}px;height:${size}px;font-size:${size * 0.44}px;background:
radial-gradient(circle at 35% 30%, ${col}cc, ${col}55 60%, #00000088);border-color:${col}">${initial}</div>`;
}
statChips(g) {
return `<div class="stat-row">
<span class="stat-chip">LDR <b>${g.st.ldr}</b></span><span class="stat-chip">WAR <b>${g.st.war}</b></span>
<span class="stat-chip">INT <b>${g.st.int}</b></span><span class="stat-chip">POL <b>${g.st.pol}</b></span>
<span class="stat-chip">CHA <b>${g.st.cha}</b></span></div>`;
}
loyaltyColor(l) { return l > 70 ? "#7ab55f" : l > 45 ? "#c9a53c" : "#c05540"; }
// ---------------- GENERALS PANEL ----------------
renderGenerals(root) {
const pf = playerFaction();
root.appendChild(el("div", "panel-h", `Your Officers · ${pf.name}`));
const mine = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id)
.sort((a, b) => (b.isLeader ? 1 : 0) - (a.isLeader ? 1 : 0) || b.st.ldr + b.st.war - (a.st.ldr + a.st.war));
for (const g of mine) root.appendChild(this.genCard(g));
// free agents known
const agents = Object.values(G.generals).filter(g => g.alive && g.freeAgent && !g.hidden);
if (agents.length) {
root.appendChild(el("div", "sub-h", "Wandering Heroes (recruit via court events or espionage)"));
for (const g of agents.slice(0, 6)) root.appendChild(this.genCard(g, true));
}
}
genCard(g, dim = false) {
const card = el("div", "gen-card");
if (dim) card.style.opacity = 0.75;
const loc = typeof g.location === "string" ? (CITY(g.location)?.name ?? (g.location.startsWith("army") ? "with army" : "wandering")) : "on campaign";
card.innerHTML = `
${this.portrait(g)}
<div class="gen-info">
<div class="gen-name">${esc(g.name)}${g.isLeader ? ' 👑' : ''}<span class="age">age ${g.age}</span></div>
<div class="gen-title">${esc(g.title || "Officer")} · at ${esc(loc)}</div>
${this.statChips(g)}
<div style="margin-top:5px;display:flex;gap:4px;flex-wrap:wrap">
${g.traits.map(t => `<span class="trait-tag">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name ?? t}</span>`).join("")}
<span class="trait-tag" title="${esc(D.SKILLS[g.skill]?.name ?? "")}">★ ${esc(D.SKILLS[g.skill]?.name ?? "Rally")}</span>
</div>
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
</div>
<div class="gen-flags">
${g.wounded > 0 ? '<span class="flag-wounded" title="Wounded">✚</span>' : ""}
${Object.values(G.armies).some(a => a.genId === g.id) ? '<span class="flag-battle" title="Commanding an army">🐴</span>' : ""}
${g.freeAgent ? '<span class="flag-hidden" title="Free agent">🕊</span>' : ""}
</div>`;
if (!dim) {
card.onclick = () => this.showGeneralDetail(g);
}
return card;
}
showGeneralDetail(g) {
const pf = playerFaction();
const mine = g.faction === pf.id;
const armyHere = Object.values(G.armies).find(a => a.genId === g.id);
const locCity = typeof g.location === "string" ? CITY(g.location) : null;
const box = el("div");
box.appendChild(el("div", "panel-h", esc(g.name)));
box.insertAdjacentHTML("beforeend", `
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px">${this.portrait(g, 64)}
<div><div class="gen-title" style="font-size:13px">${esc(g.title || "Officer")} of ${esc(F(g.faction)?.name ?? "?")}</div>
<div style="color:#b3a17c;font-size:12px">Age ${g.age} · Renown ${Math.round(g.renown)} · Salary ${g.salary}/quarter</div></div></div>
${this.statChips(g)}
<div style="margin-top:8px">
${g.traits.map(t => `<span class="trait-tag" title="${esc(D.TRAITS[t]?.desc ?? "")}">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name}</span>`).join(" ")}
<span class="trait-tag" title="Heroic skill">★ ${esc(D.SKILLS[g.skill]?.name)} — <i>${esc(D.SKILLS[g.skill]?.text ?? "")}</i></span>
</div>
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${g.morale}%"></div></div><span>${Math.round(g.morale)}</span></div>
${(g.sworn || []).length ? `<div style="font-size:12px;color:#b3a17c;margin-top:6px">Sworn brothers: ${(g.sworn || []).map(id => GEN(id)?.alive ? GEN(id).name : "").filter(Boolean).join(", ")}</div>` : ""}
${(g.rivals || []).length ? `<div style="font-size:12px;color:#b38070">Rivals: ${(g.rivals || []).map(id => GEN(id)?.name).filter(Boolean).join(", ")}</div>` : ""}
`);
if (mine) {
const row = el("div", "btn-row");
row.innerHTML = `
<button class="btn-tiny" data-act="promote">Promote (400g)</button>
<button class="btn-tiny" data-act="gift">Gift (300g)</button>
${locCity && !armyHere ? `<button class="btn-tiny" data-act="search">Search for talents here</button>` : ""}
`;
row.querySelectorAll("button").forEach(b => {
b.onclick = () => {
const res = this.hooks.onCourtAction(b.dataset.act, g.id);
this.toast(res.msg, res.ok ? "good" : "bad");
this.refreshAll();
if (res.discovered) this.toast(`Discovered: ${res.discovered.name}!`, "epic");
};
});
box.appendChild(row);
}
this.swapPanel(box);
}
// ---------------- CITIES PANEL ----------------
renderCities(root) {
const pf = playerFaction();
root.appendChild(el("div", "panel-h", "Your Cities"));
for (const cid of pf.cities) {
root.appendChild(this.cityCard(CITY(cid)));
}
if (!pf.cities.length) root.appendChild(el("p", "", "You hold no cities."));
}
cityCard(c) {
const detail = this.hooks.onCityDetail(c.id);
const card = el("div", "city-card");
const fac = F(c.owner);
card.innerHTML = `
<div class="city-head">
<div class="city-name"><span class="owner-dot" style="background:${fac.color}"></span>${esc(c.name)}${c.capitalOf === c.owner ? " ★" : ""}<span class="prov">${D.PROVINCES[c.prov].cn} · Tier ${detail.tier}</span></div>
</div>
<div class="city-stats">
<span>Pop <b>${fmt(c.pop)}k</b></span>
<span>Tax <b>+${detail.tax}</b></span>
<span>Food <b>${detail.food >= 0 ? "+" : ""}${detail.food}</b></span>
<span>Order <b>${Math.round(c.order)}</b></span>
</div>
<div class="bar-wrap"><span>Garrison</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, detail.garrison / 50)}%;background:#7a8fbf"></div></div><span>${fmt(detail.garrison)}</span></div>
<div class="bar-wrap"><span>Levies</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, c.levies / (c.pop / 2.5 / 100) )}%;background:#9aa87a"></div></div><span>${fmt(c.levies)}</span></div>`;
// buildings
const grid = el("div", "build-grid");
for (const [key, bd] of Object.entries(D.BUILDINGS)) {
const lv = c.buildings[key];
const maxed = lv >= bd.maxLv;
const slot = el("div", `build-slot ${maxed ? "max" : "up"}`);
slot.innerHTML = `${bd.icon} <span class="lv">${maxed ? "L.v " + lv : lv >= 0 ? lv + "→" + (lv + 1) : "build"}</span><br><small>${bd.name}</small>`;
slot.title = maxed ? `${bd.name} at maximum` : `Upgrade ${bd.name}: ${bd.cost(lv)} gold — ${bd.desc}`;
slot.onclick = () => {
const res = this.hooks.onBuild(c.id, key);
this.toast(res.msg, res.ok ? "good" : "bad");
this.refreshAll();
};
grid.appendChild(slot);
}
card.appendChild(grid);
// recruitment
const recRow = el("div", "btn-row");
for (const [key, ut] of Object.entries(D.UNIT_TYPES)) {
const btn = el("button", "btn-tiny", `${ut.icon} +200 ${ut.name.split(" ")[0]}`);
btn.title = `${ut.desc} — cost ~${ut.cost * 2} gold`;
btn.onclick = () => {
const res = this.hooks.onRecruit(c.id, key, 200);
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
this.refreshAll();
};
recRow.appendChild(btn);
}
card.appendChild(recRow);
// raise army
const idleGens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && !g.hidden &&
g.location === c.id && !Object.values(G.armies).some(a => a.genId === g.id));
if (idleGens.length && detail.garrison >= 500) {
const form = el("div", "");
form.style.marginTop = "8px";
const sel = el("select");
sel.style.cssText = "background:#120c06;border:1px solid #33271a;color:var(--parch);padding:5px;border-radius:3px;width:170px";
for (const g of idleGens) sel.appendChild(new Option(`${g.name} (LDR ${g.st.ldr})`, g.id));
const halfBtn = el("button", "btn-tiny warn", `⚑ March out with ${idleGens[0].name}`);
const updateLabel = () => {
const g = GEN(sel.value);
halfBtn.textContent = `⚑ March out with ${g.name}`;
};
sel.onchange = updateLabel;
halfBtn.onclick = () => {
// draft ~60% of garrison balanced
const troops = {};
for (const [t, n] of Object.entries(c.garrison)) {
troops[t] = Math.floor(n * (t === "hcav" || t === "cav" ? 0.85 : 0.62));
}
const res = this.hooks.onRaiseArmy(c.id, sel.value, troops);
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
this.refreshAll();
};
form.appendChild(sel);
form.appendChild(halfBtn);
card.appendChild(form);
}
card.style.cursor = "pointer";
card.querySelector(".city-head").onclick = () => { this.hooks.onFocusCity(c.id); };
return card;
}
showCityPanel(cityId) {
const c = CITY(cityId);
if (!c) return;
if (c.owner === G.playerFaction) { this.openTab("cities"); }
else this.showForeignCity(c);
}
showForeignCity(c) {
const fac = F(c.owner);
const detail = this.hooks.onCityDetail(c.id);
const box = el("div");
box.appendChild(el("div", "panel-h", `${esc(c.name)}${esc(fac?.name ?? "Wasteland")}`));
box.insertAdjacentHTML("beforeend", `
<p style="color:#cdbb93;line-height:1.7;font-size:13px">${esc(D.PROVINCES[c.prov].desc)}</p>
<div class="city-stats">
<span>Owner <b style="color:${fac.color}">${esc(fac?.name ?? "-")}</b></span>
<span>Pop <b>${fmt(c.pop)}k</b></span>
<span>Garrison <b>${fmt(detail.garrison)}</b></span>
<span>Walls <b>${c.buildings.wall}</b></span>
</div>`);
const gens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && g.location === c.id);
if (gens.length) {
box.appendChild(el("div", "sub-h", "Present officers"));
for (const g of gens.slice(0, 4)) box.insertAdjacentHTML("beforeend", `<div style="font-size:12.5px;color:#d6c49b">· ${esc(g.name)} <span style="color:#97835b">(WAR ${g.st.war})</span></div>`);
}
this.swapPanel(box);
}
// ---------------- ARMIES ----------------
renderArmies(root) {
const pf = playerFaction();
root.appendChild(el("div", "panel-h", "Armies on Campaign"));
const mine = Object.values(G.armies).filter(a => a.faction === pf.id);
if (!mine.length) root.appendChild(el("p", "", `<span style="color:#97835b">No armies in the field. Raise one from a city.</span>`));
for (const a of mine) root.appendChild(this.armyCard(a));
// enemy armies visible summary
root.appendChild(el("div", "sub-h", "Foreign banners sighted"));
const foreign = Object.values(G.armies).filter(a => a.faction !== pf.id);
for (const a of foreign.slice(0, 10)) {
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
root.insertAdjacentHTML("beforeend", `<div class="journal-entry war">${GEN(a.genId)?.name ?? "?"} (${F(a.faction).name}) — ${fmt(total)} men at ${D.PROVINCES[a.prov].name}</div>`);
}
}
armyCard(a) {
const card = el("div", "city-card");
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
const gen = GEN(a.genId);
card.innerHTML = `
<div class="city-head"><div class="city-name">${esc(gen?.name ?? "?" )}'s Host<span class="prov">${D.PROVINCES[a.prov].name}${a.moved ? " · marched" : ""}</span></div>
<b style="color:#e8c66a">${fmt(total)}</b></div>
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${a.morale}%;background:${this.loyaltyColor(a.morale)}"></div></div><span>${Math.round(a.morale)}</span></div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
${Object.entries(a.troops).filter(([, n]) => n > 0).map(([t, n]) => `<span class="stat-chip">${D.UNIT_TYPES[t].icon} ${fmt(n)}</span>`).join("")}
</div>`;
const row = el("div", "btn-row");
const sameProv = Object.values(G.armies).filter(x => x.faction === a.faction && x.prov === a.prov && x.id !== a.id);
if (sameProv.length) {
const mb = el("button", "btn-tiny", "⇊ Merge armies here");
mb.onclick = () => { this.hooks.onMerge([a.id, ...sameProv.map(x => x.id)]); this.refreshAll(); };
row.appendChild(mb);
}
const db = el("button", "btn-tiny warn", "✕ Disband");
db.title = "Return troops to nearest friendly city";
db.onclick = () => { this.hooks.onDisband(a.id); this.refreshAll(); };
row.appendChild(db);
card.appendChild(row);
card.querySelector(".city-head").style.cursor = "pointer";
card.querySelector(".city-head").onclick = () => { this.hooks.onSelectArmy(a.id); };
return card;
}
// ---------------- DIPLOMACY ----------------
renderDiplomacy(root) {
const pf = playerFaction();
root.appendChild(el("div", "panel-h", "Diplomacy of the Realm"));
root.insertAdjacentHTML("beforeend", `<p style="font-size:12px;color:#97835b;margin-bottom:10px">Trust is remembered. Gifts build it; betrayal poisons every court in China.</p>`);
for (const f of Object.values(G.factions)) {
if (!f.alive || f.id === pf.id || f.id === "neutral") continue;
root.appendChild(this.diploRow(pf, f));
}
}
diploRow(pf, f) {
const trust = f.trust[pf.id] ?? 0;
const war = G.wars.includes([pf.id, f.id].sort().join("|"));
const ally = G.alliances.includes([pf.id, f.id].sort().join("|"));
const nap = G.naps.includes([pf.id, f.id].sort().join("|"));
const trade = G.trade.includes([pf.id, f.id].sort().join("|"));
const rel = war ? '<span class="rel-war">⚔ WAR</span>' : ally ? '<span class="rel-alliance">🤝 ALLIANCE</span>' :
nap ? '<span class="rel-nap">📜 NAP</span>' : trade ? '<span class="rel-trade">⚖ TRADE</span>' : '<span class="rel-peace">PEACE</span>';
const card = el("div", "diplo-row");
card.innerHTML = `
<div class="diplo-flag" style="background:${f.color};color:#111">${(f.emblem || f.name[0])}</div>
<div class="diplo-name"><b>${esc(f.name)}</b> ${f.rank !== "Governor" ? `<span style="color:#d8b25f;font-size:11px">(${f.rank})</span>` : ""}
<div class="diplo-rel">${rel} · cities ${f.cities.length} · legit ${Math.round(f.legitimacy)}</div></div>
<div class="trust-meter" title="Trust"><div style="position:absolute;left:${trust >= 0 ? 50 : 50 + trust / 2}%;top:0;bottom:0;width:${Math.abs(trust) / 2}%;background:${trust >= 0 ? "#7ab55f" : "#c05540"}"></div></div>`;
const acts = el("div", "btn-row");
acts.style.cssText = "flex-basis:100%;justify-content:flex-start";
const mk = (label, act, cls = "") => {
const b = el("button", "btn-tiny " + cls, label);
b.onclick = () => {
const res = this.hooks.onDiplo(act, f.id);
this.toast(res.msg, res.ok ? "good" : res.spyFail ? "bad" : "info");
if (res.spy && res.unhappyGen) {
this.toast(`Disgruntled officer: ${res.unhappyGen.name} — you may attempt a bribe.`, "epic");
G.flags.spyTarget = { gen: res.unhappyGen.id, fac: f.id };
}
this.refreshAll();
};
return b;
};
acts.append(
mk("🎁 Gift", "gift"), mk("🤝 Alliance", "alliance"),
mk(!war ? "⚔ Declare War" : "🕊 Peace", !war ? "declare-war" : "peace", war ? "" : "warn"),
mk("👁 Spy", "spy")
);
if (!war) acts.append(mk("📜 NAP", "nap"), mk("⚖ Trade", "trade"), mk("💍 Marriage", "marriage"), mk("💢 Demand tribute", "demand"));
if (G.flags.spyTarget?.fac === f.id) acts.append(mk("💰 Bribe the disgruntled general", "bribe-gen"));
card.appendChild(acts);
return card;
}
// ---------------- COURT ----------------
renderCourt(root) {
const pf = playerFaction();
root.appendChild(el("div", "panel-h", `The Court of ${esc(pf.name)}`));
const statRow = (label, val, desc) => `<div class="bar-wrap" title="${desc}"><span style="min-width:86px">${label}</span><div class="bar-track"><div class="bar-fill" style="width:${clampN(val)}%;background:linear-gradient(90deg,#a8874f,#d8b25f)"></div></div><span>${Math.round(val)}</span></div>`;
root.insertAdjacentHTML("beforeend", `
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
${this.portrait(GEN(pf.leader) ?? { name: pf.name[0], faction: pf.id }, 58)}
<div><b style="font-size:15px">${esc(GEN(pf.leader)?.name ?? pf.name)}</b>
<div class="gen-title">${pf.rank} of ${esc(pf.name)}</div>
<div style="font-size:11.5px;color:#97835b">Origin: ${pf.origin ? D.ORIGINS[pf.origin]?.name : "Historical faction"}</div></div>
</div>
${statRow("Legitimacy", pf.legitimacy, "How rightful the realm believes your rule is")}
${statRow("Fame", Math.min(pf.fame, 100), "Renown across China")}
${statRow("Fear", pf.fear, "How much enemies dread you")}
${statRow("Honor", pf.honor, "Your reputation for virtue")}
${statRow("Corruption", pf.corruption, "High corruption erodes legitimacy")}
`);
// imperial decisions
const dec = el("div", "sub-h", "Imperial Decisions");
root.appendChild(dec);
const row = el("div", "btn-row");
if (G.flags.emperorProtectedBy === pf.id) {
row.appendChild(el("span", "trait-tag", "🏯 You protect the Han Emperor (+legitimacy monthly)"));
} else if (CITY("luoyang").owner === pf.id) {
const pb = el("button", "btn-tiny", "🏯 Protect the Emperor");
pb.onclick = () => { this.hooks.onProtectEmperor(); this.refreshAll(); };
row.appendChild(pb);
}
const canEmperor = pf.rank === "King" && pf.legitimacy >= 65;
if (canEmperor) {
const eb = el("button", "btn-tiny warn", "👑 Proclaim a New Dynasty!");
eb.title = "Claim the Mandate of Heaven. The realm will turn on you.";
eb.onclick = () => { this.hooks.onProclaimEmperor(); this.refreshAll(); };
row.appendChild(eb);
}
if (G.flags.emperorProtectedBy && G.flags.emperorProtectedBy !== pf.id) {
row.appendChild(el("span", "trait-tag", `${esc(F(G.flags.emperorProtectedBy)?.name)} protects the Emperor`));
}
if (!row.children.length) row.appendChild(el("span", "hint-box", "Grow your rank and legitimacy to unlock imperial decisions."));
root.appendChild(row);
// prisoners
const pris = G.prisoners.filter(p => p.heldBy === pf.id);
if (pris.length) {
root.appendChild(el("div", "sub-h", "Prisoners of War"));
for (const p of pris) {
const g = GEN(p.gen);
if (!g) continue;
const pc = el("div", "gen-card");
pc.innerHTML = `${this.portrait(g)}
<div class="gen-info"><div class="gen-name">${esc(g.name)}</div>
<div class="gen-title">captive since ${p.since} · formerly of ${esc(F(g.faction)?.name ?? "?")}</div></div>`;
const pr = el("div", "prisoner-actions");
for (const [label, act, cls] of [
["⚔ Execute (+Fear Honor)", "execute-prisoner", "warn"],
["🕊 Release (+Honor)", "release-prisoner", ""],
[`🤝 Recruit (persuade)`, "recruit-prisoner", ""],
["💰 Ransom back", "ransom-prisoner", ""],
]) {
const b = el("button", "btn-tiny " + cls, label);
b.onclick = () => {
const res = this.hooks.onPrisoner(act, g.id);
this.toast(res.msg, res.ok ? "good" : "bad");
this.refreshAll();
};
pr.appendChild(b);
}
pc.appendChild(pr);
root.appendChild(pc);
}
}
// banquet
const brow = el("div", "btn-row");
const bb = el("button", "btn-tiny", "🍶 Host Grand Banquet (600g)");
bb.title = "+6 loyalty to ALL officers, +1 corruption";
bb.onclick = () => { const r = this.hooks.onCourtAction2("banquet"); this.toast(r.msg, r.ok ? "good" : "bad"); this.refreshAll(); };
brow.appendChild(bb);
root.appendChild(brow);
}
// ---------------- JOURNAL & CHRONICLE ----------------
renderJournal(root) {
root.appendChild(el("div", "panel-h", "Journal of the Age"));
if (!G.journal.length) root.appendChild(el("p", "", "Nothing yet recorded."));
for (const j of G.journal.slice(0, 80)) {
root.insertAdjacentHTML("beforeend",
`<div class="journal-entry ${j.type}"><span class="je-date">${j.y}/${String(j.m).padStart(2, "0")}</span>${esc(j.text)}</div>`);
}
}
renderChronicle(root) {
root.appendChild(el("div", "panel-h", "The Chronicle of Your Dynasty"));
const byYear = new Map();
for (const c of [...G.chronicle].reverse()) {
if (!byYear.has(c.y)) byYear.set(c.y, []);
byYear.get(c.y).push(c);
}
for (const [y, items] of byYear) {
root.insertAdjacentHTML("beforeend", `<div class="chron-year">${y}</div>`);
for (const c of items) root.insertAdjacentHTML("beforeend", `<div class="journal-entry ${c.type}" style="border-left:none;padding-left:16px">${esc(c.text)}</div>`);
}
}
renderHelp(root) {
root.innerHTML = `
<div class="panel-h">How to Play</div>
<div class="help-cols">
<h4>The Loop</h4>
Develop cities → recruit troops → raise armies → march → besiege → repeat. Each END TURN advances one month.
<h4>The Map</h4>
Drag to orbit, wheel to zoom, WASD to pan. Click a <b>city</b>, <b>army</b>, or <b>province</b>. Colored hexes show ownership.
<h4>Cities</h4>
Upgrade buildings (farm/market/walls…), recruit units from levies, then pick an idle general and <b>march out</b>.
<h4>War</h4>
Move armies to adjacent provinces. Attacking opens a cinematic battle — choose formation & stance first. Sieges can also be <b>starved out</b> over months.
<h4>Officers</h4>
Loyalty drifts monthly. Promote, gift, banquets — ignore them and they defect. Captured officers can be recruited, ransomed, executed…
<h4>Diplomacy</h4>
Alliances, NAPs, trade, marriage, tribute demands, spying. Betrayal is always available — and never forgotten.
<h4>Events</h4>
History adapts: Dong Zhuo may fall, Yuan Shu may crown himself, a village boy may be the greatest mind of the age.
<h4>Winning</h4>
Hold 20 cities, destroy every rival, or claim the Mandate (King rank + Luoyang & Chang'an + legitimacy 80).
</div>`;
}
swapPanel(node) {
$("#side-panel").classList.remove("hidden");
$("#panel-content").innerHTML = "";
$("#panel-content").appendChild(node);
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
}
refreshAll() {
this.refreshHUD();
if (this.activeTab) this.openTab(this.activeTab);
}
// ---------------- TOASTS ----------------
toast(text, type = "info") {
if (!text) return;
const t = el("div", `toast ${type}`, text);
$("#toasts").appendChild(t);
setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .6s"; }, 4200);
setTimeout(() => t.remove(), 4900);
}
// ---------------- EVENT MODALS ----------------
showNextEvent(onResolve) {
const ev = G.pendingEvents.shift();
if (!ev) { onResolve?.(); return false; }
const modal = $("#event-modal");
$("#event-kind").textContent = ev.kind.toUpperCase();
$("#event-title").textContent = ev.title;
$("#event-art").textContent = ev.art;
$("#event-text").textContent = ev.text;
const wrap = $("#event-choices");
wrap.innerHTML = "";
for (const ch of ev.choices) {
const b = el("button", "event-choice");
b.innerHTML = `${esc(ch.label)}<span class="ec-hint">${esc(ch.hint ?? "")}</span>`;
b.onclick = () => {
modal.classList.add("hidden");
onResolve?.(ch);
};
wrap.appendChild(b);
}
modal.classList.remove("hidden");
return true;
}
hasEvents() { return G.pendingEvents.length > 0; }
// ---------------- TURN BANNER ----------------
turnBanner(text) {
const b = $("#turn-banner");
$("#turn-banner-text").innerHTML = text;
b.classList.remove("hidden");
clearTimeout(this._bannerT);
this._bannerT = setTimeout(() => b.classList.add("hidden"), 1600);
}
// ---------------- BATTLE OVERLAY ----------------
openBattleOverlay() { $("#battle-overlay").classList.remove("hidden"); }
closeBattleOverlay() { $("#battle-overlay").classList.add("hidden"); $("#battle-result").classList.add("hidden"); }
updateBattleHUD(rec, roundIdx, round) {
const meta = rec.meta;
$("#bh-att-name").textContent = meta.atkName;
$("#bh-def-name").textContent = meta.defName;
$("#bh-att-name").style.color = meta.atkColor;
$("#bh-def-name").style.color = meta.defColor;
$("#bh-att-morale").style.width = clampN(round?.attMorale ?? 80) + "%";
$("#bh-def-morale").style.width = clampN(round?.defMorale ?? 80) + "%";
const t0 = rec.initialAtkTotal ?? "?", t1 = rec.initialDefTotal ?? "?";
$("#bh-att-troops").textContent = `${fmt(round?.attTroops ?? t0)} / ${fmt(t0)} men`;
$("#bh-def-troops").textContent = `${fmt(t1)} / ${fmt(round?.defTroops ?? t1)} men`;
$("#bh-round").textContent = rec.kind === "siege" ? `ASSAULT ${roundIdx + 1}/${rec.rounds.length}` : `ROUND ${roundIdx + 1}/${rec.rounds.length}`;
$("#bh-terrain").textContent = `${meta.terrainName ?? ""}${meta.walls ? ` · walls lv${meta.walls}` : ""}`;
}
battleCallout(html) {
const c = $("#battle-callout");
c.innerHTML = html ? `<div class="callout">${html}</div>` : "";
}
showBattleResult(rec, extraHTML, onClose) {
const won = rec.playerWon;
const box = $("#battle-result");
box.classList.remove("hidden");
box.innerHTML = `
<div class="br-card">
<h2 class="${won ? "br-victory" : "br-defeat"}">${won ? "VICTORY" : "DEFEAT"}${esc(rec.title)}</h2>
<div class="br-lines">
Your losses: <b>${fmt(rec.playerLosses ?? 0)}</b> · Enemy losses: <b>${fmt(rec.enemyLosses ?? 0)}</b><br>
${extraHTML ?? ""}
</div>
<button class="btn btn-primary" id="br-close">Continue</button>
</div>`;
$("#br-close").onclick = () => { box.classList.add("hidden"); onClose?.(); };
}
}
function clampN(v) { return Math.max(0, Math.min(100, Number(v) || 0)); }