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) {