Six tiers of ore, trees, hides and gear; dragons; tier tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 22:13:50 +01:00
parent a18e8e5bb7
commit ba68a62080
6 changed files with 319 additions and 103 deletions

View file

@ -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 - **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 health you eat food from your bank, and out of food you retreat, losing
nothing but the fight. HP recovers 25% an hour. nothing but the fight. HP recovers 25% an hour.
- **Gear and tools:** smiths make weapons, copper and iron armour, and tools - **Tiers:** six RuneScape-style tiers (copper, iron, steel, mithril,
(pickaxe, axe, hoe, fishing rod, hunting knife) that make gathering 15–30% adamant, rune at levels 1–75) of ore, trees (up to magic) and hides (deer,
faster; leatherworkers turn hides into leather armour. 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 - **Classes:** warrior, scout, artisan, pioneer. Every class can train every
skill; a class makes some skills 20% faster and has one signature ability. 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 - **Bank:** 12 slots of up to 1,000 each to start. When something doesn't

3
app.py
View file

@ -361,6 +361,9 @@ class Handler(BaseHTTPRequestHandler):
return "That can't be equipped" return "That can't be equipped"
if player["bank"].get(item, 0) < 1: if player["bank"].get(item, 0) < 1:
return "You don't have one" return "You don't have one"
problem = game.equip_problem(player, item)
if problem:
return problem
game.bank_take(player, item, 1) game.bank_take(player, item, 1)
current = player["equip"][spec["slot"]] current = player["equip"][spec["slot"]]
if current: if current:

313
game.py
View file

@ -49,21 +49,11 @@ def level_for(xp: float) -> int:
ITEMS = { ITEMS = {
"stone": {"name": "Stone", "icon": "🪨", "value": 1}, "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}, "wheat": {"name": "Wheat", "icon": "🌾", "value": 1},
"vegetables": {"name": "Vegetables", "icon": "🥕", "value": 2}, "vegetables": {"name": "Vegetables", "icon": "🥕", "value": 2},
"feathers": {"name": "Feathers", "icon": "🪶", "value": 1}, "feathers": {"name": "Feathers", "icon": "🪶", "value": 1},
"raw_meat": {"name": "Raw meat", "icon": "🥩", "value": 2}, "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}, "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}, "bricks": {"name": "Bricks", "icon": "🧱", "value": 3},
"cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5}, "cut_stone": {"name": "Cut stone", "icon": "🔷", "value": 5},
"beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "🪜", "value": 25}, "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}, "bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1, "heal": 8},
"cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12}, "cooked_meat": {"name": "Cooked meat", "icon": "🍖", "value": 5, "food": 1, "heal": 12},
"stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20}, "stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2, "heal": 20},
"raw_shrimp": {"name": "Raw shrimp", "icon": "🦐", "value": 1}, "studded_body": {"name": "Studded jerkin", "icon": "🧥", "value": 40, "slot": "body", "armour": 6,
"raw_trout": {"name": "Raw trout", "icon": "🐟", "value": 3}, "req": ["combat", 10]},
"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},
} }
# --- 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. # Gathering tools: one slot per skill, `speed` cuts the action time.
TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"), TOOLS = {"mining": ("pickaxe", "⛏️"), "woodcutting": ("axe", "🪓"), "farming": ("hoe", "🪏"),
"fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")} "fishing": ("fishing rod", "🎣"), "hunting": ("hunting knife", "🔪")}
for _skill, (_tool, _icon) in TOOLS.items(): TOOL_SPEED = [0.15, 0.30, 0.38, 0.45, 0.52, 0.60]
for _metal, _value, _speed in (("copper", 14, 0.15), ("iron", 30, 0.3)):
ITEMS[f"{_metal}_{_tool.replace(' ', '_')}"] = { # item id, name, icon, value, action id, verb
"name": f"{_metal.title()} {_tool}", "icon": _icon, "value": _value, TREES = [
"slot": f"tool_{_skill}", "tool": _skill, "speed": _speed, ("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. # 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] EQUIP_SLOTS = ["weapon", "shield", "helmet", "body", "legs"] + [f"tool_{skill}" for skill in TOOLS]
SKILLS = [ 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} "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 = [ ACTIONS = [
_action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), _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_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}),
_action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 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_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("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("hunt_boar", "hunting", "Hunt boar", 25, 50, 30, outputs={"raw_meat": 2, "boar_hide": 1}),
_action("fish_trout", "fishing", "Fly-fish trout", 15, 35, 15, outputs={"raw_trout": 1}), _action("hunt_bear", "hunting", "Hunt bears", 40, 60, 48, outputs={"raw_meat": 3, "bear_pelt": 1}),
_action("fish_salmon", "fishing", "Catch salmon", 30, 45, 28, outputs={"raw_salmon": 1}), *_tiered_actions(),
_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("smith_arrows", "smithing", "Arrows", 5, 30, 10, _action("smith_arrows", "smithing", "Arrows", 5, 30, 10,
{"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Supplies"), {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}, group="Bars"),
_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}),
_action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, _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_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_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}),
_action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, _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_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}),
_action("cook_stew", "cooking", "Stew", 15, 35, 16, _action("cook_stew", "cooking", "Stew", 15, 35, 16,
{"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), {"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, _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]]), 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, _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, _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]]), 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, _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, _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, damage=10,
special="sabotage"), special="sabotage"),
_action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1),
@ -233,31 +321,37 @@ BUILDINGS = {
"houses": { "houses": {
"name": "Houses", "icon": "🏠", "max": 8, "gold": 200, "name": "Houses", "icon": "🏠", "max": 8, "gold": 200,
"base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 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", "effect": "Room for 20 more people per level",
}, },
"walls": { "walls": {
"name": "Walls", "icon": "🏰", "max": 5, "gold": 300, "name": "Walls", "icon": "🏰", "max": 5, "gold": 300,
"base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, "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", "effect": "+20 defence per level, and raiders carry off less",
}, },
"barracks": { "barracks": {
"name": "Barracks", "icon": "⛺", "max": 5, "gold": 300, "name": "Barracks", "icon": "⛺", "max": 5, "gold": 300,
"base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, "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", "effect": "Trains townsfolk as militia: more defence per citizen",
}, },
"smithy": { "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}, "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": { "spymaster": {
"name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400, "name": "Spymaster's guild", "icon": "🕵️", "max": 3, "gold": 400,
"base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, "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", "effect": "One more level of intel on every event per level",
}, },
"storehouse": { "storehouse": {
"name": "Storehouse", "icon": "📦", "max": 5, "gold": 200, "name": "Storehouse", "icon": "📦", "max": 5, "gold": 200,
"base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 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", "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: def build_cost(key: str, level: int) -> dict:
spec = BUILDINGS[key] spec = BUILDINGS[key]
cost = {item: qty * level for item, qty in spec["base"].items()} cost = {item: qty * level for item, qty in spec["base"].items()}
for item, qty in spec["advanced"].items(): # Later levels add materials from higher tiers: advanced from level 2, late from 3, final from 5.
if level > 1: for key, first in (("advanced", 2), ("late", 3), ("final", 5)):
cost[item] = cost.get(item, 0) + qty * (level - 1) 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 cost["gold"] = spec["gold"] * level
return cost 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: def defence(world: dict) -> dict:
"""The city's defence right now, part by part.""" """The city's defence right now, part by part."""
city = world["city"] city = world["city"]
pop = city["population"] pop = city["population"]
stock = city["stockpile"] stock = city["stockpile"]
armed = int(pop // 2) 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 = { parts = {
"townsfolk": 10, "townsfolk": 10,
"walls": 20 * building_level(world, "walls"), "walls": 20 * building_level(world, "walls"),
"militia": round(0.3 * building_level(world, "barracks") * pop, 1), "militia": round(0.3 * building_level(world, "barracks") * pop, 1),
"weapons": round(3 * iron + 1.5 * (swords - iron), 1), "weapons": round(0.35 * best_kit(stock, "weapon", "power", armed), 1),
"shields": 2 * min(armed, stock.get("iron_shield", 0)), "shields": round(0.5 * best_kit(stock, "shield", "armour", armed), 1),
"arrows": min(100, stock.get("arrows", 0) // 10), "arrows": min(100, stock.get("arrows", 0) // 10),
"rallied": city.get("rally", 0), "rallied": city.get("rally", 0),
"garrison": 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)))), ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))),
("bread", math.ceil(city["population"] * 1.5)), ("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)))) wants.append(("iron_shield", min(armed, math.ceil(strength / 10))))
requests = [] requests = []
for item, qty in wants: for item, qty in wants:
have = stock.get(item, 0) have = stock.get(item, 0)
if item == "copper_sword": if item in ("copper_sword", "iron_shield"):
have += stock.get("iron_sword", 0) have = kit_count(stock, ITEMS[item]["slot"])
if item == "bread": if item == "bread":
have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) 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 return requests

View file

@ -12,6 +12,7 @@ let skew = 0; // server clock minus client clock, seconds
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town";
let selected = null; // bank item let selected = null; // bank item
let pickedClass = null; // registration let pickedClass = null; // registration
const tabs = {}; // skill -> selected recipe tab
let refreshing = false; let refreshing = false;
let refreshedFor = null; // action start a completion refresh was sent for let refreshedFor = null; // action start a completion refresh was sent for
let version = null; // server build this page was loaded from let version = null; // server build this page was loaded from
@ -315,8 +316,18 @@ function skillPage(id) {
if (id === "combat") html += combatPanel(); if (id === "combat") html += combatPanel();
if (`tool_${id}` in me.equip) html += toolPanel(id); if (`tool_${id}` in me.equip) html += toolPanel(id);
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`; if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
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) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
return html + `<div class="tabs">${bar}</div><div class="grid">${actions.filter((a) => a.group === current).map(actionCard).join("")}</div>`;
}
let group = null, cards = ""; 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) { if (a.group && a.group !== group) {
group = a.group; group = a.group;
cards += `<div class="section-title grid-head">${esc(group)}</div>`; cards += `<div class="section-title grid-head">${esc(group)}</div>`;
@ -326,6 +337,12 @@ function skillPage(id) {
return html + `<div class="grid">${cards}</div>`; return html + `<div class="grid">${cards}</div>`;
} }
// 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) { function toolPanel(skill) {
const tool = S.me.equip[`tool_${skill}`]; const tool = S.me.equip[`tool_${skill}`];
return `<div class="panel tool-panel">${tool return `<div class="panel tool-panel">${tool
@ -365,7 +382,7 @@ function actionCard(a) {
const me = S.me; const me = S.me;
const active = me.action?.id === a.id; const active = me.action?.id === a.id;
const why = me.blocked[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 out = Object.keys(a.outputs)[0];
const icon = out ? item(out).icon : skillOf(a.skill).icon; const icon = out ? item(out).icon : skillOf(a.skill).icon;
const time = me.times[a.id]; const time = me.times[a.id];
@ -440,7 +457,7 @@ function itemPanel(id) {
<div class="ico">${it.icon}</div> <div class="ico">${it.icon}</div>
<h3>${esc(it.name)}</h3> <h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div> <div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${it.slot ? `<div class="row"><button class="btn gold" data-equip="${id}">${equipLabel(it)}</button></div>` : ""} ${it.slot ? equipRow(id, it) : ""}
${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div> ${it.heal ? `<div class="row"><div class="small muted">Heals ${it.heal} in fights.</div>
${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""} ${S.me.food === id ? `<span class="tag good">Your fighting food</span>` : `<button class="btn sm" data-food="${id}">Eat this first in fights</button>`}</div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""} ${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""}
@ -451,6 +468,13 @@ function itemPanel(id) {
</div>${goldPanel()}`; </div>${goldPanel()}`;
} }
function equipRow(id, it) {
const [skill, level] = it.req || [];
const short = skill && S.me.levels[skill] < level;
return `<div class="row"><button class="btn gold" data-equip="${id}" ${short ? "disabled" : ""}>${equipLabel(it)}</button>
${skill ? `<div class="small ${short ? "why" : "muted"}">Needs ${esc(skillOf(skill).name.toLowerCase())} level ${level}</div>` : ""}</div>`;
}
function equipLabel(it) { function equipLabel(it) {
if (it.tool) return `Use as ${skillOf(it.tool).name.toLowerCase()} tool (${Math.round(it.speed * 100)}% faster)`; 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(", "); 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 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 reqs = (ev.requests || []).map((r) => {
const mine = me.bank[r.item] || 0; const mine = me.bank[r.item] || 0;
return `<div class="req"><span>${item(r.item).icon}</span><span>${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span> return `<div class="req"><span>${item(r.item).icon}</span><span>${esc(r.label)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"} ${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`; <div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join(""); }).join("");
@ -812,6 +836,7 @@ document.addEventListener("click", (e) => {
if (d.food) return act("/api/food", { item: d.food }); if (d.food) return act("/api/food", { item: d.food });
if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.overflow) return act("/api/settings", { overflow: d.overflow });
if (d.class) { pickedClass = d.class; render(); return; } 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 (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) });
if ("devEvent" in d) return act("/api/dev/event", {}); if ("devEvent" in d) return act("/api/dev/event", {});
}); });

View file

@ -370,3 +370,7 @@ button.sheet-skill:hover { background: var(--panel-2); }
.favour-option:first-of-type { border-top: 0; } .favour-option:first-of-type { border-top: 0; }
.favour-option .ico { font-size: 24px; width: 30px; text-align: center; } .favour-option .ico { font-size: 24px; width: 30px; text-align: center; }
.favour-option .grow { display: flex; flex-direction: column; } .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; }

View file

@ -514,5 +514,64 @@ class FavourTest(unittest.TestCase):
self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 40 favour") 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__": if __name__ == "__main__":
unittest.main() unittest.main()