Personal ledger on the character sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 13:20:26 +00:00
parent ece0f101aa
commit 4b0724e6e5
4 changed files with 159 additions and 11 deletions

114
game.py
View file

@ -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."),

View file

@ -2733,10 +2733,34 @@ function characterSheet(uid) {
${equipmentPanel(p, mine)}
${mine ? lookPanel(p) : ""}
</div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
<div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
${mine ? ledgerPanel(p) : ""}
</div>
</div>`;
}
// 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" ? `<span class="item-chip">${COIN} ${fmt(q)}</span>` : `${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]) => `<span>${got(id, q)}</span>`).join("");
const fights = l.won !== undefined ? ` · ${fmt(l.won)} won${l.lost ? `, ${fmt(l.lost)} lost` : ""}` : ` · ×${fmt(l.n)}`;
line = `${skillOf(a.skill).icon} <b>${a.enemy ? "Fought " : ""}${esc(a.name)}</b> <span class="muted">· ${dur(l.at - l.start)}${fights}</span>
<div class="got">${loot}${l.gold ? `<span class="item-chip">${COIN} ${fmt(l.gold)}</span>` : ""}${l.xp ? `<span class="item-chip">${fmt(Math.round(l.xp))} XP</span>` : ""}</div>`;
} else {
const who = l.who ? ` ${l.verb === "Gave" ? "to" : "from"} ${esc(playerName(l.who))}` : "";
line = `<b>${esc(l.verb)}</b> ${l.item ? got(l.item, l.qty) : esc(l.label)}${l.item && l.label ? ` <span class="muted">· ${esc(l.label)}</span>` : ""}${who}`
+ (l.gold ? ` <span class="muted">·</span> <span class="item-chip">${COIN} ${l.verb === "Bought" ? "" : "+"}${fmt(l.gold)}</span>` : "");
}
return `<li>${line}<span class="when">${ago(l.at)}</span></li>`;
}).join("");
return `<div class="panel"><h2>Ledger</h2><ul class="ledger">${rows || `<li class="muted">Nothing yet.</li>`}</ul></div>`;
}
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) => `<button class="swatch ${look[part] === k ? "on" : ""}" style="background:${C.cosmetics[k].value}"

View file

@ -317,6 +317,7 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num
.ledger { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; }
.ledger li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.ledger .when { color: var(--muted); font-size: 12px; display: block; }
.ledger .got { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
.person .top { display: flex; gap: 12px; align-items: center; }
.person .ico { font-size: 30px; }
.levels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; font-size: 13px; margin-top: 12px; }

View file

@ -3378,3 +3378,32 @@ class TownsfolkTest(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class LedgerTest(unittest.TestCase):
def test_a_stretch_of_work_is_one_line(self):
world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
game.start_action(world, p, "mine_stone", T0)
game.process_player(world, p, T0 + 100, random.Random(1))
game.process_player(world, p, T0 + 200, random.Random(1))
[line] = p["ledger"]
self.assertEqual((line["act"], line["n"], line["items"], line["xp"]), ("mine_stone", 10, {"stone": 10}, 50))
self.assertEqual(line["at"] - line["start"], 200)
# Stopped and started again later: a new stretch.
game.start_action(world, p, "mine_stone", T0 + 1000)
game.process_player(world, p, T0 + 1020, random.Random(1))
self.assertEqual([l["n"] for l in p["ledger"]], [10, 1])
def test_gifts_and_sales_add_up_within_the_hour(self):
world = world_with(("fred", "commoner"), ("ann", "commoner"))
fred, ann = world["players"]["fred"], world["players"]["ann"]
fred["bank"] = {"stone": 30}
game.give(world, fred, ann, "stone", 5, T0)
game.give(world, fred, ann, "stone", 5, T0 + 60)
game.sell(world, fred, "stone", 10, T0 + 120)
self.assertEqual([(l["verb"], l["qty"]) for l in fred["ledger"]], [("Gave", 10), ("Sold", 10)])
self.assertEqual(fred["ledger"][1]["gold"], 10 * game.ITEMS["stone"]["value"])
self.assertEqual((ann["ledger"][0]["verb"], ann["ledger"][0]["who"]), ("Received", "fred"))
game.sell(world, fred, "stone", 1, T0 + 2 * game.HOUR)
self.assertEqual([l["verb"] for l in fred["ledger"]], ["Gave", "Sold", "Sold"])