Character sheets; make one then go back to what you were doing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 19:35:21 +01:00
parent d2991ebf7a
commit 34dc85284d
5 changed files with 167 additions and 9 deletions

12
app.py
View file

@ -144,11 +144,17 @@ def city_view(world: dict, now: float) -> dict:
} }
def roster(world: dict) -> list[dict]: def roster(world: dict, now: float) -> list[dict]:
"""What everyone can see about each other: no banks, no gold."""
people = [] people = []
for player in world["players"].values(): for player in world["players"].values():
action = game.ACTION_BY_ID[player["action"]["id"]] if player["action"] else None action = game.ACTION_BY_ID[player["action"]["id"]] if player["action"] else None
game.regen(world, player, now)
people.append({ people.append({
"xp": player["xp"], "equip": player["equip"], "joined": player["joined"],
"hp": int(player["hp"]), "max_hp": game.max_hp(player), "power": game.combat_power(player),
"armour": game.armour(player), "reduction": round(game.damage_reduction(player), 3),
"favour": player["favour"],
"id": player["id"], "name": player["name"], "cls": player["cls"], "id": player["id"], "name": player["name"], "cls": player["cls"],
"total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS), "total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS),
"levels": {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}, "levels": {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS},
@ -287,7 +293,7 @@ class Handler(BaseHTTPRequestHandler):
if not isinstance(qty, int): if not isinstance(qty, int):
return "Bad quantity" return "Bad quantity"
if path == "/api/action": if path == "/api/action":
return game.start_action(world, player, str(body.get("id")), now) return game.start_action(world, player, str(body.get("id")), now, once=bool(body.get("once")))
if path == "/api/stop": if path == "/api/stop":
game.stop(player) game.stop(player)
return None return None
@ -406,7 +412,7 @@ class Handler(BaseHTTPRequestHandler):
"city": city_view(world, now), "city": city_view(world, now),
"upcoming": game.event_view(world, upcoming, now) if upcoming else None, "upcoming": game.event_view(world, upcoming, now) if upcoming else None,
"history": history, "history": history,
"players": roster(world), "players": roster(world, now),
"ledger": world["ledger"][-80:], "ledger": world["ledger"][-80:],
"board": world["board"][-80:], "board": world["board"][-80:],
} }

24
game.py
View file

@ -642,7 +642,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict: def empty_summary() -> dict:
return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0, return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], "eaten": {}, "damage": 0,
"gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None} "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None, "resumed": None}
def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict: def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict:
@ -673,6 +673,16 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
_complete(world, player, action, current["start"] + duration, rng, summary) _complete(world, player, action, current["start"] + duration, rng, summary)
summary["done"] += 1 summary["done"] += 1
current["start"] += duration current["start"] += duration
if player["action"] is current and "left" in current:
current["left"] -= 1
if current["left"] <= 0:
back = current.get("resume")
if back:
player["action"] = current = {"id": back, "start": current["start"]}
action = ACTION_BY_ID[back]
summary["resumed"] = back
else:
stop(player)
if player["action"] and limit < now and current["start"] < now: if player["action"] and limit < now and current["start"] < now:
current["start"] = now current["start"] = now
return summary return summary
@ -683,14 +693,22 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason player["stopped"] = reason
def start_action(world: dict, player: dict, action_id: str, now: float) -> str | None: def start_action(world: dict, player: dict, action_id: str, now: float, once: bool = False) -> str | None:
"""Start an action. `once` does it a single time, then goes back to what you were doing."""
action = ACTION_BY_ID.get(action_id) action = ACTION_BY_ID.get(action_id)
if not action: if not action:
return "No such action" return "No such action"
reason = can_start(world, player, action) reason = can_start(world, player, action)
if reason: if reason:
return reason return reason
player["action"] = {"id": action_id, "start": now} entry = {"id": action_id, "start": now}
if once:
current = player["action"]
back = current and (current.get("resume") or current["id"])
entry["left"] = 1
if back and back != action_id:
entry["resume"] = back
player["action"] = entry
player["stopped"] = None player["stopped"] = None
return None return None

