diff --git a/app.py b/app.py
index f0fa9d5..a9700de 100644
--- a/app.py
+++ b/app.py
@@ -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 = []
for player in world["players"].values():
action = game.ACTION_BY_ID[player["action"]["id"]] if player["action"] else None
+ game.regen(world, player, now)
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"],
"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},
@@ -287,7 +293,7 @@ class Handler(BaseHTTPRequestHandler):
if not isinstance(qty, int):
return "Bad quantity"
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":
game.stop(player)
return None
@@ -406,7 +412,7 @@ class Handler(BaseHTTPRequestHandler):
"city": city_view(world, now),
"upcoming": game.event_view(world, upcoming, now) if upcoming else None,
"history": history,
- "players": roster(world),
+ "players": roster(world, now),
"ledger": world["ledger"][-80:],
"board": world["board"][-80:],
}
diff --git a/game.py b/game.py
index 1b01e0c..7989606 100644
--- a/game.py
+++ b/game.py
@@ -642,7 +642,7 @@ def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summar
def empty_summary() -> dict:
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:
@@ -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)
summary["done"] += 1
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:
current["start"] = now
return summary
@@ -683,14 +693,22 @@ def stop(player: dict, reason: str | None = None) -> None:
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)
if not action:
return "No such action"
reason = can_start(world, player, action)
if 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
return None
diff --git a/static/app.js b/static/app.js
index 6c8b416..9d9ecf6 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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`);
for (const [skill, level] of sum.levels) toast(`🎉 ${skillOf(skill).name} level ${level}!`, "level");
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) {
@@ -217,6 +218,7 @@ function sidebar() {
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
You
+ ${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`;
let group = null;
for (const skill of C.skills) {
@@ -242,11 +244,12 @@ function devbar() {
`;
}
-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() {
let title = TITLES[page] || "Guildhall";
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";
let chips = "";
if (S.me?.action) {
@@ -276,6 +279,8 @@ function pageHtml() {
if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id);
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)();
}
@@ -386,9 +391,12 @@ function actionCard(a) {
${io ? `${io}
` : ""}
${extra}
${active ? `
` : ""}
+ ${active && me.action.left ? `Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
` : ""}
`;
}
@@ -619,10 +627,86 @@ function roster() {
${esc(p.name)} ${cls.name} · total level ${p.total}
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
✨ ${fmt(p.donated)} given to the city
- ${levels}
`;
+ ${levels}
+ `;
}).join("")}`;
}
+// --- 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 `No such adventurer.
`;
+ 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 `
+
${label}
+ ${id ? `
${it.icon}${esc(it.name)}${stat}
+ ${mine ? `` : ""}
`
+ : `
${empty}
`}
+
`;
+ };
+
+ 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) ? `${cls.icon}` : "")
+ + (tool ? `${item(tool).icon}` : "");
+ return `<${mine ? `button data-nav="skill:${s.id}"` : "div"} class="sheet-skill">
+ ${s.icon}
+ ${s.name}${marks}${level}
+
+ ${mine ? "button" : "div"}>`;
+ }).join("");
+
+ return `
+
+
${cls.icon}
+
+
${esc(p.name)}
+
${cls.name} · total level ${total} · ${days === 1 ? "first day" : `${days} days`} in the city
+
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
+
+
+
+
Health
${fmt(p.hp)} / ${p.max_hp}
+
Combat power
${p.power}
${guard} in the garrison
+
Armour
${p.armour}
${Math.round(p.reduction * 100)}% less damage
+
Given to the city
${fmt(p.donated)}
${fmt(p.favour)} favour to spend
+ ${mine ? `
Purse
${fmt(p.gold)}
${Object.keys(p.bank).length} / ${p.slots} bank slots
` : ""}
+
+
+
+
Class
+
${cls.icon} ${cls.name}
+
${esc(cls.bonus)}.
+
${esc(cls.signature)}.
+
+
Equipment
+
${Object.entries(SLOT_NAMES).map(([slot, label]) => gearSlot(slot, label, "Nothing")).join("")}
+
Tools
+
${C.skills.filter((s) => `tool_${s.id}` in p.equip).map((s) => gearSlot(`tool_${s.id}`, s.name, "No tool")).join("")}
+ ${mine ? `
Equip gear and tools from the .
` : ""}
+
+
+
+
`;
+}
+
// --- registration ---------------------------------------------------------
function register() {
@@ -684,6 +768,7 @@ document.addEventListener("click", (e) => {
render(); window.scrollTo(0, 0); return;
}
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 ("select" in d) {
selected = d.select || null;
diff --git a/static/styles.css b/static/styles.css
index 48863d7..15170af 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -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 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 --------------------------------------------------------- */
.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; }
.btn.sm { padding: 6px 11px; }
}
+.linkish { background: none; border: 0; padding: 0; color: var(--xp); text-decoration: underline; font: inherit; }
diff --git a/tests/test_game.py b/tests/test_game.py
index 7403e25..a7d7d5f 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -37,6 +37,29 @@ class ActionTest(unittest.TestCase):
self.assertEqual(p["xp"]["mining"], 50)
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):
world = world_with(("pia", "pioneer"))
p = world["players"]["pia"]