diff --git a/README.md b/README.md index 6ef6752..6e808df 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. ## What's in it - **Skills:** mining, woodcutting, farming, hunting, fishing, smithing, - masonry, cooking, leatherworking, combat, plus the scout's reconnaissance. + masonry, construction, cooking, leatherworking, herblore, combat, prayer and + reconnaissance. - **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. @@ -21,8 +22,14 @@ Alpha. One season, bandit raids plus two friendly events, six buildings. 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. +- **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, + 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. + 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 spills into another slot. When something doesn't fit it goes to the city stockpile (or the action stops, if you prefer). diff --git a/app.py b/app.py index 38e094b..e7bb4bc 100644 --- a/app.py +++ b/app.py @@ -117,7 +117,7 @@ def player_view(world: dict, player: dict, now: float) -> dict: 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) - view["recon_gain"] = {action["id"]: game.recon_gain(world, action) for action in game.ACTIONS + view["recon_gain"] = {action["id"]: game.recon_gain(world, player, action) for action in game.ACTIONS if action.get("special") == "recon"} return view @@ -361,6 +361,15 @@ class Handler(BaseHTTPRequestHandler): return None if path == "/api/favour": return game.spend_favour(world, player, str(body.get("kind")), now) + if path == "/api/class": + cls = body.get("cls") + if not player.get("repick"): + return "You've already chosen your trade" + if cls not in game.CLASSES: + return "Pick a class" + 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/perk": return game.buy_perk(player, str(body.get("perk"))) if path == "/api/settings": diff --git a/game.py b/game.py index bdeb2f7..657aec2 100644 --- a/game.py +++ b/game.py @@ -66,6 +66,13 @@ ITEMS = { "dragon_bones": {"name": "Dragon bones", "icon": "๐Ÿฆด", "value": 15}, "studded_body": {"name": "Studded jerkin", "icon": "๐Ÿงฅ", "value": 40, "slot": "body", "armour": 6, "req": ["combat", 10]}, + # Building materials from every trade: thatch and flax from the fields, rope + # twisted from flax, glue boiled from hides, and provisions to feed the builders. + "thatch": {"name": "Thatch", "icon": "๐ŸŒพ", "value": 2}, + "flax": {"name": "Flax", "icon": "๐ŸŒฟ", "value": 2}, + "rope": {"name": "Rope", "icon": "๐Ÿชข", "value": 8}, + "hide_glue": {"name": "Hide glue", "icon": "๐Ÿฏ", "value": 7}, + "provisions": {"name": "Provisions", "icon": "๐ŸŽ’", "value": 10}, } # --- tier ladders ----------------------------------------------------------- @@ -280,35 +287,46 @@ SKILLS = [ {"id": "herblore", "name": "Herblore", "icon": "๐Ÿงช", "group": "Crafting"}, {"id": "combat", "name": "Combat", "icon": "โš”๏ธ", "group": "Combat"}, {"id": "prayer", "name": "Prayer", "icon": "๐Ÿ™", "group": "Combat"}, - {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐Ÿ”ญ", "group": "Signature", "class": "scout"}, + {"id": "reconnaissance", "name": "Reconnaissance", "icon": "๐Ÿ”ญ", "group": "Combat"}, ] SKILL_IDS = [skill["id"] for skill in SKILLS] GATHERING = {"mining", "woodcutting", "farming", "hunting", "fishing"} -CRAFTING = {"smithing", "masonry", "cooking", "leatherworking", "herblore"} -SURVEYED = {"mining", "woodcutting", "farming"} # what the pioneer's double yield applies to +CRAFTING = {"smithing", "masonry", "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. +# Nothing is locked: every class can train every skill. CLASSES = { - "warrior": { - "name": "Warrior", "icon": "๐Ÿ›ก๏ธ", "fast": ["combat"], - "bonus": "Combat actions 20% faster", - "signature": "Command: counts for half again as much in the garrison", + "miner": { + "name": "Miner", "icon": "โ›๏ธ", "fast": ["mining", "smithing"], "craft": ["smithing"], + "bonus": "Mining and smithing 20% faster", + "masterwork": "Masterwork: 1 in 10 smithing crafts make double", + "signature": "Prospector: 1 in 10 mining trips bring back double", }, - "scout": { - "name": "Scout", "icon": "๐Ÿน", "fast": ["hunting", "fishing", "reconnaissance"], - "bonus": "Hunting and fishing 20% faster", - "signature": "Reconnaissance: scout the roads to sharpen intel on what's coming", + "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", + "signature": "Foreman: +1 labour every time you work a building site", }, - "artisan": { - "name": "Artisan", "icon": "โš’๏ธ", "fast": ["smithing", "masonry", "cooking", "leatherworking"], - "bonus": "Smithing, masonry, cooking and leatherworking 20% faster", - "signature": "Masterwork: 1 in 10 crafts makes double", + "homesteader": { + "name": "Homesteader", "icon": "๐ŸŒพ", "fast": ["farming", "cooking", "herblore"], + "craft": ["cooking", "herblore"], + "bonus": "Farming, cooking and herblore 20% faster", + "masterwork": "Masterwork: 1 in 10 cooking and herblore crafts make double", + "signature": "Steady hand: potions you drink last 25% longer", }, - "pioneer": { - "name": "Pioneer", "icon": "๐Ÿงญ", "fast": ["mining", "woodcutting", "farming", "construction"], - "bonus": "Mining, woodcutting, farming and construction 20% faster", - "signature": "Surveying: 1 in 10 gathers yields double", + "ranger": { + "name": "Ranger", "icon": "๐Ÿน", "fast": ["hunting", "fishing", "leatherworking", "cooking"], + "craft": ["leatherworking", "cooking"], + "bonus": "Hunting, fishing, leatherworking and cooking 20% faster", + "masterwork": "Masterwork: 1 in 10 leatherworking and cooking crafts make double", + "signature": "Pathfinder: your scouting gathers 50% more reconnaissance", }, } +# Classes from before the trades, as a starting point until their player re-picks. +OLD_CLASSES = {"pioneer": "miner", "artisan": "builder", "scout": "ranger", "warrior": "builder"} def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra): @@ -384,6 +402,14 @@ ACTIONS = [ _action("lw_studded", "leatherworking", "Studded jerkin", 25, 50, 40, {"leather": 4, "iron_bar": 1}, {"studded_body": 1}, group="Leather"), _action("labour", "construction", "Work on the building site", 1, 30, 10, special="labour"), + _action("twist_rope", "construction", "Twist rope", 5, 30, 10, {"flax": 3}, {"rope": 1}, group="Supplies"), + _action("grow_thatch", "farming", "Cut thatch reeds", 3, 30, 7, outputs={"thatch": 3}), + _action("grow_flax", "farming", "Grow flax", 10, 35, 11, outputs={"flax": 3}), + _action("boil_glue", "leatherworking", "Boil hide glue", 5, 30, 10, {"hide": 1, "logs": 1}, {"hide_glue": 2}, + group="Tanning"), + _action("pack_bread", "cooking", "Pack bread rations", 1, 20, 6, {"bread": 2}, {"provisions": 1}), + _action("pack_meat", "cooking", "Pack meat rations", 5, 20, 7, {"cooked_meat": 2}, {"provisions": 1}), + _action("pack_fish", "cooking", "Pack fish rations", 1, 20, 6, {"cooked_shrimp": 2}, {"provisions": 1}), *[_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)], @@ -431,62 +457,77 @@ ACTION_BY_ID = {action["id"]: action for action in ACTIONS} BUILDINGS = { "houses": { "name": "Houses", "icon": "๐Ÿ ", "max": 8, "gold": 200, - "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, - "late": {"willow_logs": 150, "oak_plank": 60}, "final": {"maple_logs": 100, "willow_plank": 60}, + "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}, "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, "oak_plank": 40}, "final": {"mithril_bar": 40, "maple_plank": 40}, + "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}, "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, "oak_plank": 50}, "final": {"mithril_sword": 6, "willow_plank": 50}, + "base": {"logs": 300, "bricks": 200, "thatch": 200, "leather": 80, "copper_sword": 25, "provisions": 80}, + "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}, "effect": "Trains townsfolk as militia: more defence per citizen", }, "smithy": { "name": "Smithy", "icon": "โš’๏ธ", "max": 5, "gold": 250, - "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, - "late": {"steel_bar": 80, "oak_plank": 40}, "final": {"mithril_bar": 60, "maple_plank": 40}, + "base": {"stone": 400, "logs": 250, "copper_bar": 180, "provisions": 60}, + "advanced": {"iron_bar": 180, "leather": 70}, + "late": {"steel_bar": 100, "oak_plank": 25, "hide_glue": 40}, + "final": {"mithril_bar": 70, "maple_plank": 20, "bear_leather": 35}, "effect": "Smithing 8% faster per level; each level unlocks a metal: iron, steel, mithril, adamant, rune", }, "temple": { "name": "Temple", "icon": "โ›ช", "max": 3, "gold": 300, - "base": {"stone": 800, "bricks": 400, "cut_stone": 100}, "advanced": {"beam": 20}, - "late": {"willow_logs": 150, "oak_plank": 60}, + "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}, "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": 400, "bricks": 200, "plank": 60}, "advanced": {"cut_stone": 80}, - "late": {"oak_plank": 60}, + "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}, "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": 300, "stone": 200, "plank": 80}, "advanced": {"oak_plank": 60}, - "late": {"willow_plank": 40}, + "base": {"logs": 200, "stone": 250, "plank": 50, "thatch": 180, "provisions": 40}, + "advanced": {"oak_plank": 40, "rope": 40, "iron_bar": 20}, + "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": 150}, "advanced": {"cut_stone": 80}, - "late": {"oak_plank": 40}, + "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}, "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": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, - "late": {"bear_leather": 40, "oak_plank": 40}, + "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}, "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": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, - "late": {"willow_logs": 200, "oak_plank": 80}, "final": {"yew_logs": 80, "yew_plank": 40}, + "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}, "effect": "+3 bank slots and +250 per stack for everyone, per level", }, } @@ -523,7 +564,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows # --- world --------------------------------------------------------------- -WORLD_VERSION = 11 +WORLD_VERSION = 12 def new_world(now: float, speed: float = 1.0) -> dict: @@ -633,6 +674,13 @@ def migrate(world: dict, now: float | None = None) -> dict: if world["version"] < 11: _migrate_potions(world) world["version"] = 11 + if world["version"] < 12: + # Classes became trades: everyone gets a stand-in and one free pick. + for player in world["players"].values(): + player["cls"] = OLD_CLASSES.get(player["cls"], player["cls"]) + player["repick"] = True + _return_overfunding(world) + world["version"] = 12 return world @@ -656,6 +704,24 @@ def _migrate_potions(world: dict) -> None: action[key] = f"brew_{OLD_POTIONS[old]}" +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"] + for key, building in city["buildings"].items(): + target = fund_target(world, key) or {} + for item, qty in list(building["fund"].items()): + extra = qty - target.get(item, 0) + if extra <= 0: + continue + building["fund"][item] = qty - extra + if not building["fund"][item]: + del building["fund"][item] + if item == "gold": + city["treasury"] += extra + else: + city["stockpile"][item] = city["stockpile"].get(item, 0) + extra + + def _sell_useless_stock(world: dict, now: float) -> None: """The council sells what the city can't use, into the treasury.""" city = world["city"] @@ -1006,7 +1072,8 @@ def use_potion(player: dict, item: str) -> str | None: bank_take(player, item, 1) current = player["potions"].get(spec["skill"]) charges = current["charges"] if current and current["item"] == item else 0 - player["potions"][spec["skill"]] = {"item": item, "charges": charges + POTION_CHARGES} + fresh = round(POTION_CHARGES * (1.25 if player["cls"] == "homesteader" else 1)) + player["potions"][spec["skill"]] = {"item": item, "charges": charges + fresh} return None @@ -1042,8 +1109,6 @@ def upcoming_event(world: dict) -> dict | None: def can_start(world: dict, player: dict, action: dict) -> str | None: """Why the action can't run right now, or None.""" - if action["skill"] == "reconnaissance" and player["cls"] != "scout": - return "Only scouts can do that" if skill_level(player, action["skill"]) < action["level"]: return f"Needs level {action['level']}" for key, level in action.get("city", {}).items(): @@ -1086,9 +1151,9 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R for item, qty in action["inputs"].items(): bank_take(player, item, qty) xp = action["xp"] - lucky = ( - (player["cls"] == "artisan" and action["skill"] in CRAFTING) - or (player["cls"] == "pioneer" and action["skill"] in SURVEYED) + cls = CLASSES[player["cls"]] + lucky = bool(action["outputs"]) and ( + action["skill"] in cls["craft"] or (player["cls"] == "miner" and action["skill"] == "mining") ) and rng.random() < LUCKY_CHANCE outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()} if lucky: @@ -1155,7 +1220,7 @@ def _complete(world: dict, player: dict, action: dict, now: float, rng: random.R # Once a threat is fully scouted, scouting only trains the skill (and keeps the informant's leads). if event and event["recon"] < intel_costs(event)[-1]: city = world["city"] - gain = recon_gain(world, action) + gain = recon_gain(world, player, action) # An informant's leads double the reconnaissance (and the XP) of whoever follows them up. boost = min(city.get("recon_boost", 0), gain) city["recon_boost"] = city.get("recon_boost", 0) - boost @@ -1598,7 +1663,7 @@ def defence(world: dict) -> dict: garrison = [] for player in world["players"].values(): if player["garrison"]: - power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1) + power = combat_power(player) garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)}) parts["garrison"] += power total = sum(parts.values()) @@ -1690,7 +1755,8 @@ def labour_needed(level: int, repair: bool) -> float: def labour_per_action(player: dict) -> float: - return 1 + skill_level(player, "construction") // 20 # one more labour every 20 levels + # One more labour every 20 levels, and one more for a builder. + return 1 + skill_level(player, "construction") // 20 + (player["cls"] == "builder") def _finish_construction(world: dict, now: float) -> None: @@ -1758,8 +1824,9 @@ SPYMASTER_AGENTS = 0.5 # recon per guild level per game hour, wi INFORMANT_RECON = 30 # boosted recon per informant: doubled recon and XP for the scout who works it -def recon_gain(world: dict, action: dict) -> float: - return (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster")) +def recon_gain(world: dict, player: dict, action: dict) -> float: + gain = (action["recon"] + fitted(world, "map_table")) * (1 + SPYMASTER_RECON * building_level(world, "spymaster")) + return gain * (1.5 if player["cls"] == "ranger" else 1) def intel_costs(event: dict) -> list[int]: @@ -2635,9 +2702,7 @@ def favours(world: dict, now: float) -> dict: "informant": { "cost": round(INFORMANT_FAVOUR * (1 + 0.5 * (raid or {}).get("informants", 0))), "why": "No news to dig into" if not raid else - "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else - "No scout in the guild to follow up the leads" - if not any(p["cls"] == "scout" for p in world["players"].values()) else None, + "Nothing more to learn" if raid["recon"] >= intel_costs(raid)[-1] else None, "recon": INFORMANT_RECON, "boost": round(city.get("recon_boost", 0)), }, @@ -2728,6 +2793,8 @@ GLYPHS = { "bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"), "dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"), "coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"), + "thatch": ("high-grass", "#d8c070"), "flax": ("flax", "#8fb0d8"), "rope": ("rope-coil", "#c9a66b"), + "hide_glue": ("honey-jar", "#c08a40"), "provisions": ("knapsack", "#a08050"), "raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"), "raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"), "cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"), @@ -2742,8 +2809,8 @@ SKILL_GLYPHS = { "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), "reconnaissance": ("spyglass", "#6f8fec"), } -CLASS_GLYPHS = {"warrior": ("black-knight-helm", "#c0c6d0"), "scout": ("bow-arrow", "#8fbf6a"), - "artisan": ("anvil-impact", "#e0a050"), "pioneer": ("compass", "#4fb3d9")} +CLASS_GLYPHS = {"miner": ("miner", "#d07a3c"), "builder": ("crane", "#d1a46a"), + "homesteader": ("farmer", "#8fbf6a"), "ranger": ("bow-arrow", "#5fae8a")} BUILDING_GLYPHS = { "houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"), "smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"), diff --git a/static/app.js b/static/app.js index 3309aa1..8ee5644 100644 --- a/static/app.js +++ b/static/app.js @@ -299,6 +299,7 @@ function eventWhen(ev, short = false) { } function pageHtml() { + if (S.me.repick) return repickPage(); if (page.startsWith("skill:")) { const id = page.slice(6); const skill = skillOf(id); @@ -450,7 +451,7 @@ function combatPanel() {

