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