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}`)} + ${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())}` : ""}
` : ""}
${!active && why ? esc(why) : ""} - ${active ? `` : ``} + ${active ? `` + : ` + `}
`; } @@ -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} + + `; + }).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 .

` : ""} +
+
+

Skills

${skillRows}
+
`; +} + // --- 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"]