Equipment

${slot("weapon", "weapon")}${slot("shield", "shield")}${slot("helmet", "helmet")}${slot("body", "body armour")}${slot("legs", "leg armour")}
-

Power ${me.power} (level + weapon + shield + half your armour) ยท armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take${me.cls === "warrior" ? " ยท as a warrior you count ร—1.5 in the garrison" : ""}. Equip gear from the bank.

+

Power ${me.power} (level + weapon + shield + half your armour) ยท armour ${me.armour} takes ${Math.round(me.reduction * 100)}% off the damage you take. Equip gear from the bank.

`; } @@ -822,9 +823,9 @@ function council() { ${ev.range ? `Strength${ev.strength ?? `${ev.range[0]}โ€“${ev.range[1]}`}` : ""} ${ev.trait ? `Scouts say${esc(ev.trait)}` : ""} ${ev.camp ? `Their campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} - ${me.cls === "scout" || ev.recon ? `Reconnaissance${reconText(ev)}` : ""} + Reconnaissance${reconText(ev)} - ${ev.intel < 3 ? `

Better intel comes from the spymaster's guild and scouts' reconnaissance, and the sharpest only as the threat draws near.

` : ""}`; + ${ev.intel < 3 ? `

Better intel comes from the spymaster's guild and anyone's scouting (rangers are best at it), and the sharpest only as the threat draws near.

