Firekeeping: shared guild fires

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-21 12:16:22 +01:00
parent c64e28fc38
commit 54ae3ae641
6 changed files with 657 additions and 34 deletions

View file

@ -11,7 +11,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
- **Skills:** mining, woodcutting, farming, hunting, fishing, smithing,
jewelcrafting, construction (with masonry), cooking, leatherworking, tailoring, herblore,
combat, prayer and reconnaissance. Smiths also make hardware for builders,
firekeeping, combat, prayer and reconnaissance. Smiths also make hardware for builders,
one kind per metal (copper rivets to rune locks), which fittings and
buildings need.
- **Combat:** every fight is played out swing by swing: you and the enemy
@ -76,7 +76,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
- **Classes are trades,** from gathering to finished goods, so each player has
a quiet gathering side and a busier crafting side: miner (mining, smithing,
jewelcrafting),
builder (woodcutting, construction), homesteader (farming, cooking,
builder (woodcutting, construction, firekeeping), homesteader (farming, cooking,
herblore, tailoring) and ranger (hunting, fishing, leatherworking,
cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make
double, and each has one bonus of its own. Homesteaders farm differently:
@ -120,6 +120,16 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
potions a 10–35% chance to keep materials, Prayer more points from bones,
and combat has Strength and Defence ladders. Potions last 50 uses, one per
skill at a time; an empty one is replaced from the bank automatically.
- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen
fire (cooking), the kiln (bricks) and the still (potions). Every craft at a
fire takes one log of its tier as fuel; a fire burning that tier's flame or
hotter pays it, and a hot forge takes a coal off every bar (never the last).
Anyone can tend any fire: each go feeds one log, for minutes of fire and XP
that grow with level, and stops when the fire is full. Better logs burn
hotter colours (red, gold, white, blue, green, violet), burn first, and push
cheaper fuel out of a full fire. Whoever fed the fuel gets 10% of the XP of
every craft it pays for. Because the fires are shared, the server runs
everyone's actions as time passes, not only when they next look.
- **Events:** raids are announced a day or two ahead. Reconnaissance from
scouts, informants and the spymaster's guild narrows down when, how strong,
and finally where their camp is, so fighters can sabotage it. Fighters can

19
app.py
View file

@ -87,13 +87,17 @@ class Store:
def tick(self) -> None:
with self.lock:
if game.tick(self.world, self.now(), self.rng):
now = self.now()
changed = game.tick(self.world, now, self.rng)
# Everyone's actions run as time passes: the fires are shared, so a craft
# has to meet the fire as it was, not as it is when its player next looks.
if game.catch_up(self.world, now, self.rng) or changed:
self.save()
def player_view(world: dict, player: dict, now: float) -> dict:
game.regen(world, player, now)
view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at")}
view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at", "away")}
view["hp"] = int(player["hp"])
view["max_hp"] = game.max_hp(player)
view["armour"] = game.armour(player)
@ -127,6 +131,10 @@ def player_view(world: dict, player: dict, now: float) -> dict:
view["drill_per_action"] = game.drill_per_action(player)
view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS
if action.get("special") == "recon"}
level = game.skill_level(player, "firekeeping")
view["firekeeping"] = {"minutes": game.fire_minutes(level), "bank": game.fire_bank(level)}
# Inputs as the fires leave them: a lit fire pays the log.
view["fuelled"] = {action["id"]: game.fire_inputs(world, action) for action in game.ACTIONS if action.get("fire")}
return view
@ -162,6 +170,7 @@ def city_view(world: dict, now: float) -> dict:
"repelled": city["repelled"],
"defence": game.defence(world),
"day": game.calendar_day(world, now) + 1,
"fires": game.fires_view(world, now),
}
@ -263,8 +272,9 @@ class Handler(BaseHTTPRequestHandler):
game.tick(world, now, self.store.rng)
player = world["players"].get(uid)
summary = None
game.catch_up(world, now, self.store.rng)
if player:
summary = game.process_player(world, player, now, self.store.rng)
summary = game.take_summary(player, game.empty_summary())
game.regen(world, player, now)
player["last_active"] = now
self.store.save()
@ -294,7 +304,8 @@ class Handler(BaseHTTPRequestHandler):
elif not player:
return self.fail("Create your adventurer first", HTTPStatus.CONFLICT)
else:
summary = game.process_player(world, player, now, self.store.rng)
game.catch_up(world, now, self.store.rng) # everyone, so the fires are as they really are
summary = game.take_summary(player, game.empty_summary())
game.regen(world, player, now)
player["last_active"] = now
error = self.player_route(path, player, body, now)

243
game.py
View file

