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 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
Anyone can tend any fire: each go feeds one log onto your own pile in it, for
minutes of fire and XP that grow with level, until your pile reaches your
bank. The fire burns everyone's piles at once, sharing one minute a minute,
so every log adds to how long it lasts and a full fire never shuts a
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.
- **Events:** raids are announced a day or two ahead. Reconnaissance from
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 -----------------------------------------------------------------
# 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.
# Each fire is a list of fuel: {tier, left (game seconds), by (who fed it)}. Everyone who
# feeds a fire keeps their own pile in it, up to their own bank, so a full fire never
# 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:
return {"fuel": [], "at": now, "saved": 0}
@ -2069,39 +2071,44 @@ def burn_fires(world: dict, now: float) -> None:
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)
while seconds > 1e-9 and fuel:
# Each pile burns its best fuel at 1/n of the pace, until the first of those runs out.
heads = [burning(fuel, by) for by in {chunk["by"] for chunk in fuel}]
step = min(seconds, min(chunk["left"] for chunk in heads) * len(heads))
for chunk in heads:
chunk["left"] -= step / len(heads)
if chunk["left"] <= 1e-6:
fuel.remove(chunk)
seconds -= step
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 burning(fuel: list, by: str | None = None) -> dict | None:
"""The fuel burning hottest (in one pile, with `by`): the hottest, and the oldest of those."""
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:
"""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
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)
def _room(fire: dict, player: dict, tier: int) -> float:
"""Game seconds of the player's own cheaper fuel a log of `tier` would push out, or -1 if it won't fit."""
level = skill_level(player, "firekeeping")
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)
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")
short = _room(fire, tier, level)
short = _room(fire, player, tier)
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"])
cheapest["left"] -= 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."""
fire = world["city"]["fires"][action["fire"]]
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:
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()}"
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") == "tend" and _room(world["city"]["fires"][action["tend"]], player, action["tier"]) < 0:
return f"Your pile in 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:

View file

@ -571,8 +571,9 @@ function skillGear(skill) {
}
// --- fires ----------------------------------------------------------------
// The guild's shared fires. Between polls the client burns them down itself, hottest
// fuel first, the same way the server does.
// The guild's shared fires. Between polls the client burns them down itself, the same
// 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"];
// 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) {
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;
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);
while (burn > 1e-9 && fuel.length) {
const heads = [...new Set(fuel.map((c) => c.by))].map(hottest);
const step = Math.min(burn, Math.min(...heads.map((c) => c.left)) * heads.length);
for (const c of heads) {
c.left -= step / heads.length;
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);
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() {
@ -628,6 +633,8 @@ function fireCard(id, pick) {
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 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
? `<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>`;
@ -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-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>
<div class="fire-line"><span data-fire-left="${id}" title="Until it's out"></span><span data-fire-covers="${id}"></span></div>
<div class="fuel" data-fuel="${id}" title="Your pile"></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>` : ""}
<div class="foot"><span class="why">${esc(why || "")}</span>${mine
? `<button class="btn danger" data-stop>Stop</button>`
@ -670,10 +678,11 @@ function animateFires() {
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)}`);
const bank = S.me.firekeeping.bank * 60, mine = n.mine.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 / Math.max(bank, mine) * 100).toFixed(2)}%"></i>` : "").join(""));
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-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]);

View file

@ -1875,28 +1875,47 @@ class FirekeepingTest(unittest.TestCase):
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")
self.assertEqual(summary["stopped"], "Your pile in 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(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)
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
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["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
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")
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["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
game.burn_fires(self.world, T0 + 200)
self.assertEqual(game.heat(self.world, "still"), 2)