// ============================================================
// 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 20 cities (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(" ");
}
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 `
${initial}
`;
}
statChips(g) {
return `
LDR ${g.st.ldr} WAR ${g.st.war}
INT ${g.st.int} POL ${g.st.pol}
CHA ${g.st.cha}
`;
}
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)}
${esc(g.name)}${g.isLeader ? ' 👑' : ''}age ${g.age}
${esc(g.title || "Officer")} · at ${esc(loc)}
${this.statChips(g)}
${g.traits.map(t => `${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name ?? t} `).join("")}
★ ${esc(D.SKILLS[g.skill]?.name ?? "Rally")}
Loyalty ${Math.round(g.loyalty)}
${g.wounded > 0 ? '✚ ' : ""}
${Object.values(G.armies).some(a => a.genId === g.id) ? '🐴 ' : ""}
${g.freeAgent ? '🕊 ' : ""}
`;
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", `
${this.portrait(g, 64)}
${esc(g.title || "Officer")} of ${esc(F(g.faction)?.name ?? "?")}
Age ${g.age} · Renown ${Math.round(g.renown)} · Salary ${g.salary}/quarter
${this.statChips(g)}
${g.traits.map(t => `${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name} `).join(" ")}
★ ${esc(D.SKILLS[g.skill]?.name)} — ${esc(D.SKILLS[g.skill]?.text ?? "")}
Loyalty ${Math.round(g.loyalty)}
Morale ${Math.round(g.morale)}
${(g.sworn || []).length ? `Sworn brothers: ${(g.sworn || []).map(id => GEN(id)?.alive ? GEN(id).name : "").filter(Boolean).join(", ")}
` : ""}
${(g.rivals || []).length ? `Rivals: ${(g.rivals || []).map(id => GEN(id)?.name).filter(Boolean).join(", ")}
` : ""}
`);
if (mine) {
const row = el("div", "btn-row");
row.innerHTML = `
Promote (400g)
Gift (300g)
${locCity && !armyHere ? `Search for talents here ` : ""}
`;
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 = `
${esc(c.name)}${c.capitalOf === c.owner ? " ★" : ""}${D.PROVINCES[c.prov].cn} · Tier ${detail.tier}
Pop ${fmt(c.pop)}k
Tax +${detail.tax}
Food ${detail.food >= 0 ? "+" : ""}${detail.food}
Order ${Math.round(c.order)}
Garrison ${fmt(detail.garrison)}
`;
// 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} ${maxed ? "L.v " + lv : lv >= 0 ? lv + "→" + (lv + 1) : "build"} ${bd.name} `;
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", `
${esc(D.PROVINCES[c.prov].desc)}
Owner ${esc(fac?.name ?? "-")}
Pop ${fmt(c.pop)}k
Garrison ${fmt(detail.garrison)}
Walls ${c.buildings.wall}
`);
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", `· ${esc(g.name)} (WAR ${g.st.war})
`);
}
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", "", `No armies in the field. Raise one from a city. `));
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", `${GEN(a.genId)?.name ?? "?"} (${F(a.faction).name}) — ${fmt(total)} men at ${D.PROVINCES[a.prov].name}
`);
}
}
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 = `
${esc(gen?.name ?? "?" )}'s Host${D.PROVINCES[a.prov].name}${a.moved ? " · marched" : ""}
${fmt(total)}
Morale ${Math.round(a.morale)}
${Object.entries(a.troops).filter(([, n]) => n > 0).map(([t, n]) => `${D.UNIT_TYPES[t].icon} ${fmt(n)} `).join("")}
`;
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", `Trust is remembered. Gifts build it; betrayal poisons every court in China.
`);
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 ? '⚔ WAR ' : ally ? '🤝 ALLIANCE ' :
nap ? '📜 NAP ' : trade ? '⚖ TRADE ' : 'PEACE ';
const card = el("div", "diplo-row");
card.innerHTML = `
${(f.emblem || f.name[0])}
${esc(f.name)} ${f.rank !== "Governor" ? `
(${f.rank}) ` : ""}
${rel} · cities ${f.cities.length} · legit ${Math.round(f.legitimacy)}
`;
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) => `${label} ${Math.round(val)} `;
root.insertAdjacentHTML("beforeend", `
${this.portrait(GEN(pf.leader) ?? { name: pf.name[0], faction: pf.id }, 58)}
${esc(GEN(pf.leader)?.name ?? pf.name)}
${pf.rank} of ${esc(pf.name)}
Origin: ${pf.origin ? D.ORIGINS[pf.origin]?.name : "Historical faction"}
${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)}
${esc(g.name)}
captive since ${p.since} · formerly of ${esc(F(g.faction)?.name ?? "?")}
`;
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",
`${j.y}/${String(j.m).padStart(2, "0")} ${esc(j.text)}
`);
}
}
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", `${y}
`);
for (const c of items) root.insertAdjacentHTML("beforeend", `${esc(c.text)}
`);
}
}
renderHelp(root) {
root.innerHTML = `
How to Play
The Loop
Develop cities → recruit troops → raise armies → march → besiege → repeat. Each END TURN advances one month.
The Map
Drag to orbit, wheel to zoom, WASD to pan. Click a city , army , or province . Colored hexes show ownership.
Cities
Upgrade buildings (farm/market/walls…), recruit units from levies, then pick an idle general and march out .
War
Move armies to adjacent provinces. Attacking opens a cinematic battle — choose formation & stance first. Sieges can also be starved out over months.
Officers
Loyalty drifts monthly. Promote, gift, banquets — ignore them and they defect. Captured officers can be recruited, ransomed, executed…
Diplomacy
Alliances, NAPs, trade, marriage, tribute demands, spying. Betrayal is always available — and never forgotten.
Events
History adapts: Dong Zhuo may fall, Yuan Shu may crown himself, a village boy may be the greatest mind of the age.
Winning
Hold 20 cities, destroy every rival, or claim the Mandate (King rank + Luoyang & Chang'an + legitimacy 80).
`;
}
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)}${esc(ch.hint ?? "")} `;
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 ? `${html}
` : "";
}
showBattleResult(rec, extraHTML, onClose) {
const won = rec.playerWon;
const box = $("#battle-result");
box.classList.remove("hidden");
box.innerHTML = `
${won ? "VICTORY" : "DEFEAT"} — ${esc(rec.title)}
Your losses: ${fmt(rec.playerLosses ?? 0)} · Enemy losses: ${fmt(rec.enemyLosses ?? 0)}
${extraHTML ?? ""}
Continue
`;
$("#br-close").onclick = () => { box.classList.add("hidden"); onClose?.(); };
}
}
function clampN(v) { return Math.max(0, Math.min(100, Number(v) || 0)); }