View file

@ -134,6 +134,7 @@ function showSummary(sum) {
if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`); if (spilled.length) toast(`📦 Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level"); for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad");
if (sum.resumed) toast(`Back to ${C.actions.find((x) => x.id === sum.resumed).name.toLowerCase()}`);
} }
function awayModal(sum) { function awayModal(sum) {
@ -217,6 +218,7 @@ function sidebar() {
${nav("board", "💬", "Town board")} ${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`)} ${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
<div class="navhead">You</div> <div class="navhead">You</div>
${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; ${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`;
let group = null; let group = null;
for (const skill of C.skills) { for (const skill of C.skills) {
@ -242,11 +244,12 @@ function devbar() {
</div></div>`; </div></div>`;
} }
const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank" }; const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" };
function topbar() { function topbar() {
let title = TITLES[page] || "Guildhall"; let title = TITLES[page] || "Guildhall";
if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title; if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title;
if (page.startsWith("character:")) title = S.players.find((p) => p.id === page.slice(10))?.name || "Character";
if (!S.me) title = "Welcome to Guildhall"; if (!S.me) title = "Welcome to Guildhall";
let chips = ""; let chips = "";
if (S.me?.action) { if (S.me?.action) {
@ -276,6 +279,8 @@ function pageHtml() {
if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id); if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id);
page = "town"; page = "town";
} }
if (page.startsWith("character:")) return characterSheet(page.slice(10));
if (page === "character") return characterSheet(S.uid);
return ({ town, council, board, roster, bank }[page] || town)(); return ({ town, council, board, roster, bank }[page] || town)();
} }
@ -386,9 +391,12 @@ function actionCard(a) {
${io ? `<div class="io">${io}</div>` : ""} ${io ? `<div class="io">${io}</div>` : ""}
${extra} ${extra}
${active ? `<div class="bar thick"><span data-progress></span></div>` : ""} ${active ? `<div class="bar thick"><span data-progress></span></div>` : ""}
${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""}
<div class="foot"> <div class="foot">
<span class="why">${!active && why ? esc(why) : ""}</span> <span class="why">${!active && why ? esc(why) : ""}</span>
${active ? `<button class="btn danger" data-stop>Stop</button>` : `<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button>`} ${active ? `<button class="btn danger" data-stop>Stop</button>`
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${a.skill === "combat" ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
</div> </div>
</div>`; </div>`;
} }
@ -619,10 +627,86 @@ function roster() {
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</span> <b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</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">${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">✨ ${fmt(p.donated)} given to the city</div></div></div> <div class="small favour">✨ ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div></div>`; <div class="levels">${levels}</div>
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button></p></div>`;
}).join("")}</div>`; }).join("")}</div>`;
} }
// --- character sheet -----------------------------------------------------
const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" };
function characterSheet(uid) {
const mine = uid === S.uid;
const p = mine ? { ...S.players.find((x) => x.id === uid), ...S.me } : S.players.find((x) => x.id === uid);
if (!p) return `<p class="muted">No such adventurer.</p>`;
const cls = C.classes[p.cls];
const skills = C.skills.filter((s) => !s.class || s.class === p.cls);
const total = skills.reduce((sum, s) => sum + p.levels[s.id], 0);
const days = Math.max(1, Math.ceil((serverNow() - p.joined) / 86400));
const guard = Math.round(p.power * (p.cls === "warrior" ? 1.5 : 1));
const gearSlot = (slot, label, empty) => {
const id = p.equip[slot];
const it = id && item(id);
const stat = !it ? "" : it.tool ? `${Math.round(it.speed * 100)}% faster`
: [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", ");
return `<div class="sheet-slot ${id ? "" : "empty"}">
<div class="k">${label}</div>
${id ? `<div class="sheet-item"><span class="ico">${it.icon}</span><span><b>${esc(it.name)}</b><span class="muted small">${stat}</span></span>
${mine ? `<button class="btn sm" data-unequip="${slot}" title="Back to the bank">✕</button>` : ""}</div>`
: `<div class="muted small">${empty}</div>`}
</div>`;
};
const skillRows = skills.map((s) => {
const level = p.levels[s.id], xp = p.xp[s.id] || 0;
const floor = C.xp_table[level], ceil = C.xp_table[level + 1];
const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100;
const tool = p.equip[`tool_${s.id}`];
const marks = (cls.fast.includes(s.id) ? `<span title="${cls.name} bonus">${cls.icon}</span>` : "")
+ (tool ? `<span title="${esc(item(tool).name)}">${item(tool).icon}</span>` : "");
return `<${mine ? `button data-nav="skill:${s.id}"` : "div"} class="sheet-skill">
<span class="ico">${s.icon}</span>
<span class="grow"><span class="row1"><b>${s.name}</b><span class="marks">${marks}</span><span class="lv">${level}</span></span>
<span class="bar xp"><span style="width:${pct}%"></span></span></span>
</${mine ? "button" : "div"}>`;
}).join("");
return `
<div class="panel sheet-head">
<div class="big">${cls.icon}</div>
<div class="grow">
<h2>${esc(p.name)}</h2>
<div class="muted">${cls.name} · total level ${total} · ${days === 1 ? "first day" : `${days} days`} in the city</div>
<div class="small" style="margin-top:6px">${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>
</div>
<div class="stats">
<div class="stat"><div class="k">Health</div><div class="v">${fmt(p.hp)} <small>/ ${p.max_hp}</small></div><div class="bar hp" style="margin-top:6px"><span style="width:${p.hp / p.max_hp * 100}%"></span></div></div>
<div class="stat"><div class="k">Combat power</div><div class="v">${p.power}</div><div class="note">${guard} in the garrison</div></div>
<div class="stat"><div class="k">Armour</div><div class="v">${p.armour}</div><div class="note">${Math.round(p.reduction * 100)}% less damage</div></div>
<div class="stat"><div class="k">Given to the city</div><div class="v favour">${fmt(p.donated)}</div><div class="note">${fmt(p.favour)} favour to spend</div></div>
${mine ? `<div class="stat"><div class="k">Purse</div><div class="v gold">${fmt(p.gold)}</div><div class="note">${Object.keys(p.bank).length} / ${p.slots} bank slots</div></div>` : ""}
</div>
<div class="cols">
<div>
<div class="panel"><h2>Class</h2>
<p><b>${cls.icon} ${cls.name}</b></p>
<p class="small">${esc(cls.bonus)}.</p>
<p class="small">${esc(cls.signature)}.</p>
</div>
<div class="panel"><h2>Equipment</h2>
<div class="sheet-gear">${Object.entries(SLOT_NAMES).map(([slot, label]) => gearSlot(slot, label, "Nothing")).join("")}</div>
<h2 style="margin-top:16px">Tools</h2>
<div class="sheet-gear">${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}</div>
${mine ? `<p class="small muted">Equip gear and tools from the <button class="linkish" data-nav="bank">bank</button>.</p>` : ""}
</div>
</div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
</div>`;
}
// --- registration --------------------------------------------------------- // --- registration ---------------------------------------------------------
function register() { function register() {
@ -684,6 +768,7 @@ document.addEventListener("click", (e) => {
render(); window.scrollTo(0, 0); return; render(); window.scrollTo(0, 0); return;
} }
if (d.start) return act("/api/action", { id: d.start }); if (d.start) return act("/api/action", { id: d.start });
if (d.once) return act("/api/action", { id: d.once, once: true });
if ("stop" in d) return act("/api/stop", {}); if ("stop" in d) return act("/api/stop", {});
if ("select" in d) { if ("select" in d) {
selected = d.select || null; selected = d.select || null;

View file

@ -273,6 +273,31 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
.levels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 10px; font-size: 13px; margin-top: 10px; } .levels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 10px; font-size: 13px; margin-top: 10px; }
.levels span { color: var(--muted); } .levels span { color: var(--muted); }
/* --- character sheet -------------------------------------------------- */
.sheet-head { display: flex; gap: 16px; align-items: center; }
.sheet-head .big { font-size: 44px; width: 72px; height: 72px; display: grid; place-items: center; background: var(--panel-2); border-radius: 16px; flex: none; }
.sheet-head h2 { margin: 0; font-size: 24px; letter-spacing: 0; text-transform: none; color: var(--text); }
.grow { flex: 1; min-width: 0; }
.sheet-gear { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); }
.sheet-slot { background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; min-height: 64px; }
.sheet-slot.empty { background: transparent; border-style: dashed; }
.sheet-slot .k { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 4px; }
.sheet-item { display: flex; align-items: center; gap: 8px; }
.sheet-item .ico { font-size: 24px; }
.sheet-item > span:nth-child(2) { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.sheet-skills { display: flex; flex-direction: column; gap: 4px; }
.sheet-skill { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 8px; border: 0; background: none; text-align: left; width: 100%; }
button.sheet-skill:hover { background: var(--panel-2); }
.sheet-skill .ico { font-size: 20px; width: 26px; text-align: center; }
.sheet-skill .row1 { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.sheet-skill .row1 b { flex: 1; font-weight: 600; }
.sheet-skill .marks { font-size: 14px; }
.sheet-skill .lv { font-variant-numeric: tabular-nums; font-weight: 650; min-width: 22px; text-align: right; }
.sheet-skill .grow { display: flex; flex-direction: column; }
.sheet-skill .bar { display: block; height: 5px; }
.favour { color: var(--favour); }
/* --- register --------------------------------------------------------- */ /* --- register --------------------------------------------------------- */
.welcome { max-width: 760px; } .welcome { max-width: 760px; }
@ -340,3 +365,4 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
.nav { padding: 11px 18px; } .nav { padding: 11px 18px; }
.btn.sm { padding: 6px 11px; } .btn.sm { padding: 6px 11px; }
} }
.linkish { background: none; border: 0; padding: 0; color: var(--xp); text-decoration: underline; font: inherit; }

View file

@ -37,6 +37,29 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["xp"]["mining"], 50) self.assertEqual(p["xp"]["mining"], 50)
self.assertEqual(p["action"]["start"], T0 + 200) self.assertEqual(p["action"]["start"], T0 + 200)
def test_make_one_then_back_to_work(self):
world = world_with(("fred", "warrior"))
p = world["players"]["fred"]
p["bank"] = {"copper_bar": 10, "logs": 10}
game.start_action(world, p, "mine_stone", T0)
game.process_player(world, p, T0 + 100, random.Random(1))
p["xp"]["smithing"] = game.XP_TABLE[5]
self.assertIsNone(game.start_action(world, p, "smith_copper_pickaxe", T0 + 100, once=True))
summary = game.process_player(world, p, T0 + 100 + 30 + 40, random.Random(1))
self.assertEqual(p["bank"]["copper_pickaxe"], 1)
self.assertEqual(summary["resumed"], "mine_stone")
self.assertEqual(p["action"]["id"], "mine_stone")
self.assertEqual(p["bank"]["stone"], 5 + 2) # mining carried on from when the pickaxe was done
def test_make_one_while_idle_just_stops(self):
world = world_with(("fred", "warrior"))
p = world["players"]["fred"]
game.start_action(world, p, "cut_logs", T0, once=True)
game.process_player(world, p, T0 + 500, random.Random(1))
self.assertEqual(p["bank"], {"logs": 1})
self.assertIsNone(p["action"])
self.assertIsNone(p["stopped"])
def test_class_speed_and_lucky_yield(self): def test_class_speed_and_lucky_yield(self):
world = world_with(("pia", "pioneer")) world = world_with(("pia", "pioneer"))
p = world["players"]["pia"] p = world["players"]["pia"]