@ -123,7 +123,8 @@ COPPER_ICONS = {"sword": "🗡️", "helmet": "⛑️", "legs": "🦵", "body":
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪"), "construction": ("hammer", "🔨"),
"smithing": ("chisel", "🔧"), "jewelcrafting": ("gem drill", "🪛"), "cooking": ("cleaver", "🔪"),
"herblore": ("mortar and pestle", "🥣"), "leatherworking": ("awl", "📍"), "tailoring": ("shears", "✂️")}
"herblore": ("mortar and pestle", "🥣"), "leatherworking": ("awl", "📍"), "tailoring": ("shears", "✂️"),
"firekeeping": ("bellows", "🪭")}
TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
# Crafting stations: a second slot for every crafting skill, beside its tool, and as fast.
# Builders make every station, from the tier's planks and hardware and a material from
@ -151,6 +152,38 @@ TREES = [
("yew_logs", "Yew logs", "🌲", 30, "cut_yew", "Cut yews"),
("magic_logs", "Enchanted logs", "🔮", 55, "cut_magic", "Cut enchanted trees"),
]
FIREWOOD = tuple(tree[0] for tree in TREES)
# The guild's fires, shared by everyone. A craft at a fire takes one log of its tier as
# fuel, and a fire burning that tier's flame or hotter pays it (the forge a coal too).
# Firekeepers feed them: fuel burns hottest first, and a better log pushes cheaper fuel
# out of a full fire. Level gives more minutes per log and a bigger bank.
FIRES = {
"forge": {"name": "Forge", "icon": "⚒️", "skill": "smithing", "glyph": "anvil-impact"},
"kitchen": {"name": "Kitchen fire", "icon": "🍳", "skill": "cooking", "glyph": "cooking-pot"},
"kiln": {"name": "Kiln", "icon": "🧱", "skill": "construction", "glyph": "brick-pile"},
"still": {"name": "Still", "icon": "⚗️", "skill": "herblore", "glyph": "erlenmeyer"},
}
FLAMES = [("Red", "#ff6a2a"), ("Gold", "#ffcb3d"), ("White", "#eeeaf8"),
("Blue", "#4fa3ff"), ("Green", "#3ed37a"), ("Violet", "#b07cff")]
TEND_XP = [2, 3, 4.5, 5.5, 6.5, 7.5] # per game minute of fire fed, by the log's tier
WARMTH = 0.1 # of a craft's XP, to whoever fed the fuel that paid for it
FIRE_LOW = 30 * 60 # game seconds left when a fire counts as low
def fire_minutes(level: int) -> float:
"""Game minutes of fire one log gives at this Firekeeping level."""
return 3 + 0.12 * level
def fire_bank(level: int) -> float:
"""The most fuel, in game minutes, a keeper of this level can bank in a fire."""
return 60 + 9 * level
def _fuelled(inputs: dict, tier: int) -> dict:
"""A fire craft's inputs: its materials and one log of its tier to burn."""
return {**inputs, FIREWOOD[tier]: inputs.get(FIREWOOD[tier], 0) + 1}
# Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value),
# armour id prefix, armour name prefix, leatherworking level
@ -452,6 +485,7 @@ SKILLS = [
{"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"},
{"id": "tailoring", "name": "Tailoring", "icon": "🧵", "group": "Crafting"},
{"id": "herblore", "name": "Herblore", "icon": "🧪", "group": "Crafting"},
{"id": "firekeeping", "name": "Firekeeping", "icon": "🔥", "group": "Crafting"},
{"id": "combat", "name": "Combat", "icon": "⚔️", "group": "Combat"},
{"id": "prayer", "name": "Prayer", "icon": "🙏", "group": "Combat"},
{"id": "reconnaissance", "name": "Reconnaissance", "icon": "🔭", "group": "Combat"},
@ -473,8 +507,8 @@ CLASSES = {
"combat": "Warhammer: +15% damage",
},
"builder": {
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction"], "craft": ["construction"],
"bonus": "Woodcutting and construction 20% faster",
"name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction", "firekeeping"], "craft": ["construction"],
"bonus": "Woodcutting, construction and firekeeping 20% faster",
"masterwork": "Masterwork: 10% double construction crafts",
"signature": "Foreman: +1 labour per site action",
"combat": "Sword: +15% damage",
@ -520,11 +554,10 @@ def _tiered_actions() -> list[dict]:
if tier == 2:
actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1}))
city = {"smithy": smithy} if smithy else {}
inputs = {"iron_ore" if key == "steel" else ore[0]: 1}
inputs.update({"coal": coal} if coal else {"logs": 1})
inputs = {"iron_ore" if key == "steel" else ore[0]: 1, **({"coal": coal} if coal else {})}
actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier,
bar_xp[tier], inputs, {f"{key}_bar": 1}, group="Bars",
city={"smithy": smithy} if tier >= 2 else {}))
bar_xp[tier], _fuelled(inputs, tier), {f"{key}_bar": 1}, group="Bars",
city={"smithy": smithy} if tier >= 2 else {}, fire="forge", fuel=tier))
hardware, _name, _plural, bars, made = HARDWARE[tier]
actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier,
bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware",
@ -576,7 +609,8 @@ def _tiered_actions() -> list[dict]:
actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier],
[6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1}))
actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier],
[6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}, group="Fish"))
[6, 15, 27, 42, 58, 80][tier], _fuelled({raw[0]: 1}, tier), {cooked[0]: 1}, group="Fish",
fire="kitchen", fuel=tier))
return actions
@ -934,7 +968,8 @@ ACTIONS = [
*[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs")
for herb, name, _icon, level, seconds, xp, _value in HERBS],
*[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6),
{herb: 1, second: 1}, {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}")
_fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}",
fire="still", fuel=POTION_TIER[potion])
for potion, name, level, herb, second, skill, _effect in POTIONS],
_action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}),
_action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}),
@ -957,14 +992,17 @@ ACTIONS = [
*[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier,
[6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks")
for tier, (plank, name) in enumerate(PLANKS)],
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"),
_action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry",
fire="kiln", fuel=0),
_action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"),
_action("mason_beam", "construction", "Reinforced beam", 20, 45, 30,
{"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"),
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals"),
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals"),
_action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals",
fire="kitchen", fuel=0),
_action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals",
fire="kitchen", fuel=0),
_action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals"),
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals", fire="kitchen", fuel=0),
_action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"),
_action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"),
_action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"),
@ -980,6 +1018,10 @@ ACTIONS = [
devotion=20, city={"temple": 2}, group="Offer at the temple"),
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
_action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2),
# Tending: one log a go into one fire; better logs need more Firekeeping.
*[_action(f"tend_{fire}_{log}", "firekeeping", f"Tend the {spec['name'].lower()}", TIER_LEVELS[tier], 30, 0,
{log: 1}, special="tend", tend=fire, tier=tier, group=spec["name"])
for fire, spec in FIRES.items() for tier, log in enumerate(FIREWOOD)],
]
ACTION_BY_ID = {action["id"]: action for action in ACTIONS}
for _id, _gem in GEM_ROCKS.items():
@ -1095,7 +1137,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world ---------------------------------------------------------------
WORLD_VERSION = 23
WORLD_VERSION = 24
def new_world(now: float, speed: float = 1.0) -> dict:
@ -1118,6 +1160,7 @@ def new_world(now: float, speed: float = 1.0) -> dict:
"food_due": 0.0,
"starving": False,
"repelled": 0,
"fires": {key: new_fire(now) for key in FIRES},
},
"events": [],
"next_event_at": now + 12 * HOUR / speed,
@ -1273,6 +1316,15 @@ def migrate(world: dict, now: float | None = None) -> dict:
if item and ITEMS[item].get("station"):
equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item
world["version"] = 23
if world["version"] < 24:
# Firekeeping: a new skill, its bellows slot, and the guild's fires.
world["city"].setdefault("fires", {})
for key in FIRES:
world["city"]["fires"].setdefault(key, new_fire(now))
for player in world["players"].values():
player["xp"].setdefault("firekeeping", 0)
player["equip"].setdefault("tool_firekeeping", None)
world["version"] = 24
return world
@ -2000,6 +2052,106 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None:
summary["potion_out"].append(active["item"])
# --- fires -----------------------------------------------------------------
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. It burns on
# game time, hottest fuel first and the oldest of that first, so the flame's colour is
# the best fuel still in it.
def new_fire(now: float) -> dict:
return {"fuel": [], "at": now, "saved": 0}
def burn_fires(world: dict, now: float) -> None:
"""Burn every fire down to `now`. Never runs backwards."""
for fire in world["city"]["fires"].values():
if now <= fire["at"]:
continue
seconds = (now - fire["at"]) * speed(world)
fire["at"] = now
fuel = fire["fuel"]
while seconds > 0 and fuel:
chunk = burning(fire)
used = min(seconds, chunk["left"])
chunk["left"] -= used
seconds -= used
if chunk["left"] <= 1e-6:
fuel.remove(chunk)
def burning(fire: dict) -> dict | None:
"""The fuel burning right now: the hottest, and the oldest of those."""
return max(fire["fuel"], key=lambda chunk: chunk["tier"], default=None)
def heat(world: dict, fire: str) -> int:
"""The tier of a fire's flame, or -1 when it's out."""
chunk = burning(world["city"]["fires"][fire])
return chunk["tier"] if chunk else -1
def _room(fire: dict, tier: int, level: int) -> float:
"""Game seconds of cheaper fuel a log of `tier` would push out, or -1 if it won't fit."""
short = sum(chunk["left"] for chunk in fire["fuel"]) + fire_minutes(level) * 60 - fire_bank(level) * 60
cheaper = sum(chunk["left"] for chunk in fire["fuel"] if chunk["tier"] < tier)
return -1 if short > cheaper + 1e-6 else max(0.0, short)
def feed_fire(world: dict, player: dict, fire_id: str, tier: int) -> float:
"""Put one log in a fire, pushing out the cheapest fuel if it's full. Returns the minutes added."""
fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping")
short = _room(fire, tier, level)
while short > 1e-6:
cheapest = min((chunk for chunk in fire["fuel"] if chunk["tier"] < tier), key=lambda chunk: chunk["tier"])
out = min(short, cheapest["left"])
cheapest["left"] -= out
short -= out
if cheapest["left"] <= 1e-6:
fire["fuel"].remove(cheapest)
minutes = fire_minutes(level)
last = fire["fuel"][-1] if fire["fuel"] else None
if last and last["tier"] == tier and last["by"] == player["id"]:
last["left"] += minutes * 60
else:
fire["fuel"].append({"tier": tier, "left": minutes * 60, "by": player["id"]})
return minutes
def fire_inputs(world: dict, action: dict) -> dict:
"""What a craft takes right now: a hot enough fire pays its log, and the forge a coal (never the last)."""
fire = action.get("fire")
if not fire or heat(world, fire) < action["fuel"]:
return action["inputs"]
inputs = dict(action["inputs"])
log = FIREWOOD[action["fuel"]]
inputs[log] -= 1
if not inputs[log]:
del inputs[log]
if fire == "forge" and inputs.get("coal", 0) > 1:
inputs["coal"] -= 1
return inputs
def _warm(world: dict, crafter: dict, action: dict, xp: float, now: float) -> None:
"""A fire paid for a craft: count it, and give its feeder a share of the XP."""
fire = world["city"]["fires"][action["fire"]]
fire["saved"] = fire.get("saved", 0) + 1
keeper = world["players"].get(burning(fire)["by"])
if keeper and keeper is not crafter:
gain_xp(world, keeper, "firekeeping", xp * WARMTH, now, keeper.setdefault("away", empty_summary()))
def fires_view(world: dict, now: float) -> dict:
burn_fires(world, now)
users = {}
for player in world["players"].values():
action = player["action"] and ACTION_BY_ID.get(player["action"]["id"])
if action and (action.get("fire") or action.get("tend")):
users.setdefault(action.get("fire") or action["tend"], []).append(
{"id": player["id"], "name": player["name"], "action": action["id"]})
return {key: {**fire, "heat": heat(world, key), "speed": speed(world), "users": users.get(key, [])}
for key, fire in world["city"]["fires"].items()}
def upcoming_raid(world: dict) -> dict | None:
"""The coming major threat: a bandit raid or a dragon."""
for event in world["events"]:
@ -2026,12 +2178,15 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]:
if not order or order["status"] != "open" or order["claimed"] != player["id"]:
return "Work order done" if order and order["status"] == "done" else "Work order closed"
for item, qty in action["inputs"].items():
for item, qty in fire_inputs(world, action).items():
if order and item in order["provided"]:
if order["provided"][item] < qty:
return f"Out of {ITEMS[item]['name'].lower()}"
elif player["bank"].get(item, 0) < qty:
return f"Out of {ITEMS[item]['name'].lower()}"
if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], action["tier"],
skill_level(player, "firekeeping")) < 0:
return f"The {FIRES[action['tend']]['name'].lower()} is full"
if action.get("special") == "sabotage":
raid = upcoming_raid(world)
if not raid or intel_level(world, raid) < 3:
@ -2061,9 +2216,11 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None:
brew = potion_effect(player, action["skill"])
order = working_order(world, player, action)
own = {item: qty for item, qty in action["inputs"].items() if not order or item not in order["provided"]}
inputs = fire_inputs(world, action)
warmed = inputs is not action["inputs"]
own = {item: qty for item, qty in inputs.items() if not order or item not in order["provided"]}
if order:
for item, qty in action["inputs"].items():
for item, qty in inputs.items():
if item in order["provided"]:
order["provided"][item] -= qty
if own and rng.random() < brew.get("keep", 0):
@ -2164,6 +2321,10 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R
city["drill_boost"] = city.get("drill_boost", 0) - boost
xp = DRILL_XP * (gain + boost) * mods["xp"]
threat["drilled"] = min(drill_needed(threat), threat.get("drilled", 0) + gain + boost)
if action.get("special") == "tend":
xp = TEND_XP[action["tier"]] * feed_fire(world, player, action["tend"], action["tier"]) * mods["xp"]
if warmed:
_warm(world, player, action, xp, now)
if action.get("special") == "labour":
city = world["city"]
site = city["construction"]
@ -2249,6 +2410,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N
duration = action_time(world, player, action)
if current["start"] + duration > limit:
break
burn_fires(world, current["start"] + duration)
reason = can_start(world, player, action)
if reason and reason.startswith("Too hurt"):
if blessing(player)["wind"] and not current.get("wind_used"):
@ -2304,6 +2466,43 @@ def stop(player: dict, reason: str | None = None) -> None:
player["stopped"] = reason
def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool:
"""Run everyone's actions up to now, so the shared fires see feeds and crafts as they
happen. What each player missed waits in their `away` summary for their next visit."""
changed = False
for player in world["players"].values():
summary = process_player(world, player, now, rng)
if summary["done"] or summary["stopped"]:
merge_summary(player.setdefault("away", empty_summary()), summary)
changed = True
return changed
def merge_summary(into: dict, more: dict) -> dict:
"""Fold a later summary into an earlier one."""
for key, value in more.items():
if isinstance(value, bool):
into[key] = into.get(key) or value
elif isinstance(value, (int, float)):
into[key] = into.get(key, 0) + value
elif isinstance(value, dict):
for item, qty in value.items():
into.setdefault(key, {})[item] = into[key].get(item, 0) + qty
elif key == "levels":
into[key] = [entry for entry in into.get(key, []) if entry[0] not in {s for s, _ in value}] + value
elif isinstance(value, list):
into[key] = into.get(key, []) + value
elif value is not None:
into[key] = value
return into
def take_summary(player: dict, summary: dict) -> dict:
"""Everything that happened since the player last looked: the catch-up's and this request's."""
away = player.pop("away", None)
return merge_summary(away, summary) if away else summary
# --- work orders -----------------------------------------------------------
# A player asks the guild for something. A crafting order names a recipe: the
# requester can put in some of its reagents, one crafter claims it and makes it
@ -2876,6 +3075,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool:
rng = rng or random.Random()
city = world["city"]
changed = False
burn_fires(world, now)
step = HOUR / speed(world)
# Never replay more than two weeks if the server was off.
city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world))
@ -3348,7 +3548,6 @@ SEASON_WEATHER = [
{"clear": 30, "rain": 30, "storm": 25, "fog": 15},
{"clear": 30, "snow": 45, "fog": 15, "storm": 10},
]
FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs")
WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS)
# Minor threats: they slow or block a skill until someone deals with them,
@ -4022,7 +4221,8 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi
"legs": "leg-armor", "body": "breastplate"}
TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole",
"hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill",
"cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears"}
"cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears",
"bellows": "bellows"}
LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"}
CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"}
FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower",
@ -4057,6 +4257,7 @@ SKILL_GLYPHS = {
"cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"),
"herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"),
"reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"),
"firekeeping": ("campfire", "#ff7a2a"),
}
CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"),
"homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")}
@ -4098,6 +4299,9 @@ ACTION_GLYPHS.update({
for _index, (_area, _level, _prefix, _races) in enumerate(AREAS):
for _race, *_rest in _races:
ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index])
for _tend in ACTIONS:
if _tend.get("special") == "tend":
ACTION_GLYPHS[_tend["id"]] = ("campfire", FLAMES[_tend["tier"]][1])
UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds",
"archery-target", "dodging", "shield-reflect",
"cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "knife-fork", "round-bottom-flask", "visored-helm", "breastplate",
@ -4211,4 +4415,5 @@ def content() -> dict:
"styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE,
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,
"fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW,
}

