diff --git a/README.md b/README.md index 8c6fb6a..2a951dc 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. - **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. -- **Gear and tools:** smiths make weapons, copper and iron armour, and tools - (pickaxe, axe, hoe, fishing rod, hunting knife) that make gathering 15โ€“30% - faster; leatherworkers turn hides into leather armour. +- **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril, + adamant, rune at levels 1โ€“75) of ore, trees (up to magic) and hides (deer, + boar and bear from hunting; green, blue and red dragonhide from fighting + dragons). Every metal tier has a sword, shield, helmet, greaves, cuirass and + five tools; every leather tier a cap, trousers and jerkin. Higher bars need + coal; each metal past copper needs a Smithy level (rune needs level 5, so a + city). Gear needs a combat level to wear, tools a skill level to use. - **Classes:** warrior, scout, artisan, pioneer. Every class can train every skill; a class makes some skills 20% faster and has one signature ability. - **Bank:** 12 slots of up to 1,000 each to start. When something doesn't diff --git a/app.py b/app.py index 6d749a0..ceb2b1f 100644 --- a/app.py +++ b/app.py @@ -361,6 +361,9 @@ class Handler(BaseHTTPRequestHandler): return "That can't be equipped" if player["bank"].get(item, 0) < 1: return "You don't have one" + problem = game.equip_problem(player, item) + if problem: + return problem game.bank_take(player, item, 1) current = player["equip"][spec["slot"]] if current: diff --git a/game.py b/game.py index 2e71460..a3fa585 100644 --- a/game.py +++ b/game.py @@ -49,21 +49,11 @@ def level_for(xp: float) -> int: ITEMS = { "stone": {"name": "Stone", "icon": "๐Ÿชจ", "value": 1}, - "copper_ore": {"name": "Copper ore", "icon": "๐ŸŸค", "value": 2}, - "iron_ore": {"name": "Iron ore", "icon": "โšซ", "value": 4}, - "logs": {"name": "Logs", "icon": "๐Ÿชต", "value": 1}, - "oak_logs": {"name": "Oak logs", "icon": "๐ŸŒณ", "value": 4}, "wheat": {"name": "Wheat", "icon": "๐ŸŒพ", "value": 1}, "vegetables": {"name": "Vegetables", "icon": "๐Ÿฅ•", "value": 2}, "feathers": {"name": "Feathers", "icon": "๐Ÿชถ", "value": 1}, "raw_meat": {"name": "Raw meat", "icon": "๐Ÿฅฉ", "value": 2}, - "hide": {"name": "Hide", "icon": "๐ŸŸซ", "value": 3}, - "copper_bar": {"name": "Copper bar", "icon": "๐ŸŸง", "value": 5}, - "iron_bar": {"name": "Iron bar", "icon": "โฌ›", "value": 10}, "arrows": {"name": "Arrows", "icon": "๐Ÿน", "value": 1}, - "copper_sword": {"name": "Copper sword", "plural": "Copper swords", "icon": "๐Ÿ—ก๏ธ", "value": 14, "slot": "weapon", "power": 4}, - "iron_sword": {"name": "Iron sword", "plural": "Iron swords", "icon": "โš”๏ธ", "value": 30, "slot": "weapon", "power": 9}, - "iron_shield": {"name": "Iron shield", "plural": "Iron shields", "icon": "๐Ÿ›ก๏ธ", "value": 40, "slot": "shield", "power": 6}, "bricks": {"name": "Bricks", "icon": "๐Ÿงฑ", "value": 3}, "cut_stone": {"name": "Cut stone", "icon": "๐Ÿ”ท", "value": 5}, "beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "๐Ÿชœ", "value": 25}, @@ -71,39 +61,120 @@ ITEMS = { "bread": {"name": "Bread", "icon": "๐Ÿž", "value": 4, "food": 1, "heal": 8}, "cooked_meat": {"name": "Cooked meat", "icon": "๐Ÿ–", "value": 5, "food": 1, "heal": 12}, "stew": {"name": "Stew", "icon": "๐Ÿฒ", "value": 10, "food": 2, "heal": 20}, - "raw_shrimp": {"name": "Raw shrimp", "icon": "๐Ÿฆ", "value": 1}, - "raw_trout": {"name": "Raw trout", "icon": "๐ŸŸ", "value": 3}, - "raw_salmon": {"name": "Raw salmon", "icon": "๐Ÿ ", "value": 6}, - "raw_eel": {"name": "Raw eel", "icon": "๐Ÿ", "value": 10}, - "cooked_shrimp": {"name": "Cooked shrimp", "icon": "๐Ÿค", "value": 4, "food": 1, "heal": 10}, - "cooked_trout": {"name": "Grilled trout", "icon": "๐Ÿข", "value": 8, "food": 1, "heal": 18}, - "cooked_salmon": {"name": "Baked salmon", "icon": "๐Ÿฃ", "value": 14, "food": 2, "heal": 28}, - "smoked_eel": {"name": "Smoked eel", "icon": "๐Ÿฅ“", "value": 22, "food": 2, "heal": 40}, - "leather": {"name": "Leather", "icon": "๐Ÿชข", "value": 5}, - # `armour` lowers the damage a fight deals; `power` adds to fighting strength. - "leather_cap": {"name": "Leather cap", "icon": "๐Ÿงข", "value": 12, "slot": "helmet", "armour": 1}, - "leather_legs": {"name": "Leather trousers", "plural": "Leather trousers", "icon": "๐Ÿ‘–", "value": 18, "slot": "legs", "armour": 2}, - "leather_body": {"name": "Leather jerkin", "icon": "๐Ÿฆบ", "value": 24, "slot": "body", "armour": 3}, - "studded_body": {"name": "Studded jerkin", "icon": "๐Ÿงฅ", "value": 40, "slot": "body", "armour": 6}, - "copper_helmet": {"name": "Copper helmet", "icon": "โ›‘๏ธ", "value": 12, "slot": "helmet", "armour": 2}, - "copper_legs": {"name": "Copper greaves", "plural": "Copper greaves", "icon": "๐Ÿฆต", "value": 22, "slot": "legs", "armour": 3}, - "copper_body": {"name": "Copper cuirass", "icon": "๐Ÿฅ‹", "value": 28, "slot": "body", "armour": 4}, - "iron_helmet": {"name": "Iron helmet", "icon": "๐Ÿช–", "value": 25, "slot": "helmet", "armour": 4}, - "iron_legs": {"name": "Iron greaves", "plural": "Iron greaves", "icon": "๐Ÿฆฟ", "value": 45, "slot": "legs", "armour": 6}, - "iron_body": {"name": "Iron cuirass", "icon": "๐Ÿฆพ", "value": 55, "slot": "body", "armour": 8}, + "studded_body": {"name": "Studded jerkin", "icon": "๐Ÿงฅ", "value": 40, "slot": "body", "armour": 6, + "req": ["combat", 10]}, } + +# --- tier ladders ----------------------------------------------------------- +# Six tiers, RuneScape's metals with Melvor-ish level spacing. Each row of a +# ladder generates its items and recipes below; the copper and iron ids +# predate the ladders and are kept as they were. + +TIER_LEVELS = [1, 15, 30, 45, 60, 75] # gathering / smelting level of each tier +EQUIP_LEVELS = [1, 10, 20, 30, 40, 50] # combat level to wear, skill level to use a tool + +# key, name, ore (item id, name, icon, value), coal per bar, bar icon, bar value, smithy level, handle wood +METALS = [ + ("copper", "Copper", ("copper_ore", "Copper ore", "๐ŸŸค", 2), 0, "๐ŸŸง", 5, 0, "logs"), + ("iron", "Iron", ("iron_ore", "Iron ore", "โšซ", 4), 0, "โฌ›", 10, 1, "oak_logs"), + ("steel", "Steel", None, 2, "๐Ÿ”ฒ", 20, 2, "willow_logs"), + ("mithril", "Mithril", ("mithril_ore", "Mithril ore", "๐Ÿ”ต", 15), 3, "๐ŸŸฆ", 40, 3, "maple_logs"), + ("adamant", "Adamant", ("adamantite_ore", "Adamantite ore", "๐ŸŸข", 30), 4, "๐ŸŸฉ", 70, 4, "yew_logs"), + ("rune", "Rune", ("runite_ore", "Runite ore", "๐Ÿ’ ", 55), 5, "๐ŸŸช", 120, 5, "magic_logs"), +] +# piece, slot, display noun, bars, smithing level above the tier, power by tier, armour by tier +PIECES = [ + ("sword", "weapon", "sword", 2, 7, [4, 9, 14, 20, 27, 35], None), + ("shield", "shield", "shield", 3, 10, [3, 6, 8, 11, 14, 18], [2, 4, 6, 9, 12, 15]), + ("helmet", "helmet", "helmet", 2, 5, None, [2, 4, 6, 8, 11, 14]), + ("legs", "legs", "greaves", 4, 9, None, [3, 6, 9, 12, 16, 21]), + ("body", "body", "cuirass", 5, 11, None, [4, 8, 12, 16, 22, 28]), +] +PLURALS = {"sword": "swords", "shield": "shields", "helmet": "helmets", "greaves": "greaves", "cuirass": "cuirasses"} +PIECE_ICONS = {"sword": "โš”๏ธ", "shield": "๐Ÿ›ก๏ธ", "helmet": "๐Ÿช–", "legs": "๐Ÿฆฟ", "body": "๐Ÿฆพ"} +COPPER_ICONS = {"sword": "๐Ÿ—ก๏ธ", "helmet": "โ›‘๏ธ", "legs": "๐Ÿฆต", "body": "๐Ÿฅ‹"} # Gathering tools: one slot per skill, `speed` cuts the action time. TOOLS = {"mining": ("pickaxe", "โ›๏ธ"), "woodcutting": ("axe", "๐Ÿช“"), "farming": ("hoe", "๐Ÿช"), "fishing": ("fishing rod", "๐ŸŽฃ"), "hunting": ("hunting knife", "๐Ÿ”ช")} -for _skill, (_tool, _icon) in TOOLS.items(): - for _metal, _value, _speed in (("copper", 14, 0.15), ("iron", 30, 0.3)): - ITEMS[f"{_metal}_{_tool.replace(' ', '_')}"] = { - "name": f"{_metal.title()} {_tool}", "icon": _icon, "value": _value, - "slot": f"tool_{_skill}", "tool": _skill, "speed": _speed, +TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60] + +# item id, name, icon, value, action id, verb +TREES = [ + ("logs", "Logs", "๐Ÿชต", 1, "cut_logs", "Cut trees"), + ("oak_logs", "Oak logs", "๐ŸŒณ", 4, "cut_oak", "Cut oaks"), + ("willow_logs", "Willow logs", "๐ŸŒฟ", 8, "cut_willow", "Cut willows"), + ("maple_logs", "Maple logs", "๐Ÿ", 15, "cut_maple", "Cut maples"), + ("yew_logs", "Yew logs", "๐ŸŒฒ", 30, "cut_yew", "Cut yews"), + ("magic_logs", "Magic logs", "๐Ÿ”ฎ", 55, "cut_magic", "Cut magic trees"), +] + +# raw hide (id, name, icon, value), leather (id, name, value), armour id prefix, armour name prefix, +# leatherworking level, armour by piece +LEATHERS = [ + (("hide", "Hide", "๐ŸŸซ", 3), ("leather", "Leather", 5), "leather", "Leather", 1, (1, 2, 3)), + (("boar_hide", "Boar hide", "๐Ÿ—", 8), ("hard_leather", "Hard leather", 12), "hard_leather", "Hard leather", 20, (2, 4, 6)), + (("bear_pelt", "Bear pelt", "๐Ÿป", 16), ("bear_leather", "Bear leather", 24), "bear_leather", "Bear leather", 35, (4, 6, 9)), + (("green_dragonhide", "Green dragonhide", "๐Ÿ‰", 35), ("green_dragon_leather", "Green dragon leather", 50), + "green_dhide", "Green dragonhide", 50, (6, 9, 13)), + (("blue_dragonhide", "Blue dragonhide", "๐Ÿฒ", 60), ("blue_dragon_leather", "Blue dragon leather", 85), + "blue_dhide", "Blue dragonhide", 65, (8, 12, 17)), + (("red_dragonhide", "Red dragonhide", "๐Ÿ”ฅ", 100), ("red_dragon_leather", "Red dragon leather", 140), + "red_dhide", "Red dragonhide", 80, (10, 15, 21)), +] +# piece suffix, slot, noun, leather used, level above the tier, icon +LEATHER_PIECES = [("cap", "helmet", "cap", 2, 2, "๐Ÿงข"), ("legs", "legs", "trousers", 3, 7, "๐Ÿ‘–"), + ("body", "body", "jerkin", 4, 11, "๐Ÿฆบ")] + +# raw (id, name, icon, value), cooked (id, name, icon, value, heal, food), fishing level, fish action, verb +FISH = [ + (("raw_shrimp", "Raw shrimp", "๐Ÿฆ", 1), ("cooked_shrimp", "Cooked shrimp", "๐Ÿค", 4, 10, 1), 1, "fish_shrimp", "Net shrimp"), + (("raw_trout", "Raw trout", "๐ŸŸ", 3), ("cooked_trout", "Grilled trout", "๐Ÿข", 8, 18, 1), 15, "fish_trout", "Fly-fish trout"), + (("raw_salmon", "Raw salmon", "๐Ÿ ", 6), ("cooked_salmon", "Baked salmon", "๐Ÿฃ", 14, 28, 2), 30, "fish_salmon", "Catch salmon"), + (("raw_eel", "Raw eel", "๐Ÿ", 10), ("smoked_eel", "Smoked eel", "๐Ÿฅ“", 22, 40, 2), 45, "fish_eel", "Trap eels"), + (("raw_lobster", "Raw lobster", "๐Ÿฆž", 18), ("cooked_lobster", "Cooked lobster", "๐Ÿœ", 35, 55, 3), 60, "fish_lobster", "Pot lobsters"), + (("raw_swordfish", "Raw swordfish", "๐Ÿฌ", 30), ("cooked_swordfish", "Grilled swordfish", "๐Ÿฑ", 55, 70, 3), 75, "fish_swordfish", "Harpoon swordfish"), +] +COOK_IDS = {"raw_shrimp": "cook_shrimp", "raw_trout": "cook_trout", "raw_salmon": "cook_salmon", "raw_eel": "cook_eel", + "raw_lobster": "cook_lobster", "raw_swordfish": "cook_swordfish"} + +ITEMS["coal"] = {"name": "Coal", "icon": "๐ŸŒ‘", "value": 6} +for _tier, (_key, _name, _ore, _coal, _bar_icon, _bar_value, _smithy, _wood) in enumerate(METALS): + if _ore: + ITEMS[_ore[0]] = {"name": _ore[1], "icon": _ore[2], "value": _ore[3]} + ITEMS[f"{_key}_bar"] = {"name": f"{_name} bar", "icon": _bar_icon, "value": _bar_value, "tier": _tier} + for _piece, _slot, _noun, _bars, _offset, _power, _armour in PIECES: + _item = {"name": f"{_name} {_noun}", "plural": f"{_name} {PLURALS[_noun]}", + "icon": COPPER_ICONS.get(_piece, PIECE_ICONS[_piece]) if _key == "copper" else PIECE_ICONS[_piece], + "value": round(_bars * _bar_value * 1.2), "slot": _slot, "tier": _tier, + "req": ["combat", EQUIP_LEVELS[_tier]]} + if _power: + _item["power"] = _power[_tier] + if _armour: + _item["armour"] = _armour[_tier] + ITEMS[f"{_key}_{_piece}"] = {k: v for k, v in _item.items() if v is not None} + for _skill, (_tool, _icon) in TOOLS.items(): + ITEMS[f"{_key}_{_tool.replace(' ', '_')}"] = { + "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]], } -ITEMS["iron_shield"]["armour"] = 4 +for _log, _name, _icon, _value, _action_id, _verb in TREES: + ITEMS[_log] = {"name": _name, "icon": _icon, "value": _value} +for _tier, (_raw, _leather, _prefix, _label, _level, _armour) in enumerate(LEATHERS): + ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} + ITEMS[_leather[0]] = {"name": _leather[1], "icon": "๐Ÿชข", "value": _leather[2]} + for (_suffix, _slot, _noun, _used, _offset, _icon), _points in zip(LEATHER_PIECES, _armour): + ITEMS[f"{_prefix}_{_suffix}"] = { + "name": f"{_label} {_noun}", "icon": _icon, "value": round(_used * _leather[2] * 1.3), + "slot": _slot, "armour": _points, "tier": _tier, "req": ["combat", EQUIP_LEVELS[_tier]], + } + if _noun == "trousers": + ITEMS[f"{_prefix}_{_suffix}"]["plural"] = ITEMS[f"{_prefix}_{_suffix}"]["name"] +for _raw, _cooked, _level, _action_id, _verb in FISH: + ITEMS[_raw[0]] = {"name": _raw[1], "icon": _raw[2], "value": _raw[3]} + ITEMS[_cooked[0]] = {"name": _cooked[1], "icon": _cooked[2], "value": _cooked[3], "heal": _cooked[4], "food": _cooked[5]} + # What the city eats first: plain food before the good stuff. -FOOD_ORDER = ("bread", "cooked_meat", "cooked_shrimp", "cooked_trout", "stew", "cooked_salmon", "smoked_eel") +FOOD_ORDER = tuple(sorted((item for item, spec in ITEMS.items() if "food" in spec), key=lambda i: ITEMS[i]["heal"])) EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS] SKILLS = [ @@ -153,55 +224,68 @@ def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra "inputs": inputs or {}, "outputs": outputs or {}, **extra} +def _tiered_actions() -> list[dict]: + """Mining, woodcutting, smelting, smithing, tanning and leatherworking for every tier.""" + actions = [] + ore_xp = [7, 15, 30, 40, 55, 75] + ore_time = [25, 35, 50, 55, 65, 80] + bar_xp = [6, 14, 22, 32, 44, 58] + piece_xp = [6, 13, 20, 28, 37, 48] # per bar used + for tier, (key, name, ore, coal, _icon, _value, smithy, wood) in enumerate(METALS): + if ore: + actions.append(_action(f"mine_{key}" if key in ("copper", "iron") else f"mine_{ore[0].removesuffix('_ore')}", + "mining", f"Mine {ore[1].removesuffix(' ore').lower()}", TIER_LEVELS[tier], + ore_time[tier], ore_xp[tier], outputs={ore[0]: 1})) + 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}) + 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 {})) + 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}, + {f"{key}_{piece}": 1}, group=name, city=city)) + for tool, _icon in TOOLS.values(): + tool_id = f"{key}_{tool.replace(' ', '_')}" + actions.append(_action(f"smith_{tool_id}", "smithing", f"{name} {tool}", TIER_LEVELS[tier] + 3, + 30 + 2 * tier, 2 * piece_xp[tier], {f"{key}_bar": 2, wood: 1}, {tool_id: 1}, + group=name, city=city)) + for tier, (log, _name, _icon, _value, action_id, verb) in enumerate(TREES): + actions.append(_action(action_id, "woodcutting", verb, TIER_LEVELS[tier], [20, 35, 40, 50, 60, 75][tier], + [5, 15, 24, 36, 52, 75][tier], outputs={log: 1})) + for tier, (raw, leather, prefix, label, level, _armour) in enumerate(LEATHERS): + actions.append(_action("tan_leather" if tier == 0 else f"tan_{leather[0]}", "leatherworking", + f"Tan {leather[1].lower()}", level, 25 + 3 * tier, bar_xp[tier], + {raw[0]: 1}, {leather[0]: 1}, group="Tanning")) + for suffix, _slot, noun, used, offset, _icon in LEATHER_PIECES: + actions.append(_action(f"lw_{suffix}" if tier == 0 else f"lw_{prefix}_{suffix}", "leatherworking", + f"{label} {noun}", level + offset, 25 + 5 * used + 2 * tier, used * piece_xp[tier], + {leather[0]: used}, {f"{prefix}_{suffix}": 1}, group=label)) + for raw, cooked, level, fish_id, verb in FISH: + tier = TIER_LEVELS.index(level) if level in TIER_LEVELS else 3 + 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})) + return actions + + ACTIONS = [ _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), - _action("mine_copper", "mining", "Mine copper", 1, 25, 7, outputs={"copper_ore": 1}), - _action("mine_iron", "mining", "Mine iron", 15, 35, 15, outputs={"iron_ore": 1}), - _action("cut_logs", "woodcutting", "Cut trees", 1, 20, 5, outputs={"logs": 1}), - _action("cut_oak", "woodcutting", "Cut oaks", 15, 35, 15, outputs={"oak_logs": 1}), _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}), _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}), _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}), - _action("fish_shrimp", "fishing", "Net shrimp", 1, 25, 6, outputs={"raw_shrimp": 1}), - _action("fish_trout", "fishing", "Fly-fish trout", 15, 35, 15, outputs={"raw_trout": 1}), - _action("fish_salmon", "fishing", "Catch salmon", 30, 45, 28, outputs={"raw_salmon": 1}), - _action("fish_eel", "fishing", "Trap eels", 45, 55, 45, outputs={"raw_eel": 1}), - _action("smith_copper_bar", "smithing", "Copper bar", 1, 20, 6, - {"copper_ore": 1, "logs": 1}, {"copper_bar": 1}, group="Bars"), - _action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14, - {"iron_ore": 1, "logs": 1}, {"iron_bar": 1}, group="Bars"), - _action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18, - {"copper_bar": 3}, {"copper_sword": 1}, group="Weapons"), - _action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32, - {"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}, group="Weapons"), - _action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40, - {"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}, group="Weapons"), - _action("smith_copper_helmet", "smithing", "Copper helmet", 6, 35, 14, - {"copper_bar": 2}, {"copper_helmet": 1}, group="Armour"), - _action("smith_copper_legs", "smithing", "Copper greaves", 10, 40, 20, - {"copper_bar": 4}, {"copper_legs": 1}, group="Armour"), - _action("smith_copper_body", "smithing", "Copper cuirass", 12, 45, 26, - {"copper_bar": 5}, {"copper_body": 1}, group="Armour"), - _action("smith_iron_helmet", "smithing", "Iron helmet", 22, 40, 34, - {"iron_bar": 2}, {"iron_helmet": 1}, city={"smithy": 1}, group="Armour"), - _action("smith_iron_legs", "smithing", "Iron greaves", 27, 45, 46, - {"iron_bar": 4}, {"iron_legs": 1}, city={"smithy": 1}, group="Armour"), - _action("smith_iron_body", "smithing", "Iron cuirass", 30, 50, 56, - {"iron_bar": 5}, {"iron_body": 1}, city={"smithy": 2}, group="Armour"), - *[_action(f"smith_{metal}_{tool.replace(' ', '_')}", "smithing", f"{metal.title()} {tool}", level, time, xp, - {f"{metal}_bar": 2, wood: 1}, {f"{metal}_{tool.replace(' ', '_')}": 1}, group="Tools", - **({"city": {"smithy": 1}} if metal == "iron" else {})) - for metal, level, time, xp, wood in (("copper", 4, 30, 12, "logs"), ("iron", 20, 40, 30, "oak_logs")) - for tool, _icon in TOOLS.values()], + _action("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}), + _action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}), + *_tiered_actions(), _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, - {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Supplies"), - _action("tan_leather", "leatherworking", "Tan leather", 1, 25, 6, {"hide": 1}, {"leather": 1}), - _action("lw_cap", "leatherworking", "Leather cap", 3, 30, 10, {"leather": 2}, {"leather_cap": 1}), - _action("lw_legs", "leatherworking", "Leather trousers", 8, 35, 16, {"leather": 3}, {"leather_legs": 1}), - _action("lw_body", "leatherworking", "Leather jerkin", 12, 40, 22, {"leather": 4}, {"leather_body": 1}), + {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"), _action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, - {"leather": 4, "iron_bar": 1}, {"studded_body": 1}), + {"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"), _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, @@ -210,10 +294,6 @@ ACTIONS = [ _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, {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), - _action("cook_shrimp", "cooking", "Cooked shrimp", 1, 20, 6, {"raw_shrimp": 1, "logs": 1}, {"cooked_shrimp": 1}), - _action("cook_trout", "cooking", "Grilled trout", 15, 30, 15, {"raw_trout": 1, "logs": 1}, {"cooked_trout": 1}), - _action("cook_salmon", "cooking", "Baked salmon", 30, 35, 27, {"raw_salmon": 1, "logs": 1}, {"cooked_salmon": 1}), - _action("cook_eel", "cooking", "Smoked eel", 45, 40, 42, {"raw_eel": 1, "logs": 1}, {"smoked_eel": 1}), _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, damage=2, gold=2, drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5]]), _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, damage=6, gold=10, @@ -221,7 +301,15 @@ ACTIONS = [ _action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, damage=14, gold=25, drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]), _action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, damage=25, gold=60, - drops=[["iron_bar", 2, 0.5], ["iron_shield", 1, 0.04]]), + drops=[["steel_bar", 1, 0.4], ["coal", 3, 0.5], ["iron_shield", 1, 0.04]]), + _action("fight_green_dragon", "combat", "Green dragon", 50, 120, 135, difficulty=90, damage=30, gold=80, + drops=[["green_dragonhide", 1, 0.9], ["mithril_ore", 2, 0.3]]), + _action("fight_blue_dragon", "combat", "Blue dragon", 62, 130, 175, difficulty=115, damage=40, gold=110, + drops=[["blue_dragonhide", 1, 0.9], ["adamantite_ore", 2, 0.3]]), + _action("fight_red_dragon", "combat", "Red dragon", 75, 140, 220, difficulty=140, damage=52, gold=150, + drops=[["red_dragonhide", 1, 0.9], ["runite_ore", 1, 0.25]]), + _action("fight_black_dragon", "combat", "Black dragon", 85, 150, 280, difficulty=165, damage=65, gold=220, + drops=[["red_dragonhide", 2, 0.9], ["rune_bar", 1, 0.1]]), _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10, special="sabotage"), _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), @@ -233,31 +321,37 @@ BUILDINGS = { "houses": { "name": "Houses", "icon": "๐Ÿ ", "max": 8, "gold": 200, "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, + "late": {"willow_logs": 150}, "final": {"maple_logs": 100}, "effect": "Room for 20 more people per level", }, "walls": { "name": "Walls", "icon": "๐Ÿฐ", "max": 5, "gold": 300, "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, + "late": {"steel_bar": 60}, "final": {"mithril_bar": 40}, "effect": "+20 defence per level, and raiders carry off less", }, "barracks": { "name": "Barracks", "icon": "โ›บ", "max": 5, "gold": 300, "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, + "late": {"steel_sword": 10}, "final": {"mithril_sword": 6}, "effect": "Trains townsfolk as militia: more defence per citizen", }, "smithy": { - "name": "Smithy", "icon": "โš’๏ธ", "max": 3, "gold": 250, + "name": "Smithy", "icon": "โš’๏ธ", "max": 5, "gold": 250, "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, - "effect": "Smithing 8% faster per level; unlocks iron swords (1) and shields (2)", + "late": {"steel_bar": 80}, "final": {"mithril_bar": 60}, + "effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune", }, "spymaster": { "name": "Spymaster's guild", "icon": "๐Ÿ•ต๏ธ", "max": 3, "gold": 400, "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, + "late": {"bear_leather": 40}, "effect": "One more level of intel on every event per level", }, "storehouse": { "name": "Storehouse", "icon": "๐Ÿ“ฆ", "max": 5, "gold": 200, "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, + "late": {"willow_logs": 200}, "final": {"yew_logs": 80}, "effect": "+3 bank slots and +250 per stack for everyone, per level", }, } @@ -367,9 +461,11 @@ def building_level(world: dict, key: str) -> int: def build_cost(key: str, level: int) -> dict: spec = BUILDINGS[key] cost = {item: qty * level for item, qty in spec["base"].items()} - for item, qty in spec["advanced"].items(): - if level > 1: - cost[item] = cost.get(item, 0) + qty * (level - 1) + # Later levels add materials from higher tiers: advanced from level 2, late from 3, final from 5. + for key, first in (("advanced", 2), ("late", 3), ("final", 5)): + for item, qty in spec.get(key, {}).items(): + if level >= first: + cost[item] = cost.get(item, 0) + qty * (level - first + 1) cost["gold"] = spec["gold"] * level return cost @@ -889,20 +985,43 @@ def population_view(world: dict) -> dict: } +def best_kit(stock: dict, slot: str, stat: str, people: int) -> float: + """Total `stat` when the best `slot` items in the stockpile go to `people` defenders.""" + total = 0 + for item in sorted((i for i in stock if ITEMS[i].get("slot") == slot), key=lambda i: -ITEMS[i].get(stat, 0)): + take = min(people, stock[item]) + total += take * ITEMS[item].get(stat, 0) + people -= take + if not people: + break + return total + + +def kit_count(stock: dict, slot: str) -> int: + return sum(qty for item, qty in stock.items() if ITEMS[item].get("slot") == slot) + + +def equip_problem(player: dict, item: str) -> str | None: + """Why the player can't wear or use this yet, or None.""" + req = ITEMS[item].get("req") + if req and skill_level(player, req[0]) < req[1]: + name = next(s["name"] for s in SKILLS if s["id"] == req[0]) + return f"Needs {name.lower()} level {req[1]}" + return None + + def defence(world: dict) -> dict: """The city's defence right now, part by part.""" city = world["city"] pop = city["population"] stock = city["stockpile"] armed = int(pop // 2) - swords = min(armed, stock.get("copper_sword", 0) + stock.get("iron_sword", 0)) - iron = min(swords, stock.get("iron_sword", 0)) parts = { "townsfolk": 10, "walls": 20 * building_level(world, "walls"), "militia": round(0.3 * building_level(world, "barracks") * pop, 1), - "weapons": round(3 * iron + 1.5 * (swords - iron), 1), - "shields": 2 * min(armed, stock.get("iron_shield", 0)), + "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1), + "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1), "arrows": min(100, stock.get("arrows", 0) // 10), "rallied": city.get("rally", 0), "garrison": 0, @@ -1093,16 +1212,18 @@ def council_requests(world: dict, event: dict) -> list[dict]: ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))), ("bread", math.ceil(city["population"] * 1.5)), ] - if building_level(world, "smithy") >= 2: + if building_level(world, "smithy") >= 1: wants.append(("iron_shield", min(armed, math.ceil(strength / 10)))) requests = [] for item, qty in wants: have = stock.get(item, 0) - if item == "copper_sword": - have += stock.get("iron_sword", 0) + if item in ("copper_sword", "iron_shield"): + have = kit_count(stock, ITEMS[item]["slot"]) if item == "bread": have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) - requests.append({"item": item, "want": qty, "have": have}) + label = {"copper_sword": "Swords (any)", "iron_shield": "Shields (any)", "bread": "Food (any)"}[item] \ + if item in ("copper_sword", "iron_shield", "bread") else ITEMS[item]["name"] + requests.append({"item": item, "label": label, "want": qty, "have": have}) return requests diff --git a/static/app.js b/static/app.js index 614d01c..e44a3d4 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,7 @@ let skew = 0; // server clock minus client clock, seconds let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let selected = null; // bank item let pickedClass = null; // registration +const tabs = {}; // skill -> selected recipe tab let refreshing = false; let refreshedFor = null; // action start a completion refresh was sent for let version = null; // server build this page was loaded from @@ -315,8 +316,18 @@ function skillPage(id) { if (id === "combat") html += combatPanel(); if (`tool_${id}` in me.equip) html += toolPanel(id); if (id === "reconnaissance") html += `
Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
`; + const actions = C.actions.filter((a) => a.skill === id); + const groups = [...new Set(actions.map((a) => a.group).filter(Boolean))]; + if (groups.length > 2) { + // Many tiers: one tab per tier, opening on the highest one you can work. + const open = (g) => actions.some((a) => a.group === g && !isLocked(a)); + let current = tabs[id] || remember(`tab.${id}`); + if (!groups.includes(current)) current = [...groups].reverse().find(open) || groups[0]; + const bar = groups.map((g) => ``).join(""); + return html + `
${bar}
${actions.filter((a) => a.group === current).map(actionCard).join("")}
`; + } let group = null, cards = ""; - for (const a of C.actions.filter((a) => a.skill === id)) { + for (const a of actions) { if (a.group && a.group !== group) { group = a.group; cards += `
${esc(group)}
`; @@ -326,6 +337,12 @@ function skillPage(id) { return html + `
${cards}
`; } +// Locked: missing a level, class or building, as opposed to just out of materials. +function isLocked(a) { + const why = S.me.blocked[a.id]; + return Boolean(why && (why.startsWith("Needs") || why.startsWith("Only"))); +} + function toolPanel(skill) { const tool = S.me.equip[`tool_${skill}`]; return `
${tool @@ -365,7 +382,7 @@ function actionCard(a) { const me = S.me; const active = me.action?.id === a.id; const why = me.blocked[a.id]; - const locked = why && (why.startsWith("Needs") || why.startsWith("Only")); + const locked = isLocked(a); const out = Object.keys(a.outputs)[0]; const icon = out ? item(out).icon : skillOf(a.skill).icon; const time = me.times[a.id]; @@ -440,7 +457,7 @@ function itemPanel(id) {
${it.icon}

${esc(it.name)}

You have ${fmt(have)} ยท worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
- ${it.slot ? `
` : ""} + ${it.slot ? equipRow(id, it) : ""} ${it.heal ? `
Heals ${it.heal} in fights.
${S.me.food === id ? `Your fighting food` : ``}
` : ""} ${it.food ? `
Feeds ${it.food} townsfolk for two days in the city granary.
` : ""} @@ -451,6 +468,13 @@ function itemPanel(id) {
${goldPanel()}`; } +function equipRow(id, it) { + const [skill, level] = it.req || []; + const short = skill && S.me.levels[skill] < level; + return `
+ ${skill ? `
Needs ${esc(skillOf(skill).name.toLowerCase())} level ${level}
` : ""}
`; +} + function equipLabel(it) { if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`; const stats = [it.power && `+${it.power} power`, it.armour && `+${it.armour} armour`].filter(Boolean).join(", "); @@ -609,7 +633,7 @@ function raidPrep(ev) { const names = { rallied: "Rallied militia (favour)", townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; const reqs = (ev.requests || []).map((r) => { const mine = me.bank[r.item] || 0; - return `
${item(r.item).icon}${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} ${fmt(r.have)} / ${fmt(r.want)} + return `
${item(r.item).icon}${esc(r.label)} ${fmt(r.have)} / ${fmt(r.want)} ${mine ? `` : ""}
`; }).join(""); @@ -812,6 +836,7 @@ document.addEventListener("click", (e) => { if (d.food) return act("/api/food", { item: d.food }); if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } + if (d.tab) { tabs[d.tab] = d.group; remember(`tab.${d.tab}`, d.group); render(); return; } if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) }); if ("devEvent" in d) return act("/api/dev/event", {}); }); diff --git a/static/styles.css b/static/styles.css index 0fb9ede..f3aa514 100644 --- a/static/styles.css +++ b/static/styles.css @@ -370,3 +370,7 @@ button.sheet-skill:hover { background: var(--panel-2); } .favour-option:first-of-type { border-top: 0; } .favour-option .ico { font-size: 24px; width: 30px; text-align: center; } .favour-option .grow { display: flex; flex-direction: column; } +.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.tab { border: 1px solid var(--line); background: var(--panel); border-radius: 999px; padding: 5px 14px; font-weight: 600; font-size: 14px; } +.tab.on { background: var(--gold); border-color: var(--gold); color: var(--bg); } +.tab.dim:not(.on) { opacity: .5; } diff --git a/tests/test_game.py b/tests/test_game.py index 8717b34..3051cc4 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -514,5 +514,64 @@ class FavourTest(unittest.TestCase): self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 40 favour") + +class TierTest(unittest.TestCase): + def test_steel_needs_coal_and_the_smithy(self): + world = world_with(("kay", "artisan")) + p = world["players"]["kay"] + p["xp"]["smithing"] = game.XP_TABLE[30] + p["bank"] = {"iron_ore": 5, "coal": 10} + 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)) + game.process_player(world, p, T0 + 1000, random.Random(1)) + self.assertGreaterEqual(p["bank"]["steel_bar"], 5) + self.assertNotIn("iron_ore", p["bank"]) + + def test_every_tier_has_full_kit(self): + for key, *_ in game.METALS: + for piece in ("sword", "shield", "helmet", "legs", "body", "pickaxe", "axe", "hoe", "fishing_rod", "hunting_knife"): + self.assertIn(f"{key}_{piece}", game.ITEMS) + self.assertIn(f"smith_{key}_{piece}", game.ACTION_BY_ID) + for *_, prefix, _label, _level, _armour in game.LEATHERS: + for piece in ("cap", "legs", "body"): + self.assertIn(f"{prefix}_{piece}", game.ITEMS) + self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses") + + def test_gear_needs_levels_to_wear(self): + world = world_with(("wil", "warrior")) + p = world["players"]["wil"] + self.assertEqual(game.equip_problem(p, "rune_sword"), "Needs combat level 50") + self.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 20") + self.assertIsNone(game.equip_problem(p, "copper_sword")) + p["xp"]["combat"] = game.XP_TABLE[50] + self.assertIsNone(game.equip_problem(p, "rune_sword")) + + def test_militia_takes_the_best_weapons_first(self): + world = world_with() + world["city"]["stockpile"] = {"copper_sword": 50, "rune_sword": 2, "iron_shield": 3} + parts = game.defence(world)["parts"] + armed = int(world["city"]["population"] // 2) # 10 + self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 4), 1)) + self.assertEqual(parts["shields"], 0.5 * 3 * 4) + + 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"], 60) + self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 40) + self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) + + def test_dragons_drop_dragonhide(self): + world = world_with(("wil", "warrior")) + p = world["players"]["wil"] + p["xp"]["combat"] = game.XP_TABLE[80] + p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs") + p["hp"] = game.max_hp(p) + p["bank"] = {"cooked_swordfish": 200} + game.start_action(world, p, "fight_green_dragon", T0) + game.process_player(world, p, T0 + 3600 * 2, random.Random(3)) + self.assertGreater(p["bank"].get("green_dragonhide", 0), 30) + + if __name__ == "__main__": unittest.main()