Townsfolk mood: needs, kindling, clothes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 10:46:43 +00:00
parent 002e990dc4
commit 58a3e66454
6 changed files with 385 additions and 30 deletions

View file

@ -934,12 +934,16 @@ function cityNeed(id) {
const it = item(id), fund = fundNeeds()[id];
let qty = fund?.qty || 0;
const why = [...(fund?.why || [])];
for (const r of S.upcoming?.requests || []) {
const short = r.want - r.have;
const counts = r.any === "food" ? it.food : r.any ? (it.slot || it.kit) === r.any : id === r.item;
if (short <= 0 || !counts) continue;
qty += r.any === "food" ? Math.ceil(short / it.food) : short;
why.push(`Raid readiness: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`);
const townsfolk = Object.values(S.city.happiness.needs).flatMap((n) => n.rows).filter((r) => !r.kinds);
for (const [source, rows] of [["Raid readiness", S.upcoming?.requests || []], ["Townsfolk", townsfolk]]) {
for (const r of rows) {
const short = r.want - r.have;
const counts = r.any === "food" ? it.food : r.any === "fuel" ? id === "kindling" || C.firewood.includes(id)
: r.any ? (it.slot || it.kit) === r.any : id === r.item;
if (short <= 0 || !counts) continue;
qty += r.any === "food" ? Math.ceil(short / it.food) : short;
why.push(`${source}: ${r.label} · ${fmt(r.have)} / ${fmt(r.want)}`);
}
}
return qty ? { qty, why } : null;
}
@ -1140,7 +1144,9 @@ function firekeepingPage() {
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span>
${tendingAll ? `<button class="btn danger" data-stop>Stop</button>`
: `<button class="btn primary" data-start="${all}" ${allWhy ? "disabled" : ""} title="${esc(allWhy || "Each log goes on the lowest fire with room in your pile")}">🔥 Tend all</button>`}</div>
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div>`;
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}</div>
<div class="section-title">Kindling</div>
<div class="grid">${C.actions.filter((a) => a.skill === "firekeeping" && a.group === "Kindling").map(actionCard).join("")}</div>`;
}
function fireCard(id, pick, target) {
@ -1972,18 +1978,22 @@ function town() {
const city = S.city;
const growth = city.starving ? `<span class="bad">Hungry: ${city.growth} a day</span>`
: city.growth > 0 ? `+${city.growth} a day`
: city.growth < 0 && city.leaving ? `<span class="bad">Leaving: ${city.growth} a day</span>`
: city.growth < 0 ? `<span class="bad">Overcrowded: ${city.growth} a day</span>`
: city.halted ? `<span class="warn">Halted: mood</span>`
: `<span class="warn">Full: build houses</span>`;
const next = city.next_tier ? `${city.next_tier.name} at ${city.next_tier.pop} people` : "Largest tier";
let html = `<div class="stats">
<div class="stat"><div class="k">Settlement</div><div class="v">${city.tier_name}</div><div class="note">${next}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div><div class="note">${growth}</div></div>
<div class="stat"><div class="k">Food</div><div class="v ${city.food_days < 1 ? "bad" : ""}">${city.food_days ?? 0} <small>days</small></div><div class="note">${fmt(city.food)} meals in the granary</div></div>
${moodStat()}
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div><div class="note">Day ${city.day}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div><div class="note">${city.repelled} raid${city.repelled === 1 ? "" : "s"} repelled</div></div>
${weatherCard()}
</div>`;
html += happeningsPanel();
html += moodPanel();
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>`;
@ -1999,6 +2009,35 @@ function town() {
return html;
}
// --- townsfolk ---------------------------------------------------------------
const NEED_NAMES = { food: ["🍲", "Food"], warmth: ["🔥", "Firewood"], clothes: ["👕", "Clothes"] };
const NEED_TIERS = { warmth: "Village", clothes: "Town" };
const pct = (x) => `${Math.round(x * 100)}%`;
function moodStat() {
const h = S.city.happiness;
const trend = Math.abs(h.target - h.score) < .005 ? "Steady" : `${h.target > h.score ? "↗" : "↘"} ${h.heading}`;
return `<div class="stat" title="${esc(`Mood ${pct(h.score)}, heading to ${pct(h.target)}`)}"><div class="k">Mood ${h.icon}</div>
<div class="v ${h.score < .4 ? "bad" : ""}">${h.name}</div><div class="note">${trend}</div></div>`;
}
function moodPanel() {
const h = S.city.happiness, e = h.effects;
const x = (v) => `×${+v.toFixed(2)}`;
const needs = Object.entries(h.needs).map(([key, n]) => `<div class="need">
<div class="need-head"><b>${NEED_NAMES[key][0]} ${NEED_NAMES[key][1]}</b><span class="muted small">${pct(n.score)}</span></div>
${n.rows.map(reqRow).join("")}</div>`).join("");
const locked = h.locked.map((key) => `<span class="tag" title="Needed from ${NEED_TIERS[key]}">🔒 ${NEED_NAMES[key][1]} · ${NEED_TIERS[key]}</span>`).join("");
const mods = h.mods.map((m) => `<span class="tag ${m.value < 0 ? "bad" : "good"}">${esc(m.name)} ${m.value > 0 ? "+" : "−"}${pct(Math.abs(m.value))}</span>`).join("");
const effects = [`🌱 Growth ${x(e.growth)}`, `🛡️ Guards ${x(e.militia)}`, `🔨 Labour ${x(e.work)}`, `${COIN} Taxes ${x(e.work)}`,
...(e.leave ? [`<span class="bad">🚶 ${pct(e.leave)} leave a day</span>`] : [])];
return `<div class="panel mood"><h2>${h.icon} Townsfolk · ${h.name}</h2>
<div class="mood-bar" title="${esc(`Now ${pct(h.score)} · heading to ${pct(h.target)}`)}"><span style="width:${h.score * 100}%;--c:var(--${h.score < .4 ? "bad" : h.score < .6 ? "warn" : "good"})"></span><i style="left:${h.target * 100}%"></i></div>
<div class="mood-effects small">${effects.join(" · ")}</div>
<div class="needs">${needs}</div>
${mods || locked ? `<div class="mood-tags">${mods}${locked}</div>` : ""}</div>`;
}
function lookChip(id) {
const c = C.cosmetics[id];
if (c.kind === "colour") return `<span class="swatch" style="background:${c.value}"></span>`;
@ -2285,16 +2324,19 @@ function favourSpends() {
</div>`;
}
// A have / need row with its bar, and a Give button when you hold the item.
function reqRow(r) {
const mine = !r.kinds && S.me.bank[r.item] || 0;
return `<div class="req ${r.next ? "dim" : ""}"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${r.want ? Math.min(100, r.have / r.want * 100) : 100}%"></span></div></div>`;
}
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: "Paid guards (barracks)", weapons: "Weapons the guards hold", shields: "Shields the guards hold", jerkins: "Body armour the guards wear", salves: "Salves", 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>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join("");
const reqs = (ev.requests || []).map(reqRow).join("");
return `<div class="cols">
<div class="panel"><h2>Raid readiness</h2>${reqs}
<p class="small muted">Stockpile gear arms the defenders, best first. Fights use up arrows and break some gear.</p></div>