Gold: guard wages, raid retune, strongbox, caravan wares, looks, monuments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9d61de2012
commit
cd44709d80
6 changed files with 816 additions and 31 deletions
119
static/app.js
119
static/app.js
|
|
@ -34,6 +34,11 @@ const $ = (sel, root = document) => root.querySelector(sel);
|
|||
|
||||
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
|
||||
const glyph = (name, tint) => `<svg class="gi" style="color:${tint}" aria-hidden="true"><use href="/static/icons.svg#${name}"></use></svg>`;
|
||||
// A player's banner: field and trim colours, with their emblem on it.
|
||||
const crest = (look, h = 26) => look ? `<svg class="crest" width="${Math.round(h * 0.8)}" height="${h}" viewBox="0 0 30 38" aria-hidden="true">
|
||||
<path d="M2 2h26v24l-13 10L2 26z" fill="${look.field}" stroke="${look.trim}" stroke-width="2.5"></path>
|
||||
${look.emblem ? `<use href="/static/icons.svg#${look.emblem}" x="6" y="6" width="18" height="18" style="color:${look.trim}"></use>` : ""}</svg>` : "";
|
||||
const titled = (p) => `${crest(p.look)} <b>${esc(p.name)}</b>${p.look?.title ? ` <span class="title">${esc(p.look.title)}</span>` : ""}`;
|
||||
const COIN = glyph("two-coins", "#e0b04a"), ANY_FOOD = glyph("knife-fork", "#d9c6a0"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c");
|
||||
|
||||
function iconise(content) {
|
||||
|
|
@ -1329,6 +1334,7 @@ function bank() {
|
|||
<br>Overflow earns half favour; what the city can't use is sold.</p>
|
||||
</div>
|
||||
${favourPanel()}
|
||||
${strongboxPanel()}
|
||||
</div>
|
||||
<div>${selected ? itemPanel(selected) : goldPanel()}</div>
|
||||
</div>`;
|
||||
|
|
@ -1387,6 +1393,13 @@ function goldPanel() {
|
|||
<p class="small muted">1 favour per gold.</p></div>`;
|
||||
}
|
||||
|
||||
function strongboxPanel() {
|
||||
const me = S.me;
|
||||
return `<div class="panel"><h2>Strongbox${me.rented ? ` · ${me.rented} rented` : ""}</h2>
|
||||
<div class="btns"><button class="btn gold" data-rent ${me.gold < me.rent_cost ? "disabled" : ""}>+1 bank slot (${COIN} ${fmt(me.rent_cost)})</button></div>
|
||||
<p class="small muted">Yours for good. Each costs 35% more.</p></div>`;
|
||||
}
|
||||
|
||||
function favourPanel() {
|
||||
const me = S.me;
|
||||
return `<div class="panel"><h2>Favour with the council</h2>
|
||||
|
|
@ -1413,12 +1426,14 @@ function town() {
|
|||
${weatherCard()}
|
||||
</div>`;
|
||||
html += happeningsPanel();
|
||||
html += caravanPanel();
|
||||
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>Granary empty.</b> People are leaving, defence is down. Donate food.</div></div>`;
|
||||
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>Merchant in town.</b> Sell prices +50% for ${countdown(city.merchant_until)}.</div></div>`;
|
||||
if (city.construction) html += siteBanner();
|
||||
html += `<div class="section-title">Buildings</div>
|
||||
<p class="small muted">Funds are safe from raiders. Building starts once a fund is full.</p>
|
||||
<div class="grid wide">${Object.entries(city.buildings).map(([key, b]) => buildingCard(key, b)).join("")}</div>`;
|
||||
html += monumentsPanel();
|
||||
html += `<div class="section-title">City stockpile</div><div class="panel">`;
|
||||
const stock = Object.entries(city.stockpile);
|
||||
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty.</p>`;
|
||||
|
|
@ -1426,6 +1441,60 @@ function town() {
|
|||
return html;
|
||||
}
|
||||
|
||||
function lookChip(id) {
|
||||
const c = C.cosmetics[id];
|
||||
if (c.kind === "colour") return `<span class="swatch" style="background:${c.value}"></span>`;
|
||||
if (c.kind === "emblem") return glyph(emblemGlyph(id), "#e7e9f0");
|
||||
return `<span class="ico">📜</span>`;
|
||||
}
|
||||
|
||||
const emblemGlyph = (id) => C.cosmetics[id].value || C.actions.find((a) => a.id === C.cosmetics[id].boss).glyph;
|
||||
|
||||
function caravanPanel() {
|
||||
const c = S.me.caravan;
|
||||
if (!c) return "";
|
||||
const rows = c.wares.map((w) => {
|
||||
const price = w.skill ? ` · ${COIN} ${fmt(w.prices[w.skill])}` : "";
|
||||
const picker = w.skill ? "" : `<select data-keep="tutor-skill">${Object.keys(w.prices).map((s) =>
|
||||
`<option value="${s}">${skillOf(s).name} · ${fmt(w.prices[s])}</option>`).join("")}</select>`;
|
||||
const per = w.charges ? `next ${fmt(w.charges)} ${w.per}` : "against the coming threat";
|
||||
return `<div class="ware">${glyph(...w.glyph)}<span class="grow"><b>${esc(w.name)}</b>
|
||||
<span class="small">${w.skill ? `${skillOf(w.skill).icon} ` : ""}${esc(w.text)} · ${per}</span>${picker}</span>
|
||||
<button class="btn sm gold" data-buy="${w.id}" ${w.bought ? "disabled" : ""}>${w.bought ? "Bought" : `Buy${price}`}</button></div>`;
|
||||
});
|
||||
const look = c.look, have = look.owned || look.bought;
|
||||
rows.push(`<div class="ware">${lookChip(look.id)}<span class="grow"><b>${esc(look.name)}</b><span class="small muted">For your banner (character sheet)</span></span>
|
||||
<button class="btn sm gold" data-buy="${look.id}" ${have ? "disabled" : ""}>${have ? "Owned" : `Buy · ${COIN} ${fmt(look.price)}`}</button></div>`);
|
||||
return `<div class="panel"><h2>🐫 Caravan · leaves in ${countdown(c.until)}</h2><div class="wares">${rows.join("")}</div>
|
||||
<p class="small muted">One of each per visit. Wares boost your own work. Pricier the higher your level.</p></div>`;
|
||||
}
|
||||
|
||||
function monumentsPanel() {
|
||||
const m = S.city.monuments, me = S.me;
|
||||
const rows = Object.entries(C.monuments).map(([key, spec]) => {
|
||||
if (spec.once && m.built.some((b) => b.kind === key)) return "";
|
||||
const head = `<span class="ico">${glyph(spec.glyph, spec.tint)}</span>`;
|
||||
if (key === "statue" && m.carving) {
|
||||
const pick = m.carving.by === S.uid
|
||||
? `<span class="btns"><select data-keep="statue-of">${S.players.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select>
|
||||
<button class="btn sm gold" data-statue>Carve</button></span>`
|
||||
: `<span class="small muted">${esc(playerName(m.carving.by))} picks who it honours.</span>`;
|
||||
return `<div class="favour-option">${head}<span class="grow"><b>${spec.name}</b><span class="small muted">Funded. Who should it honour?</span></span>${pick}</div>`;
|
||||
}
|
||||
const fund = m.funds[key] || { gold: 0 };
|
||||
return `<div class="favour-option">${head}<span class="grow"><b>${spec.name}</b><span class="small muted">${esc(spec.text)}</span>
|
||||
<span class="small">${COIN} ${fmt(fund.gold)} / ${fmt(spec.cost)}</span><span class="bar"><span style="width:${fund.gold / spec.cost * 100}%"></span></span></span>
|
||||
<form data-form="monument" data-id="${key}" class="btns"><input type="number" min="1" data-keep="mon-${key}" placeholder="Gold" style="width:90px"><button class="btn sm gold" ${me.gold ? "" : "disabled"}>Give</button></form></div>`;
|
||||
}).join("");
|
||||
const built = m.built.map((b) => {
|
||||
const spec = C.monuments[b.kind];
|
||||
const donors = Object.entries(b.donors).sort((x, y) => y[1] - x[1]).map(([uid]) => esc(playerName(uid))).join(", ");
|
||||
return `<div class="report win"><b>${glyph(spec.glyph, spec.tint)} ${b.of ? `Statue of ${esc(playerName(b.of))}` : spec.name}</b> <span class="muted small">${ago(b.at)}</span><p class="small">Carved in: ${donors}</p></div>`;
|
||||
}).join("");
|
||||
return `<div class="section-title">Monuments</div><div class="panel">${rows}${built}
|
||||
<p class="small muted">Looks only. Gold here earns no favour: your name goes in the stone.</p></div>`;
|
||||
}
|
||||
|
||||
// Progress towards a goal favour can boost. Favour doubles the next `boost` points players put
|
||||
// in, so the striped stretch behind the bar (twice the boost) is where work counts double.
|
||||
function boostBar(done, total, boost, tip) {
|
||||
|
|
@ -1521,6 +1590,10 @@ function skillHappenings(skill) {
|
|||
: `<button class="btn" data-nav="town">Help on the Town square</button>`;
|
||||
banner("bad", p.icon, p.name, p.text, button);
|
||||
}
|
||||
for (const b of S.me.boosts.filter((x) => x.skill === skill)) {
|
||||
const w = C.wares[b.ware];
|
||||
banner("good", glyph(...w.glyph), w.name, `${w.text} · ${b.left ? `${fmt(b.left)} ${w.per} left` : "until the threat lands"}`);
|
||||
}
|
||||
return banners.join("");
|
||||
}
|
||||
|
||||
|
|
@ -1654,7 +1727,8 @@ function favourSpends() {
|
|||
|
||||
function raidPrep(ev) {
|
||||
const city = S.city, me = S.me, d = city.defence;
|
||||
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" };
|
||||
const names = { mercenaries: "Hired sellswords", ward: "Divine ward (temple)", rallied: "Rallied militia (favour)", drilled: "Drilled militia", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Paid guards (barracks)", weapons: "Swords the guards hold", shields: "Shields the guards hold", arrows: "Arrows", garrison: "Garrison" };
|
||||
const full = d.posts * d.wage, owed = Math.max(0, full - d.wages);
|
||||
const reqs = (ev.requests || []).map((r) => {
|
||||
const mine = me.bank[r.item] || 0;
|
||||
return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
|
||||
|
|
@ -1664,7 +1738,14 @@ function raidPrep(ev) {
|
|||
return `<div class="cols">
|
||||
<div class="panel"><h2>Council requests</h2>${reqs}
|
||||
<p class="small muted">Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.</p></div>
|
||||
<div class="panel"><h2>Defence</h2>
|
||||
<div class="panel"><h2>Guards · ${d.guards} / ${d.posts} paid</h2>
|
||||
<p class="small">Wage chest ${COIN} ${fmt(d.wages)} / ${fmt(full)} · ${d.wage} gold a guard per threat</p>
|
||||
<div class="bar"><span style="width:${full ? Math.min(100, d.wages / full * 100) : 0}%"></span></div>
|
||||
<div class="btns" style="margin-top:8px">
|
||||
<button class="btn sm gold" data-wages="bank" ${!owed || !me.gold ? "disabled" : ""}>Pay ${COIN} ${fmt(Math.min(owed, me.gold))}</button>
|
||||
<button class="btn sm" data-wages="city" ${!owed || city.treasury < 1 ? "disabled" : ""}>From the treasury (${fmt(city.treasury)})</button></div>
|
||||
<p class="small muted">Only paid guards stand, and only they hold the armoury's swords and shields. Paying earns favour.</p>
|
||||
<h2 style="margin-top:16px">Defence</h2>
|
||||
<table class="parts">${Object.entries(d.parts).filter(([k, v]) => k !== "rallied" || v).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")}
|
||||
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>
|
||||
<h2 style="margin-top:16px">Garrison</h2>
|
||||
|
|
@ -1854,7 +1935,7 @@ function orders() {
|
|||
// --- board & roster -------------------------------------------------------
|
||||
|
||||
function board() {
|
||||
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${esc(playerName(m.who))}</span><span class="when">${ago(m.at)}</span>
|
||||
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${crest(S.players.find((p) => p.id === m.who)?.look, 18)} ${esc(playerName(m.who))}</span><span class="when">${ago(m.at)}</span>
|
||||
${m.who === S.uid && m.id ? `<button class="msg-delete" data-unpost="${m.id}" title="Delete this message">✕</button>` : ""}
|
||||
<div class="text">${esc(m.text)}</div></div>`).join("");
|
||||
const ledger = [...S.ledger].reverse().map((l) => `<li>${esc(l.text)}<span class="when">${ago(l.at)}</span></li>`).join("");
|
||||
|
|
@ -1878,7 +1959,7 @@ function roster() {
|
|||
const cls = C.classes[p.cls];
|
||||
const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `<div class="lvl ${p.levels[s.id] <= 1 ? "low" : ""}" title="${s.name} level ${p.levels[s.id]}"><span class="ico">${s.icon}</span><span class="nm">${s.name}</span><b>${p.levels[s.id]}</b></div>`).join("");
|
||||
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
|
||||
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</span>
|
||||
${titled(p)} <span class="muted small">${cls.name} · total level ${p.total} · ${esc(p.look.home)}</span>
|
||||
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
|
||||
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
|
||||
<div class="levels">${levels}</div>
|
||||
|
|
@ -1955,11 +2036,30 @@ function characterSheet(uid) {
|
|||
<p class="small">${esc(cls.combat)}.</p>
|
||||
</div>
|
||||
${equipmentPanel(p, mine)}
|
||||
${mine ? lookPanel(p) : ""}
|
||||
</div>
|
||||
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function lookPanel(p) {
|
||||
const look = p.look, wear = (kind) => Object.keys(C.cosmetics).filter((k) => C.cosmetics[k].kind === kind && (C.free_looks.includes(k) || p.owned.includes(k)));
|
||||
const swatches = (part) => wear("colour").map((k) => `<button class="swatch ${look[part] === k ? "on" : ""}" style="background:${C.cosmetics[k].value}"
|
||||
data-look="${part}" data-value="${k}" title="${esc(C.cosmetics[k].name)}" aria-label="${esc(C.cosmetics[k].name)}"></button>`).join("");
|
||||
const emblems = [`<button class="btn sm ${look.emblem ? "" : "gold"}" data-look="emblem" data-value="">None</button>`,
|
||||
...wear("emblem").map((k) => `<button class="btn sm ${look.emblem === k ? "gold" : ""}" data-look="emblem" data-value="${k}" title="${esc(C.cosmetics[k].name)}" aria-label="${esc(C.cosmetics[k].name)}">${glyph(emblemGlyph(k), "#e7e9f0")}</button>`)].join("");
|
||||
const home = C.homes.findIndex(([name]) => name === p.shown.home), next = C.homes[home + 1];
|
||||
return `<div class="panel"><h2>Banner & title</h2>
|
||||
<div class="look-preview">${crest(p.shown, 48)}<span><b>${esc(p.name)}</b>${p.shown.title ? ` <span class="title">${esc(p.shown.title)}</span>` : ""}</span></div>
|
||||
<p class="small muted">Field</p><div class="swatches">${swatches("field")}</div>
|
||||
<p class="small muted">Trim</p><div class="swatches">${swatches("trim")}</div>
|
||||
<p class="small muted">Emblem</p><div class="btns">${emblems}</div>
|
||||
<p class="small muted">Title</p><select data-look-title><option value="">None</option>${p.titles.map((t) => `<option ${look.title === t ? "selected" : ""}>${esc(t)}</option>`).join("")}</select>
|
||||
<p class="small muted" style="margin-top:12px">Home · ${esc(p.shown.home)}</p>
|
||||
${next ? `<button class="btn sm gold" data-home ${p.gold < next[1] ? "disabled" : ""}>${esc(next[0])} (${COIN} ${fmt(next[1])})</button>` : `<p class="small">As grand as it gets.</p>`}
|
||||
<p class="small muted">Dyes, emblems and titles come with the caravan. Titles are also earned: master a skill (50), defend the city, slay a dragon.</p></div>`;
|
||||
}
|
||||
|
||||
// The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with
|
||||
// room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded.
|
||||
// Picking a slot shows what's in it beside the grid.
|
||||
|
|
@ -2242,6 +2342,12 @@ document.addEventListener("click", (e) => {
|
|||
}
|
||||
if (d.aid) return act("/api/aid", { kind: d.aid, item: d.item, source: d.source });
|
||||
if ("hire" in d) return act("/api/hire", {});
|
||||
if (d.wages) return act("/api/wages", { source: d.wages, qty: "all" });
|
||||
if ("rent" in d) return act("/api/rent", {});
|
||||
if (d.buy) return act("/api/buy", { ware: d.buy, skill: $("[data-keep=tutor-skill]")?.value });
|
||||
if ("statue" in d) return act("/api/statue", { of: $("[data-keep=statue-of]").value });
|
||||
if ("home" in d) return act("/api/home", {});
|
||||
if (d.look) return act("/api/look", { look: { ...S.me.look, [d.look]: d.value || null } });
|
||||
if (d.devHappen) return act("/api/dev/happen", { track: d.devHappen });
|
||||
if (d.pray) {
|
||||
const on = S.me.prayers.includes(d.pray);
|
||||
|
|
@ -2276,6 +2382,7 @@ document.addEventListener("input", (e) => {
|
|||
});
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
if ("lookTitle" in e.target.dataset) return act("/api/look", { look: { ...S.me.look, title: e.target.value || null } });
|
||||
const key = e.target.dataset.draft;
|
||||
if (!key || !draft) return;
|
||||
if (key === "item") {
|
||||
|
|
@ -2321,6 +2428,10 @@ document.addEventListener("submit", async (e) => {
|
|||
field.value = "";
|
||||
return act("/api/board", { text: value });
|
||||
}
|
||||
if (form.dataset.form === "monument") {
|
||||
field.value = "";
|
||||
return act("/api/monument", { id: form.dataset.id, qty: Number(value) });
|
||||
}
|
||||
if (form.dataset.form === "gold") {
|
||||
field.value = "";
|
||||
return act("/api/donate", { item: "gold", qty: Number(value) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue