Fires: everyone keeps their own pile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-21 13:14:03 +01:00
parent a02a81dffd
commit b4ecacf96c
4 changed files with 91 additions and 54 deletions

View file

@ -124,11 +124,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings.
fire (cooking), the kiln (bricks) and the still (potions). Every craft at a 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 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). 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 Anyone can tend any fire: each go feeds one log onto your own pile in it, for
that grow with level, and stops when the fire is full. Better logs burn minutes of fire and XP that grow with level, until your pile reaches your
hotter colours (red, gold, white, blue, green, violet), burn first, and push bank. The fire burns everyone's piles at once, sharing one minute a minute,
cheaper fuel out of a full fire. Whoever fed the fuel gets 10% of the XP of so every log adds to how long it lasts and a full fire never shuts a
every craft it pays for. Because the fires are shared, the server runs beginner out. Better logs burn hotter colours (red, gold, white, blue,
green, violet), burn first in their pile, and push your own cheaper fuel out
when your pile is full; the fire is as hot as its hottest pile. Whoever owns
that pile gets 10% of the XP of every craft the fire pays for. Because the fires are shared, the server runs
everyone's actions as time passes, not only when they next look. everyone's actions as time passes, not only when they next look.
- **Events:** raids are announced a day or two ahead. Reconnaissance from - **Events:** raids are announced a day or two ahead. Reconnaissance from
scouts, informants and the spymaster's guild narrows down when, how strong, scouts, informants and the spymaster's guild narrows down when, how strong,

56
game.py
View file

@ -2053,9 +2053,11 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None:
# --- fires ----------------------------------------------------------------- # --- fires -----------------------------------------------------------------
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. It burns on # Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. Everyone who
# game time, hottest fuel first and the oldest of that first, so the flame's colour is # feeds a fire keeps their own pile in it, up to their own bank, so a full fire never
# the best fuel still in it. # shuts a beginner out. The fire burns a game minute a minute, shared evenly between the
# piles, so every log adds to how long it lasts; each pile burns its hottest fuel first,
# and the fire is as hot as its hottest pile.
def new_fire(now: float) -> dict: def new_fire(now: float) -> dict:
return {"fuel": [], "at": now, "saved": 0} return {"fuel": [], "at": now, "saved": 0}
@ -2069,39 +2071,44 @@ def burn_fires(world: dict, now: float) -> None:
seconds = (now - fire["at"]) * speed(world) seconds = (now - fire["at"]) * speed(world)
fire["at"] = now fire["at"] = now
fuel = fire["fuel"] fuel = fire["fuel"]
while seconds > 0 and fuel: while seconds > 1e-9 and fuel:
chunk = burning(fire) # Each pile burns its best fuel at 1/n of the pace, until the first of those runs out.
used = min(seconds, chunk["left"]) heads = [burning(fuel, by) for by in {chunk["by"] for chunk in fuel}]
chunk["left"] -= used step = min(seconds, min(chunk["left"] for chunk in heads) * len(heads))
seconds -= used for chunk in heads:
if chunk["left"] <= 1e-6: chunk["left"] -= step / len(heads)
fuel.remove(chunk) if chunk["left"] <= 1e-6:
fuel.remove(chunk)
seconds -= step
def burning(fire: dict) -> dict | None: def burning(fuel: list, by: str | None = None) -> dict | None:
"""The fuel burning right now: the hottest, and the oldest of those.""" """The fuel burning hottest (in one pile, with `by`): the hottest, and the oldest of those."""
return max(fire["fuel"], key=lambda chunk: chunk["tier"], default=None) return max((chunk for chunk in fuel if by is None or chunk["by"] == by), key=lambda chunk: chunk["tier"], default=None)
def heat(world: dict, fire: str) -> int: def heat(world: dict, fire: str) -> int:
"""The tier of a fire's flame, or -1 when it's out.""" """The tier of a fire's flame, or -1 when it's out."""
chunk = burning(world["city"]["fires"][fire]) chunk = burning(world["city"]["fires"][fire]["fuel"])
return chunk["tier"] if chunk else -1 return chunk["tier"] if chunk else -1
def _room(fire: dict, tier: int, level: int) -> float: def _room(fire: dict, player: dict, tier: int) -> float:
"""Game seconds of cheaper fuel a log of `tier` would push out, or -1 if it won't fit.""" """Game seconds of the player's own 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 level = skill_level(player, "firekeeping")
cheaper = sum(chunk["left"] for chunk in fire["fuel"] if chunk["tier"] < tier) mine = [chunk for chunk in fire["fuel"] if chunk["by"] == player["id"]]
short = sum(chunk["left"] for chunk in mine) + fire_minutes(level) * 60 - fire_bank(level) * 60
cheaper = sum(chunk["left"] for chunk in mine if chunk["tier"] < tier)
return -1 if short > cheaper + 1e-6 else max(0.0, short) 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: 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.""" """Put one log on your pile in a fire, pushing out your cheapest fuel if it's full. Returns the minutes added."""
fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping") fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping")
short = _room(fire, tier, level) short = _room(fire, player, tier)
while short > 1e-6: while short > 1e-6:
cheapest = min((chunk for chunk in fire["fuel"] if chunk["tier"] < tier), key=lambda chunk: chunk["tier"]) cheapest = min((chunk for chunk in fire["fuel"] if chunk["by"] == player["id"] and chunk["tier"] < tier),
key=lambda chunk: chunk["tier"])
out = min(short, cheapest["left"]) out = min(short, cheapest["left"])
cheapest["left"] -= out cheapest["left"] -= out
short -= out short -= out
@ -2135,7 +2142,7 @@ def _warm(world: dict, crafter: dict, action: dict, xp: float, now: float) -> No
"""A fire paid for a craft: count it, and give its feeder a share of the XP.""" """A fire paid for a craft: count it, and give its feeder a share of the XP."""
fire = world["city"]["fires"][action["fire"]] fire = world["city"]["fires"][action["fire"]]
fire["saved"] = fire.get("saved", 0) + 1 fire["saved"] = fire.get("saved", 0) + 1
keeper = world["players"].get(burning(fire)["by"]) keeper = world["players"].get(burning(fire["fuel"])["by"])
if keeper and keeper is not crafter: if keeper and keeper is not crafter:
gain_xp(world, keeper, "firekeeping", xp * WARMTH, now, keeper.setdefault("away", empty_summary())) gain_xp(world, keeper, "firekeeping", xp * WARMTH, now, keeper.setdefault("away", empty_summary()))
@ -2184,9 +2191,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None:
return f"Out of {ITEMS[item]['name'].lower()}" return f"Out of {ITEMS[item]['name'].lower()}"
elif player["bank"].get(item, 0) < qty: elif player["bank"].get(item, 0) < qty:
return f"Out of {ITEMS[item]['name'].lower()}" return f"Out of {ITEMS[item]['name'].lower()}"
if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], action["tier"], if action.get("special") == "tend" and _room(world["city"]["fires"][action["tend"]], player, action["tier"]) < 0:
skill_level(player, "firekeeping")) < 0: return f"Your pile in the {FIRES[action['tend']]['name'].lower()} is full"
return f"The {FIRES[action['tend']]['name'].lower()} is full"
if action.get("special") == "sabotage": if action.get("special") == "sabotage":
raid = upcoming_raid(world) raid = upcoming_raid(world)
if not raid or intel_level(world, raid) < 3: if not raid or intel_level(world, raid) < 3:

View file

@ -571,8 +571,9 @@ function skillGear(skill) {
} }
// --- fires ---------------------------------------------------------------- // --- fires ----------------------------------------------------------------
// The guild's shared fires. Between polls the client burns them down itself, hottest // The guild's shared fires. Between polls the client burns them down itself, the same
// fuel first, the same way the server does. // way the server does: the burn shared evenly between everyone's piles, each pile's
// hottest fuel first.
const ROMAN = ["I", "II", "III", "IV", "V", "VI"]; const ROMAN = ["I", "II", "III", "IV", "V", "VI"];
// Core to edge of each flame colour, for the animated fires. // Core to edge of each flame colour, for the animated fires.
@ -590,16 +591,20 @@ const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firew
function fireNow(id) { function fireNow(id) {
const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c })); const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c }));
const hottest = (by) => fuel.filter((c) => c.by === by).reduce((best, c) => (c.tier > best.tier ? c : best));
let burn = Math.max(0, serverNow() - f.at) * f.speed; let burn = Math.max(0, serverNow() - f.at) * f.speed;
while (burn > 0 && fuel.length) { while (burn > 1e-9 && fuel.length) {
const top = fuel.reduce((best, c) => (c.tier > best.tier ? c : best)); const heads = [...new Set(fuel.map((c) => c.by))].map(hottest);
const used = Math.min(burn, top.left); const step = Math.min(burn, Math.min(...heads.map((c) => c.left)) * heads.length);
top.left -= used; for (const c of heads) {
burn -= used; c.left -= step / heads.length;
if (top.left <= 1e-6) fuel.splice(fuel.indexOf(top), 1); if (c.left <= 1e-6) fuel.splice(fuel.indexOf(c), 1);
}
burn -= step;
} }
const heat = fuel.reduce((h, c) => Math.max(h, c.tier), -1), left = fuel.reduce((sum, c) => sum + c.left, 0); 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 }; const mine = fuel.filter((c) => c.by === S.uid);
return { fuel, mine, heat, left, low: heat >= 0 && left < C.fire_low, speed: f.speed };
} }
function pickFirewood() { function pickFirewood() {
@ -628,6 +633,8 @@ function fireCard(id, pick) {
const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start]; const start = `tend_${id}_${C.firewood[pick]}`, why = mine ? "" : me.blocked[start];
const crafting = f.users.filter((u) => !u.action.startsWith("tend_")); const crafting = f.users.filter((u) => !u.action.startsWith("tend_"));
const doing = (u) => C.actions.find((a) => a.id === u.action); const doing = (u) => C.actions.find((a) => a.id === u.action);
const piles = [...new Set(f.fuel.map((c) => c.by))];
const keepers = piles.length ? `<span title="${esc(piles.map(playerName).join("\n"))}">🔥 ${piles.length}</span>` : "";
const who = crafting.length 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 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>`; : `<span class="muted">👤 0</span>`;
@ -635,9 +642,10 @@ function fireCard(id, pick) {
<div class="fire-stage"><canvas data-flame="${id}"></canvas><span class="fire-state" data-fire-state="${id}"></span></div> <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-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="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}" title="Until it's out"></span><span data-fire-covers="${id}"></span></div>
<div class="fire-line"><span data-fire-left="${id}"></span><span data-fire-covers="${id}"></span></div> <div class="fuel" data-fuel="${id}" title="Your pile"></div>
<div class="fire-line small">${who}<span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div> <div class="fire-line small"><span data-fire-mine="${id}" title="Your pile / the most you can bank"></span><span title="Logs this fire has paid for">🪵 ${fmt(f.saved || 0)}</span></div>
<div class="fire-line small">${who}${keepers}</div>
${mine ? `<div class="bar thick"><span data-progress></span></div>` : ""} ${mine ? `<div class="bar thick"><span data-progress></span></div>` : ""}
<div class="foot"><span class="why">${esc(why || "")}</span>${mine <div class="foot"><span class="why">${esc(why || "")}</span>${mine
? `<button class="btn danger" data-stop>Stop</button>` ? `<button class="btn danger" data-stop>Stop</button>`
@ -670,10 +678,11 @@ function animateFires() {
if (canvas) drawFlame(canvas, id, n); if (canvas) drawFlame(canvas, id, n);
if (!text) continue; 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 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 bank = S.me.firekeeping.bank * 60, mine = n.mine.reduce((sum, c) => sum + c.left, 0);
const tiers = C.flames.map((_, t) => n.fuel.filter((c) => c.tier === t).reduce((sum, c) => sum + c.left, 0)); const tiers = C.flames.map((_, t) => n.mine.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-fuel="${id}"]`, tiers.map((left, t) => left ? `<i style="--c:${flameTint(t)};width:${(left / Math.max(bank, mine) * 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-left="${id}"]`, n.heat >= 0 ? `🔥 <b>${dur(n.left / n.speed)}</b>` : "");
set(`[data-fire-mine="${id}"]`, `You ${dur(mine / n.speed)} / ${dur(bank / n.speed)}`);
set(`[data-fire-time="${id}"]`, dur(n.left / 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-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]); set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]);

View file

@ -1875,28 +1875,47 @@ class FirekeepingTest(unittest.TestCase):
def test_a_full_fire_stops_tending_and_pays_nothing(self): def test_a_full_fire_stops_tending_and_pays_nothing(self):
self.kai["bank"] = {"logs": 100} self.kai["bank"] = {"logs": 100}
summary = self.tend(self.kai, "forge", "logs", T0, 3000) summary = self.tend(self.kai, "forge", "logs", T0, 3000)
self.assertEqual(summary["stopped"], "The forge is full") self.assertEqual(summary["stopped"], "Your pile in the forge is full")
left = sum(chunk["left"] for chunk in self.fires["forge"]["fuel"]) 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 self.assertGreater(left + game.fire_minutes(1) * 60, game.fire_bank(1) * 60) # no room for another log
xp = self.kai["xp"]["firekeeping"] 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(game.start_action(self.world, self.kai, "tend_forge_logs", T0 + 3000), "Your pile in the forge is full")
self.assertEqual(self.kai["xp"]["firekeeping"], xp) self.assertEqual(self.kai["xp"]["firekeeping"], xp)
def test_a_better_log_pushes_cheaper_fuel_out_but_not_the_reverse(self): def test_a_better_log_pushes_your_cheaper_fuel_out_but_not_the_reverse(self):
bank = game.fire_bank(15) * 60 bank = game.fire_bank(15) * 60
self.fires["kiln"].update(fuel=[{"tier": 0, "left": bank, "by": "sam"}], at=T0) # full of plain logs self.fires["kiln"].update(fuel=[{"tier": 0, "left": bank, "by": "kai"}], at=T0) # kai's pile: full of plain logs
self.kai["xp"]["firekeeping"] = game.XP_TABLE[15] self.kai["xp"]["firekeeping"] = game.XP_TABLE[15]
self.kai["bank"] = {"oak_logs": 1, "logs": 1} 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.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "Your pile in the kiln is full")
self.assertIsNone(game.start_action(self.world, self.kai, "tend_kiln_oak_logs", T0)) # a better log still fits 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) game.feed_fire(self.world, self.kai, "kiln", 1)
self.assertEqual(game.heat(self.world, "kiln"), 1) self.assertEqual(game.heat(self.world, "kiln"), 1)
self.assertAlmostEqual(sum(chunk["left"] for chunk in self.fires["kiln"]["fuel"]), bank) 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") self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "Your pile in the kiln is full")
def test_fuel_burns_hottest_first_on_game_time(self): def test_a_beginner_can_always_feed_a_fire_a_veteran_filled(self):
self.fires["forge"].update(fuel=[{"tier": 5, "left": 100 * 3600, "by": "kai"}], at=T0)
self.sam["bank"] = {"logs": 5}
self.tend(self.sam, "forge", "logs", T0, 95)
self.assertEqual(self.sam["bank"]["logs"], 2)
self.assertGreater(self.sam["xp"]["firekeeping"], 0)
self.assertEqual(game.heat(self.world, "forge"), 5)
def test_piles_share_the_burn_and_the_time_adds_up(self):
fire = self.fires["forge"]
fire.update(fuel=[{"tier": 5, "left": 600, "by": "kai"}, {"tier": 0, "left": 600, "by": "sam"}], at=T0)
game.burn_fires(self.world, T0 + 600) # half each
self.assertEqual([round(chunk["left"]) for chunk in fire["fuel"]], [300, 300])
self.assertEqual(game.heat(self.world, "forge"), 5) # the beginner's logs keep the violet going longer
game.burn_fires(self.world, T0 + 1199)
self.assertEqual(game.heat(self.world, "forge"), 5)
game.burn_fires(self.world, T0 + 1200) # 20 minutes of fuel, 20 minutes of fire
self.assertEqual(fire["fuel"], [])
def test_a_pile_burns_its_hottest_fuel_first_on_game_time(self):
fire = self.fires["still"] fire = self.fires["still"]
fire["fuel"] = [{"tier": 0, "left": 600, "by": "sam"}, {"tier": 2, "left": 300, "by": "kai"}] fire["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}, {"tier": 2, "left": 300, "by": "kai"}]
fire["at"] = T0 fire["at"] = T0
game.burn_fires(self.world, T0 + 200) game.burn_fires(self.world, T0 + 200)
self.assertEqual(game.heat(self.world, "still"), 2) self.assertEqual(game.heat(self.world, "still"), 2)