From 4b0724e6e55f2dc5c08eebc8bdf7359f205bf31f Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 13:20:26 +0000 Subject: [PATCH] Personal ledger on the character sheet Co-Authored-By: Claude Opus 5.5 --- game.py | 114 +++++++++++++++++++++++++++++++++++++++++---- static/app.js | 26 ++++++++++- static/styles.css | 1 + tests/test_game.py | 29 ++++++++++++ 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/game.py b/game.py index 4ae1ed2..42829f7 100644 --- a/game.py +++ b/game.py @@ -23,6 +23,7 @@ CLASS_SPEED = 0.8 LUCKY_CHANCE = 0.1 OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose MAX_LOG = 200 +PLAYER_LOG = 60 # lines in each player's own ledger REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour REST_RATE = 0.01 # share of max HP back per game second resting between fights... BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster @@ -1624,7 +1625,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 35 +WORLD_VERSION = 36 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1890,6 +1891,11 @@ def migrate(world: dict, now: float | None = None) -> dict: # Townsfolk needs: everyone starts Content and drifts to what the city gives them. world["city"].update(happiness=0.5, clothes_due=0.0, unheated=False) world["version"] = 35 + if world["version"] < 36: + # Your own ledger, from here on. + for player in world["players"].values(): + player.setdefault("ledger", []) + world["version"] = 36 return world @@ -2079,6 +2085,64 @@ def log(world: dict, now: float, kind: str, text: str, who: str | None = None) - del world["ledger"][:-MAX_LOG] +def note(world: dict, player: dict, now: float, verb: str, item: str | None = None, qty: int = 0, + gold: int = 0, who: str | None = None, label: str | None = None) -> None: + """A line in the player's own ledger: "Sold 40 bones". The same again within the hour adds up.""" + ledger = player["ledger"] + key = (verb, item, who, label) + entry = next((e for e in reversed(ledger[-10:]) if "verb" in e and (e["verb"], e.get("item"), e.get("who"), e.get("label")) == key + and now - e["at"] < HOUR / speed(world)), None) + if entry: + ledger.remove(entry) + else: + entry = {"verb": verb, **{k: v for k, v in (("item", item), ("who", who), ("label", label)) if v}} + entry["at"] = now + if qty: + entry["qty"] = entry.get("qty", 0) + qty + if gold: + entry["gold"] = entry.get("gold", 0) + gold + ledger.append(entry) + del ledger[:-PLAYER_LOG] + + +def _tally(action: dict, summary: dict) -> tuple: + """What a summary holds so far that track() counts: items got (kept, spilled, sold or delivered), XP, gold, wins, losses.""" + got = {} + for key in ("items", "overflow", "sold"): + for item, qty in summary[key].items(): + got[item] = got.get(item, 0) + qty + if summary.get("delivered") and action["outputs"]: + made = next(iter(action["outputs"])) + got[made] = got.get(made, 0) + summary["delivered"] + return got, sum(summary["xp"].values()), summary["gold"], summary["wins"], summary["losses"] + + +def track(world: dict, player: dict, action: dict, began: float, end: float, before: tuple, summary: dict) -> None: + """One action done, added to its stretch in the ledger: "Mine copper 路 3h 20m 路 250 copper ore". + A stretch goes on while the next one starts where the last ended (two crops take turns).""" + got, xp, gold, won, lost = _tally(action, summary) + old_got, old_xp, old_gold, old_won, old_lost = before + ledger = player["ledger"] + entry = next((e for e in reversed(ledger[-10:]) if e.get("act") == action["id"] + and began - e["at"] <= end - began + 300 / speed(world)), None) + if entry: + ledger.remove(entry) + else: + entry = {"act": action["id"], "start": began, "n": 0, "xp": 0, "gold": 0, "items": {}} + entry["at"] = end + entry["n"] += 1 + entry["xp"] = round(entry["xp"] + xp - old_xp, 1) + entry["gold"] += gold - old_gold + for item, qty in got.items(): + if qty > old_got.get(item, 0): + entry["items"][item] = entry["items"].get(item, 0) + qty - old_got.get(item, 0) + if "enemy" in action: + entry["won"] = entry.get("won", 0) + won - old_won + entry["lost"] = entry.get("lost", 0) + lost - old_lost + ledger.append(entry) + del ledger[:-PLAYER_LOG] + + def building_level(world: dict, key: str) -> int: """Effective level: a damaged building does nothing until repaired.""" building = world["city"]["buildings"][key] @@ -2151,6 +2215,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "garrison": False, "donated": 0, "gave": {}, # item (or "gold"): {qty, worth, favour} given to the city, see credit() + "ledger": [], # what you did, see note() and track() "pinned": [], # skills pinned to the top of the sidebar "tracked": None, # recipe shown under the top bar } @@ -3015,8 +3080,16 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: return None -def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict, +def _complete(world: dict, player: dict, action: dict, began: float, now: float, rng: random.Random, summary: dict, fight: dict | None = None, loot: tuple | None = None) -> None: + """Finish one action that ran from `began`, and put it in the player's ledger.""" + before = _tally(action, summary) + _finish_action(world, player, action, now, rng, summary, fight, loot) + track(world, player, action, began, now, before, summary) + + +def _finish_action(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict, + fight: dict | None = None, loot: tuple | None = None) -> None: """Finish one action. A shared fight comes in as `fight`, with this member's share of its `loot`.""" brew = potion_effect(player, action["skill"]) order = working_order(world, player, action) @@ -3217,7 +3290,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N """Run the player's action forward to `now` (capped at 24h past last_active).""" rng = rng or random.Random() summary = empty_summary() - collect_orders(world, player) + collect_orders(world, player, now) current = player["action"] if current and current.get("party"): if current["party"] in world["parties"]: @@ -3265,7 +3338,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N stop(player, "Bank full") summary["stopped"] = "Bank full" break - _complete(world, player, action, current["start"] + duration, rng, summary) + _complete(world, player, action, current["start"], current["start"] + duration, rng, summary) summary["done"] += 1 current["start"] += duration if current.get("plot2"): @@ -3645,7 +3718,7 @@ def process_party(world: dict, pid: str, now: float, rng: random.Random) -> None if results.get(player["id"], "left") == "left": continue summary = player.setdefault("away", empty_summary()) - _complete(world, player, action, end, rng, summary, fight=fight, loot=shares.get(player["id"])) + _complete(world, player, action, end - fight["duration"], end, rng, summary, fight=fight, loot=shares.get(player["id"])) summary["done"] += 1 together = summary.setdefault("with", {}) for other in members: @@ -3714,9 +3787,11 @@ def _clear_bonus(world: dict, player: dict, spec: dict, now: float, rng: random. summary = player.setdefault("away", empty_summary()) player["gold"] += spec["bonus_gold"] summary["gold"] += spec["bonus_gold"] + note(world, player, now, "Looted", "gold", spec["bonus_gold"], label=spec["name"]) found = [trophy(rng.choice(list(STYLES)), spec["tier"]), *(reagent(rng.choice(list(REAGENT_NOUNS)), spec["tier"]) for _ in range(2))] if spec["tier"] else [] for item in found: + note(world, player, now, "Looted", item, 1, label=spec["name"]) kept = bank_add(world, player, item, 1, now, summary) if kept: summary["items"][item] = summary["items"].get(item, 0) + kept @@ -4077,13 +4152,16 @@ def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float, share = min(pay, round(base * INPUT_SHARE * sum(ITEMS[i]["value"] * n for i, n in items.items()) / worth)) if share: helper["gold"] += share + note(world, helper, now, "Earned", "gold", share, who=order["by"]) earned = order.setdefault("earned", {}) earned[uid] = earned.get(uid, 0) + share pay -= share giver["gold"] += pay + if pay: + note(world, giver, now, "Earned", "gold", pay, who=order["by"]) owner = world["players"].get(order["by"]) if owner: - _hand_over(world, order, owner) + _hand_over(world, order, owner, now) if order["delivered"] >= order["qty"]: order["status"] = "done" order["done"] = now @@ -4092,18 +4170,19 @@ def _deliver(world: dict, order: dict, giver: dict, qty: int, now: float, f"{amount(order['item'], order['qty'])}") -def _hand_over(world: dict, order: dict, owner: dict) -> None: +def _hand_over(world: dict, order: dict, owner: dict, now: float) -> None: """Put delivered items in the requester's bank; what doesn't fit waits on the order.""" fits = min(order["held"], bank_room(world, owner, order["item"])) if fits: owner["bank"][order["item"]] = owner["bank"].get(order["item"], 0) + fits order["held"] -= fits + note(world, owner, now, "Received", order["item"], fits, label="Work order") -def collect_orders(world: dict, player: dict) -> None: +def collect_orders(world: dict, player: dict, now: float) -> None: for order in world["orders"]: if order["by"] == player["id"] and order["held"]: - _hand_over(world, order, player) + _hand_over(world, order, player, now) def expire_orders(world: dict, now: float) -> None: @@ -4189,6 +4268,7 @@ def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | city["stockpile"][item] = city["stockpile"].get(item, 0) + qty label = amount(item, qty) credit(player, item, qty, city_rate(world, "favour")) + note(world, player, now, "Donated", item, qty) log(world, now, "donation", f"{player['name']} donated {label}", player["id"]) return None @@ -4203,6 +4283,8 @@ def give(world: dict, giver: dict, receiver: dict, item: str, qty: int, now: flo return f"{receiver['name']} only has room for {room:,}" bank_take(giver, item, qty) receiver["bank"][item] = receiver["bank"].get(item, 0) + qty + note(world, giver, now, "Gave", item, qty, who=receiver["id"]) + note(world, receiver, now, "Received", item, qty, who=giver["id"]) log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"]) return None @@ -4211,7 +4293,9 @@ def sell(world: dict, player: dict, item: str, qty: int, now: float) -> str | No if qty <= 0 or player["bank"].get(item, 0) < qty: return "You don't have that many" bank_take(player, item, qty) - player["gold"] += int(qty * sell_price(world, item, now)) + gold = int(qty * sell_price(world, item, now)) + player["gold"] += gold + note(world, player, now, "Sold", item, qty, gold) return None @@ -4256,6 +4340,7 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source: else: bank_take(player, item, qty) credit(player, item, qty, city_rate(world, "favour")) + note(world, player, now, "Donated", item, qty, label=BUILDINGS[key]["name"]) elif item == "gold": city["treasury"] -= qty else: @@ -4287,6 +4372,7 @@ def install(world: dict, player: dict, fitting: str, source: str, now: float) -> return "You don't have one" bank_take(player, fitting, 1) credit(player, fitting, 1, city_rate(world, "favour")) + note(world, player, now, "Donated", fitting, 1, label=BUILDINGS[spec["building"]]["name"]) elif source == "city": if not city["stockpile"].get(fitting): return "There isn't one in the stockpile" @@ -4662,6 +4748,7 @@ def pay_wages(world: dict, player: dict, qty, source: str, now: float) -> str | if source == "bank": player["gold"] -= qty credit(player, "gold", qty, city_rate(world, "favour")) + note(world, player, now, "Donated", "gold", qty, label="Guard wages") log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages", player["id"]) else: city["treasury"] -= qty @@ -5130,8 +5217,10 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) -> player["favour"] += 40 gain_xp(world, player, "combat", round(attack * 2.5), now) bank_add(world, player, "dragon_bones", 2, now, empty_summary()) + note(world, player, now, "Looted", "dragon_bones", 2) for weight in WEIGHTS: bank_add(world, player, reagent(weight, 3), 1, now, empty_summary()) + note(world, player, now, "Looted", reagent(weight, 3), 1) if garrison: lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.") log(world, now, "raid", report["title"]) @@ -5631,6 +5720,7 @@ def _fortune(world: dict, now: float, rng: random.Random, kind: str | None = Non lines.append(f"They prayed at the temple: +{gift} devotion.") for player in world["players"].values(): bank_add(world, player, "bones", 10, now, empty_summary()) + note(world, player, now, "Received", "bones", 10, label="Pilgrims") lines.append("They left 10 bones with every adventurer.") _record(world, now, "fortune", kind, "馃檹", "Pilgrims passed through the city", lines) elif kind == "mercenaries": @@ -5925,6 +6015,7 @@ def buy_ware(world: dict, player: dict, ware_id: str, skill: str | None, now: fl return f"Needs {spec['price']:,} gold" player["gold"] -= spec["price"] player["owned"].append(ware_id) + note(world, player, now, "Bought", gold=spec["price"], label=spec["name"]) else: if ware_id not in caravan["wares"]: return "Not for sale" @@ -5939,6 +6030,7 @@ def buy_ware(world: dict, player: dict, ware_id: str, skill: str | None, now: fl if player["gold"] < price: return f"Needs {price:,} gold" player["gold"] -= price + note(world, player, now, "Bought", gold=price, label=spec["name"]) held = next((b for b in player["boosts"] if b["ware"] == ware_id and b["skill"] == skill), None) if spec["charges"] is None: if held: @@ -6106,6 +6198,7 @@ def give_monument(world: dict, player: dict, key: str, qty, now: float) -> str | return "Not enough gold" qty = min(qty, room) player["gold"] -= qty + note(world, player, now, "Donated", "gold", qty, label=spec["name"]) fund["gold"] += qty fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty if fund["gold"] >= spec["cost"]: @@ -6591,6 +6684,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ + ("new", "Added a personal ledger to character sheet"), ("new", "Work orders no longer erase your crafting queue"), ("new", "Items link to where they're made: click to jump to the recipe."), ("new", "Work order slots link to the recipe when you have none."), diff --git a/static/app.js b/static/app.js index a9feceb..8c4d217 100644 --- a/static/app.js +++ b/static/app.js @@ -2733,10 +2733,34 @@ function characterSheet(uid) { ${equipmentPanel(p, mine)} ${mine ? lookPanel(p) : ""} -

