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 `