From bc99a1e0c594b0e333a89397a1e7b036cd7496df Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 24 Oct 2026 14:02:24 +0100 Subject: [PATCH] Choose when you eat between fights Co-Authored-By: Claude Opus 5.5 --- app.py | 2 ++ game.py | 22 ++++++++++++++++++---- static/app.js | 7 ++++++- static/styles.css | 2 ++ tests/test_game.py | 15 +++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 0947fdd..e39aab4 100644 --- a/app.py +++ b/app.py @@ -454,6 +454,8 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/prayers": ids = body.get("ids") return game.set_prayers(player, ids) if isinstance(ids, list) else "Bad request" + if path == "/api/eat": + return game.set_eat_below(player, body.get("below")) if path == "/api/food": food = body.get("item") if food is not None and "heal" not in game.ITEMS.get(str(food), {}): diff --git a/game.py b/game.py index 089a343..e5196e6 100644 --- a/game.py +++ b/game.py @@ -26,6 +26,8 @@ MAX_LOG = 200 REGEN_PER_HOUR = 0.25 # share of max HP recovered per game hour REST_RATE = 0.01 # share of max HP back per game second resting between fights... BITE = 2.0 # ...and each bite of food takes this long, so food rests you faster +EAT_BELOW = 0.5 # unless you choose otherwise, you only eat while under this share of your HP... +EAT_CHOICES = (0, 0.25, 0.5, 0.75, 1.0) # ...and these are the choices (never, up to always) KNOCKED_OUT_HP = 0.3 # share of max HP you come round with after being knocked out KNOCKOUTS_TO_STOP = 3 # in a row, and you stop fighting @@ -2018,7 +2020,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: "hp": 44, "hp_at": now, "action": None, "stopped": None, "last_active": now, "overflow": "city", - "perks": {"bank_slot": 0}, "manual": [], "patterns": [], + "perks": {"bank_slot": 0}, "manual": [], "patterns": [], "eat_below": EAT_BELOW, "rented": 0, "boosts": [], "bought": None, "deeds": {}, "owned": [], "look": dict(DEFAULT_LOOK), "garrison": False, "donated": 0, @@ -2211,14 +2213,16 @@ def _average_hit(max_hit: float, crit: float = CRIT_CHANCE) -> float: def rest_plan(world: dict | None, player: dict, hp: float | None = None) -> tuple[float, dict]: """Resting back to full before a fight: how many game seconds, and the food eaten on the way. - A bite is only taken when it's quicker than resting, so better food makes for shorter rests.""" + You eat while you're under the share of your HP you chose, and only when a bite is quicker than + resting, so better food makes for shorter rests. The rest of the way you rest.""" top = max_hp(player) missing = top - (player["hp"] if hp is None else hp) rate = top * REST_RATE * regen_boost(world, player) / ware(player, "rest", "combat") + hungry = top * (1 - player.get("eat_below", EAT_BELOW)) # eat while more than this is missing seconds, eaten = 0.0, {} for food in edible(player): heal, left = ITEMS[food]["heal"], player["bank"].get(food, 0) - while missing > 0 and left and min(heal, missing) / rate > BITE: + while missing > hungry and missing > 0 and left and min(heal, missing) / rate > BITE: eaten[food] = eaten.get(food, 0) + 1 left -= 1 missing -= heal @@ -2398,6 +2402,14 @@ def _input(world: dict, player: dict, now: float, kind: str, *args) -> str | Non return None +def set_eat_below(player: dict, share) -> str | None: + """Below what share of your HP you eat between fights (0: never, 1: whenever it's quicker).""" + if share not in EAT_CHOICES: + return "Pick one of the choices" + player["eat_below"] = share + return None + + def learn_pattern(player: dict, item: str) -> str | None: """Read a pattern: from then on you can reforge its masterwork. The pattern's used up.""" spec = ITEMS.get(item, {}) @@ -5964,6 +5976,8 @@ PATCH_NOTES = [ ("new", "One unique per dungeon from its last boss (8% each), worn as a ring, amulet or trinket, that changes an " "ability: One-Eye's fang, Ogre's knuckle, Barrow king's pendant, Warlord's whistle, Forgeheart, Rimeglass " "lens, Wyrmscale quiver. Abilities it changes get a ✦."), + ("new", "Choose when you eat between fights: below 25 / 50 / 75% of your HP, always, or never (the food slot, " + "under \"Eat below\"). Default 50%: above it you just rest, so a scratch doesn't cost food."), ]}, {"date": "2026-10-24", "title": "A dungeon in every area", "notes": [ ("new", "Six more dungeons for 2–4, seven pulls each with two mini-bosses and a final boss: Goblin Warren " @@ -6081,7 +6095,7 @@ def content() -> dict: "prayers": [{**prayer, "text": describe_prayer(prayer)} for prayer in PRAYERS], "max_prayers": MAX_PRAYERS, "potion_charges": POTION_CHARGES, "order_recipes": ORDER_RECIPES, "order_raw": ORDER_RAW, "order_limit": ORDER_LIMIT, "order_max": ORDER_MAX, "input_share": INPUT_SHARE, "problems": PROBLEMS, "fittings": list(FITTING_BY_ID.values()), - "equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, + "equip_slots": EQUIP_SLOTS, "class_speed": CLASS_SPEED, "rest_rate": REST_RATE, "bite": BITE, "eat_below": EAT_BELOW, "eat_choices": EAT_CHOICES, "abilities": combat.ABILITIES, "enemy_abilities": combat.ENEMY_ABILITIES, "unlock": combat.UNLOCK, "auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD, "weapon_kits": combat.WEAPON_KITS, "group_size": GROUP_SIZE, "dungeon_size": DUNGEON_SIZE, "dungeon_wipes": DUNGEON_WIPES, "trade_ability": combat.TRADE_ABILITY, "potion_below": combat.POTION_BELOW, "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, diff --git a/static/app.js b/static/app.js index fa820e1..a0b8e76 100644 --- a/static/app.js +++ b/static/app.js @@ -2180,8 +2180,12 @@ function foodPanel(p) { const tiles = [...p.edible].sort((a, b) => item(a).heal - item(b).heal).map((f) => ``); const cheap = ``; + const below = p.eat_below ?? C.eat_below; + const label = (x) => x === 0 ? "Never" : x === 1 ? "Always" : `${Math.round(x * 100)}%`; + const when = `
Eat below
+
${C.eat_choices.map((x) => ``).join("")}
`; return head + (p.edible.length ? `
Eat first
${cheap}${tiles.join("")}
` - : `
No food in your bank.
`); + : `
No food in your bank.
`) + when; } // The equipment panel, the same on the character sheet and the combat page (which leaves out tools). @@ -2301,6 +2305,7 @@ document.addEventListener("click", (e) => { if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) }); if ("pull" in d) return act("/api/pull", {}); if (d.learn) return act("/api/learn", { item: d.learn }); + if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) }); if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim }); if (d.start) return act("/api/action", { id: d.start }); if (d.once) return act("/api/action", { id: d.once, once: true }); diff --git a/static/styles.css b/static/styles.css index 09cbe53..64a33d5 100644 --- a/static/styles.css +++ b/static/styles.css @@ -587,6 +587,8 @@ button.sheet-skill:hover { background: var(--panel-2); } .ab-lv .gi { width: 10px; height: 10px; } .ab.boosted .ab-press::after { content: "✦"; position: absolute; top: 1px; right: 3px; font-size: 10px; color: var(--gold); } .unique-text { color: var(--gold); margin-top: 4px; } +.eat-below { display: flex; flex-wrap: wrap; gap: 4px; } +.eat-below .btn.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); } .ab.locked { opacity: .35; filter: grayscale(1); } .ab.fired .ab-press { box-shadow: 0 0 0 2px var(--gold); } .ab.spent { opacity: .35; } diff --git a/tests/test_game.py b/tests/test_game.py index 5c5375b..9856494 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -587,6 +587,7 @@ class CombatTest(unittest.TestCase): def test_food_shortens_the_rest_between_fights(self): world, p = self.fighter(level=30) + p["eat_below"] = 1.0 # eat whenever a bite is quicker top = game.max_hp(p) p["bank"] = {} resting, eaten = game.rest_plan(world, p, top / 2) @@ -605,6 +606,20 @@ class CombatTest(unittest.TestCase): p["bank"] = {"bread": 100} self.assertEqual(game.rest_plan(world, p, 100)[1], {}) # a bite of bread is no faster than resting + def test_you_choose_how_hurt_you_eat_at(self): + world, p = self.fighter(level=30) + top = game.max_hp(p) + p["bank"] = {"cooked_salmon": 100} + self.assertEqual(p["eat_below"], 0.5) + self.assertEqual(game.rest_plan(world, p, top * 0.95)[1], {}) # a scratch: just rest + _seconds, eaten = game.rest_plan(world, p, top * 0.2) + self.assertEqual(eaten, {"cooked_salmon": 2}) # back over half, then rest + self.assertIsNone(game.set_eat_below(p, 0)) + self.assertEqual(game.rest_plan(world, p, top * 0.2)[1], {}) # never + self.assertIsNone(game.set_eat_below(p, 1.0)) + self.assertEqual(game.rest_plan(world, p, top * 0.2)[1], {"cooked_salmon": 5}) # all the way + self.assertEqual(game.set_eat_below(p, 0.3), "Pick one of the choices") + def test_food_is_eaten_resting_not_fighting(self): world, p = self.fighter(level=15, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs") p["bank"] = {"cooked_trout": 100}