From fd855804664e915b85590c017a5fb0e745d48d96 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 27 Oct 2026 20:11:40 +0000 Subject: [PATCH] Firekeeping: tend all fires Co-Authored-By: Claude Opus 5.5 --- game.py | 19 ++++++++++++++++--- static/app.js | 21 +++++++++++++++++---- tests/test_game.py | 12 ++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/game.py b/game.py index 2b71f75..0c2ee3d 100644 --- a/game.py +++ b/game.py @@ -1318,6 +1318,10 @@ ACTIONS = [ *[_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)], + # Tending all: each log goes on the lowest fire with room in your pile. + *[_action(f"tend_all_{log}", "firekeeping", "Tend all fires", TIER_LEVELS[tier], 30, 0, + {log: 1}, special="tend", tend=None, tier=tier, group="All fires") + for tier, log in enumerate(FIREWOOD)], ] # --- dungeon loot -------------------------------------------------------------- @@ -2768,6 +2772,14 @@ def _room(fire: dict, player: dict, tier: int) -> float: return -1 if short > cheaper + 1e-6 else max(0.0, short) +def tend_target(world: dict, player: dict, action: dict) -> str | None: + """The fire a tend feeds, or None if the log won't fit: its own fire, or when tending + all, the one with the least fuel left among those with room in your pile.""" + fires, tier = world["city"]["fires"], action["tier"] + room = [key for key in ([action["tend"]] if action["tend"] else FIRES) if _room(fires[key], player, tier) >= 0] + return min(room, key=lambda key: sum(chunk["left"] for chunk in fires[key]["fuel"]), default=None) + + def feed_fire(world: dict, player: dict, fire_id: str, tier: int) -> float: """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") @@ -2860,8 +2872,8 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: pooled = order["provided"].get(item, 0) if order else 0 if pooled + 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"]], player, action["tier"]) < 0: - return f"Your pile in the {FIRES[action['tend']]['name'].lower()} is full" + if action.get("special") == "tend" and not tend_target(world, player, action): + return f"Your pile in the {FIRES[action['tend']]['name'].lower()} is full" if action["tend"] else "Your piles are all full" if action.get("special") == "sabotage": raid = upcoming_raid(world) if not raid or intel_level(world, raid) < 3: @@ -3018,7 +3030,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R 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"] + xp = TEND_XP[action["tier"]] * feed_fire(world, player, tend_target(world, player, action), action["tier"]) * mods["xp"] if warmed: _warm(world, player, action, xp, now) if action.get("special") == "labour": @@ -6258,6 +6270,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-27", "title": "Prayer", "notes": [ + ("new", "Firekeeping: Tend all fires. Each log goes on the lowest fire with room in your pile."), ("new", "Added resource dumps for Leatherworking: Guard Jerkins"), ("fix", "+Q button: rounded corners."), ("fix", "Make box: number field sits in front of +Q."), diff --git a/static/app.js b/static/app.js index 6c0c1fe..f566bf6 100644 --- a/static/app.js +++ b/static/app.js @@ -1111,8 +1111,19 @@ function pickFirewood() { return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0; } +// The fire tending all feeds next: the least fuel left among those with room in your pile. +function tendAllTarget() { + const action = S.me.action && C.actions.find((a) => a.id === S.me.action.id), fk = S.me.firekeeping; + if (!action?.id.startsWith("tend_all_")) return null; + const sum = (fuel) => fuel.reduce((total, c) => total + c.left, 0); + const room = Object.keys(C.fires).map((id) => [id, fireNow(id)]) + .filter(([, n]) => sum(n.mine) + (fk.minutes - fk.bank) * 60 <= sum(n.mine.filter((c) => c.tier < action.tier)) + 1e-6); + return room.reduce((best, f) => (!best || f[1].left < best[1].left ? f : best), null)?.[0] ?? null; +} + function firekeepingPage() { const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed; + const all = `tend_all_${C.firewood[pick]}`, tendingAll = me.action?.id.startsWith("tend_all_"), allWhy = me.blocked[all]; 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; @@ -1121,13 +1132,15 @@ function firekeepingPage() { }).join(""); return `
${logs}
${glyph("stopwatch", "#98a1b4")} ${dur(fk.minutes * 60 / speed)} · ${perLog(pick)} XP - ${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)}
-
${Object.keys(C.fires).map((id) => fireCard(id, pick)).join("")}
`; + ${glyph("campfire", "#98a1b4")} ${dur(fk.bank * 60 / speed)} + ${tendingAll ? `` + : ``} +
${Object.keys(C.fires).map((id) => fireCard(id, pick, tendAllTarget())).join("")}
`; } -function fireCard(id, pick) { +function fireCard(id, pick, target) { 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 mine = me.action?.id.startsWith(`tend_${id}_`) || target === 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); diff --git a/tests/test_game.py b/tests/test_game.py index 0b4cab0..9915576 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -2785,6 +2785,18 @@ class FirekeepingTest(unittest.TestCase): 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_tending_all_feeds_the_lowest_fire_with_room(self): + self.fires["forge"].update(fuel=[{"tier": 0, "left": 600, "by": "sam"}], at=T0) + self.fires["kitchen"].update(fuel=[{"tier": 0, "left": game.fire_bank(1) * 60, "by": "kai"}], at=T0) # kai's pile is full + self.fires["kiln"].update(fuel=[{"tier": 0, "left": 300, "by": "sam"}], at=T0) + self.kai["bank"] = {"logs": 1} + self.tend(self.kai, "all", "logs", T0, 35) + self.assertEqual([chunk["by"] for chunk in self.fires["still"]["fuel"]], ["kai"]) # out, so lowest + self.kai["bank"] = {"logs": 100} + for key in ("forge", "kiln", "still"): + self.fires[key].update(fuel=[{"tier": 0, "left": game.fire_bank(1) * 60, "by": "kai"}], at=T0 + 35) + self.assertEqual(game.start_action(self.world, self.kai, "tend_all_logs", T0 + 35), "Your piles are all full") + 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")