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:
parent
d2991ebf7a
commit
34dc85284d
5 changed files with 167 additions and 9 deletions
12
app.py
12
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:],
|
||||
}
|
||||
|
|
|
|||
24
game.py
24
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)}
|
||||
<div class="navhead">You</div>
|
||||
${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() {
|
|||
</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() {
|
||||
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 ? `<div class="io">${io}</div>` : ""}
|
||||
${extra}
|
||||
${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">
|
||||
<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>`;
|
||||
}
|
||||
|
|
@ -619,10 +627,86 @@ function roster() {
|
|||
<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 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>`;
|
||||
}
|
||||
|
||||
// --- 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 ---------------------------------------------------------
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue