From e825518c5e9465b7867a31a4dbb198192496840b Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 23:17:23 +0100 Subject: [PATCH] Masonry joins Construction; smith hardware; homesteader plots Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- app.py | 6 +- game.py | 219 +++++++++++++++++++++++++++++++++------------ static/app.js | 29 ++++-- static/icons.svg | 7 +- tests/test_game.py | 59 +++++++++++- 6 files changed, 258 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 6e808df..d4f363d 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. ## What's in it - **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, - masonry, construction, cooking, leatherworking, herblore, combat, prayer and - reconnaissance. + construction (with masonry), cooking, leatherworking, herblore, 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:** fights cost HP (less when out-levelled or armoured); below half health you eat food from your bank, and out of food you retreat, losing nothing but the fight. HP recovers 25% an hour. @@ -24,10 +25,11 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. city). Gear needs a combat level to wear, tools a skill level to use. - **Classes are trades,** from gathering to finished goods, so each player has a quiet gathering side and a busier crafting side: miner (mining, smithing), - builder (woodcutting, construction, masonry), homesteader (farming, cooking, + builder (woodcutting, construction), homesteader (farming, cooking, herblore) 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. Every class can train every skill, scout and drill. + bonus of its own. Homesteaders farm differently: two plots, either one crop + 30% faster or two crops at once sharing a 20% faster pace. Every class can train every skill, scout and drill. Building costs draw on all four trades: thatch, flax for rope, hide glue, leather and provisions as well as wood, stone and metal. - **Bank:** 12 slots of 1,000 each to start; more than a stack of one item diff --git a/app.py b/app.py index e7bb4bc..f3540ae 100644 --- a/app.py +++ b/app.py @@ -112,8 +112,8 @@ def player_view(world: dict, player: dict, now: float) -> dict: view["power"] = game.combat_power(player) view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} if player["action"]: - action = game.ACTION_BY_ID[player["action"]["id"]] - view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} + action = game.growing(player) + view["action"] = {**player["action"], "growing": action["id"], "duration": game.action_time(world, player, action)} view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} view["drill_per_action"] = game.drill_per_action(player) @@ -370,6 +370,8 @@ class Handler(BaseHTTPRequestHandler): player["cls"], player["repick"] = cls, False game.log(world, now, "join", f"{player['name']} took up the {game.CLASSES[cls]['name'].lower()}'s trade", player["id"]) return None + if path == "/api/plot": + return game.plant_second_plot(world, player, body.get("id") or None) if path == "/api/perk": return game.buy_perk(player, str(body.get("perk"))) if path == "/api/settings": diff --git a/game.py b/game.py index 657aec2..97abf16 100644 --- a/game.py +++ b/game.py @@ -167,6 +167,20 @@ for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in "name": f"{_name} {_tool}", "icon": _icon, "value": round(2 * _bar_value * 1.4), "tier": _tier, "slot": f"tool_{_skill}", "tool": _skill, "speed": TOOL_SPEED[_tier], "req": [_skill, EQUIP_LEVELS[_tier]], } +# Hardware: what the smith makes for builders, one kind per metal, used in +# fittings and in building levels in place of plain bars. +# id, singular, plural, bars used, pieces made +HARDWARE = [ + ("copper_rivets", "Copper rivet", "Copper rivets", 1, 5), + ("iron_nails", "Iron nail", "Iron nails", 1, 5), + ("steel_hinges", "Steel hinge", "Steel hinges", 1, 2), + ("mithril_brackets", "Mithril bracket", "Mithril brackets", 1, 2), + ("adamant_chain", "Adamant chain", "Adamant chains", 2, 1), + ("rune_lock", "Rune lock", "Rune locks", 2, 1), +] +for (_id, _name, _plural, _bars, _made), _metal in zip(HARDWARE, METALS): + ITEMS[_id] = {"name": _name, "plural": _plural, "icon": "🔩", "value": max(1, round(_bars * _metal[5] * 1.2 / _made)), + "tier": METALS.index(_metal)} PLANKS = [("plank", "Plank"), ("oak_plank", "Oak plank"), ("willow_plank", "Willow plank"), ("maple_plank", "Maple plank"), ("yew_plank", "Yew plank"), ("magic_plank", "Enchanted plank")] for (_log, _name, _icon, _value, _action_id, _verb), (_plank, _plank_name) in zip(TREES, PLANKS): @@ -221,7 +235,6 @@ POTION_LINES = [ ("cooking", "Cooking potion", "cooking", "keep", SPEEDS, [fish[1][0] for fish in FISH]), ("leatherworking", "Leatherworking potion", "leatherworking", "keep", SPEEDS, [leather[1][0] for leather in LEATHERS]), - ("masonry", "Masonry potion", "masonry", "keep", SPEEDS[:3], ["stone", "bricks", "cut_stone"]), ("prayer", "Prayer potion", "prayer", "bones", [0.25, 0.5, 0.75], ["bones", "big_bones", "dragon_bones"]), ("strength", "Strength potion", "combat", "power", [4, 7, 11, 15, 20, 26], [leather[0][0] for leather in LEATHERS]), ("defence", "Defence potion", "combat", "reduce", [0.10, 0.14, 0.18, 0.22, 0.26, 0.30], @@ -242,7 +255,7 @@ OLD_POTIONS = { "strength_potion": "strength_potion_2", "anglers_brew": "fishing_potion_3", "prospectors_draught": "mining_potion_2", "growth_potion": "farming_potion_3", "defence_potion": "defence_potion_2", "chefs_spice": "cooking_potion_2", "bonedust_tonic": "prayer_potion_2", - "smiths_focus": "smithing_potion_2", "masons_patience": "masonry_potion_2", "tanners_oil": "leatherworking_potion_2", + "smiths_focus": "smithing_potion_2", "masons_patience": "construction_potion_2", "tanners_oil": "leatherworking_potion_2", "regeneration_potion": "defence_potion_3", "super_strength": "strength_potion_4", "super_defence": "defence_potion_5", } @@ -280,7 +293,6 @@ SKILLS = [ {"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"}, {"id": "fishing", "name": "Fishing", "icon": "🎣", "group": "Gathering"}, {"id": "smithing", "name": "Smithing", "icon": "🔨", "group": "Crafting"}, - {"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"}, {"id": "construction", "name": "Construction", "icon": "🏗️", "group": "Crafting"}, {"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"}, {"id": "leatherworking", "name": "Leatherworking", "icon": "🪡", "group": "Crafting"}, @@ -291,7 +303,7 @@ SKILLS = [ ] SKILL_IDS = [skill["id"] for skill in SKILLS] GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} -CRAFTING = {"smithing", "masonry", "construction", "cooking", "leatherworking", "herblore"} +CRAFTING = {"smithing", "construction", "cooking", "leatherworking", "herblore"} # A class is a whole trade, from gathering to the finished goods: its skills are # 20% faster, 1 in 10 of its crafts make double, and it has one bonus of its own. @@ -304,16 +316,14 @@ CLASSES = { "signature": "Prospector: 1 in 10 mining trips bring back double", }, "builder": { - "name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction", "masonry"], - "craft": ["construction", "masonry"], - "bonus": "Woodcutting, construction and masonry 20% faster", - "masterwork": "Masterwork: 1 in 10 construction and masonry crafts make double", + "name": "Builder", "icon": "🏗️", "fast": ["woodcutting", "construction"], "craft": ["construction"], + "bonus": "Woodcutting and construction (masonry included) 20% faster", + "masterwork": "Masterwork: 1 in 10 construction crafts make double", "signature": "Foreman: +1 labour every time you work a building site", }, "homesteader": { - "name": "Homesteader", "icon": "🌾", "fast": ["farming", "cooking", "herblore"], - "craft": ["cooking", "herblore"], - "bonus": "Farming, cooking and herblore 20% faster", + "name": "Homesteader", "icon": "🌾", "fast": ["cooking", "herblore"], "craft": ["cooking", "herblore"], + "bonus": "Cooking and herblore 20% faster. Two plots: farm one crop 30% faster, or two crops at once", "masterwork": "Masterwork: 1 in 10 cooking and herblore crafts make double", "signature": "Steady hand: potions you drink last 25% longer", }, @@ -354,6 +364,10 @@ def _tiered_actions() -> list[dict]: 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 {})) + 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", + city=city)) for piece, _slot, noun, bars, offset, _power, _armour in PIECES: actions.append(_action(f"smith_{key}_{piece}", "smithing", f"{name} {noun}", TIER_LEVELS[tier] + offset, 25 + 5 * bars + 2 * tier, bars * piece_xp[tier], {f"{key}_bar": bars}, @@ -413,10 +427,10 @@ 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", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}), - _action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}), - _action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, - {"oak_logs": 2, "iron_bar": 1}, {"beam": 1}), + _action("mason_bricks", "construction", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}, group="Masonry"), + _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}), _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), _action("cook_stew", "cooking", "Stew", 15, 35, 16, @@ -457,23 +471,23 @@ ACTION_BY_ID = {action["id"]: action for action in ACTIONS} BUILDINGS = { "houses": { "name": "Houses", "icon": "🏠", "max": 8, "gold": 200, - "base": {"logs": 300, "bricks": 150, "thatch": 300, "provisions": 80}, - "advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_bar": 30}, - "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50}, - "final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60}, + "base": {"logs": 300, "bricks": 150, "thatch": 300, "provisions": 80, "copper_rivets": 60}, + "advanced": {"oak_logs": 100, "leather": 80, "rope": 40, "iron_nails": 150}, + "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 50, "steel_hinges": 20}, + "final": {"maple_logs": 50, "willow_plank": 30, "hard_leather": 60, "mithril_brackets": 15}, "effect": "Room for 20 more people per level", }, "walls": { "name": "Walls", "icon": "🏰", "max": 5, "gold": 300, "base": {"stone": 1000, "bricks": 250, "provisions": 90}, - "advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_bar": 40}, - "late": {"steel_bar": 80, "oak_plank": 25, "hide_glue": 50}, - "final": {"mithril_bar": 50, "maple_plank": 20, "hide_glue": 60}, + "advanced": {"cut_stone": 150, "beam": 20, "rope": 50, "iron_nails": 200}, + "late": {"steel_bar": 60, "oak_plank": 25, "hide_glue": 50, "steel_hinges": 30}, + "final": {"mithril_brackets": 60, "maple_plank": 20, "hide_glue": 60, "adamant_chain": 10}, "effect": "+20 defence per level, and raiders carry off less", }, "barracks": { "name": "Barracks", "icon": "⛺", "max": 5, "gold": 300, - "base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80}, + "base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80, "copper_rivets": 60}, "advanced": {"iron_sword": 20, "hard_leather": 40}, "late": {"steel_sword": 12, "oak_plank": 30, "hide_glue": 40}, "final": {"mithril_sword": 8, "willow_plank": 25, "bear_leather": 40}, @@ -490,44 +504,44 @@ BUILDINGS = { "temple": { "name": "Temple", "icon": "⛪", "max": 3, "gold": 300, "base": {"stone": 700, "bricks": 200, "cut_stone": 60, "provisions": 60}, - "advanced": {"beam": 15, "rope": 40, "iron_bar": 30}, - "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40}, + "advanced": {"beam": 15, "rope": 40, "iron_nails": 150}, + "late": {"willow_logs": 80, "oak_plank": 35, "hide_glue": 40, "steel_hinges": 20}, "effect": "Offerings ward the city in raids; +1 prayer point per bone buried and +25% devotion per level", }, "granary": { "name": "Granary", "icon": "🌾", "max": 3, "gold": 150, - "base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50}, - "advanced": {"cut_stone": 40, "rope": 40, "iron_bar": 20}, - "late": {"oak_plank": 35, "hide_glue": 35}, + "base": {"logs": 200, "bricks": 100, "plank": 40, "thatch": 300, "provisions": 50, "copper_rivets": 40}, + "advanced": {"cut_stone": 40, "rope": 40, "iron_nails": 100}, + "late": {"oak_plank": 35, "hide_glue": 35, "steel_hinges": 15}, "effect": "Protects a quarter of the food from locusts and rats per level", }, "lumber_shed": { "name": "Lumber shed", "icon": "🪵", "max": 3, "gold": 120, - "base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40}, - "advanced": {"oak_plank": 40, "rope": 40, "iron_bar": 20}, + "base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40, "copper_rivets": 40}, + "advanced": {"oak_plank": 40, "rope": 40, "iron_nails": 100}, "late": {"willow_plank": 25, "hide_glue": 35}, "effect": "Keeps a quarter of the stored wood safe from rot per level", }, "well": { "name": "Well", "icon": "🪣", "max": 3, "gold": 120, "base": {"stone": 500, "bricks": 80, "rope": 50, "provisions": 40}, - "advanced": {"cut_stone": 40, "leather": 60, "iron_bar": 25}, - "late": {"oak_plank": 25, "hide_glue": 35}, + "advanced": {"cut_stone": 40, "leather": 60, "iron_nails": 125}, + "late": {"oak_plank": 25, "hide_glue": 35, "steel_hinges": 15}, "effect": "Each level cuts fire losses by a quarter and may save the building", }, "spymaster": { "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "base": {"logs": 200, "bricks": 150, "hide": 120, "feathers": 300, "provisions": 60}, - "advanced": {"cut_stone": 80, "hard_leather": 40, "iron_bar": 20}, - "late": {"bear_leather": 50, "oak_plank": 25}, + "advanced": {"cut_stone": 80, "hard_leather": 40, "iron_nails": 100}, + "late": {"bear_leather": 50, "oak_plank": 25, "steel_hinges": 15}, "effect": "+25% reconnaissance per level, and its agents gather some every hour on their own", }, "storehouse": { "name": "Storehouse", "icon": "📦", "max": 5, "gold": 200, - "base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60}, - "advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_bar": 30}, - "late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45}, - "final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40}, + "base": {"logs": 400, "stone": 800, "thatch": 200, "provisions": 60, "copper_rivets": 60}, + "advanced": {"oak_logs": 150, "cut_stone": 100, "rope": 50, "iron_nails": 150}, + "late": {"willow_logs": 120, "oak_plank": 45, "hide_glue": 45, "steel_hinges": 25}, + "final": {"yew_logs": 40, "yew_plank": 20, "bear_leather": 40, "rune_lock": 6}, "effect": "+3 bank slots and +250 per stack for everyone, per level", }, } @@ -564,7 +578,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 12 +WORLD_VERSION = 13 def new_world(now: float, speed: float = 1.0) -> dict: @@ -681,6 +695,10 @@ def migrate(world: dict, now: float | None = None) -> dict: player["repick"] = True _return_overfunding(world) world["version"] = 12 + if world["version"] < 13: + _fold_masonry(world) + _return_overfunding(world) + world["version"] = 13 return world @@ -704,6 +722,32 @@ def _migrate_potions(world: dict) -> None: action[key] = f"brew_{OLD_POTIONS[old]}" +def _fold_masonry(world: dict) -> None: + """Masonry became part of Construction: its XP, potions and brews carry over.""" + moved = {f"masonry_potion_{tier}": f"construction_potion_{tier}" for tier in (1, 2, 3)} + def renamed(stock: dict) -> None: + for old, new in moved.items(): + if old in stock: + stock[new] = stock.get(new, 0) + stock.pop(old) + renamed(world["city"]["stockpile"]) + for player in world["players"].values(): + player["xp"]["construction"] = player["xp"].get("construction", 0) + player["xp"].pop("masonry", 0) + renamed(player["bank"]) + active = player["potions"].pop("masonry", None) + if active: + item = moved.get(active["item"], active["item"]) + if "construction" in player["potions"]: + player["bank"][item] = player["bank"].get(item, 0) + 1 # a fresh one back, rather than lose it + else: + player["potions"]["construction"] = {"item": item, "charges": active["charges"]} + action = player["action"] + if action: + for key in ("id", "resume"): + brew = (action.get(key) or "").removeprefix("brew_") + if brew in moved: + action[key] = f"brew_{moved[brew]}" + + def _return_overfunding(world: dict) -> None: """After building costs change, anything a fund holds beyond its new target goes back to the stockpile.""" city = world["city"] @@ -1038,6 +1082,9 @@ def action_time(world: dict, player: dict, action: dict) -> float: seconds = action["time"] if action["skill"] in CLASSES[player["cls"]]["fast"]: seconds *= CLASS_SPEED + if action["skill"] == "farming" and player["cls"] == "homesteader": + # Focus on one plot for 30% faster, or split the same 20% pace between two crops. + seconds *= PLOTS_SPEED if two_plots(player) else FOCUS_SPEED tool = player["equip"].get(f"tool_{action['skill']}") if tool: seconds *= 1 - ITEMS[tool]["speed"] @@ -1051,6 +1098,48 @@ def action_time(world: dict, player: dict, action: dict) -> float: return seconds / speed(world) +FOCUS_SPEED = 0.7 # a homesteader tending one plot +PLOTS_SPEED = 0.8 # each harvest when tending two: together, one crop's worth at 20% faster + + +def two_plots(player: dict) -> bool: + current = player["action"] + return bool(current and current.get("plot2")) + + +def growing(player: dict) -> dict: + """The action the player is on right now: with two plots, harvests alternate between them.""" + current = player["action"] + if current.get("plot2") and current.get("turn"): + return ACTION_BY_ID[current["plot2"]] + return ACTION_BY_ID[current["id"]] + + +def plant_second_plot(world: dict, player: dict, action_id: str | None) -> str | None: + """Let a homesteader's second plot grow another crop, or clear it.""" + current = player["action"] + if not action_id: + if current: + current.pop("plot2", None) + current.pop("turn", None) + return None + if player["cls"] != "homesteader": + return "Only homesteaders tend two plots" + action = ACTION_BY_ID.get(action_id) + if not action or action["skill"] != "farming" or action.get("special"): + return "That isn't a crop" + if not current or ACTION_BY_ID[current["id"]]["skill"] != "farming" or "left" in current: + return "Plant your first plot first" + if action_id == current["id"]: + return "That's already growing in your first plot" + reason = can_start(world, player, action) + if reason: + return reason + current["plot2"] = action_id + current["turn"] = 0 + return None + + def with_potion(blessed: dict, brew: dict) -> dict: """A fight's prayer boosts with a combat potion's added on top.""" return {**blessed, "power": blessed["power"] + brew.get("power", 0), @@ -1295,9 +1384,13 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N current = player["action"] if not current: return summary - action = ACTION_BY_ID[current["id"]] limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) while player["action"]: + action = growing(player) + if current.get("plot2") and can_start(world, player, action): + # The second crop can't grow right now (a problem, too low a level): tend the first only. + current["turn"] = 0 + action = growing(player) duration = action_time(world, player, action) if current["start"] + duration > limit: break @@ -1323,13 +1416,17 @@ def process_player(world: dict, player: dict, now: float, rng: random.Random | N _complete(world, player, action, current["start"] + duration, rng, summary) summary["done"] += 1 current["start"] += duration + if current.get("plot2"): + current["turn"] = 1 - current.get("turn", 0) if player["action"] is current and "left" in current: current["left"] -= 1 if current["left"] <= 0: back = current.get("resume") if back: + plot2 = current.get("resume_plot2") player["action"] = current = {"id": back, "start": current["start"]} - action = ACTION_BY_ID[back] + if plot2: + current.update(plot2=plot2, turn=0) summary["resumed"] = back else: stop(player) @@ -1352,12 +1449,18 @@ def start_action(world: dict, player: dict, action_id: str, now: float, once: bo if reason: return reason entry = {"id": action_id, "start": now} + current = player["action"] if once: - current = player["action"] back = current and (current.get("resume") or current["id"]) entry["left"] = 1 if back and back != action_id: entry["resume"] = back + plot2 = current.get("resume_plot2") or current.get("plot2") + if plot2: + entry["resume_plot2"] = plot2 + elif current and current.get("plot2") and current["plot2"] != action_id and action["skill"] == "farming": + # Replanting the first plot leaves the second one growing. + entry.update(plot2=current["plot2"], turn=0) player["action"] = entry player["stopped"] = None return None @@ -2176,7 +2279,7 @@ ACTIONS.extend(PROBLEM_ACTIONS) ACTION_BY_ID.update({action["id"]: action for action in PROBLEM_ACTIONS}) ITEMS["water_barrel"] = {"name": "Water barrel", "icon": "🛢️", "value": 20} _barrel = _action("cooper_barrel", "construction", "Water barrel", 10, 35, 18, - {"oak_plank": 2, "iron_bar": 1}, {"water_barrel": 1}, group="Supplies") + {"oak_plank": 2, "iron_nails": 4}, {"water_barrel": 1}, group="Supplies") ACTIONS.append(_barrel) ACTION_BY_ID[_barrel["id"]] = _barrel @@ -2185,29 +2288,29 @@ ACTION_BY_ID[_barrel["id"]] = _barrel # building stands; damage pauses it like the rest of the building. # id, name, building, building level, construction level, inputs, effect, glyph, tint FITTINGS = [ - ("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_bar": 5}, + ("grain_bins", "Grain bins", "granary", 1, 15, {"oak_plank": 20, "iron_nails": 25}, "The city eats 10% less food", "grain-bundle", "#e3c060"), - ("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_bar": 10}, + ("saw_benches", "Saw benches", "lumber_shed", 1, 15, {"oak_plank": 20, "iron_nails": 40}, "Sawing planks is 20% faster", "hand-saw", "#8f5e30"), - ("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_bar": 10}, + ("shelving", "Shelving", "storehouse", 2, 20, {"oak_plank": 40, "iron_nails": 40}, "+1 bank slot for everyone", "bookshelf", "#8f5e30"), - ("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10}, + ("map_table", "Map table", "spymaster", 1, 30, {"willow_plank": 25, "hard_leather": 10, "steel_hinges": 4}, "Scouting gathers one more point of reconnaissance", "treasure-map", "#a3b86c"), - ("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_bar": 10}, + ("well_pump", "Well pump", "well", 2, 30, {"willow_plank": 25, "steel_hinges": 10}, "Half of all fires are put out before they spread", "water-tank", "#5aa0d8"), - ("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30}, + ("furniture", "Furniture", "houses", 2, 35, {"willow_plank": 40, "leather": 30, "steel_hinges": 10}, "Population grows 25% faster", "sofa", "#a3b86c"), - ("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_bar": 20}, + ("weapon_racks", "Weapon racks", "barracks", 2, 40, {"willow_plank": 30, "steel_hinges": 16}, "Militia defence +50%", "spears", "#d4dae2"), - ("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_bar": 10}, + ("hoardings", "Hoardings", "walls", 3, 45, {"maple_plank": 40, "mithril_brackets": 20}, "+25 defence", "palisade", "#d0663a"), - ("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15}, + ("bellows", "Great bellows", "smithy", 3, 50, {"maple_plank": 30, "bear_leather": 15, "mithril_brackets": 6}, "Smithing another 10% faster", "bellows", "#d0663a"), - ("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60}, + ("carved_altar", "Carved altar", "temple", 2, 60, {"yew_plank": 30, "cut_stone": 60, "adamant_chain": 4}, "+1 prayer point per bone buried, and offerings can ward 15 more", "star-altar", "#6f9a4a"), - ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_bar": 5}, + ("enchanted_chests", "Enchanted chests", "storehouse", 4, 75, {"magic_plank": 40, "rune_lock": 6}, "+500 per bank stack for everyone", "open-treasure-chest", "#a77be8"), - ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5}, + ("enchanted_hearths", "Enchanted hearths", "houses", 4, 80, {"magic_plank": 50, "red_dragon_leather": 5, "adamant_chain": 6}, "Everyone recovers HP twice as fast", "fireplace", "#a77be8"), ] FITTING_BY_ID = {} @@ -2785,6 +2888,7 @@ PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "vi TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", "hunting knife": "bowie-knife", "hammer": "claw-hammer"} LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} +HARDWARE_GLYPHS = ["screw", "nails", "door-handle", "metal-plate", "crossed-chains", "padlock"] GLYPHS = { "stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"), "feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"), @@ -2804,7 +2908,7 @@ GLYPHS = { SKILL_GLYPHS = { "mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"), "hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"), - "masonry": ("brick-wall", "#c66a45"), "construction": ("hammer-nails", "#d1a46a"), + "construction": ("hammer-nails", "#d1a46a"), "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), "reconnaissance": ("spyglass", "#6f8fec"), @@ -2837,11 +2941,12 @@ UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] def _assign_glyphs() -> None: for item, (glyph, tint) in GLYPHS.items(): ITEMS[item].update(glyph=glyph, tint=tint) - for key, _name, ore, *_rest in METALS: + for tier, (key, _name, ore, *_rest) in enumerate(METALS): tint = METAL_TINTS[key] if ore: ITEMS[ore[0]].update(glyph="ore", tint=tint) ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint) + ITEMS[HARDWARE[tier][0]].update(glyph=HARDWARE_GLYPHS[tier], tint=tint) for piece, glyph in PIECE_GLYPHS.items(): ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint) for tool, glyph in TOOL_GLYPHS.items(): diff --git a/static/app.js b/static/app.js index 8ee5644..1fdf7c3 100644 --- a/static/app.js +++ b/static/app.js @@ -277,7 +277,7 @@ function topbar() { if (!S.me) title = "Welcome to Guildhall"; let chips = ""; if (S.me?.action) { - const action = C.actions.find((a) => a.id === S.me.action.id); + const action = C.actions.find((a) => a.id === (S.me.action.growing || S.me.action.id)); chips += ``; } else if (S.me) { chips += `Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}`; @@ -343,6 +343,7 @@ function skillPage(id) { if (id === "prayer") html += prayerPanel(); if (id === "construction") html += S.city.construction ? siteBanner() : `
Nothing is being built right now. Fund a building on the Town square and its site opens here.
`; + if (id === "farming" && me.cls === "homesteader") html += plotsPanel(); html += potionPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
Reconnaissance on a coming threat raises its intel. Each level takes more than the last, a bigger band takes longer to read, and the sharpest news (the exact day and their camp) only comes in the last hours before they strike. The spymaster's guild adds to every point and gathers some on its own. The council & threats page shows the progress.
`; @@ -375,6 +376,16 @@ function isLocked(a) { return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only"))); } +// A homesteader's two plots: one crop 30% faster, or two crops sharing a 20% faster pace. +function plotsPanel() { + const a = S.me.action, farming = a && C.actions.find((x) => x.id === a.id)?.skill === "farming"; + const name = (id) => esc(C.actions.find((x) => x.id === id).name); + const status = !farming ? `Start a crop to plant your first plot.` + : a.plot2 ? `Plot 1: ${name(a.id)} · Plot 2: ${name(a.plot2)} · harvests alternate, together 20% faster than one crop` + : `Plot 1: ${name(a.id)} · Plot 2: empty, so you're focused: 30% faster. Use "Plot 2" on another crop to grow both.`; + return `
🌱 ${status}
`; +} + // The skill's active potion, or the ones in the bank that could be drunk for it. function potionPanel(skill) { const me = S.me; @@ -476,6 +487,10 @@ function itemStats(id) { function actionCard(a) { const me = S.me; const active = me.action?.id === a.id; + const second = me.action?.plot2 === a.id; // a homesteader's second plot + const growingNow = me.action?.growing === a.id; + const canPlot = me.cls === "homesteader" && a.skill === "farming" && !a.special && !active && !second + && me.action && C.actions.find((x) => x.id === me.action.id)?.skill === "farming" && !me.action.left; const why = me.blocked[a.id]; const locked = isLocked(a); const out = Object.keys(a.outputs)[0]; @@ -516,19 +531,22 @@ function actionCard(a) { const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); if (made.length) extra += `
${made.join(" · ")}
`; return ` -
+
${icon}
${esc(a.name)}
Level ${a.level} · ${dur(time)} · ${a.xp} XP
${io ? `
${io}
` : ""} ${extra} - ${active ? `
` : ""} + ${me.action?.plot2 && (active || second) ? `
Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}
` : ""} + ${growingNow ? `
` : ""} ${active && me.action.left ? `
Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}
` : ""}
- ${!active && why ? esc(why) : ""} - ${active ? `` + ${!active && !second && why ? esc(why) : ""} + ${second ? `` + : active ? `` : ` + ${canPlot ? `` : ""} `}
`; @@ -1093,6 +1111,7 @@ document.addEventListener("click", (e) => { if (d.unequip) return act("/api/equip", { slot: d.unequip }); if (d.fund) return act("/api/fund", { building: d.fund, item: d.item, source: d.source, qty: "all" }); if (d.install) return act("/api/install", { fitting: d.install, source: d.source }); + if ("plot" in d) return act("/api/plot", { id: d.plot || null }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.favour) return act("/api/favour", { kind: d.favour }); diff --git a/static/icons.svg b/static/icons.svg index a4a68c6..04c43d5 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -19,7 +19,6 @@ - @@ -33,9 +32,11 @@ + + @@ -70,10 +71,13 @@ + + + @@ -84,6 +88,7 @@ + diff --git a/tests/test_game.py b/tests/test_game.py index c07cf6a..3d2ecbc 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -719,9 +719,9 @@ class TierTest(unittest.TestCase): def test_higher_building_levels_need_higher_tiers(self): self.assertNotIn("steel_bar", game.build_cost("walls", 2)) - self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 80) - self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 50) - self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 240) + self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60) + self.assertEqual(game.build_cost("walls", 5)["mithril_brackets"], 60) + self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) def test_dragons_drop_dragonhide(self): world = world_with(("wil", "commoner")) @@ -1246,6 +1246,59 @@ class ClassTest(unittest.TestCase): self.assertEqual(world["city"]["stockpile"], {"bricks": 250}) +class TradeSkillsTest(unittest.TestCase): + def test_masonry_is_part_of_construction(self): + self.assertNotIn("masonry", game.SKILL_IDS) + bricks = game.ACTION_BY_ID["mason_bricks"] + self.assertEqual((bricks["skill"], bricks["group"]), ("construction", "Masonry")) + world = world_with(("swa", "builder")) + p = world["players"]["swa"] + world["version"] = 12 + p["xp"]["masonry"], p["xp"]["construction"] = 5000, 1000 + p["bank"] = {"masonry_potion_2": 3} + p["potions"] = {"masonry": {"item": "masonry_potion_1", "charges": 20}} + p["action"] = {"id": "brew_masonry_potion_3", "start": T0} + game.migrate(world, T0) + self.assertEqual(p["xp"]["construction"], 6000) + self.assertNotIn("masonry", p["xp"]) + self.assertEqual(p["bank"], {"construction_potion_2": 3}) + self.assertEqual(p["potions"], {"construction": {"item": "construction_potion_1", "charges": 20}}) + self.assertEqual(p["action"]["id"], "brew_construction_potion_3") + + def test_smiths_make_hardware_for_builders(self): + nails = game.ACTION_BY_ID["smith_iron_nails"] + self.assertEqual((nails["inputs"], nails["outputs"], nails["group"]), ({"iron_bar": 1}, {"iron_nails": 5}, "Hardware")) + self.assertIn("iron_nails", game.ACTION_BY_ID["mason_beam"]["inputs"]) + self.assertIn("rune_lock", game.ACTION_BY_ID["make_enchanted_chests"]["inputs"]) + self.assertNotIn("iron_bar", game.build_cost("houses", 2)) + self.assertIn("iron_nails", game.build_cost("houses", 2)) + + def test_homesteader_focuses_or_tends_two_plots(self): + world = world_with(("hom", "homesteader"), ("com", "commoner")) + h, c = world["players"]["hom"], world["players"]["com"] + h["xp"]["farming"] = game.XP_TABLE[10] + wheat = game.ACTION_BY_ID["farm_wheat"] + self.assertAlmostEqual(game.action_time(world, h, wheat), wheat["time"] * 0.7) # focused + self.assertEqual(game.plant_second_plot(world, c, "grow_thatch"), "Only homesteaders tend two plots") + self.assertEqual(game.plant_second_plot(world, h, "grow_thatch"), "Plant your first plot first") + game.start_action(world, h, "farm_wheat", T0) + self.assertEqual(game.plant_second_plot(world, h, "farm_wheat"), "That's already growing in your first plot") + self.assertIsNone(game.plant_second_plot(world, h, "grow_thatch")) + self.assertAlmostEqual(game.action_time(world, h, wheat), wheat["time"] * 0.8) + summary = game.process_player(world, h, T0 + 0.8 * (25 + 30) * 10 + 1, random.Random(1)) + self.assertEqual(summary["done"], 20) + self.assertEqual((h["bank"]["wheat"], h["bank"]["thatch"]), (20, 30)) # ten harvests from each plot + # Replanting the first plot keeps the second; a "make one" elsewhere comes back to both. + game.start_action(world, h, "grow_guam_leaf", T0 + 1000) + self.assertEqual(h["action"]["plot2"], "grow_thatch") + h["bank"].update(wheat=10, logs=10) + game.start_action(world, h, "cook_bread", T0 + 1000, once=True) + game.process_player(world, h, T0 + 1000 + 30, random.Random(1)) + self.assertEqual((h["action"]["id"], h["action"]["plot2"]), ("grow_guam_leaf", "grow_thatch")) + game.start_action(world, h, "mine_stone", T0 + 2000) + self.assertNotIn("plot2", h["action"]) + + class FittingTest(unittest.TestCase): def setUp(self): self.world = world_with(("fred", "miner"))