` : ""}`; if (threat) { const strength = ev.strength ?? (ev.range ? `${ev.range[0]}โ€“${ev.range[1]}` : "?"); html += `
Our defence${fmt(city.defence.total)}
vs
Raiders${strength}
`; @@ -863,7 +864,7 @@ function favourSpends() { }; return `

Spend favour on the city ยท you have ${FAVOUR} ${fmt(me.favour)}

${option("builders", "๐Ÿ—๏ธ", "Hire a master builder", f.builders.why ? esc(f.builders.why) + "." : `The next ${f.builders.labour} labour anyone puts into the site counts double, with double Construction XP${f.builders.boost ? ` (${fmt(f.builders.boost)} still to use)` : ""}. Costs more each time on the same site.`)} - ${option("informant", "๐Ÿ•ต๏ธ", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads for the scouts: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)} + ${option("informant", "๐Ÿ•ต๏ธ", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : `Leads to follow up: the next ${f.informant.recon} reconnaissance on the coming threat counts double, with double XP${f.informant.boost ? ` (${fmt(f.informant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)} ${option("sergeant", "๐Ÿ“ฏ", "Hire a drill sergeant", f.sergeant.why ? esc(f.sergeant.why) + "." : `The next ${f.sergeant.drill} drill points fighters put into the militia count double, with double combat XP${f.sergeant.boost ? ` (${fmt(f.sergeant.boost)} still to use)` : ""}. Costs more each time for the same threat.`)}
`; } @@ -886,7 +887,7 @@ function raidPrep(ev) {

Garrison

${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet.`}

${me.garrison ? `` - : ``} + : ``}

You can keep working while you're signed up. Victory earns the garrison favour and combat XP.

`; } @@ -932,7 +933,7 @@ function characterSheet(uid) { const skills = C.skills.filter((s) => !s.class || s.class === p.cls); const total = skills.reduce((sum, s) => sum + p.levels[s.id], 0); const days = Math.max(1, Math.ceil((serverNow() - p.joined) / 86400)); - const guard = Math.round(p.power * (p.cls === "warrior" ? 1.5 : 1)); + const guard = Math.round(p.power); const gearSlot = (slot, label, empty) => { const id = p.equip[slot]; @@ -983,6 +984,7 @@ function characterSheet(uid) {

Class

${cls.icon} ${cls.name}

${esc(cls.bonus)}.

+

${esc(cls.masterwork)}.

${esc(cls.signature)}.

Equipment

@@ -998,11 +1000,28 @@ function characterSheet(uid) { // --- registration --------------------------------------------------------- -function register() { - const cards = Object.entries(C.classes).map(([id, c]) => ` +function classCards() { + return Object.entries(C.classes).map(([id, c]) => ` `).join(""); +} + +// After the classes became trades, everyone picks once more. Nothing is lost. +function repickPage() { + return `
+

Pick your trade

+

Classes are now whole trades, from gathering through to the finished goods, so you can play + the quiet gathering side and the busier crafting side yourself. You keep every level and item. This choice + is free, once.

+
${classCards()}
+
+

Every class can still train every skill, scout and drill. A trade makes its skills faster and better.

+
`; +} + +function register() { + const cards = classCards(); return `

A new city on the frontier

You're one of a handful of adventurers settling a young city at the edge of the kingdom. @@ -1013,7 +1032,7 @@ function register() { -

Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.

+

Every class can train every skill. Your class is a trade: its skills are faster, its crafts sometimes make double, and it has one bonus of its own.

`; } @@ -1093,6 +1112,7 @@ document.addEventListener("click", (e) => { } if (d.overflow) return act("/api/settings", { overflow: d.overflow }); if (d.class) { pickedClass = d.class; render(); return; } + if ("repick" in d) return act("/api/class", { cls: pickedClass }); 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/icons.svg b/static/icons.svg index 042873b..a4a68c6 100644 --- a/static/icons.svg +++ b/static/icons.svg @@ -4,7 +4,6 @@ - @@ -13,7 +12,6 @@ - @@ -31,9 +29,9 @@ - + @@ -41,12 +39,14 @@ + + @@ -56,17 +56,21 @@ + + + + @@ -76,6 +80,7 @@ + diff --git a/tests/test_game.py b/tests/test_game.py index 5244e5f..c07cf6a 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -8,6 +8,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import game # noqa: E402 T0 = 1_800_000_000.0 +# A test-only class with no bonuses, so tests of the rules aren't skewed by a trade. +game.CLASSES.setdefault("commoner", {"name": "Commoner", "icon": "", "fast": [], "craft": [], + "bonus": "", "masterwork": "", "signature": "", "glyph": "farmer", "tint": ""}) def world_with(*players): @@ -28,7 +31,7 @@ class XpTest(unittest.TestCase): class ActionTest(unittest.TestCase): def test_gathering_runs_while_away(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] self.assertIsNone(game.start_action(world, p, "mine_stone", T0)) summary = game.process_player(world, p, T0 + 200, random.Random(1)) @@ -38,7 +41,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["action"]["start"], T0 + 200) def test_make_one_then_back_to_work(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"copper_bar": 10, "logs": 10} game.start_action(world, p, "mine_stone", T0) @@ -52,7 +55,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["bank"]["stone"], 5 + 2) # mining carried on from when the pickaxe was done def test_make_one_while_idle_just_stops(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] game.start_action(world, p, "cut_logs", T0, once=True) game.process_player(world, p, T0 + 500, random.Random(1)) @@ -61,7 +64,7 @@ class ActionTest(unittest.TestCase): self.assertIsNone(p["stopped"]) def test_class_speed_and_lucky_yield(self): - world = world_with(("pia", "pioneer")) + world = world_with(("pia", "miner")) p = world["players"]["pia"] game.start_action(world, p, "mine_stone", T0) summary = game.process_player(world, p, T0 + 1600, random.Random(3)) @@ -70,7 +73,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"]) def test_offline_cap(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] game.start_action(world, p, "cut_logs", T0) summary = game.process_player(world, p, T0 + 3 * game.DAY, random.Random(1)) @@ -78,7 +81,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["action"]["start"], T0 + 3 * game.DAY) def test_crafting_stops_when_inputs_run_out(self): - world = world_with(("ann", "warrior")) + world = world_with(("ann", "commoner")) p = world["players"]["ann"] p["bank"] = {"copper_ore": 3, "logs": 10} game.start_action(world, p, "smith_copper_bar", T0) @@ -89,16 +92,16 @@ class ActionTest(unittest.TestCase): self.assertEqual(summary["stopped"], "Out of copper ore") def test_level_and_city_requirements(self): - world = world_with(("ann", "warrior")) + world = world_with(("ann", "commoner")) p = world["players"]["ann"] self.assertEqual(game.start_action(world, p, "mine_iron", T0), "Needs level 15") p["xp"]["smithing"] = game.XP_TABLE[30] p["bank"] = {"iron_bar": 10} self.assertEqual(game.start_action(world, p, "smith_iron_sword", T0), "Needs Smithy level 1") - self.assertEqual(game.start_action(world, p, "recon_roads", T0), "Only scouts can do that") + self.assertIsNone(game.can_start(world, p, game.ACTION_BY_ID["recon_roads"])) # anyone can scout def test_full_bank_overflows_to_city(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"stone": game.BASE_STACK * game.BASE_SLOTS - 2} game.start_action(world, p, "mine_stone", T0) @@ -110,7 +113,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["donated"], 3) def test_full_bank_sells_what_the_city_cannot_use(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"copper_ore": game.BASE_STACK * game.BASE_SLOTS - 1} game.start_action(world, p, "mine_copper", T0) @@ -121,7 +124,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(p["favour"], 0) def test_only_useful_things_can_be_donated(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"copper_ore": 10, "bricks": 10, "bread": 5, "iron_helmet": 1, "steel_sword": 1} self.assertEqual(game.donate(world, p, "copper_ore", 10, T0), "The city has no use for that") @@ -132,7 +135,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(game.city_uses("bread"), ["the granary"]) def test_a_full_stack_spills_into_another_slot(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"coal": game.BASE_STACK, "logs": 10} self.assertEqual(game.slots_used(world, p), 2) @@ -143,7 +146,7 @@ class ActionTest(unittest.TestCase): self.assertEqual(game.bank_room(world, p, "coal"), 500 + (game.BASE_SLOTS - 4) * game.BASE_STACK) def test_full_bank_can_stop_instead(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["overflow"] = "stop" p["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)} @@ -153,11 +156,11 @@ class ActionTest(unittest.TestCase): self.assertNotIn("stone", p["bank"]) def test_combat_pays_and_loses(self): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["bank"] = {"bread": 500} game.start_action(world, p, "fight_wolves", T0) - summary = game.process_player(world, p, T0 + 48 * 100, random.Random(5)) + summary = game.process_player(world, p, T0 + 60 * 100, random.Random(5)) self.assertEqual(summary["done"], 100) self.assertEqual(summary["wins"] + summary["losses"], 100) self.assertEqual(p["gold"], summary["wins"] * 2) @@ -165,7 +168,7 @@ class ActionTest(unittest.TestCase): class GiftTest(unittest.TestCase): def test_give_moves_items_within_bank_room(self): - world = world_with(("fred", "pioneer"), ("kay", "artisan")) + world = world_with(("fred", "miner"), ("kay", "miner")) fred, kay = world["players"]["fred"], world["players"]["kay"] fred["bank"] = {"copper_ore": 300} self.assertIsNone(game.give(world, fred, kay, "copper_ore", 200, T0)) @@ -184,17 +187,19 @@ class GiftTest(unittest.TestCase): class CityTest(unittest.TestCase): def test_fund_from_bank_and_stores_then_builds(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] p["bank"] = {"stone": 1500} - city["stockpile"] = {"bricks": 600} + city["stockpile"] = {"bricks": 600, "provisions": 90} city["treasury"] = 300 self.assertIsNone(game.fund(world, p, "walls", "stone", "all", "bank", T0)) - self.assertEqual(p["bank"], {"stone": 300}) # only what the fund needed - self.assertEqual(p["favour"], 1200) + self.assertEqual(p["bank"], {"stone": 500}) # only what the fund needed + self.assertEqual(p["favour"], 1000) self.assertIsNone(game.fund(world, p, "walls", "bricks", 500, "city", T0)) - self.assertEqual(city["stockpile"], {"bricks": 100}) + self.assertEqual(city["stockpile"], {"bricks": 350, "provisions": 90}) + self.assertIsNone(game.fund(world, p, "walls", "provisions", "all", "city", T0)) + self.assertEqual(city["stockpile"], {"bricks": 350}) self.assertIsNone(city["construction"]) self.assertEqual(game.fund(world, p, "walls", "stone", 1, "bank", T0), "The fund doesn't need that") self.assertIsNone(game.fund(world, p, "walls", "gold", "all", "city", T0)) @@ -207,7 +212,7 @@ class CityTest(unittest.TestCase): self.assertEqual(game.defence(world)["parts"]["walls"], 20) def test_funded_buildings_queue_behind_the_builders(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] for key in ("walls", "houses"): @@ -222,7 +227,7 @@ class CityTest(unittest.TestCase): self.assertEqual(city["queue"], []) def test_raids_never_touch_funds(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) city = world["city"] city["buildings"]["walls"]["fund"] = {"stone": 1000} city["stockpile"] = {"stone": 1000} @@ -235,7 +240,7 @@ class CityTest(unittest.TestCase): self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 1000}) def test_tier_caps_levels_and_promotes_for_good(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) city = world["city"] city["buildings"]["walls"]["level"] = 1 self.assertIsNone(game.fund_target(world, "walls")) # hamlet: level 1 only @@ -252,7 +257,7 @@ class CityTest(unittest.TestCase): self.assertEqual(city["tier"], 1) def test_damage_turns_the_fund_towards_repairs_and_keeps_the_rest(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] city["buildings"]["walls"].update(level=1, damaged=True, fund={"stone": 2000}) @@ -336,7 +341,7 @@ class CityTest(unittest.TestCase): def test_speed_scales_time(self): world = game.new_world(T0, speed=60) - world["players"]["fred"] = game.new_player("fred", "Fred", "warrior", T0) + world["players"]["fred"] = game.new_player("fred", "Fred", "commoner", T0) p = world["players"]["fred"] game.start_action(world, p, "mine_stone", T0) game.process_player(world, p, T0 + 10, random.Random(1)) @@ -361,7 +366,7 @@ class EventTest(unittest.TestCase): self.assertGreaterEqual(view["latest"], raid["fire_at"]) def test_recon_sharpens_intel_as_the_raid_draws_near(self): - world = world_with(("sam", "scout")) + world = world_with(("sam", "ranger")) raid = self.announce(world) p = world["players"]["sam"] p["xp"]["reconnaissance"] = game.XP_TABLE[20] @@ -382,14 +387,14 @@ class EventTest(unittest.TestCase): self.assertTrue(view["camp"]) def test_scouting_stops_counting_once_the_threat_is_fully_scouted(self): - world = world_with(("sam", "scout")) + world = world_with(("sam", "commoner")) raid = self.announce(world) full = game.intel_costs(raid)[-1] raid["recon"] = full - 1 world["city"]["recon_boost"] = 30 p = world["players"]["sam"] game.start_action(world, p, "recon_roads", T0) - game.process_player(world, p, T0 + 48 * 3 + 1, random.Random(1)) + game.process_player(world, p, T0 + 60 * 3 + 1, random.Random(1)) self.assertEqual(raid["recon"], full) self.assertEqual(world["city"]["recon_boost"], 29) # leads only spent while there's something to learn self.assertIsNotNone(p["action"]) # still trains the skill @@ -404,17 +409,17 @@ class EventTest(unittest.TestCase): self.assertEqual(game.intel_costs(dragon), [8, 22, 45]) def test_the_spymasters_guild_boosts_and_gathers_recon(self): - world = world_with() + world = world_with(("sam", "commoner")) raid = self.announce(world) - action = game.ACTION_BY_ID["recon_roads"] - self.assertEqual(game.recon_gain(world, action), 1) + action, p = game.ACTION_BY_ID["recon_roads"], world["players"]["sam"] + self.assertEqual(game.recon_gain(world, p, action), 1) world["city"]["buildings"]["spymaster"]["level"] = 2 - self.assertEqual(game.recon_gain(world, action), 1.5) + self.assertEqual(game.recon_gain(world, p, action), 1.5) game.tick(world, T0 + 2 * game.HOUR, random.Random(2)) self.assertAlmostEqual(raid["recon"], 2.0) def test_victory_rewards_garrison(self): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) raid = self.announce(world) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[60] @@ -427,7 +432,7 @@ class EventTest(unittest.TestCase): self.assertFalse(p["garrison"]) def test_defeat_culls_and_loots_but_spares_banks(self): - world = world_with(("fred", "warrior")) + world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"stone": 500} city = world["city"] @@ -453,7 +458,7 @@ class EventTest(unittest.TestCase): self.assertLess(qty, full[item]) def test_sabotage_needs_a_located_camp(self): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[40] p["hp"] = game.max_hp(p) @@ -471,7 +476,7 @@ class EventTest(unittest.TestCase): class CombatTest(unittest.TestCase): def fighter(self, level=10, **equip): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[level] p["hp"] = game.max_hp(p) @@ -524,7 +529,7 @@ class CombatTest(unittest.TestCase): class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self): - world = world_with(("pia", "warrior")) + world = world_with(("pia", "commoner")) p = world["players"]["pia"] plain = game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]) p["equip"]["tool_mining"] = "iron_pickaxe" @@ -532,19 +537,19 @@ class NewSkillsTest(unittest.TestCase): self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain) def test_fish_to_plate(self): - world = world_with(("sam", "scout")) + world = world_with(("sam", "ranger")) p = world["players"]["sam"] game.start_action(world, p, "fish_shrimp", T0) game.process_player(world, p, T0 + 200, random.Random(1)) self.assertEqual(p["bank"]["raw_shrimp"], 10) # 20s with the scout bonus p["bank"]["logs"] = 10 game.start_action(world, p, "cook_shrimp", T0 + 200) - game.process_player(world, p, T0 + 1000, random.Random(1)) - self.assertEqual(p["bank"]["cooked_shrimp"], 10) + summary = game.process_player(world, p, T0 + 1000, random.Random(1)) + self.assertEqual(p["bank"]["cooked_shrimp"], 10 + summary["lucky"]) # rangers cook their catch, sometimes double self.assertIn("cooked_shrimp", game.FOOD_ORDER) def test_hides_to_armour(self): - world = world_with(("kay", "artisan")) + world = world_with(("kay", "miner")) p = world["players"]["kay"] p["bank"] = {"hide": 4} game.start_action(world, p, "tan_leather", T0) @@ -554,7 +559,7 @@ class NewSkillsTest(unittest.TestCase): self.assertIsNone(game.start_action(world, p, "lw_body", T0 + 1000)) def test_v2_worlds_gain_hp_and_slots(self): - world = world_with(("fred", "pioneer")) + world = world_with(("fred", "miner")) p = world["players"]["fred"] world["version"] = 2 for key in ("hp", "hp_at", "food"): @@ -572,7 +577,7 @@ class NewSkillsTest(unittest.TestCase): class FavourTest(unittest.TestCase): def setUp(self): - self.world = world_with(("fred", "pioneer")) + self.world = world_with(("fred", "miner")) self.p = self.world["players"]["fred"] self.p["favour"] = 5000 @@ -585,7 +590,7 @@ class FavourTest(unittest.TestCase): self.assertEqual(game.favours(world, T0)["builders"]["cost"], 225) self.assertEqual(world["city"]["construction"]["labour"], 0) # nothing is built for you game.start_action(world, p, "labour", T0) - summary = game.process_player(world, p, T0 + 24 * 60, random.Random(1)) # pioneer: 24s an action + summary = game.process_player(world, p, T0 + 30 * 60, random.Random(1)) # 30s an action self.assertEqual(summary["done"], 60) self.assertEqual(summary["labour"], 60 + 50) # the first 50 labour counted double self.assertEqual(world["city"]["site_boost"], 0) @@ -610,13 +615,12 @@ class FavourTest(unittest.TestCase): world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) event = game.upcoming_raid(world) - self.assertEqual(game.spend_favour(world, p, "informant", T0), "No scout in the guild to follow up the leads") - world["players"]["sam"] = scout = game.new_player("sam", "Sam", "scout", T0) + world["players"]["sam"] = scout = game.new_player("sam", "Sam", "commoner", T0) self.assertIsNone(game.spend_favour(world, p, "informant", T0)) self.assertEqual(event["recon"], 0) # leads, not intel self.assertEqual(game.favours(world, T0)["informant"]["cost"], 375) game.start_action(world, scout, "recon_roads", T0) - game.process_player(world, scout, T0 + 48 * 10 + 1, random.Random(1)) # ten scouting runs + game.process_player(world, scout, T0 + 60 * 10 + 1, random.Random(1)) # ten scouting runs self.assertEqual(event["recon"], 20) self.assertEqual(scout["xp"]["reconnaissance"], 240) self.assertEqual(world["city"]["recon_boost"], 20) @@ -675,7 +679,7 @@ class FavourTest(unittest.TestCase): class TierTest(unittest.TestCase): def test_steel_needs_coal_and_the_smithy(self): - world = world_with(("kay", "artisan")) + world = world_with(("kay", "miner")) p = world["players"]["kay"] p["xp"]["smithing"] = game.XP_TABLE[30] p["bank"] = {"iron_ore": 5, "coal": 10} @@ -697,7 +701,7 @@ class TierTest(unittest.TestCase): self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses") def test_gear_needs_levels_to_wear(self): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) 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") @@ -715,12 +719,12 @@ 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"], 60) - self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 40) - self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) + 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) def test_dragons_drop_dragonhide(self): - world = world_with(("wil", "warrior")) + world = world_with(("wil", "commoner")) 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") @@ -734,7 +738,7 @@ class TierTest(unittest.TestCase): class PrayerTest(unittest.TestCase): def priest(self, level=1, combat=10): - world = world_with(("pat", "warrior")) + world = world_with(("pat", "commoner")) p = world["players"]["pat"] p["xp"]["prayer"] = game.XP_TABLE[level] p["xp"]["combat"] = game.XP_TABLE[combat] @@ -802,7 +806,7 @@ class PrayerTest(unittest.TestCase): self.assertEqual(game.start_action(world, p, "offer_bones", T0), "Needs Temple level 1") def test_v4_worlds_get_prayer(self): - world = world_with(("fred", "pioneer")) + world = world_with(("fred", "miner")) world["version"] = 4 p = world["players"]["fred"] del p["pp"], p["prayers"], p["xp"]["prayer"] @@ -815,7 +819,7 @@ class PrayerTest(unittest.TestCase): class HerbloreTest(unittest.TestCase): def setUp(self): - self.world = world_with(("hal", "warrior")) + self.world = world_with(("hal", "commoner")) self.p = self.world["players"]["hal"] def test_herbs_to_potion(self): @@ -901,7 +905,7 @@ class HerbloreTest(unittest.TestCase): self.assertIn(action, game.ACTION_BY_ID) def test_v5_worlds_get_herblore(self): - world = world_with(("fred", "pioneer")) + world = world_with(("fred", "miner")) world["version"] = 5 p = world["players"]["fred"] del p["potions"], p["xp"]["herblore"] @@ -912,7 +916,7 @@ class HerbloreTest(unittest.TestCase): class ConstructionTest(unittest.TestCase): def site(self): - world = world_with(("bob", "pioneer"), ("ann", "warrior")) + world = world_with(("bob", "miner"), ("ann", "commoner")) world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360} world["next_event_at"] = None return world @@ -950,7 +954,7 @@ class ConstructionTest(unittest.TestCase): self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["labour"]), plain * 0.7) def test_no_site_no_work(self): - world = world_with(("ann", "warrior")) + world = world_with(("ann", "commoner")) self.assertEqual(game.start_action(world, world["players"]["ann"], "labour", T0), "Nothing is being built") def test_planks_and_hammers_exist_for_every_tier(self): @@ -961,7 +965,7 @@ class ConstructionTest(unittest.TestCase): self.assertIn("oak_plank", game.build_cost("houses", 3)) def test_v6_timer_becomes_labour(self): - world = world_with(("fred", "pioneer")) + world = world_with(("fred", "miner")) world["version"] = 6 p = world["players"]["fred"] del p["xp"]["construction"], p["equip"]["tool_construction"] @@ -977,7 +981,7 @@ class ConstructionTest(unittest.TestCase): class HappeningsTest(unittest.TestCase): def setUp(self): - self.world = world_with(("ann", "warrior"), ("sam", "scout")) + self.world = world_with(("ann", "commoner"), ("sam", "ranger")) self.world["next_event_at"] = None for track in ("minor", "hardship", "fortune"): self.world[f"next_{track}_at"] = None @@ -1156,7 +1160,7 @@ class HappeningsTest(unittest.TestCase): class WearTest(unittest.TestCase): def setUp(self): - self.world = world_with(("fred", "warrior")) + self.world = world_with(("fred", "commoner")) self.city = self.world["city"] self.city["population"] = 40.0 # 20 defenders @@ -1186,9 +1190,65 @@ class WearTest(unittest.TestCase): self.assertIn("broken in the fighting", text) +class ClassTest(unittest.TestCase): + def lucky_run(self, cls, action_id, bank): + world = world_with(("pat", cls)) + p = world["players"]["pat"] + p["xp"] = {skill: game.XP_TABLE[50] for skill in p["xp"]} + p["bank"] = dict(bank) + game.start_action(world, p, action_id, T0) + return p, game.process_player(world, p, T0 + 3 * game.HOUR, random.Random(3)) + + def test_every_trade_sometimes_crafts_double(self): + for cls, action, bank in (("miner", "smith_copper_bar", {"copper_ore": 300, "logs": 300}), + ("builder", "saw_plank", {"logs": 600}), + ("homesteader", "cook_bread", {"wheat": 600, "logs": 300}), + ("ranger", "cook_shrimp", {"raw_shrimp": 300, "logs": 300})): + _p, summary = self.lucky_run(cls, action, bank) + self.assertGreater(summary["lucky"], 0, cls) + _p, summary = self.lucky_run("commoner", action, bank) + self.assertEqual(summary["lucky"], 0, cls) + + def test_trade_bonuses(self): + _p, summary = self.lucky_run("miner", "mine_stone", {}) + self.assertGreater(summary["lucky"], 0) # prospector + _p, summary = self.lucky_run("builder", "mine_stone", {}) + self.assertEqual(summary["lucky"], 0) + builder = game.new_player("b", "B", "builder", T0) + self.assertEqual(game.labour_per_action(builder), 2) # foreman + home = game.new_player("h", "H", "homesteader", T0) + home["bank"] = {"mining_potion_1": 1} + game.use_potion(home, "mining_potion_1") + self.assertEqual(home["potions"]["mining"]["charges"], 62) # steady hand + world = world_with() + ranger = game.new_player("r", "R", "ranger", T0) + self.assertEqual(game.recon_gain(world, ranger, game.ACTION_BY_ID["recon_roads"]), 1.5) # pathfinder + self.assertEqual(game.action_time(world, ranger, game.ACTION_BY_ID["cook_shrimp"]), + game.ACTION_BY_ID["cook_shrimp"]["time"] * game.CLASS_SPEED) + + def test_old_classes_become_trades_with_a_free_pick(self): + world = world_with() + world["version"] = 11 + for uid, cls in (("a", "pioneer"), ("b", "artisan"), ("c", "scout"), ("d", "warrior")): + world["players"][uid] = game.new_player(uid, uid, "miner", T0) + world["players"][uid]["cls"] = cls + game.migrate(world, T0) + self.assertEqual([p["cls"] for p in world["players"].values()], ["miner", "builder", "ranger", "builder"]) + self.assertTrue(all(p["repick"] for p in world["players"].values())) + + def test_overfunded_buildings_return_the_extra(self): + world = world_with() + world["version"] = 11 + world["city"]["buildings"]["houses"]["fund"] = {"bricks": 400, "logs": 100} + world["city"]["stockpile"] = {} + game.migrate(world, T0) + self.assertEqual(world["city"]["buildings"]["houses"]["fund"], {"bricks": 150, "logs": 100}) + self.assertEqual(world["city"]["stockpile"], {"bricks": 250}) + + class FittingTest(unittest.TestCase): def setUp(self): - self.world = world_with(("fred", "artisan")) + self.world = world_with(("fred", "miner")) self.p = self.world["players"]["fred"] self.buildings = self.world["city"]["buildings"]