Skills

${skillRows}
+
+

Skills

${skillRows}
+ ${mine ? ledgerPanel(p) : ""} +
`; } +// What you did, newest first: a stretch at one action is one line ("Mine copper 路 3h 20m"), gifts and sales their own. +function ledgerPanel(p) { + const got = (id, q) => id === "gold" ? `${COIN} ${fmt(q)}` : `${itemChip(id, q)} ${esc(item(id).name)}`; + const rows = [...p.ledger].reverse().map((l) => { + let line; + if (l.act) { + const a = C.actions.find((x) => x.id === l.act); + const loot = Object.entries(l.items).map(([id, q]) => `${got(id, q)}`).join(""); + const fights = l.won !== undefined ? ` 路 ${fmt(l.won)} won${l.lost ? `, ${fmt(l.lost)} lost` : ""}` : ` 路 脳${fmt(l.n)}`; + line = `${skillOf(a.skill).icon} ${a.enemy ? "Fought " : ""}${esc(a.name)} 路 ${dur(l.at - l.start)}${fights} +
${loot}${l.gold ? `${COIN} ${fmt(l.gold)}` : ""}${l.xp ? `${fmt(Math.round(l.xp))} XP` : ""}
`; + } else { + const who = l.who ? ` ${l.verb === "Gave" ? "to" : "from"} ${esc(playerName(l.who))}` : ""; + line = `${esc(l.verb)} ${l.item ? got(l.item, l.qty) : esc(l.label)}${l.item && l.label ? ` 路 ${esc(l.label)}` : ""}${who}` + + (l.gold ? ` 路 ${COIN} ${l.verb === "Bought" ? "" : "+"}${fmt(l.gold)}` : ""); + } + return `
  • ${line}${ago(l.at)}
  • `; + }).join(""); + return `

    Ledger

      ${rows || `
    • Nothing yet.
    • `}
    `; +} + function lookPanel(p) { const look = p.look, wear = (kind) => Object.keys(C.cosmetics).filter((k) => C.cosmetics[k].kind === kind && (C.free_looks.includes(k) || p.owned.includes(k))); const swatches = (part) => wear("colour").map((k) => `