Files
warlords-fate/js/ui.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

632 lines
31 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.
// ============================================================
// 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)); }