diff --git a/README.md b/README.md index eb31a6c..01af2cf 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/game.py b/game.py index b75063e..8aaeb7d 100644 --- a/game.py +++ b/game.py @@ -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: diff --git a/static/app.js b/static/app.js index bc1ee11..c2e799e 100644 --- a/static/app.js +++ b/static/app.js @@ -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 ? `🔥 ${piles.length}` : ""; const who = crafting.length ? `👤 ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}` : `👤 0`; @@ -635,9 +642,10 @@ function fireCard(id, pick) {
${glyph(spec.glyph, "#e7e9f0")}${esc(spec.name)}${skill.icon} ${esc(skill.name)}
-
-
-
${who}🪵 ${fmt(f.saved || 0)}
+
+
+
🪵 ${fmt(f.saved || 0)}
+
${who}${keepers}
${mine ? `
` : ""}
${esc(why || "")}${mine ? `` @@ -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 ? `` : "").join("")); - set(`[data-fire-left="${id}"]`, `${dur(n.left / n.speed)} / ${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 ? `` : "").join("")); + set(`[data-fire-left="${id}"]`, n.heat >= 0 ? `🔥 ${dur(n.left / n.speed)}` : ""); + 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 ? `🪵 ≤ ${ROMAN[n.heat]}` : ""); set(`[data-fire-state="${id}"]`, n.heat < 0 ? "Out" : n.low ? "Low" : C.flames[n.heat][0]); diff --git a/tests/test_game.py b/tests/test_game.py index a80b0d7..7478ff9 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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)