Choose when you eat between fights

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 14:02:24 +01:00
parent 8afcba79fc
commit bc99a1e0c5
5 changed files with 43 additions and 5 deletions

2
app.py
View file

@ -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), {}):

22
game.py
View file

@ -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,

View file

@ -2180,8 +2180,12 @@ function foodPanel(p) {
const tiles = [...p.edible].sort((a, b) => item(a).heal - item(b).heal).map((f) => `<button class="doll-slot swap ${f === id ? "on" : ""}" data-food="${f}"
title="${esc(`${item(f).name}: heals ${item(f).heal}`)}">${item(f).icon}<span class="swap-qty">${fmt(p.bank[f])}</span></button>`);
const cheap = `<button class="doll-slot swap ${id ? "" : "on"}" data-food="cheapest" title="Cheapest first">${ANY_FOOD}</button>`;
const below = p.eat_below ?? C.eat_below;
const label = (x) => x === 0 ? "Never" : x === 1 ? "Always" : `${Math.round(x * 100)}%`;
const when = `<div class="swap-head muted small" title="Between fights you eat while under this much of your HP, then rest the rest of the way">Eat below</div>
<div class="eat-below">${C.eat_choices.map((x) => `<button class="btn sm ${x === below ? "on" : ""}" data-eat="${x}">${label(x)}</button>`).join("")}</div>`;
return head + (p.edible.length ? `<div class="swap-head muted small">Eat first</div><div class="swap-grid">${cheap}${tiles.join("")}</div>`
: `<div class="small warn">No food in your bank.</div>`);
: `<div class="small warn">No food in your bank.</div>`) + 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 });

View file

@ -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; }

View file

@ -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}