View file

@ -149,7 +149,7 @@ function toast(text, kind = "") {
}
function showSummary(sum) {
if (!sum.done && !sum.stopped) return;
if (!sum.done && !sum.stopped && !sum.levels.length) return;
if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum);
const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`);
if (sum.gold) got.push(`+${fmt(sum.gold)} gold`);
@ -310,7 +310,11 @@ function nav(id, icon, label, meta = "", extra = "", tip = "") {
function skillNav(skill, me, doing) {
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1;
const flame = heat >= 0 ? `<span class="fdot" style="--c:${flameTint(heat)}"></span>` : "";
const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `<span class="alert"></span>` : "";
const tip = heat >= 0 ? `${C.fires[fire].name}: ${C.flames[heat][0]}` : low ? "A fire is low" : "";
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, flame + busy + low, tip);
}
function pinnedNav(me, doing) {
@ -381,6 +385,12 @@ function topbar() {
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : [];
if (fires.some(([, n]) => n.heat >= 0)) {
const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n");
chips += `<button class="chip fire-pips" data-nav="skill:firekeeping" title="${esc(tip)}">${fires.map(([, n]) =>
`<i class="pip ${n.heat >= 0 ? "lit" : ""} ${n.low ? "low" : ""}" style="--c:${flameTint(n.heat)}"></i>`).join("")}</button>`;
}
const w = S.happenings?.weather;
if (w) chips += `<button class="chip" data-nav="town" title="${esc(weatherTip(w))}">${w.icon}</button>`;
if (S.upcoming) chips += `<button class="chip ${["raid", "dragon"].includes(S.upcoming.kind) ? "threat" : ""} ${S.upcoming.at_gates ? "alarm" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${S.upcoming.at_gates ? `<span class="label">${S.upcoming.kind === "dragon" ? "Overhead" : "At the gates"}</span> ` : ""}${eventWhen(S.upcoming, true)}</button>`;
@ -391,11 +401,11 @@ function topbar() {
function trackedBar() {
const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked);
if (!a) return "";
const ready = Object.entries(a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
const ready = Object.entries(me.fuelled?.[a.id] || a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q);
const go = !me.blocked[a.id] && me.action?.id !== a.id;
return `<div class="tracked ${ready ? "ready" : ""}">
<button class="tracked-name" data-nav="skill:${a.skill}" data-nav-group="${esc(a.group || "")}" title="${esc(a.name)}">${a.icon}<span class="label">${esc(a.name)}</span></button>
<span class="io">${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")}</span>
<span class="io">${inputChips(a)}</span>
${go ? `<button class="btn sm primary" data-start="${a.id}">Start</button>` : ""}
<button class="pin on" data-track="${a.id}" title="Untrack">📌</button>
</div>`;
@ -430,6 +440,18 @@ function itemChip(id, qty, have) {
return `<span class="item-chip ${short ? "short" : ""}" title="${esc([item(id).name, itemStats(id)].filter(Boolean).join(": "))}">${item(id).icon} ${have !== undefined ? `${fmt(have)} <span class="have">/ ${fmt(qty)}</span>` : fmt(qty)}</span>`;
}
// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through.
function inputChips(a) {
const me = S.me, ins = me.fuelled?.[a.id] || a.inputs;
return Object.entries(a.inputs).map(([id, q]) => {
const need = ins[id] || 0, have = me.bank[id] || 0;
if (need === q) return itemChip(id, q, have);
const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`;
return need ? `<span class="item-chip ${have < need ? "short" : ""}" ${tip}>${item(id).icon} ${fmt(have)} <span class="have">/ <s>${q}</s> ${need}</span></span>`
: `<span class="item-chip paid" ${tip}>${item(id).icon} <s>${q}</s></span>`;
}).join("");
}
// Progress towards a building cost: "have / need".
function costChip(icon, name, need, have) {
return `<span class="item-chip ${have < need ? "short" : ""}" title="${esc(`${name}: ${have < need ? `${fmt(need - have)} more needed` : "done"}`)}">${icon} ${fmt(have)} <span class="have">/ ${fmt(need)}</span></span>`;
@ -459,6 +481,8 @@ function skillPage(id) {
: `<div class="panel muted">Nothing under construction.</div>`;
if (id === "farming" && me.cls === "homesteader") html += plotsPanel();
html += skillGear(id);
if (id === "firekeeping") return html + firekeepingPage();
html += fireBand(id);
if (id === "reconnaissance") html += `<div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div>`;
html += skillHappenings(id);
const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o));
@ -539,6 +563,196 @@ function skillGear(skill) {
return cards.length ? `<div class="gear-row">${cards.join("")}</div>` : "";
}
// --- fires ----------------------------------------------------------------
// The guild's shared fires. Between polls the client burns them down itself, hottest
// fuel first, the same way the server does.
const ROMAN = ["I", "II", "III", "IV", "V", "VI"];
// Core to edge of each flame colour, for the animated fires.
const FLAME_RAMPS = [["#fff3cf", "#ffb54a", "#ff6a2a", "#8a210a"], ["#fffbe6", "#ffe680", "#f2a81f", "#7a4a06"],
["#ffffff", "#f6f4ff", "#c9c4e0", "#4d4a66"], ["#eef8ff", "#8fd0ff", "#3485ea", "#10347a"],
["#ecfff1", "#8af5ab", "#22b85a", "#0a4a25"], ["#f8efff", "#d6b0ff", "#9150f0", "#3a1474"]];
const STILL = matchMedia("(prefers-reduced-motion: reduce)").matches;
let firewood = Number(remember("firewood") ?? -1); // the log tier picked for tending
const flames = {}; // fire -> its particles, kept across renders
let fireTextAt = 0;
const fireOfSkill = (skill) => Object.keys(C.fires).find((id) => C.fires[id].skill === skill);
const flameTint = (tier) => tier >= 0 ? C.flames[tier][1] : "#5d626b";
const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firewood[tier]}`).level;
function fireNow(id) {
const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c }));
let burn = Math.max(0, serverNow() - f.at) * f.speed;
while (burn > 0 && fuel.length) {
const top = fuel.reduce((best, c) => (c.tier > best.tier ? c : best));
const used = Math.min(burn, top.left);
top.left -= used;
burn -= used;
if (top.left <= 1e-6) fuel.splice(fuel.indexOf(top), 1);
}
const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0);
return { fuel, heat, left, low: heat >= 0 && left < C.fire_low, speed: f.speed };
}
function pickFirewood() {
const me = S.me, open = C.firewood.map((_, t) => t).filter((t) => me.levels.firekeeping >= tendLevel(t));
if (open.includes(firewood)) return firewood;
return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0;
}
function firekeepingPage() {
const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed;
const perLog = (tier) => +(C.tend_xp[tier] * fk.minutes).toFixed(1);
const logs = C.firewood.map((log, tier) => {
const level = tendLevel(tier), locked = me.levels.firekeeping < level;
const tip = `${item(log).name} · ${C.flames[tier][0]} · ${locked ? `Needs level ${level}` : `${perLog(tier)} XP a log`}`;
return `<button class="logbtn ${tier === pick ? "on" : ""}" style="--c:${flameTint(tier)}" data-firewood="${tier}" ${locked ? "disabled" : ""} title="${esc(tip)}"><span class="swatch"></span>${item(log).icon} ${fmt(me.bank[log] || 0)}</button>`;
}).join("");
return `<div class="panel fire-tools"><div class="logs">${logs}</div>
<span class="tag" title="Fire and XP per log">${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP</span>
<span class="tag" title="Most fuel you can bank in a fire">${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}</span></div>
<div class="fire-grid">${Object.keys(C.fires).map((id) => fireCard(id, pick)).join("")}</div>`;
}
function fireCard(id, pick) {
const me = S.me, spec = C.fires[id], f = S.city.fires[id], n = fireNow(id), skill = skillOf(spec.skill);
const mine = me.action?.id.startsWith(`tend_${id}_`);
const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start];
const crafting = f.users.filter((u) => !u.action.startsWith("tend_"));
const doing = (u) => C.actions.find((a) => a.id === u.action);
const who = crafting.length
? `<span title="${esc(crafting.map((u) => `${u.name}: ${doing(u).name}`).join("\n"))}">👤 ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}</span>`
: `<span class="muted">👤 0</span>`;
return `<div class="fire-card ${n.heat >= 0 ? "lit" : "out"} ${mine ? "active" : ""}" style="--c:${flameTint(n.heat)}">
<div class="fire-stage"><canvas data-flame="${id}"></canvas><span class="fire-state" data-fire-state="${id}"></span></div>
<div class="fire-body">
<div class="fire-head">${glyph(spec.glyph, "#e7e9f0")}<b>${esc(spec.name)}</b><span class="muted small">${skill.icon} ${esc(skill.name)}</span></div>
<div class="fuel" data-fuel="${id}"></div>
<div class="fire-line"><span data-fire-left="${id}"></span><span data-fire-covers="${id}"></span></div>
<div class="fire-line small">${who}<span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div>
${mine ? `<div class="bar thick"><span data-progress></span></div>` : ""}
<div class="foot"><span class="why">${esc(why || "")}</span>${mine
? `<button class="btn danger" data-stop>Stop</button>`
: `<button class="btn primary" data-start="${start}" ${why ? "disabled" : ""}>Tend</button>`}</div>
</div></div>`;
}
// The fire behind a crafting skill, at the top of its page.
function fireBand(skill) {
const id = fireOfSkill(skill);
if (!id) return "";
const n = fireNow(id), spec = C.fires[id], lit = n.heat >= 0;
const pays = lit && `Pays the log for tier ${ROMAN[n.heat]} and below${id === "forge" ? ", and a coal" : ""}`;
return `<div class="fire-band ${lit ? "lit" : ""}" style="--c:${flameTint(n.heat)}">
${glyph(spec.glyph, lit ? flameTint(n.heat) : "#98a1b4")}
<div class="grow"><b>${esc(spec.name)}</b> <span class="muted small">${lit ? `${C.flames[n.heat][0]} · <span data-fire-time="${id}"></span>` : "Out"}</span></div>
${lit ? `<span class="tag flame" title="${esc(pays)}">🪵 ≤ ${ROMAN[n.heat]}</span>` : ""}
<button class="btn sm" data-nav="skill:firekeeping">🔥 Tend</button>
</div>`;
}
function animateFires() {
if (!S?.me || !S.city?.fires) return;
const text = performance.now() - fireTextAt > 250;
if (text) fireTextAt = performance.now();
for (const id of Object.keys(C.fires)) {
const canvas = document.querySelector(`[data-flame="${id}"]`);
if (!canvas && !text) continue;
const n = fireNow(id);
if (canvas) drawFlame(canvas, id, n);
if (!text) continue;
const set = (sel, html) => { for (const el of document.querySelectorAll(sel)) if (el.dataset.v !== html) { el.innerHTML = html; el.dataset.v = html; } };
const bank = Math.max(S.me.firekeeping.bank * 60, n.left);
const tiers = C.flames.map((_, t) => n.fuel.filter((c) => c.tier === t).reduce((sum, c) => sum + c.left, 0));
set(`[data-fuel="${id}"]`, tiers.map((left, t) => left ? `<i style="--c:${flameTint(t)};width:${(left / bank * 100).toFixed(2)}%"></i>` : "").join(""));
set(`[data-fire-left="${id}"]`, `<b>${dur(n.left / n.speed)}</b> / ${dur(S.me.firekeeping.bank * 60 / n.speed)}`);
set(`[data-fire-time="${id}"]`, dur(n.left / n.speed));
set(`[data-fire-covers="${id}"]`, n.heat >= 0 ? `<span class="tag flame" style="--c:${flameTint(n.heat)}" title="Pays the log for tier ${ROMAN[n.heat]} and below">🪵 ≤ ${ROMAN[n.heat]}</span>` : "");
set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]);
for (const el of document.querySelectorAll(`[data-fire-state="${id}"]`)) el.className = `fire-state ${n.heat < 0 ? "out" : n.low ? "low" : ""}`;
}
}
function rampAt(ramp, t) {
const s = t * (ramp.length - 1), i = Math.min(Math.floor(s), ramp.length - 2), f = s - i;
const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16));
const a = rgb(ramp[i]), b = rgb(ramp[i + 1]);
return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(",")})`;
}
// A fire on a canvas: rising flame particles in its colour, taller with more fuel;
// smoke when it's low, a grey bed of ash when it's out.
function drawFlame(cv, id, n) {
const st = flames[id] ||= { parts: [], at: performance.now() };
const t = performance.now(), dt = Math.min(0.05, (t - st.at) / 1000);
st.at = t;
const r = cv.getBoundingClientRect(), d = Math.min(devicePixelRatio || 1, 2);
if (!r.width) return;
if (cv.width !== Math.round(r.width * d)) { cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d); }
const ctx = cv.getContext("2d"), W = cv.width, H = cv.height, cx = W / 2, base = H - 22 * d;
const lit = n.heat >= 0, ramp = FLAME_RAMPS[Math.max(0, n.heat)];
const power = lit ? Math.min(1, 0.25 + n.left / 14400) : 0;
ctx.clearRect(0, 0, W, H);
if (lit && !STILL) {
for (let i = Math.round(90 * power * dt) + 1; i > 0; i--) st.parts.push({ k: "f", x: cx + (Math.random() - 0.5) * 46 * d * (0.6 + power * 0.6), y: base,
vx: (Math.random() - 0.5) * 18 * d, vy: -(40 + Math.random() * 60) * d * (0.55 + power), life: 0,
max: 0.55 + Math.random() * 0.5 * (0.5 + power), r: (7 + Math.random() * 9) * d * (0.6 + power * 0.5) });
if (Math.random() < dt * 6 * power) st.parts.push({ k: "e", x: cx + (Math.random() - 0.5) * 40 * d, y: base - 10 * d,
vx: (Math.random() - 0.5) * 30 * d, vy: -(60 + Math.random() * 60) * d, life: 0, max: 1 + Math.random(), r: 1.4 * d });
}
if (!STILL && (!lit || n.low) && Math.random() < dt * (lit ? 5 : 3)) st.parts.push({ k: "s", x: cx + (Math.random() - 0.5) * 20 * d,
y: base - (lit ? 40 * d : 0), vx: (Math.random() - 0.3) * 10 * d, vy: -(18 + Math.random() * 14) * d, life: 0, max: 2.5 + Math.random(), r: (6 + Math.random() * 5) * d });
ctx.fillStyle = "#3a2a1e";
ctx.save();
ctx.translate(cx, base + 8 * d);
for (const [angle, w] of [[-0.18, 70], [0.2, 64], [0, 50]]) {
ctx.save(); ctx.rotate(angle); ctx.beginPath(); ctx.roundRect(-w / 2 * d, -5 * d, w * d, 10 * d, 5 * d); ctx.fill(); ctx.restore();
}
ctx.restore();
if (lit) {
const glow = ctx.createRadialGradient(cx, base + 4 * d, 2, cx, base + 4 * d, 60 * d);
glow.addColorStop(0, ramp[1] + "aa");
glow.addColorStop(1, ramp[3] + "00");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
} else {
ctx.fillStyle = "#4a4a50";
ctx.beginPath(); ctx.ellipse(cx, base + 12 * d, 38 * d, 5 * d, 0, 0, Math.PI * 2); ctx.fill();
}
if (lit && STILL) {
const h = 44 * d * (0.6 + power * 0.5), g = ctx.createRadialGradient(cx, base - 18 * d, 2, cx, base - 18 * d, h);
ramp.forEach((c, i) => g.addColorStop(i / 3, c + (i === 3 ? "00" : "ff")));
ctx.fillStyle = g;
ctx.beginPath(); ctx.ellipse(cx, base - 18 * d, 26 * d, h, 0, 0, Math.PI * 2); ctx.fill();
}
st.parts = st.parts.filter((p) => (p.life += dt) < p.max);
ctx.globalCompositeOperation = "lighter";
for (const p of st.parts) {
p.x += p.vx * dt;
p.y += p.vy * dt;
const k = p.life / p.max;
if (p.k === "f") {
p.vx += (cx - p.x) * 1.8 * dt;
ctx.globalAlpha = (1 - k) * 0.55;
ctx.fillStyle = rampAt(ramp, Math.min(1, k * 1.25));
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - k * 0.7), 0, Math.PI * 2); ctx.fill();
} else if (p.k === "e") {
ctx.globalAlpha = 1 - k;
ctx.fillStyle = ramp[1];
ctx.fillRect(p.x, p.y, p.r * 2, p.r * 2);
}
}
ctx.globalCompositeOperation = "source-over";
for (const p of st.parts) if (p.k === "s") {
const k = p.life / p.max;
ctx.globalAlpha = (1 - k) * 0.22;
ctx.fillStyle = "#9aa0ae";
ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + k * 2), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function prayerPanel() {
const me = S.me, city = S.city, level = me.levels.prayer;
const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0);
@ -773,8 +987,7 @@ function actionCard(a) {
const time = me.times[a.id];
let io = "";
if (Object.keys(a.inputs).length) {
io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")
+ (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `<span class="arrow">→</span>` : "");
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
const chance = ([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}${q > 1 ? ` ×${q}` : ""}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${Math.round(odds * 100)}%</span>`;
@ -1665,6 +1878,7 @@ function animate() {
el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%";
}
for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now);
animateFires();
}
function loop() {
@ -1693,6 +1907,7 @@ document.addEventListener("click", (e) => {
setMenu(false);
render(); window.scrollTo(0, 0); return;
}
if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); }
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", {});

View file

@ -632,3 +632,80 @@ button.sheet-skill:hover { background: var(--panel-2); }
.pick-lvl { position: absolute; bottom: -5px; right: -5px; font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); color: var(--muted); }
.doll-slot.pick.on .pick-lvl { border-color: var(--gold); }
.doll-slot.pick.on .pick-lvl { color: var(--gold); }
/* --- fires ------------------------------------------------------------ */
.nav .fdot, .pip, .logbtn .swatch {
width: 9px; height: 12px; flex: none; border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
background: var(--c); box-shadow: 0 0 7px var(--c);
}
.fire-pips { gap: 5px; }
.pip { display: inline-block; background: var(--line); box-shadow: none; }
.pip.lit { background: radial-gradient(circle at 50% 75%, #fff 0 18%, var(--c) 55%); box-shadow: 0 0 8px var(--c); }
.pip.low { animation: fire-low 1.1s ease-in-out infinite; }
@keyframes fire-low { 50% { opacity: .35; } }
.item-chip.paid { color: var(--muted); }
.item-chip s { color: var(--muted); }
.tag.flame { color: var(--c); border-color: color-mix(in srgb, var(--c) 45%, transparent); }
.fire-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.fire-tools .logs { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; }
.logbtn {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel-2); font-size: 13.5px; font-variant-numeric: tabular-nums;
}
.logbtn .swatch { width: 8px; height: 11px; }
.logbtn .gi { width: 1.25em; height: 1.25em; }
.logbtn.on { border-color: var(--c); box-shadow: 0 0 0 1px var(--c) inset; }
.logbtn:disabled { opacity: .35; cursor: not-allowed; }
.fire-tools .tag { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; padding: 3px 9px; }
.fire-tools .tag .gi { width: 1.2em; height: 1.2em; }
.fire-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); }
.fire-card {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
display: flex; flex-direction: column; transition: border-color .4s, box-shadow .4s;
}
.fire-card.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); box-shadow: 0 10px 40px -18px var(--c); }
.fire-card.active { border-color: var(--good); box-shadow: 0 0 0 1px var(--good) inset, 0 10px 40px -18px var(--c); }
.fire-stage {
position: relative; height: 130px;
background: radial-gradient(120% 90% at 50% 100%, color-mix(in srgb, var(--c) 22%, #0e0f14) 0%, #0e0f14 70%);
}
.fire-card.out .fire-stage { background: radial-gradient(120% 90% at 50% 100%, #1b1c22 0%, #0e0f14 70%); }
.fire-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.fire-state {
position: absolute; top: 8px; right: 8px; font-size: 11.5px; font-weight: 700; padding: 1px 8px; border-radius: 99px;
background: rgba(14, 15, 20, .75); border: 1px solid var(--line);
}
.fire-state.low { color: var(--warn); border-color: rgba(227, 160, 67, .5); }
.fire-state.out { color: var(--muted); }
.fire-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 9px; flex: 1; }
.fire-head { display: flex; align-items: center; gap: 8px; }
.fire-head .gi { width: 22px; height: 22px; flex: none; }
.fire-head b { flex: 1; }
.fuel { height: 10px; background: var(--bg); border-radius: 99px; overflow: hidden; display: flex; }
.fuel i { display: block; height: 100%; background: var(--c); }
.fire-line { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
.fire-line b { color: var(--text); font-weight: 600; }
.fire-line .gi { width: 1.2em; height: 1.2em; vertical-align: -0.25em; }
.fire-body .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; }
.fire-body .why { font-size: 13px; color: var(--warn); }
.fire-band {
position: relative; overflow: hidden; display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 16px; margin-bottom: 14px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--panel);
}
.fire-band > .gi { width: 26px; height: 26px; flex: none; }
.fire-band.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); }
.fire-band.lit::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(60% 140% at 0% 50%, color-mix(in srgb, var(--c) 22%, transparent), transparent 70%);
animation: fire-flicker 2.6s ease-in-out infinite;
}
.fire-band > * { position: relative; }
@keyframes fire-flicker { 0%, 100% { opacity: .95; } 30% { opacity: .7; } 55% { opacity: 1; } 75% { opacity: .8; } }
@media (prefers-reduced-motion: reduce) {
.pip.low, .fire-band.lit::before { animation: none; }
}

