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 LUCKY_CHANCE = 0.1
OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose OVERFLOW_FAVOUR = 0.5 # favour for items a full bank spills to the city, vs. giving on purpose
MAX_LOG = 200 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 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... 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 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 ---------------------------------------------------------------
WORLD_VERSION = 35 WORLD_VERSION = 36
def new_world(now: float, speed: float = 1.0) -> dict: 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. # 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["city"].update(happiness=0.5, clothes_due=0.0, unheated=False)
world["version"] = 35 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 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] 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: def building_level(world: dict, key: str) -> int:
"""Effective level: a damaged building does nothing until repaired.""" """Effective level: a damaged building does nothing until repaired."""
building = world["city"]["buildings"][key] building = world["city"]["buildings"][key]
@ -2151,6 +2215,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"garrison": False, "garrison": False,
"donated": 0, "donated": 0,
"gave": {}, # item (or "gold"): {qty, worth, favour} given to the city, see credit() "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 "pinned": [], # skills pinned to the top of the sidebar
"tracked": None, # recipe shown under the top bar "tracked": None, # recipe shown under the top bar
} }
@ -3015,7 +3080,15 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return 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: 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`.""" """Finish one action. A shared fight comes in as `fight`, with this member's share of its `loot`."""
brew = potion_effect(player, action["skill"]) brew = potion_effect(player, action["skill"])
@ -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).""" """Run the player's action forward to `now` (capped at 24h past last_active)."""
rng = rng or random.Random() rng = rng or random.Random()
summary = empty_summary() summary = empty_summary()
collect_orders(world, player) collect_orders(world, player, now)
current = player["action"] current = player["action"]
if current and current.get("party"): if current and current.get("party"):
if current["party"] in world["parties"]: 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") stop(player, "Bank full")
summary["stopped"] = "Bank full" summary["stopped"] = "Bank full"
break break
_complete(world, player, action, current["start"] + duration, rng, summary) _complete(world, player, action, current["start"], current["start"] + duration, rng, summary)
summary["done"] += 1 summary["done"] += 1
current["start"] += duration current["start"] += duration
if current.get("plot2"): 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": if results.get(player["id"], "left") == "left":
continue continue
summary = player.setdefault("away", empty_summary()) 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 summary["done"] += 1
together = summary.setdefault("with", {}) together = summary.setdefault("with", {})
for other in members: 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()) summary = player.setdefault("away", empty_summary())
player["gold"] += spec["bonus_gold"] player["gold"] += spec["bonus_gold"]
summary["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"]) 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 _ in range(2))] if spec["tier"] else []
for item in found: for item in found:
note(world, player, now, "Looted", item, 1, label=spec["name"])
kept = bank_add(world, player, item, 1, now, summary) kept = bank_add(world, player, item, 1, now, summary)
if kept: if kept:
summary["items"][item] = summary["items"].get(item, 0) + 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)) share = min(pay, round(base * INPUT_SHARE * sum(ITEMS[i]["value"] * n for i, n in items.items()) / worth))
if share: if share:
helper["gold"] += share helper["gold"] += share
note(world, helper, now, "Earned", "gold", share, who=order["by"])
earned = order.setdefault("earned", {}) earned = order.setdefault("earned", {})
earned[uid] = earned.get(uid, 0) + share earned[uid] = earned.get(uid, 0) + share
pay -= share pay -= share
giver["gold"] += pay giver["gold"] += pay
if pay:
note(world, giver, now, "Earned", "gold", pay, who=order["by"])
owner = world["players"].get(order["by"]) owner = world["players"].get(order["by"])
if owner: if owner:
_hand_over(world, order, owner) _hand_over(world, order, owner, now)
if order["delivered"] >= order["qty"]: if order["delivered"] >= order["qty"]:
order["status"] = "done" order["status"] = "done"
order["done"] = now 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'])}") 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.""" """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"])) fits = min(order["held"], bank_room(world, owner, order["item"]))
if fits: if fits:
owner["bank"][order["item"]] = owner["bank"].get(order["item"], 0) + fits owner["bank"][order["item"]] = owner["bank"].get(order["item"], 0) + fits
order["held"] -= 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"]: for order in world["orders"]:
if order["by"] == player["id"] and order["held"]: 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: 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 city["stockpile"][item] = city["stockpile"].get(item, 0) + qty
label = amount(item, qty) label = amount(item, qty)
credit(player, item, qty, city_rate(world, "favour")) 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"]) log(world, now, "donation", f"{player['name']} donated {label}", player["id"])
return None 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:,}" return f"{receiver['name']} only has room for {room:,}"
bank_take(giver, item, qty) bank_take(giver, item, qty)
receiver["bank"][item] = receiver["bank"].get(item, 0) + 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"]) log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"])
return None 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: if qty <= 0 or player["bank"].get(item, 0) < qty:
return "You don't have that many" return "You don't have that many"
bank_take(player, item, qty) 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 return None
@ -4256,6 +4340,7 @@ def fund(world: dict, player: dict, key: str, item: str, qty: int | str, source:
else: else:
bank_take(player, item, qty) bank_take(player, item, qty)
credit(player, item, qty, city_rate(world, "favour")) credit(player, item, qty, city_rate(world, "favour"))
note(world, player, now, "Donated", item, qty, label=BUILDINGS[key]["name"])
elif item == "gold": elif item == "gold":
city["treasury"] -= qty city["treasury"] -= qty
else: else:
@ -4287,6 +4372,7 @@ def install(world: dict, player: dict, fitting: str, source: str, now: float) ->
return "You don't have one" return "You don't have one"
bank_take(player, fitting, 1) bank_take(player, fitting, 1)
credit(player, fitting, 1, city_rate(world, "favour")) credit(player, fitting, 1, city_rate(world, "favour"))
note(world, player, now, "Donated", fitting, 1, label=BUILDINGS[spec["building"]]["name"])
elif source == "city": elif source == "city":
if not city["stockpile"].get(fitting): if not city["stockpile"].get(fitting):
return "There isn't one in the stockpile" 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": if source == "bank":
player["gold"] -= qty player["gold"] -= qty
credit(player, "gold", qty, city_rate(world, "favour")) 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"]) log(world, now, "donation", f"{player['name']} paid {qty:,} gold of guard wages", player["id"])
else: else:
city["treasury"] -= qty city["treasury"] -= qty
@ -5130,8 +5217,10 @@ def _resolve_dragon(world: dict, event: dict, now: float, rng: random.Random) ->
player["favour"] += 40 player["favour"] += 40
gain_xp(world, player, "combat", round(attack * 2.5), now) gain_xp(world, player, "combat", round(attack * 2.5), now)
bank_add(world, player, "dragon_bones", 2, now, empty_summary()) bank_add(world, player, "dragon_bones", 2, now, empty_summary())
note(world, player, now, "Looted", "dragon_bones", 2)
for weight in WEIGHTS: for weight in WEIGHTS:
bank_add(world, player, reagent(weight, 3), 1, now, empty_summary()) bank_add(world, player, reagent(weight, 3), 1, now, empty_summary())
note(world, player, now, "Looted", reagent(weight, 3), 1)
if garrison: if garrison:
lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.") lines.append("Everyone in the garrison earned 40 favour, combat experience, dragon bones and Ironhill reagents.")
log(world, now, "raid", report["title"]) 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.") lines.append(f"They prayed at the temple: +{gift} devotion.")
for player in world["players"].values(): for player in world["players"].values():
bank_add(world, player, "bones", 10, now, empty_summary()) 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.") lines.append("They left 10 bones with every adventurer.")
_record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines) _record(world, now, "fortune", kind, "🙏", "Pilgrims passed through the city", lines)
elif kind == "mercenaries": 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" return f"Needs {spec['price']:,} gold"
player["gold"] -= spec["price"] player["gold"] -= spec["price"]
player["owned"].append(ware_id) player["owned"].append(ware_id)
note(world, player, now, "Bought", gold=spec["price"], label=spec["name"])
else: else:
if ware_id not in caravan["wares"]: if ware_id not in caravan["wares"]:
return "Not for sale" 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: if player["gold"] < price:
return f"Needs {price:,} gold" return f"Needs {price:,} gold"
player["gold"] -= price 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) held = next((b for b in player["boosts"] if b["ware"] == ware_id and b["skill"] == skill), None)
if spec["charges"] is None: if spec["charges"] is None:
if held: if held:
@ -6106,6 +6198,7 @@ def give_monument(world: dict, player: dict, key: str, qty, now: float) -> str |
return "Not enough gold" return "Not enough gold"
qty = min(qty, room) qty = min(qty, room)
player["gold"] -= qty player["gold"] -= qty
note(world, player, now, "Donated", "gold", qty, label=spec["name"])
fund["gold"] += qty fund["gold"] += qty
fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty fund["donors"][player["id"]] = fund["donors"].get(player["id"], 0) + qty
if fund["gold"] >= spec["cost"]: 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. # line here (new / balance / fix), and a new release a new entry on top.
PATCH_NOTES = [ PATCH_NOTES = [
{"date": "2026-10-28", "title": "Queue", "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", "Work orders no longer erase your crafting queue"),
("new", "Items link to where they're made: click to jump to the recipe."), ("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."), ("new", "Work order slots link to the recipe when you have none."),

View file

@ -2733,10 +2733,34 @@ function characterSheet(uid) {
${equipmentPanel(p, mine)} ${equipmentPanel(p, mine)}
${mine ? lookPanel(p) : ""} ${mine ? lookPanel(p) : ""}
</div> </div>
<div>
<div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div> <div class="panel"><h2>Skills</h2><div class="sheet-skills">${skillRows}</div></div>
${mine ? ledgerPanel(p) : ""}
</div>
</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) { 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 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}" 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 { 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 li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.ledger .when { color: var(--muted); font-size: 12px; display: block; } .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 .top { display: flex; gap: 12px; align-items: center; }
.person .ico { font-size: 30px; } .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; } .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__": if __name__ == "__main__":
unittest.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"])