diff --git a/README.md b/README.md index 6db9311..eb31a6c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. - **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, jewelcrafting, construction (with masonry), cooking, leatherworking, tailoring, herblore, - combat, prayer and reconnaissance. Smiths also make hardware for builders, + firekeeping, combat, prayer and reconnaissance. Smiths also make hardware for builders, one kind per metal (copper rivets to rune locks), which fittings and buildings need. - **Combat:** every fight is played out swing by swing: you and the enemy @@ -76,7 +76,7 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. - **Classes are trades,** from gathering to finished goods, so each player has a quiet gathering side and a busier crafting side: miner (mining, smithing, jewelcrafting), - builder (woodcutting, construction), homesteader (farming, cooking, + builder (woodcutting, construction, firekeeping), homesteader (farming, cooking, herblore, tailoring) and ranger (hunting, fishing, leatherworking, cooking). A trade's skills are 20% faster, 1 in 10 of its crafts make double, and each has one bonus of its own. Homesteaders farm differently: @@ -120,6 +120,16 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. potions a 10โ€“35% chance to keep materials, Prayer more points from bones, and combat has Strength and Defence ladders. Potions last 50 uses, one per skill at a time; an empty one is replaced from the bank automatically. +- **Firekeeping:** the guild shares four fires: the forge (bars), the kitchen + 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 + 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, and finally where their camp is, so fighters can sabotage it. Fighters can diff --git a/app.py b/app.py index a616e49..7a48aa8 100644 --- a/app.py +++ b/app.py @@ -87,13 +87,17 @@ class Store: def tick(self) -> None: with self.lock: - if game.tick(self.world, self.now(), self.rng): + now = self.now() + changed = game.tick(self.world, now, self.rng) + # Everyone's actions run as time passes: the fires are shared, so a craft + # has to meet the fire as it was, not as it is when its player next looks. + if game.catch_up(self.world, now, self.rng) or changed: self.save() def player_view(world: dict, player: dict, now: float) -> dict: game.regen(world, player, now) - view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at")} + view = {key: value for key, value in player.items() if key not in ("last_active", "hp_at", "away")} view["hp"] = int(player["hp"]) view["max_hp"] = game.max_hp(player) view["armour"] = game.armour(player) @@ -127,6 +131,10 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["drill_per_action"] = game.drill_per_action(player) view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS if action.get("special") == "recon"} + level = game.skill_level(player, "firekeeping") + view["firekeeping"] = {"minutes": game.fire_minutes(level), "bank": game.fire_bank(level)} + # Inputs as the fires leave them: a lit fire pays the log. + view["fuelled"] = {action["id"]: game.fire_inputs(world, action) for action in game.ACTIONS if action.get("fire")} return view @@ -162,6 +170,7 @@ def city_view(world: dict, now: float) -> dict: "repelled": city["repelled"], "defence": game.defence(world), "day": game.calendar_day(world, now) + 1, + "fires": game.fires_view(world, now), } @@ -263,8 +272,9 @@ class Handler(BaseHTTPRequestHandler): game.tick(world, now, self.store.rng) player = world["players"].get(uid) summary = None + game.catch_up(world, now, self.store.rng) if player: - summary = game.process_player(world, player, now, self.store.rng) + summary = game.take_summary(player, game.empty_summary()) game.regen(world, player, now) player["last_active"] = now self.store.save() @@ -294,7 +304,8 @@ class Handler(BaseHTTPRequestHandler): elif not player: return self.fail("Create your adventurer first", HTTPStatus.CONFLICT) else: - summary = game.process_player(world, player, now, self.store.rng) + game.catch_up(world, now, self.store.rng) # everyone, so the fires are as they really are + summary = game.take_summary(player, game.empty_summary()) game.regen(world, player, now) player["last_active"] = now error = self.player_route(path, player, body, now) diff --git a/game.py b/game.py index 86ff376..b75063e 100644 --- a/game.py +++ b/game.py @@ -123,7 +123,8 @@ COPPER_ICONS = {"sword": "๐Ÿ—ก๏ธ", "helmet": "โ›‘๏ธ", "legs": "๐Ÿฆต", "body": TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช"), "construction": ("hammer", "๐Ÿ”จ"), "smithing": ("chisel", "๐Ÿ”ง"), "jewelcrafting": ("gem drill", "๐Ÿช›"), "cooking": ("cleaver", "๐Ÿ”ช"), - "herblore": ("mortar and pestle", "๐Ÿฅฃ"), "leatherworking": ("awl", "๐Ÿ“"), "tailoring": ("shears", "โœ‚๏ธ")} + "herblore": ("mortar and pestle", "๐Ÿฅฃ"), "leatherworking": ("awl", "๐Ÿ“"), "tailoring": ("shears", "โœ‚๏ธ"), + "firekeeping": ("bellows", "๐Ÿชญ")} TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60] # Crafting stations: a second slot for every crafting skill, beside its tool, and as fast. # Builders make every station, from the tier's planks and hardware and a material from @@ -151,6 +152,38 @@ TREES = [ ("yew_logs", "Yew logs", "๐ŸŒฒ", 30, "cut_yew", "Cut yews"), ("magic_logs", "Enchanted logs", "๐Ÿ”ฎ", 55, "cut_magic", "Cut enchanted trees"), ] +FIREWOOD = tuple(tree[0] for tree in TREES) + +# The guild's fires, shared by everyone. A craft at a fire takes one log of its tier as +# fuel, and a fire burning that tier's flame or hotter pays it (the forge a coal too). +# Firekeepers feed them: fuel burns hottest first, and a better log pushes cheaper fuel +# out of a full fire. Level gives more minutes per log and a bigger bank. +FIRES = { + "forge": {"name": "Forge", "icon": "โš’๏ธ", "skill": "smithing", "glyph": "anvil-impact"}, + "kitchen": {"name": "Kitchen fire", "icon": "๐Ÿณ", "skill": "cooking", "glyph": "cooking-pot"}, + "kiln": {"name": "Kiln", "icon": "๐Ÿงฑ", "skill": "construction", "glyph": "brick-pile"}, + "still": {"name": "Still", "icon": "โš—๏ธ", "skill": "herblore", "glyph": "erlenmeyer"}, +} +FLAMES = [("Red", "#ff6a2a"), ("Gold", "#ffcb3d"), ("White", "#eeeaf8"), + ("Blue", "#4fa3ff"), ("Green", "#3ed37a"), ("Violet", "#b07cff")] +TEND_XP = [2, 3, 4.5, 5.5, 6.5, 7.5] # per game minute of fire fed, by the log's tier +WARMTH = 0.1 # of a craft's XP, to whoever fed the fuel that paid for it +FIRE_LOW = 30 * 60 # game seconds left when a fire counts as low + + +def fire_minutes(level: int) -> float: + """Game minutes of fire one log gives at this Firekeeping level.""" + return 3 + 0.12 * level + + +def fire_bank(level: int) -> float: + """The most fuel, in game minutes, a keeper of this level can bank in a fire.""" + return 60 + 9 * level + + +def _fuelled(inputs: dict, tier: int) -> dict: + """A fire craft's inputs: its materials and one log of its tier to burn.""" + return {**inputs, FIREWOOD[tier]: inputs.get(FIREWOOD[tier], 0) + 1} # Every tier's hide comes from Hunting. raw hide (id, name, icon, value), leather (id, name, value), # armour id prefix, armour name prefix, leatherworking level @@ -452,6 +485,7 @@ SKILLS = [ {"id": "leatherworking", "name": "Leatherworking", "icon": "๐Ÿชก", "group": "Crafting"}, {"id": "tailoring", "name": "Tailoring", "icon": "๐Ÿงต", "group": "Crafting"}, {"id": "herblore", "name": "Herblore", "icon": "๐Ÿงช", "group": "Crafting"}, + {"id": "firekeeping", "name": "Firekeeping", "icon": "๐Ÿ”ฅ", "group": "Crafting"}, {"id": "combat", "name": "Combat", "icon": "โš”๏ธ", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "๐Ÿ™", "group": "Combat"}, {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐Ÿ”ญ", "group": "Combat"}, @@ -473,8 +507,8 @@ CLASSES = { "combat": "Warhammer: +15% damage", }, "builder": { - "name": "Builder", "icon": "๐Ÿ—๏ธ", "fast": ["woodcutting", "construction"], "craft": ["construction"], - "bonus": "Woodcutting and construction 20% faster", + "name": "Builder", "icon": "๐Ÿ—๏ธ", "fast": ["woodcutting", "construction", "firekeeping"], "craft": ["construction"], + "bonus": "Woodcutting, construction and firekeeping 20% faster", "masterwork": "Masterwork: 10% double construction crafts", "signature": "Foreman: +1 labour per site action", "combat": "Sword: +15% damage", @@ -520,11 +554,10 @@ def _tiered_actions() -> list[dict]: if tier == 2: actions.append(_action("mine_coal", "mining", "Mine coal", 30, 40, 22, outputs={"coal": 1})) city = {"smithy": smithy} if smithy else {} - inputs = {"iron_ore" if key == "steel" else ore[0]: 1} - inputs.update({"coal": coal} if coal else {"logs": 1}) + inputs = {"iron_ore" if key == "steel" else ore[0]: 1, **({"coal": coal} if coal else {})} actions.append(_action(f"smith_{key}_bar", "smithing", f"{name} bar", TIER_LEVELS[tier], 20 + 4 * tier, - bar_xp[tier], inputs, {f"{key}_bar": 1}, group="Bars", - city={"smithy": smithy} if tier >= 2 else {})) + bar_xp[tier], _fuelled(inputs, tier), {f"{key}_bar": 1}, group="Bars", + city={"smithy": smithy} if tier >= 2 else {}, fire="forge", fuel=tier)) hardware, _name, _plural, bars, made = HARDWARE[tier] actions.append(_action(f"smith_{hardware}", "smithing", _plural, TIER_LEVELS[tier] + 2, 25 + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars}, {hardware: made}, group="Hardware", @@ -576,7 +609,8 @@ def _tiered_actions() -> list[dict]: actions.append(_action(fish_id, "fishing", verb, level, [25, 35, 45, 55, 65, 80][tier], [6, 15, 28, 45, 62, 85][tier], outputs={raw[0]: 1})) actions.append(_action(COOK_IDS[raw[0]], "cooking", cooked[1], level, [20, 30, 35, 40, 45, 50][tier], - [6, 15, 27, 42, 58, 80][tier], {raw[0]: 1, "logs": 1}, {cooked[0]: 1}, group="Fish")) + [6, 15, 27, 42, 58, 80][tier], _fuelled({raw[0]: 1}, tier), {cooked[0]: 1}, group="Fish", + fire="kitchen", fuel=tier)) return actions @@ -934,7 +968,8 @@ ACTIONS = [ *[_action(f"grow_{herb}", "farming", f"Grow {name.lower()}", level, seconds, xp, outputs={herb: 1}, group="Herbs") for herb, name, _icon, level, seconds, xp, _value in HERBS], *[_action(f"brew_{potion}", "herblore", name, level, 30 + level // 3, round(4 + level * 1.6), - {herb: 1, second: 1}, {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}") + _fuelled({herb: 1, second: 1}, POTION_TIER[potion]), {potion: 1}, group=f"Tier {ROMAN[POTION_TIER[potion]]}", + fire="still", fuel=POTION_TIER[potion]) for potion, name, level, herb, second, skill, _effect in POTIONS], _action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}), _action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}), @@ -957,14 +992,17 @@ ACTIONS = [ *[_action(f"saw_{plank}", "construction", f"Saw {name.lower()}s", TIER_LEVELS[tier], 25 + 3 * tier, [6, 14, 22, 32, 44, 58][tier], {TREES[tier][0]: 2}, {plank: 1}, group="Planks") for tier, (plank, name) in enumerate(PLANKS)], - _action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"), + _action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry", + fire="kiln", fuel=0), _action("mason_cut_stone", "construction", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}, group="Masonry"), _action("mason_beam", "construction", "Reinforced beam", 20, 45, 30, {"oak_logs": 2, "iron_nails": 5}, {"beam": 1}, group="Masonry"), - _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals"), - _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals"), + _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}, group="Meals", + fire="kitchen", fuel=0), + _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}, group="Meals", + fire="kitchen", fuel=0), _action("cook_stew", "cooking", "Stew", 15, 35, 16, - {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals"), + {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}, group="Meals", fire="kitchen", fuel=0), _action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}, group="Rations"), _action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}, group="Rations"), _action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}, group="Rations"), @@ -980,6 +1018,10 @@ ACTIONS = [ devotion=20, city={"temple": 2}, group="Offer at the temple"), _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), _action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2), + # Tending: one log a go into one fire; better logs need more Firekeeping. + *[_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)], ] ACTION_BY_ID = {action["id"]: action for action in ACTIONS} for _id, _gem in GEM_ROCKS.items(): @@ -1095,7 +1137,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 23 +WORLD_VERSION = 24 def new_world(now: float, speed: float = 1.0) -> dict: @@ -1118,6 +1160,7 @@ def new_world(now: float, speed: float = 1.0) -> dict: "food_due": 0.0, "starving": False, "repelled": 0, + "fires": {key: new_fire(now) for key in FIRES}, }, "events": [], "next_event_at": now + 12 * HOUR / speed, @@ -1273,6 +1316,15 @@ def migrate(world: dict, now: float | None = None) -> dict: if item and ITEMS[item].get("station"): equip[f"tool_{skill}"], equip[f"station_{skill}"] = None, item world["version"] = 23 + if world["version"] < 24: + # Firekeeping: a new skill, its bellows slot, and the guild's fires. + world["city"].setdefault("fires", {}) + for key in FIRES: + world["city"]["fires"].setdefault(key, new_fire(now)) + for player in world["players"].values(): + player["xp"].setdefault("firekeeping", 0) + player["equip"].setdefault("tool_firekeeping", None) + world["version"] = 24 return world @@ -2000,6 +2052,106 @@ def _use_charge(player: dict, skill: str, summary: dict) -> None: summary["potion_out"].append(active["item"]) +# --- 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. + +def new_fire(now: float) -> dict: + return {"fuel": [], "at": now, "saved": 0} + + +def burn_fires(world: dict, now: float) -> None: + """Burn every fire down to `now`. Never runs backwards.""" + for fire in world["city"]["fires"].values(): + if now <= fire["at"]: + continue + 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) + + +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 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]) + 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) + 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.""" + fire, level = world["city"]["fires"][fire_id], skill_level(player, "firekeeping") + short = _room(fire, tier, level) + while short > 1e-6: + cheapest = min((chunk for chunk in fire["fuel"] if chunk["tier"] < tier), key=lambda chunk: chunk["tier"]) + out = min(short, cheapest["left"]) + cheapest["left"] -= out + short -= out + if cheapest["left"] <= 1e-6: + fire["fuel"].remove(cheapest) + minutes = fire_minutes(level) + last = fire["fuel"][-1] if fire["fuel"] else None + if last and last["tier"] == tier and last["by"] == player["id"]: + last["left"] += minutes * 60 + else: + fire["fuel"].append({"tier": tier, "left": minutes * 60, "by": player["id"]}) + return minutes + + +def fire_inputs(world: dict, action: dict) -> dict: + """What a craft takes right now: a hot enough fire pays its log, and the forge a coal (never the last).""" + fire = action.get("fire") + if not fire or heat(world, fire) < action["fuel"]: + return action["inputs"] + inputs = dict(action["inputs"]) + log = FIREWOOD[action["fuel"]] + inputs[log] -= 1 + if not inputs[log]: + del inputs[log] + if fire == "forge" and inputs.get("coal", 0) > 1: + inputs["coal"] -= 1 + return inputs + + +def _warm(world: dict, crafter: dict, action: dict, xp: float, now: float) -> None: + """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"]) + if keeper and keeper is not crafter: + gain_xp(world, keeper, "firekeeping", xp * WARMTH, now, keeper.setdefault("away", empty_summary())) + + +def fires_view(world: dict, now: float) -> dict: + burn_fires(world, now) + users = {} + for player in world["players"].values(): + action = player["action"] and ACTION_BY_ID.get(player["action"]["id"]) + if action and (action.get("fire") or action.get("tend")): + users.setdefault(action.get("fire") or action["tend"], []).append( + {"id": player["id"], "name": player["name"], "action": action["id"]}) + return {key: {**fire, "heat": heat(world, key), "speed": speed(world), "users": users.get(key, [])} + for key, fire in world["city"]["fires"].items()} + + def upcoming_raid(world: dict) -> dict | None: """The coming major threat: a bandit raid or a dragon.""" for event in world["events"]: @@ -2026,12 +2178,15 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: if order is not None or "order" in (player["action"] or {}) and player["action"]["id"] == action["id"]: if not order or order["status"] != "open" or order["claimed"] != player["id"]: return "Work order done" if order and order["status"] == "done" else "Work order closed" - for item, qty in action["inputs"].items(): + for item, qty in fire_inputs(world, action).items(): if order and item in order["provided"]: if order["provided"][item] < qty: 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") == "sabotage": raid = upcoming_raid(world) if not raid or intel_level(world, raid) < 3: @@ -2061,9 +2216,11 @@ def can_start(world: dict, player: dict, action: dict) -> str | None: def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: brew = potion_effect(player, action["skill"]) order = working_order(world, player, action) - own = {item: qty for item, qty in action["inputs"].items() if not order or item not in order["provided"]} + inputs = fire_inputs(world, action) + warmed = inputs is not action["inputs"] + own = {item: qty for item, qty in inputs.items() if not order or item not in order["provided"]} if order: - for item, qty in action["inputs"].items(): + for item, qty in inputs.items(): if item in order["provided"]: order["provided"][item] -= qty if own and rng.random() < brew.get("keep", 0): @@ -2164,6 +2321,10 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R city["drill_boost"] = city.get("drill_boost", 0) - boost 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"] + if warmed: + _warm(world, player, action, xp, now) if action.get("special") == "labour": city = world["city"] site = city["construction"] @@ -2249,6 +2410,7 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N duration = action_time(world, player, action) if current["start"] + duration > limit: break + burn_fires(world, current["start"] + duration) reason = can_start(world, player, action) if reason and reason.startswith("Too hurt"): if blessing(player)["wind"] and not current.get("wind_used"): @@ -2304,6 +2466,43 @@ def stop(player: dict, reason: str | None = None) -> None: player["stopped"] = reason +def catch_up(world: dict, now: float, rng: random.Random | None = None) -> bool: + """Run everyone's actions up to now, so the shared fires see feeds and crafts as they + happen. What each player missed waits in their `away` summary for their next visit.""" + changed = False + for player in world["players"].values(): + summary = process_player(world, player, now, rng) + if summary["done"] or summary["stopped"]: + merge_summary(player.setdefault("away", empty_summary()), summary) + changed = True + return changed + + +def merge_summary(into: dict, more: dict) -> dict: + """Fold a later summary into an earlier one.""" + for key, value in more.items(): + if isinstance(value, bool): + into[key] = into.get(key) or value + elif isinstance(value, (int, float)): + into[key] = into.get(key, 0) + value + elif isinstance(value, dict): + for item, qty in value.items(): + into.setdefault(key, {})[item] = into[key].get(item, 0) + qty + elif key == "levels": + into[key] = [entry for entry in into.get(key, []) if entry[0] not in {s for s, _ in value}] + value + elif isinstance(value, list): + into[key] = into.get(key, []) + value + elif value is not None: + into[key] = value + return into + + +def take_summary(player: dict, summary: dict) -> dict: + """Everything that happened since the player last looked: the catch-up's and this request's.""" + away = player.pop("away", None) + return merge_summary(away, summary) if away else summary + + # --- work orders ----------------------------------------------------------- # A player asks the guild for something. A crafting order names a recipe: the # requester can put in some of its reagents, one crafter claims it and makes it @@ -2876,6 +3075,7 @@ def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: rng = rng or random.Random() city = world["city"] changed = False + burn_fires(world, now) step = HOUR / speed(world) # Never replay more than two weeks if the server was off. city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world)) @@ -3348,7 +3548,6 @@ SEASON_WEATHER = [ {"clear": 30, "rain": 30, "storm": 25, "fog": 15}, {"clear": 30, "snow": 45, "fog": 15, "storm": 10}, ] -FIREWOOD = ("logs", "oak_logs", "willow_logs", "maple_logs", "yew_logs", "magic_logs") WOOD = FIREWOOD + tuple(plank for plank, _name in PLANKS) # Minor threats: they slow or block a skill until someone deals with them, @@ -4022,7 +4221,8 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi "legs": "leg-armor", "body": "breastplate"} TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", "hunting knife": "bowie-knife", "hammer": "claw-hammer", "chisel": "chisel", "gem drill": "drill", - "cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears"} + "cleaver": "meat-cleaver", "mortar and pestle": "mortar", "awl": "needle-drill", "shears": "shears", + "bellows": "bellows"} LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} CLOTH_GLYPHS = {"hood": "pointy-hat", "legs": "trousers", "body": "robe"} FIBRE_GLYPHS = {"cotton": "cotton-flower", "silk_cocoons": "butterfly", "moonpetal": "lotus-flower", @@ -4057,6 +4257,7 @@ SKILL_GLYPHS = { "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), "reconnaissance": ("spyglass", "#6f8fec"), "tailoring": ("sewing-machine", "#c8a0e0"), + "firekeeping": ("campfire", "#ff7a2a"), } CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"), "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")} @@ -4098,6 +4299,9 @@ ACTION_GLYPHS.update({ for _index, (_area, _level, _prefix, _races) in enumerate(AREAS): for _race, *_rest in _races: ACTION_GLYPHS[f"fight_{_slug(_race)}"] = (RACE_GLYPHS[_race], TIER_TINTS[_index]) +for _tend in ACTIONS: + if _tend.get("special") == "tend": + ACTION_GLYPHS[_tend["id"]] = ("campfire", FLAMES[_tend["tier"]][1]) UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield", "magic-shield", "stopwatch", "swordman", "biceps", "cancel", "scroll-unfurled", "striking-diamonds", "archery-target", "dodging", "shield-reflect", "cape", "gem-pendant", "gauntlet", "ring", "quiver", "leather-boot", "crystal-ball", "hot-meal", "knife-fork", "round-bottom-flask", "visored-helm", "breastplate", @@ -4211,4 +4415,5 @@ def content() -> dict: "styles": STYLES, "damage_types": DAMAGE_TYPES, "weights": WEIGHTS, "vulnerable": VULNERABLE, "resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE, "regen_per_hour": REGEN_PER_HOUR, + "fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW, } diff --git a/static/app.js b/static/app.js index 77a0f12..c0f27a3 100644 --- a/static/app.js +++ b/static/app.js @@ -149,7 +149,7 @@ function toast(text, kind = "") { } function showSummary(sum) { - if (!sum.done && !sum.stopped) return; + if (!sum.done && !sum.stopped && !sum.levels.length) return; if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum); const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); @@ -310,7 +310,11 @@ function nav(id, icon, label, meta = "", extra = "", tip = "") { function skillNav(skill, me, doing) { const busy = doing?.skill === skill.id ? `` : ""; - return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy); + const fire = fireOfSkill(skill.id), heat = fire ? fireNow(fire).heat : -1; + const flame = heat >= 0 ? `` : ""; + const low = skill.id === "firekeeping" && Object.keys(C.fires).some((id) => fireNow(id).low) ? `` : ""; + const tip = heat >= 0 ? `${C.fires[fire].name}: ${C.flames[heat][0]}` : low ? "A fire is low" : ""; + return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, flame + busy + low, tip); } function pinnedNav(me, doing) { @@ -381,6 +385,12 @@ function topbar() { } else if (S.me) { chips += `Idle${S.me.stopped ? ` ยท ${esc(S.me.stopped)}` : ""}`; } + const fires = S.me ? Object.keys(C.fires).map((id) => [id, fireNow(id)]) : []; + if (fires.some(([, n]) => n.heat >= 0)) { + const tip = fires.map(([id, n]) => `${C.fires[id].name}: ${n.heat >= 0 ? `${C.flames[n.heat][0]}, ${dur(n.left / n.speed)}` : "out"}`).join("\n"); + chips += ``; + } const w = S.happenings?.weather; if (w) chips += ``; if (S.upcoming) chips += ``; @@ -391,11 +401,11 @@ function topbar() { function trackedBar() { const me = S.me, a = me.tracked && C.actions.find((x) => x.id === me.tracked); if (!a) return ""; - const ready = Object.entries(a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q); + const ready = Object.entries(me.fuelled?.[a.id] || a.inputs).every(([id, q]) => (me.bank[id] || 0) >= q); const go = !me.blocked[a.id] && me.action?.id !== a.id; return `
- ${Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("")} + ${inputChips(a)} ${go ? `` : ""}
`; @@ -430,6 +440,18 @@ function itemChip(id, qty, have) { return `${item(id).icon} ${have !== undefined ? `${fmt(have)} / ${fmt(qty)}` : fmt(qty)}`; } +// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through. +function inputChips(a) { + const me = S.me, ins = me.fuelled?.[a.id] || a.inputs; + return Object.entries(a.inputs).map(([id, q]) => { + const need = ins[id] || 0, have = me.bank[id] || 0; + if (need === q) return itemChip(id, q, have); + const tip = `title="${esc(`${item(id).name}: the ${C.fires[a.fire].name.toLowerCase()} pays ${q - need}`)}"`; + return need ? `${item(id).icon} ${fmt(have)} / ${q} ${need}` + : ``; + }).join(""); +} + // Progress towards a building cost: "have / need". function costChip(icon, name, need, have) { return `${icon} ${fmt(have)} / ${fmt(need)}`; @@ -459,6 +481,8 @@ function skillPage(id) { : `
Nothing under construction.
`; if (id === "farming" && me.cls === "homesteader") html += plotsPanel(); html += skillGear(id); + if (id === "firekeeping") return html + firekeepingPage(); + html += fireBand(id); if (id === "reconnaissance") html += `
Raises intel on the coming threat. Higher levels open as it nears.
`; html += skillHappenings(id); const wanted = (S.orders || []).filter((o) => o.skill === id && canTake(o)); @@ -539,6 +563,196 @@ function skillGear(skill) { return cards.length ? `
${cards.join("")}
` : ""; } +// --- fires ---------------------------------------------------------------- +// The guild's shared fires. Between polls the client burns them down itself, hottest +// fuel first, the same way the server does. + +const ROMAN = ["I", "II", "III", "IV", "V", "VI"]; +// Core to edge of each flame colour, for the animated fires. +const FLAME_RAMPS = [["#fff3cf", "#ffb54a", "#ff6a2a", "#8a210a"], ["#fffbe6", "#ffe680", "#f2a81f", "#7a4a06"], + ["#ffffff", "#f6f4ff", "#c9c4e0", "#4d4a66"], ["#eef8ff", "#8fd0ff", "#3485ea", "#10347a"], + ["#ecfff1", "#8af5ab", "#22b85a", "#0a4a25"], ["#f8efff", "#d6b0ff", "#9150f0", "#3a1474"]]; +const STILL = matchMedia("(prefers-reduced-motion: reduce)").matches; +let firewood = Number(remember("firewood") ?? -1); // the log tier picked for tending +const flames = {}; // fire -> its particles, kept across renders +let fireTextAt = 0; + +const fireOfSkill = (skill) => Object.keys(C.fires).find((id) => C.fires[id].skill === skill); +const flameTint = (tier) => tier >= 0 ? C.flames[tier][1] : "#5d626b"; +const tendLevel = (tier) => C.actions.find((a) => a.id === `tend_forge_${C.firewood[tier]}`).level; + +function fireNow(id) { + const f = S.city.fires[id], fuel = f.fuel.map((c) => ({ ...c })); + 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); + } + 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 }; +} + +function pickFirewood() { + const me = S.me, open = C.firewood.map((_, t) => t).filter((t) => me.levels.firekeeping >= tendLevel(t)); + if (open.includes(firewood)) return firewood; + return [...open].reverse().find((t) => me.bank[C.firewood[t]]) ?? 0; +} + +function firekeepingPage() { + const me = S.me, fk = me.firekeeping, pick = pickFirewood(), speed = Object.values(S.city.fires)[0].speed; + 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; + const tip = `${item(log).name} ยท ${C.flames[tier][0]} ยท ${locked ? `Needs level ${level}` : `${perLog(tier)} XP a log`}`; + return ``; + }).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("")}
`; +} + +function fireCard(id, pick) { + 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 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 who = crafting.length + ? `๐Ÿ‘ค ${crafting.length} ${crafting.map((u) => doing(u).icon).join("")}` + : `๐Ÿ‘ค 0`; + return `
+
+
+
${glyph(spec.glyph, "#e7e9f0")}${esc(spec.name)}${skill.icon} ${esc(skill.name)}
+
+
+
${who}๐Ÿชต ${fmt(f.saved || 0)}
+ ${mine ? `
` : ""} +
${esc(why || "")}${mine + ? `` + : ``}
+
`; +} + +// The fire behind a crafting skill, at the top of its page. +function fireBand(skill) { + const id = fireOfSkill(skill); + if (!id) return ""; + const n = fireNow(id), spec = C.fires[id], lit = n.heat >= 0; + const pays = lit && `Pays the log for tier ${ROMAN[n.heat]} and below${id === "forge" ? ", and a coal" : ""}`; + return `
+ ${glyph(spec.glyph, lit ? flameTint(n.heat) : "#98a1b4")} +
${esc(spec.name)} ${lit ? `${C.flames[n.heat][0]} ยท ` : "Out"}
+ ${lit ? `๐Ÿชต โ‰ค ${ROMAN[n.heat]}` : ""} + +
`; +} + +function animateFires() { + if (!S?.me || !S.city?.fires) return; + const text = performance.now() - fireTextAt > 250; + if (text) fireTextAt = performance.now(); + for (const id of Object.keys(C.fires)) { + const canvas = document.querySelector(`[data-flame="${id}"]`); + if (!canvas && !text) continue; + const n = fireNow(id); + 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)}`); + 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]); + for (const el of document.querySelectorAll(`[data-fire-state="${id}"]`)) el.className = `fire-state ${n.heat < 0 ? "out" : n.low ? "low" : ""}`; + } +} + +function rampAt(ramp, t) { + const s = t * (ramp.length - 1), i = Math.min(Math.floor(s), ramp.length - 2), f = s - i; + const rgb = (hex) => [1, 3, 5].map((k) => parseInt(hex.slice(k, k + 2), 16)); + const a = rgb(ramp[i]), b = rgb(ramp[i + 1]); + return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(",")})`; +} + +// A fire on a canvas: rising flame particles in its colour, taller with more fuel; +// smoke when it's low, a grey bed of ash when it's out. +function drawFlame(cv, id, n) { + const st = flames[id] ||= { parts: [], at: performance.now() }; + const t = performance.now(), dt = Math.min(0.05, (t - st.at) / 1000); + st.at = t; + const r = cv.getBoundingClientRect(), d = Math.min(devicePixelRatio || 1, 2); + if (!r.width) return; + if (cv.width !== Math.round(r.width * d)) { cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d); } + const ctx = cv.getContext("2d"), W = cv.width, H = cv.height, cx = W / 2, base = H - 22 * d; + const lit = n.heat >= 0, ramp = FLAME_RAMPS[Math.max(0, n.heat)]; + const power = lit ? Math.min(1, 0.25 + n.left / 14400) : 0; + ctx.clearRect(0, 0, W, H); + if (lit && !STILL) { + for (let i = Math.round(90 * power * dt) + 1; i > 0; i--) st.parts.push({ k: "f", x: cx + (Math.random() - 0.5) * 46 * d * (0.6 + power * 0.6), y: base, + vx: (Math.random() - 0.5) * 18 * d, vy: -(40 + Math.random() * 60) * d * (0.55 + power), life: 0, + max: 0.55 + Math.random() * 0.5 * (0.5 + power), r: (7 + Math.random() * 9) * d * (0.6 + power * 0.5) }); + if (Math.random() < dt * 6 * power) st.parts.push({ k: "e", x: cx + (Math.random() - 0.5) * 40 * d, y: base - 10 * d, + vx: (Math.random() - 0.5) * 30 * d, vy: -(60 + Math.random() * 60) * d, life: 0, max: 1 + Math.random(), r: 1.4 * d }); + } + if (!STILL && (!lit || n.low) && Math.random() < dt * (lit ? 5 : 3)) st.parts.push({ k: "s", x: cx + (Math.random() - 0.5) * 20 * d, + y: base - (lit ? 40 * d : 0), vx: (Math.random() - 0.3) * 10 * d, vy: -(18 + Math.random() * 14) * d, life: 0, max: 2.5 + Math.random(), r: (6 + Math.random() * 5) * d }); + ctx.fillStyle = "#3a2a1e"; + ctx.save(); + ctx.translate(cx, base + 8 * d); + for (const [angle, w] of [[-0.18, 70], [0.2, 64], [0, 50]]) { + ctx.save(); ctx.rotate(angle); ctx.beginPath(); ctx.roundRect(-w / 2 * d, -5 * d, w * d, 10 * d, 5 * d); ctx.fill(); ctx.restore(); + } + ctx.restore(); + if (lit) { + const glow = ctx.createRadialGradient(cx, base + 4 * d, 2, cx, base + 4 * d, 60 * d); + glow.addColorStop(0, ramp[1] + "aa"); + glow.addColorStop(1, ramp[3] + "00"); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, W, H); + } else { + ctx.fillStyle = "#4a4a50"; + ctx.beginPath(); ctx.ellipse(cx, base + 12 * d, 38 * d, 5 * d, 0, 0, Math.PI * 2); ctx.fill(); + } + if (lit && STILL) { + const h = 44 * d * (0.6 + power * 0.5), g = ctx.createRadialGradient(cx, base - 18 * d, 2, cx, base - 18 * d, h); + ramp.forEach((c, i) => g.addColorStop(i / 3, c + (i === 3 ? "00" : "ff"))); + ctx.fillStyle = g; + ctx.beginPath(); ctx.ellipse(cx, base - 18 * d, 26 * d, h, 0, 0, Math.PI * 2); ctx.fill(); + } + st.parts = st.parts.filter((p) => (p.life += dt) < p.max); + ctx.globalCompositeOperation = "lighter"; + for (const p of st.parts) { + p.x += p.vx * dt; + p.y += p.vy * dt; + const k = p.life / p.max; + if (p.k === "f") { + p.vx += (cx - p.x) * 1.8 * dt; + ctx.globalAlpha = (1 - k) * 0.55; + ctx.fillStyle = rampAt(ramp, Math.min(1, k * 1.25)); + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 - k * 0.7), 0, Math.PI * 2); ctx.fill(); + } else if (p.k === "e") { + ctx.globalAlpha = 1 - k; + ctx.fillStyle = ramp[1]; + ctx.fillRect(p.x, p.y, p.r * 2, p.r * 2); + } + } + ctx.globalCompositeOperation = "source-over"; + for (const p of st.parts) if (p.k === "s") { + const k = p.life / p.max; + ctx.globalAlpha = (1 - k) * 0.22; + ctx.fillStyle = "#9aa0ae"; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1 + k * 2), 0, Math.PI * 2); ctx.fill(); + } + ctx.globalAlpha = 1; +} + function prayerPanel() { const me = S.me, city = S.city, level = me.levels.prayer; const cost = C.prayers.filter((p) => me.prayers.includes(p.id)).reduce((sum, p) => sum + p.cost, 0); @@ -773,8 +987,7 @@ function actionCard(a) { const time = me.times[a.id]; let io = ""; if (Object.keys(a.inputs).length) { - io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") - + (Object.keys(a.outputs).length || a.finds ? `โ†’` : ""); + io += inputChips(a) + (Object.keys(a.outputs).length || a.finds ? `โ†’` : ""); } io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); const chance = ([id, q, odds]) => `${item(id).icon} ${Math.round(odds * 100)}%`; @@ -1665,6 +1878,7 @@ function animate() { el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%"; } for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now); + animateFires(); } function loop() { @@ -1693,6 +1907,7 @@ document.addEventListener("click", (e) => { setMenu(false); render(); window.scrollTo(0, 0); return; } + if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); } if (d.start) return act("/api/action", { id: d.start }); if (d.once) return act("/api/action", { id: d.once, once: true }); if ("stop" in d) return act("/api/stop", {}); diff --git a/static/styles.css b/static/styles.css index 4e46ab6..6cb9ef2 100644 --- a/static/styles.css +++ b/static/styles.css @@ -632,3 +632,80 @@ button.sheet-skill:hover { background: var(--panel-2); } .pick-lvl { position: absolute; bottom: -5px; right: -5px; font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 4px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); color: var(--muted); } .doll-slot.pick.on .pick-lvl { border-color: var(--gold); } .doll-slot.pick.on .pick-lvl { color: var(--gold); } + +/* --- fires ------------------------------------------------------------ */ + +.nav .fdot, .pip, .logbtn .swatch { + width: 9px; height: 12px; flex: none; border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%; + background: var(--c); box-shadow: 0 0 7px var(--c); +} +.fire-pips { gap: 5px; } +.pip { display: inline-block; background: var(--line); box-shadow: none; } +.pip.lit { background: radial-gradient(circle at 50% 75%, #fff 0 18%, var(--c) 55%); box-shadow: 0 0 8px var(--c); } +.pip.low { animation: fire-low 1.1s ease-in-out infinite; } +@keyframes fire-low { 50% { opacity: .35; } } +.item-chip.paid { color: var(--muted); } +.item-chip s { color: var(--muted); } +.tag.flame { color: var(--c); border-color: color-mix(in srgb, var(--c) 45%, transparent); } + +.fire-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.fire-tools .logs { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; min-width: 0; } +.logbtn { + display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 8px; + border: 1px solid var(--line); background: var(--panel-2); font-size: 13.5px; font-variant-numeric: tabular-nums; +} +.logbtn .swatch { width: 8px; height: 11px; } +.logbtn .gi { width: 1.25em; height: 1.25em; } +.logbtn.on { border-color: var(--c); box-shadow: 0 0 0 1px var(--c) inset; } +.logbtn:disabled { opacity: .35; cursor: not-allowed; } +.fire-tools .tag { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; padding: 3px 9px; } +.fire-tools .tag .gi { width: 1.2em; height: 1.2em; } + +.fire-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); } +.fire-card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; + display: flex; flex-direction: column; transition: border-color .4s, box-shadow .4s; +} +.fire-card.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); box-shadow: 0 10px 40px -18px var(--c); } +.fire-card.active { border-color: var(--good); box-shadow: 0 0 0 1px var(--good) inset, 0 10px 40px -18px var(--c); } +.fire-stage { + position: relative; height: 130px; + background: radial-gradient(120% 90% at 50% 100%, color-mix(in srgb, var(--c) 22%, #0e0f14) 0%, #0e0f14 70%); +} +.fire-card.out .fire-stage { background: radial-gradient(120% 90% at 50% 100%, #1b1c22 0%, #0e0f14 70%); } +.fire-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; } +.fire-state { + position: absolute; top: 8px; right: 8px; font-size: 11.5px; font-weight: 700; padding: 1px 8px; border-radius: 99px; + background: rgba(14, 15, 20, .75); border: 1px solid var(--line); +} +.fire-state.low { color: var(--warn); border-color: rgba(227, 160, 67, .5); } +.fire-state.out { color: var(--muted); } +.fire-body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 9px; flex: 1; } +.fire-head { display: flex; align-items: center; gap: 8px; } +.fire-head .gi { width: 22px; height: 22px; flex: none; } +.fire-head b { flex: 1; } +.fuel { height: 10px; background: var(--bg); border-radius: 99px; overflow: hidden; display: flex; } +.fuel i { display: block; height: 100%; background: var(--c); } +.fire-line { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; } +.fire-line b { color: var(--text); font-weight: 600; } +.fire-line .gi { width: 1.2em; height: 1.2em; vertical-align: -0.25em; } +.fire-body .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; } +.fire-body .why { font-size: 13px; color: var(--warn); } + +.fire-band { + position: relative; overflow: hidden; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 10px 16px; margin-bottom: 14px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--panel); +} +.fire-band > .gi { width: 26px; height: 26px; flex: none; } +.fire-band.lit { border-color: color-mix(in srgb, var(--c) 45%, var(--line)); } +.fire-band.lit::before { + content: ""; position: absolute; inset: 0; pointer-events: none; + background: radial-gradient(60% 140% at 0% 50%, color-mix(in srgb, var(--c) 22%, transparent), transparent 70%); + animation: fire-flicker 2.6s ease-in-out infinite; +} +.fire-band > * { position: relative; } +@keyframes fire-flicker { 0%, 100% { opacity: .95; } 30% { opacity: .7; } 55% { opacity: 1; } 75% { opacity: .8; } } + +@media (prefers-reduced-motion: reduce) { + .pip.low, .fire-band.lit::before { animation: none; } +} diff --git a/tests/test_game.py b/tests/test_game.py index f82a5e2..a80b0d7 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -741,7 +741,7 @@ class TierTest(unittest.TestCase): world = world_with(("kay", "miner")) p = world["players"]["kay"] p["xp"]["smithing"] = game.XP_TABLE[30] - p["bank"] = {"iron_ore": 5, "coal": 10} + p["bank"] = {"iron_ore": 5, "coal": 10, "willow_logs": 5} self.assertEqual(game.start_action(world, p, "smith_steel_bar", T0), "Needs Smithy level 2") world["city"]["buildings"]["smithy"]["level"] = 2 self.assertIsNone(game.start_action(world, p, "smith_steel_bar", T0)) @@ -903,7 +903,7 @@ class HerbloreTest(unittest.TestCase): game.start_action(world, p, "grow_guam_leaf", T0) game.process_player(world, p, T0 + 450, random.Random(1)) self.assertEqual(p["bank"]["guam_leaf"], 10) - p["bank"]["logs"] = 3 + p["bank"]["logs"] = 6 # one a potion, one to burn under the still self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore game.process_player(world, p, T0 + 1000, random.Random(1)) self.assertEqual(p["bank"]["woodcutting_potion_1"], 3) @@ -1818,7 +1818,7 @@ class StationAndStorageTest(unittest.TestCase): self.assertEqual(game.action_time(self.world, self.bob, labour), bare) def test_stations_move_out_of_the_tool_slot(self): - self.world["version"] = game.WORLD_VERSION - 1 + self.world["version"] = 22 self.bob["equip"]["tool_smithing"] = "oak_anvil_stand" del self.bob["equip"]["station_smithing"] game.migrate(self.world, T0) @@ -1847,3 +1847,108 @@ class StationAndStorageTest(unittest.TestCase): self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that") self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox") self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1) + + +class FirekeepingTest(unittest.TestCase): + def setUp(self): + self.world = world_with(("kai", "commoner"), ("sam", "commoner")) + self.kai, self.sam = self.world["players"]["kai"], self.world["players"]["sam"] + self.fires = self.world["city"]["fires"] + + def tend(self, player, fire, log, at, seconds): + self.assertIsNone(game.start_action(self.world, player, f"tend_{fire}_{log}", at)) + return game.process_player(self.world, player, at + seconds, random.Random(1)) + + def test_tending_feeds_only_that_fire_and_pays_by_the_minute(self): + self.kai["bank"] = {"logs": 3} + self.tend(self.kai, "forge", "logs", T0, 95) + level = game.skill_level(self.kai, "firekeeping") + self.assertEqual(self.kai["bank"].get("logs", 0), 0) + self.assertEqual(game.heat(self.world, "forge"), 0) + 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_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") + + 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") + 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(self.kai["xp"]["firekeeping"], xp) + + def test_a_better_log_pushes_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.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.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") + + def test_fuel_burns_hottest_first_on_game_time(self): + fire = self.fires["still"] + fire["fuel"] = [{"tier": 0, "left": 600, "by": "sam"}, {"tier": 2, "left": 300, "by": "kai"}] + fire["at"] = T0 + game.burn_fires(self.world, T0 + 200) + self.assertEqual(game.heat(self.world, "still"), 2) + game.burn_fires(self.world, T0 + 400) + self.assertEqual(game.heat(self.world, "still"), 0) + self.assertAlmostEqual(fire["fuel"][0]["left"], 500) + game.burn_fires(self.world, T0 + 100) # never backwards + self.assertEqual(fire["at"], T0 + 400) + + def test_a_hot_forge_pays_the_log_and_a_coal_but_never_the_last(self): + steel, copper = game.ACTION_BY_ID["smith_steel_bar"], game.ACTION_BY_ID["smith_copper_bar"] + self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1}) + self.fires["forge"]["fuel"] = [{"tier": 1, "left": 600, "by": "kai"}] + self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1}) # too cool + self.assertEqual(game.fire_inputs(self.world, copper), {"copper_ore": 1}) + self.fires["forge"]["fuel"] = [{"tier": 3, "left": 600, "by": "kai"}] + self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 1}) + self.assertEqual(game.fire_inputs(self.world, game.ACTION_BY_ID["smith_mithril_bar"]), {"mithril_ore": 1, "coal": 2}) + + def test_a_potion_brewed_from_logs_keeps_its_ingredient_log(self): + brew = game.ACTION_BY_ID["brew_woodcutting_potion_1"] + self.fires["still"]["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}] + self.assertEqual(game.fire_inputs(self.world, brew), {"guam_leaf": 1, "logs": 1}) + + def test_warmth_goes_to_whoever_fed_the_fire(self): + self.fires["kitchen"]["fuel"] = [{"tier": 0, "left": 6000, "by": "kai"}] + self.fires["kitchen"]["at"] = T0 + self.sam["bank"] = {"raw_shrimp": 4} + game.start_action(self.world, self.sam, "cook_shrimp", T0) + game.process_player(self.world, self.sam, T0 + 85, random.Random(1)) + self.assertEqual(self.sam["bank"]["cooked_shrimp"], 4) + self.assertNotIn("logs", self.sam["bank"]) + self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 4 * 6 * game.WARMTH) + self.assertAlmostEqual(self.kai["away"]["xp"]["firekeeping"], 4 * 6 * game.WARMTH) + self.assertEqual(self.fires["kitchen"]["saved"], 4) + self.assertEqual(self.sam["xp"]["firekeeping"], 0) + + def test_catch_up_runs_everyone_and_keeps_what_they_missed(self): + game.start_action(self.world, self.sam, "mine_stone", T0) + self.assertTrue(game.catch_up(self.world, T0 + 100, random.Random(1))) + game.catch_up(self.world, T0 + 200, random.Random(1)) + self.assertEqual(self.sam["away"]["done"], 10) + summary = game.take_summary(self.sam, game.process_player(self.world, self.sam, T0 + 220, random.Random(1))) + self.assertEqual(summary["done"], 11) + self.assertEqual(summary["items"], {"stone": 11}) + self.assertNotIn("away", self.sam) + + def test_old_worlds_get_their_fires(self): + del self.world["city"]["fires"] + del self.kai["xp"]["firekeeping"] + self.world["version"] = 23 + game.migrate(self.world, T0) + self.assertEqual(set(self.world["city"]["fires"]), set(game.FIRES)) + self.assertEqual(self.kai["xp"]["firekeeping"], 0) + self.assertIn("tool_firekeeping", self.kai["equip"])