View file

@ -741,7 +741,7 @@ class TierTest(unittest.TestCase):
world = world_with(("kay", "miner"))
p = world["players"]["kay"]
p["xp"]["smithing"] = game.XP_TABLE[30]
p["bank"] = {"iron_ore": 5, "coal": 10}
p["bank"] = {"iron_ore": 5, "coal": 10, "willow_logs": 5}
self.assertEqual(game.start_action(world, p, "smith_steel_bar", T0), "Needs Smithy level 2")
world["city"]["buildings"]["smithy"]["level"] = 2
self.assertIsNone(game.start_action(world, p, "smith_steel_bar", T0))
@ -903,7 +903,7 @@ class HerbloreTest(unittest.TestCase):
game.start_action(world, p, "grow_guam_leaf", T0)
game.process_player(world, p, T0 + 450, random.Random(1))
self.assertEqual(p["bank"]["guam_leaf"], 10)
p["bank"]["logs"] = 3
p["bank"]["logs"] = 6 # one a potion, one to burn under the still
self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore
game.process_player(world, p, T0 + 1000, random.Random(1))
self.assertEqual(p["bank"]["woodcutting_potion_1"], 3)
@ -1818,7 +1818,7 @@ class StationAndStorageTest(unittest.TestCase):
self.assertEqual(game.action_time(self.world, self.bob, labour), bare)
def test_stations_move_out_of_the_tool_slot(self):
self.world["version"] = game.WORLD_VERSION - 1
self.world["version"] = 22
self.bob["equip"]["tool_smithing"] = "oak_anvil_stand"
del self.bob["equip"]["station_smithing"]
game.migrate(self.world, T0)
@ -1847,3 +1847,108 @@ class StationAndStorageTest(unittest.TestCase):
self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that")
self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox")
self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1)
class FirekeepingTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("kai", "commoner"), ("sam", "commoner"))
self.kai, self.sam = self.world["players"]["kai"], self.world["players"]["sam"]
self.fires = self.world["city"]["fires"]
def tend(self, player, fire, log, at, seconds):
self.assertIsNone(game.start_action(self.world, player, f"tend_{fire}_{log}", at))
return game.process_player(self.world, player, at + seconds, random.Random(1))
def test_tending_feeds_only_that_fire_and_pays_by_the_minute(self):
self.kai["bank"] = {"logs": 3}
self.tend(self.kai, "forge", "logs", T0, 95)
level = game.skill_level(self.kai, "firekeeping")
self.assertEqual(self.kai["bank"].get("logs", 0), 0)
self.assertEqual(game.heat(self.world, "forge"), 0)
self.assertEqual(game.heat(self.world, "kitchen"), -1)
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 3 * game.TEND_XP[0] * game.fire_minutes(level))
def test_better_logs_need_the_level(self):
self.kai["bank"] = {"maple_logs": 3}
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_maple_logs", T0), "Needs level 45")
def test_a_full_fire_stops_tending_and_pays_nothing(self):
self.kai["bank"] = {"logs": 100}
summary = self.tend(self.kai, "forge", "logs", T0, 3000)
self.assertEqual(summary["stopped"], "The forge is full")
left = sum(chunk["left"] for chunk in self.fires["forge"]["fuel"])
self.assertGreater(left + game.fire_minutes(1) * 60, game.fire_bank(1) * 60) # no room for another log
xp = self.kai["xp"]["firekeeping"]
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_logs", T0 + 3000), "The forge is full")
self.assertEqual(self.kai["xp"]["firekeeping"], xp)
def test_a_better_log_pushes_cheaper_fuel_out_but_not_the_reverse(self):
bank = game.fire_bank(15) * 60
self.fires["kiln"].update(fuel=[{"tier": 0, "left": bank, "by": "sam"}], at=T0) # full of plain logs
self.kai["xp"]["firekeeping"] = game.XP_TABLE[15]
self.kai["bank"] = {"oak_logs": 1, "logs": 1}
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "The kiln is full")
self.assertIsNone(game.start_action(self.world, self.kai, "tend_kiln_oak_logs", T0)) # a better log still fits
game.feed_fire(self.world, self.kai, "kiln", 1)
self.assertEqual(game.heat(self.world, "kiln"), 1)
self.assertAlmostEqual(sum(chunk["left"] for chunk in self.fires["kiln"]["fuel"]), bank)
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "The kiln is full")
def test_fuel_burns_hottest_first_on_game_time(self):
fire = self.fires["still"]
fire["fuel"] = [{"tier": 0, "left": 600, "by": "sam"}, {"tier": 2, "left": 300, "by": "kai"}]
fire["at"] = T0
game.burn_fires(self.world, T0 + 200)
self.assertEqual(game.heat(self.world, "still"), 2)
game.burn_fires(self.world, T0 + 400)
self.assertEqual(game.heat(self.world, "still"), 0)
self.assertAlmostEqual(fire["fuel"][0]["left"], 500)
game.burn_fires(self.world, T0 + 100) # never backwards
self.assertEqual(fire["at"], T0 + 400)
def test_a_hot_forge_pays_the_log_and_a_coal_but_never_the_last(self):
steel, copper = game.ACTION_BY_ID["smith_steel_bar"], game.ACTION_BY_ID["smith_copper_bar"]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1})
self.fires["forge"]["fuel"] = [{"tier": 1, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1}) # too cool
self.assertEqual(game.fire_inputs(self.world, copper), {"copper_ore": 1})
self.fires["forge"]["fuel"] = [{"tier": 3, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 1})
self.assertEqual(game.fire_inputs(self.world, game.ACTION_BY_ID["smith_mithril_bar"]), {"mithril_ore": 1, "coal": 2})
def test_a_potion_brewed_from_logs_keeps_its_ingredient_log(self):
brew = game.ACTION_BY_ID["brew_woodcutting_potion_1"]
self.fires["still"]["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, brew), {"guam_leaf": 1, "logs": 1})
def test_warmth_goes_to_whoever_fed_the_fire(self):
self.fires["kitchen"]["fuel"] = [{"tier": 0, "left": 6000, "by": "kai"}]
self.fires["kitchen"]["at"] = T0
self.sam["bank"] = {"raw_shrimp": 4}
game.start_action(self.world, self.sam, "cook_shrimp", T0)
game.process_player(self.world, self.sam, T0 + 85, random.Random(1))
self.assertEqual(self.sam["bank"]["cooked_shrimp"], 4)
self.assertNotIn("logs", self.sam["bank"])
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
self.assertAlmostEqual(self.kai["away"]["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
self.assertEqual(self.fires["kitchen"]["saved"], 4)
self.assertEqual(self.sam["xp"]["firekeeping"], 0)
def test_catch_up_runs_everyone_and_keeps_what_they_missed(self):
game.start_action(self.world, self.sam, "mine_stone", T0)
self.assertTrue(game.catch_up(self.world, T0 + 100, random.Random(1)))
game.catch_up(self.world, T0 + 200, random.Random(1))
self.assertEqual(self.sam["away"]["done"], 10)
summary = game.take_summary(self.sam, game.process_player(self.world, self.sam, T0 + 220, random.Random(1)))
self.assertEqual(summary["done"], 11)
self.assertEqual(summary["items"], {"stone": 11})
self.assertNotIn("away", self.sam)
def test_old_worlds_get_their_fires(self):
del self.world["city"]["fires"]
del self.kai["xp"]["firekeeping"]
self.world["version"] = 23
game.migrate(self.world, T0)
self.assertEqual(set(self.world["city"]["fires"]), set(game.FIRES))
self.assertEqual(self.kai["xp"]["firekeeping"], 0)
self.assertIn("tool_firekeeping", self.kai["equip"])