import random import sys import unittest from pathlib import Path 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): world = game.new_world(T0) for uid, cls in players: world["players"][uid] = game.new_player(uid, uid.title(), cls, T0) return world class XpTest(unittest.TestCase): def test_curve_matches_runescape(self): self.assertEqual(game.XP_TABLE[2], 83) self.assertEqual(game.XP_TABLE[99], 13034431) self.assertEqual(game.level_for(0), 1) self.assertEqual(game.level_for(83), 2) self.assertEqual(game.level_for(10**9), 99) class ActionTest(unittest.TestCase): def test_gathering_runs_while_away(self): 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)) self.assertEqual(summary["done"], 10) self.assertEqual(p["bank"]["stone"], 10) self.assertEqual(p["xp"]["mining"], 50) self.assertEqual(p["action"]["start"], T0 + 200) def test_make_one_then_back_to_work(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"copper_bar": 10, "logs": 10} game.start_action(world, p, "mine_stone", T0) game.process_player(world, p, T0 + 100, random.Random(1)) p["xp"]["smithing"] = game.XP_TABLE[5] self.assertIsNone(game.start_action(world, p, "smith_copper_pickaxe", T0 + 100, once=True)) summary = game.process_player(world, p, T0 + 100 + 30 + 40, random.Random(1)) self.assertEqual(p["bank"]["copper_pickaxe"], 1) self.assertEqual(summary["resumed"], "mine_stone") self.assertEqual(p["action"]["id"], "mine_stone") 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", "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)) self.assertEqual(p["bank"], {"logs": 1}) self.assertIsNone(p["action"]) self.assertIsNone(p["stopped"]) def test_class_speed_and_lucky_yield(self): 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)) self.assertEqual(summary["done"], 100) # 16s instead of 20s self.assertGreater(p["bank"]["stone"], 100) self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"]) def test_offline_cap(self): 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)) self.assertEqual(summary["done"], game.OFFLINE_CAP // 20) self.assertEqual(p["action"]["start"], T0 + 3 * game.DAY) def test_crafting_stops_when_inputs_run_out(self): 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) summary = game.process_player(world, p, T0 + 1000, random.Random(1)) self.assertEqual(p["bank"]["copper_bar"], 3) self.assertNotIn("copper_ore", p["bank"]) self.assertIsNone(p["action"]) self.assertEqual(summary["stopped"], "Out of copper ore") def test_crafting_stops_as_the_last_one_finishes(self): world = world_with(("ann", "commoner")) p = world["players"]["ann"] p["bank"] = {"copper_ore": 1, "logs": 10} game.start_action(world, p, "smith_copper_bar", T0) duration = game.action_time(world, p, game.ACTION_BY_ID["smith_copper_bar"]) summary = game.process_player(world, p, T0 + duration + 0.1, random.Random(1)) self.assertIsNone(p["action"]) self.assertEqual(summary["stopped"], "Out of copper ore") def test_level_and_city_requirements(self): 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.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", "commoner")) p = world["players"]["fred"] p["bank"] = {"stone": game.BASE_STACK * game.BASE_SLOTS - 2} game.start_action(world, p, "mine_stone", T0) summary = game.process_player(world, p, T0 + 100, random.Random(1)) self.assertEqual(p["bank"]["stone"], game.BASE_STACK * game.BASE_SLOTS) self.assertEqual(summary["overflow"], {"stone": 3}) self.assertEqual(world["city"]["stockpile"]["stone"], 3) self.assertEqual(p["favour"], 1.5) # half favour for overflow self.assertEqual(p["donated"], 3) def test_full_bank_sells_what_the_city_cannot_use(self): 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) summary = game.process_player(world, p, T0 + 100, random.Random(1)) self.assertEqual(summary["sold"], {"copper_ore": 3}) self.assertEqual(p["gold"], 3 * game.ITEMS["copper_ore"]["value"]) self.assertNotIn("copper_ore", world["city"]["stockpile"]) self.assertEqual(p["favour"], 0) def test_only_useful_things_can_be_donated(self): 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") self.assertEqual(game.donate(world, p, "iron_helmet", 1, T0), "The city has no use for that") for item, qty in (("bricks", 10), ("bread", 5), ("steel_sword", 1)): self.assertIsNone(game.donate(world, p, item, qty, T0)) self.assertIn("Walls", game.city_uses("bricks")) self.assertEqual(game.city_uses("bread"), ["the granary"]) def test_a_full_stack_spills_into_another_slot(self): 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) self.assertEqual(game.bank_room(world, p, "coal"), (game.BASE_SLOTS - 2) * game.BASE_STACK) self.assertEqual(game.bank_room(world, p, "logs"), game.BASE_STACK - 10 + (game.BASE_SLOTS - 2) * game.BASE_STACK) p["bank"]["coal"] = 2500 self.assertEqual(game.slots_used(world, p), 4) # 1000 + 1000 + 500, plus the logs 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", "commoner")) p = world["players"]["fred"] p["overflow"] = "stop" p["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)} game.start_action(world, p, "mine_stone", T0) summary = game.process_player(world, p, T0 + 100, random.Random(1)) self.assertEqual(summary["stopped"], "Bank full") self.assertNotIn("stone", p["bank"]) def test_combat_pays_and_loses(self): 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 + 3600, random.Random(5)) self.assertGreater(summary["done"], 50) # a fight lasts as long as it takes self.assertEqual(summary["wins"] + summary["losses"], summary["done"]) low, high = game.ACTION_BY_ID["fight_wolves"]["gold"] self.assertTrue(summary["wins"] * low <= p["gold"] <= summary["wins"] * high) class GiftTest(unittest.TestCase): def test_give_moves_items_within_bank_room(self): 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)) self.assertEqual(fred["bank"], {"copper_ore": 100}) self.assertEqual(kay["bank"], {"copper_ore": 200}) self.assertEqual(world["ledger"][-1]["text"], "Fred gave 200 copper ore to Kay") kay["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)} self.assertEqual(game.give(world, fred, kay, "copper_ore", 1, T0), "Kay only has room for 0") self.assertEqual(game.give(world, fred, fred, "copper_ore", 1, T0), "You can't give to yourself") def test_amount_pluralises(self): self.assertEqual(game.amount("copper_sword", 179), "179 copper swords") self.assertEqual(game.amount("copper_sword", 1), "1 copper sword") self.assertEqual(game.amount("stone", 1200), "1,200 stone") class CityTest(unittest.TestCase): def test_fund_from_bank_and_stores_then_builds(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] p["bank"] = {"stone": 1500} 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": 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": 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)) self.assertEqual(city["construction"]["building"], "walls") self.assertEqual(city["buildings"]["walls"]["fund"], {}) self.assertEqual(city["construction"]["needed"], game.LABOUR_PER_LEVEL) city["construction"]["labour"] = game.LABOUR_PER_LEVEL game.tick(world, T0 + 1, random.Random(1)) self.assertEqual(city["buildings"]["walls"]["level"], 1) self.assertEqual(game.defence(world)["parts"]["walls"], 20) def test_funded_buildings_queue_behind_the_builders(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] for key in ("walls", "houses"): city["buildings"][key]["fund"] = {k: v for k, v in game.build_cost(key, 1).items() if k != "gold"} city["treasury"] += game.build_cost(key, 1)["gold"] self.assertIsNone(game.fund(world, p, key, "gold", "all", "city", T0)) self.assertEqual(city["construction"]["building"], "walls") self.assertEqual(city["queue"], ["houses"]) city["construction"]["labour"] = city["construction"]["needed"] game.tick(world, T0 + 1, random.Random(1)) self.assertEqual(city["construction"]["building"], "houses") self.assertEqual(city["queue"], []) def test_raids_never_touch_funds(self): world = world_with(("fred", "commoner")) city = world["city"] city["buildings"]["walls"]["fund"] = {"stone": 1000} city["stockpile"] = {"stone": 1000} world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) raid = game.upcoming_raid(world) raid["strength"] = 500 game.tick(world, raid["fire_at"], random.Random(1)) self.assertLess(city["stockpile"]["stone"], 1000) self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 1000}) def test_tier_caps_levels_and_promotes_for_good(self): world = world_with(("fred", "commoner")) city = world["city"] city["buildings"]["walls"]["level"] = 1 self.assertIsNone(game.fund_target(world, "walls")) # hamlet: level 1 only self.assertIsNotNone(game.fund_target(world, "houses")) # houses go to 2 slots = game.bank_slots(world, world["players"]["fred"]) city["population"] = 41 world["next_event_at"] = None game.tick(world, T0 + 1, random.Random(1)) self.assertEqual(city["tier"], 1) self.assertIsNotNone(game.fund_target(world, "walls")) self.assertEqual(game.bank_slots(world, world["players"]["fred"]), slots + game.TIER_SLOTS) city["population"] = 10 game.tick(world, T0 + 2, random.Random(1)) self.assertEqual(city["tier"], 1) def test_damage_turns_the_fund_towards_repairs_and_keeps_the_rest(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] city = world["city"] city["buildings"]["walls"].update(level=1, damaged=True, fund={"stone": 2000}) repair = game.repair_cost("walls", 1) self.assertEqual(game.fund_target(world, "walls"), repair) for item, qty in repair.items(): if item == "gold": city["treasury"] = qty game.fund(world, p, "walls", "gold", "all", "city", T0) elif item != "stone": city["stockpile"][item] = qty game.fund(world, p, "walls", item, "all", "city", T0) self.assertTrue(city["construction"]["repair"]) self.assertEqual(city["buildings"]["walls"]["fund"], {"stone": 2000 - repair["stone"]}) def test_old_worlds_migrate(self): world = game.new_world(T0) world["version"] = 1 for building in world["city"]["buildings"].values(): del building["fund"] del world["city"]["queue"], world["city"]["tier"] game.migrate(world) self.assertEqual(world["version"], game.WORLD_VERSION) self.assertEqual(world["city"]["queue"], []) self.assertEqual(world["city"]["buildings"]["walls"]["fund"], {}) def test_v3_worlds_sell_useless_stock_into_the_treasury(self): world = world_with() world["version"] = 3 world["city"]["stockpile"] = {"copper_ore": 8, "raw_meat": 1, "bricks": 500, "bread": 30} world["city"]["treasury"] = 10 game.migrate(world) self.assertEqual(world["city"]["stockpile"], {"bricks": 500, "bread": 30}) self.assertEqual(world["city"]["treasury"], 10 + 8 * 2 + 1 * 2) self.assertEqual(world["ledger"][-1]["text"], "The council sold 8 copper ore and 1 raw meat from the stockpile for 18 gold") def test_population_view_explains_growth(self): world = world_with() view = game.population_view(world) self.assertEqual(view["tier_name"], "Hamlet") self.assertEqual(view["next_tier"], {"name": "Village", "pop": 40}) self.assertGreater(view["growth"], 0) self.assertEqual(view["food_days"], 4.0) # 40 bread for 20 people eating 0.5 a day def test_people_eat_and_go_hungry(self): world = world_with() city = world["city"] city["stockpile"] = {"bread": 5} city["population"] = 25.0 # a full town, so nobody new arrives world["next_fortune_at"] = None # no lucky wagon of bread game.tick(world, T0 + 4 * game.DAY, random.Random(1)) self.assertTrue(city["starving"]) self.assertNotIn("bread", city["stockpile"]) self.assertLess(city["population"], 25) def test_empty_homes_draw_settlers(self): world = world_with() city = world["city"] city["population"] = 22.0 city["buildings"]["houses"]["level"] = 2 city["stockpile"] = {"bread": 10_000} self.assertAlmostEqual(game.growth_per_day(world), 0.5 * 43 + 0.05 * 22) for _hour in range(24): game._city_hour(world, T0) self.assertGreater(city["population"], 38) # Slower as the homes fill, but never down to nothing. self.assertLess(game.growth_per_day(world), 0.5 * 43) city["population"] = 64.0 self.assertAlmostEqual(game.growth_per_day(world), 0.5 + 3.2) def test_fed_city_grows_to_housing_and_pays_tax(self): world = world_with() city = world["city"] city["stockpile"] = {"bread": 1000} world["next_event_at"] = None game.tick(world, T0 + 10 * game.DAY, random.Random(1)) self.assertFalse(city["starving"]) self.assertEqual(city["population"], 25) self.assertGreater(city["treasury"], 100) def test_speed_scales_time(self): world = game.new_world(T0, speed=60) 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)) self.assertEqual(p["bank"]["stone"], 30) class EventTest(unittest.TestCase): def announce(self, world): world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) return game.upcoming_raid(world) def test_first_event_is_a_raid_with_vague_intel(self): world = world_with() raid = self.announce(world) self.assertIsNotNone(raid) view = game.event_view(world, raid, T0) self.assertEqual(view["intel"], 0) self.assertNotIn("strength", view) self.assertNotIn("group", view) self.assertLessEqual(view["earliest"], raid["fire_at"]) self.assertGreaterEqual(view["latest"], raid["fire_at"]) def test_recon_sharpens_intel_as_the_raid_draws_near(self): world = world_with(("sam", "ranger")) raid = self.announce(world) p = world["players"]["sam"] p["xp"]["reconnaissance"] = game.XP_TABLE[20] game.start_action(world, p, "recon_camps", T0) game.process_player(world, p, T0 + 4 * game.HOUR, random.Random(1)) self.assertGreaterEqual(raid["recon"], game.intel_costs(raid)[-1]) # Plenty of scouting, but a day or more out only the first level is knowable. view = game.event_view(world, raid, T0 + 4 * game.HOUR) self.assertEqual(view["intel"], 1) self.assertIn("size", view) self.assertNotIn("strength", view) self.assertEqual(view["recon_next"], 0) late = raid["fire_at"] - 12 * game.HOUR self.assertEqual(game.intel_level(world, raid, late), 2) view = game.event_view(world, raid, raid["fire_at"] - game.HOUR) self.assertEqual(view["intel"], 3) self.assertEqual(view["exact"], raid["fire_at"]) self.assertTrue(view["camp"]) def test_scouting_stops_counting_once_the_threat_is_fully_scouted(self): 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 + 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 self.assertEqual(game.event_view(world, raid, T0)["recon"], full) def test_bigger_bands_take_more_scouting(self): small = {"kind": "raid", "strength": 20} huge = {"kind": "raid", "strength": 150} dragon = {"kind": "dragon", "strength": 20} self.assertEqual(game.intel_costs(small), [15, 45, 90]) self.assertEqual(game.intel_costs(huge), [30, 90, 180]) self.assertEqual(game.intel_costs(dragon), [8, 22, 45]) def test_the_spymasters_guild_boosts_and_gathers_recon(self): world = world_with(("sam", "commoner")) raid = self.announce(world) 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, 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", "commoner")) raid = self.announce(world) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[60] p["garrison"] = True game.tick(world, raid["fire_at"], random.Random(1)) self.assertEqual(raid["status"], "done") self.assertTrue(raid["report"]["title"].startswith("Victory")) self.assertEqual(world["city"]["repelled"], 1) self.assertEqual(p["favour"], 25) self.assertFalse(p["garrison"]) def test_defeat_culls_and_loots_but_spares_banks(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] p["bank"] = {"stone": 500} city = world["city"] city["stockpile"] = {"bread": 200, "stone": 1000} city["treasury"] = 500 city["buildings"]["walls"]["level"] = 1 raid = self.announce(world) raid["strength"] = 500 game.tick(world, raid["fire_at"], random.Random(1)) self.assertTrue(raid["report"]["title"].startswith("Defeat")) self.assertLess(city["population"], 20) self.assertLess(city["stockpile"]["stone"], 1000) self.assertLess(city["treasury"], 500) self.assertTrue(city["buildings"]["walls"]["damaged"]) self.assertEqual(city["buildings"]["walls"]["level"], 1) self.assertEqual(p["bank"], {"stone": 500}) self.assertEqual(game.defence(world)["parts"]["walls"], 0) def test_repair_is_cheaper_than_building(self): cost = game.repair_cost("walls", 2) full = game.build_cost("walls", 2) for item, qty in cost.items(): self.assertLess(qty, full[item]) def test_sabotage_needs_a_located_camp(self): world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[40] p["hp"] = game.max_hp(p) raid = self.announce(world) self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located") raid["recon"] = game.intel_costs(raid)[-1] now = raid["fire_at"] - game.HOUR world["city"]["last_tick"] = p["last_active"] = now self.assertIsNone(game.start_action(world, p, "sabotage", now)) game.process_player(world, p, now + 96 * 20, random.Random(1)) self.assertGreater(raid["sabotage"], 0) self.assertLessEqual(raid["sabotage"], 0.3) class CombatTest(unittest.TestCase): def fighter(self, level=10, **equip): world = world_with(("wil", "commoner")) p = world["players"]["wil"] p["xp"]["combat"] = game.XP_TABLE[level] p["hp"] = game.max_hp(p) p["equip"].update(equip) return world, p def test_fights_hurt_and_food_heals(self): world, p = self.fighter() p["bank"] = {"bread": 100} game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 60 * 60, random.Random(4)) self.assertGreater(summary["damage"], 0) self.assertGreater(summary["eaten"].get("bread", 0), 0) self.assertEqual(p["bank"].get("bread", 0), 100 - summary["eaten"]["bread"]) self.assertGreaterEqual(p["hp"], 1) def test_out_of_food_means_retreat_and_nothing_lost(self): world, p = self.fighter(level=10, weapon="copper_sword") p["bank"] = {"stone": 10} game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1)) self.assertIsNone(p["action"]) self.assertTrue(summary["stopped"].startswith("Retreated")) self.assertEqual(p["equip"]["weapon"], "copper_sword") self.assertEqual(p["bank"]["stone"], 10) self.assertLess(p["hp"], game.max_hp(p) * game.TOO_HURT) self.assertEqual(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600), "Too hurt to fight. Rest, or bring food") def test_rest_heals(self): world, p = self.fighter() p["hp"] = 1 game.regen(world, p, T0 + 4 * 3600) self.assertEqual(p["hp"], game.max_hp(p)) def test_armour_and_levels_cut_damage(self): world, bare = self.fighter(level=10) _, armoured = self.fighter(level=10, helmet="iron_helmet", body="iron_body", legs="iron_legs") _, veteran = self.fighter(level=60) scouts = game.ACTION_BY_ID["fight_scouts"] hurt = lambda p: game.fight_odds(p, scouts)["damage"] self.assertLess(hurt(armoured), hurt(bare) * 0.7) self.assertLess(hurt(veteran), hurt(bare)) self.assertGreater(game.fight_odds(veteran, scouts)["hit"], game.fight_odds(bare, scouts)["hit"]) def test_a_fight_is_played_out_swing_by_swing(self): world, p = self.fighter(level=10, weapon="iron_sword") p["bank"] = {"bread": 50} game.start_action(world, p, "fight_scouts", T0) game.process_player(world, p, T0 + 1, random.Random(2)) fight = p["action"]["fight"] # simulated, waiting to finish, for the page to replay self.assertEqual(fight["result"], "won") self.assertTrue(all(e[1] in ("you", "foe") and e[2] in ("hit", "crit", "miss", "eat", "wind") for e in fight["events"])) self.assertEqual(fight["events"][-1][5], 0) # the last swing finished it self.assertAlmostEqual(fight["duration"], fight["end"], places=1) # rewards land on the kill summary = game.process_player(world, p, T0 + fight["duration"] + 0.01, random.Random(2)) self.assertEqual(summary["wins"], 1) self.assertAlmostEqual(p["hp"], fight["hp"], delta=0.1) self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"] + game.RESPAWN) # then a short pause def test_outclassing_an_enemy_kills_faster(self): kills = {} for level in (20, 90): world, p = self.fighter(level=level, weapon="rune_sword") game.start_action(world, p, "fight_wolves", T0) kills[level] = game.process_player(world, p, T0 + 600, random.Random(1))["wins"] self.assertGreater(kills[90], kills[20] * 1.3) def test_each_enemy_starts_with_both_swing_timers_empty(self): world, p = self.fighter(level=60, weapon="rune_sword") game.start_action(world, p, "fight_wolves", T0) game.process_player(world, p, T0 + 0.1, random.Random(1)) first = p["action"]["fight"] game.process_player(world, p, T0 + first["duration"] + 0.01, random.Random(1)) self.assertAlmostEqual(p["action"]["start"], T0 + first["duration"] + game.RESPAWN) # the pause starts at the kill second = p["action"]["fight"] self.assertEqual(next(e[0] for e in second["events"] if e[1] == "you"), 2.4) # a full swing, from scratch def test_preferred_food_is_eaten_first(self): world, p = self.fighter() p["bank"] = {"bread": 5, "cooked_salmon": 5} self.assertEqual(game.edible(p), ["bread", "cooked_salmon"]) p["food"] = "cooked_salmon" self.assertEqual(game.edible(p), ["cooked_salmon", "bread"]) class NewSkillsTest(unittest.TestCase): def test_tools_speed_up_gathering(self): 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" self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]), plain * 0.7) self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain) def test_fish_to_plate(self): 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) 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", "miner")) p = world["players"]["kay"] p["bank"] = {"hide": 4} game.start_action(world, p, "tan_leather", T0) game.process_player(world, p, T0 + 1000, random.Random(1)) self.assertGreaterEqual(p["bank"]["leather"], 4) p["xp"]["leatherworking"] = game.XP_TABLE[12] self.assertIsNone(game.start_action(world, p, "lw_body", T0 + 1000)) def test_v2_worlds_gain_hp_and_slots(self): world = world_with(("fred", "miner")) p = world["players"]["fred"] world["version"] = 2 for key in ("hp", "hp_at", "food"): del p[key] p["equip"] = {"weapon": "copper_sword", "shield": None} del p["xp"]["fishing"], p["xp"]["leatherworking"] game.migrate(world) self.assertEqual(world["version"], game.WORLD_VERSION) self.assertEqual(p["hp"], game.max_hp(p)) self.assertEqual(p["equip"]["weapon"], "copper_sword") self.assertIsNone(p["equip"]["tool_fishing"]) self.assertEqual(p["xp"]["fishing"], 0) class FavourTest(unittest.TestCase): def setUp(self): self.world = world_with(("fred", "miner")) self.p = self.world["players"]["fred"] self.p["favour"] = 5000 def test_master_builder_doubles_labour_and_xp_for_a_while(self): world, p = self.world, self.p self.assertEqual(game.spend_favour(world, p, "builders", T0), "Nothing is being built") world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360} self.assertEqual(game.favours(world, T0)["builders"]["cost"], 150) self.assertIsNone(game.spend_favour(world, p, "builders", T0)) 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 + 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) self.assertEqual(summary["xp"]["construction"], 10 * 60 + 10 * 50) def test_master_builder_price_resets_on_a_new_site(self): world, p = self.world, self.p city = world["city"] city["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 359.0, "needed": 360} game.spend_favour(world, p, "builders", T0) city["buildings"]["houses"]["fund"] = {k: v for k, v in game.build_cost("houses", 1).items()} city["queue"] = ["houses"] city["construction"]["labour"] = 360 game.tick(world, T0 + 1, random.Random(1)) self.assertEqual(city["construction"]["building"], "houses") self.assertEqual(game.favours(world, T0)["builders"]["cost"], 150) self.assertEqual(city["site_boost"], 50) # unused boost carries over def test_informant_doubles_a_scouts_recon_for_a_while(self): world, p = self.world, self.p self.assertEqual(game.spend_favour(world, p, "informant", T0), "No news to dig into") world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) event = game.upcoming_raid(world) 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 + 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) event["recon"] = game.intel_costs(event)[-1] self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn") def test_drilling_takes_as_long_as_the_scouting(self): world, p = self.world, self.p self.assertEqual(game.start_action(world, p, "drill_militia", T0), "No raid to prepare for") world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) raid = game.upcoming_raid(world) world["city"]["population"] = 20.0 needed = game.drill_needed(raid) self.assertEqual(needed, game.intel_costs(raid)[-1]) before = game.defence(world)["total"] self.assertIsNone(game.start_action(world, p, "drill_militia", T0)) game.process_player(world, p, T0 + 60 * 10 + 1, random.Random(1)) self.assertEqual(raid["drilled"], 10) self.assertAlmostEqual(game.defence(world)["parts"]["drilled"], round(20 * 10 / needed, 1)) self.assertAlmostEqual(game.defence(world)["total"], before + 20 * 10 / needed, places=0) self.assertEqual(p["xp"]["combat"], 120) self.assertEqual(p["hp"], 44) # drilling is not a fight game.process_player(world, p, T0 + 60 * (needed + 5), random.Random(1)) self.assertEqual(game.defence(world)["parts"]["drilled"], 20.0) self.assertIsNone(p["action"]) # stops once the militia is fully drilled game.tick(world, raid["fire_at"], random.Random(1)) self.assertEqual(game.defence(world)["parts"]["drilled"], 0) def test_seasoned_fighters_drill_a_little_faster(self): self.assertEqual(game.drill_per_action(self.p), 1.0) self.p["xp"]["combat"] = game.XP_TABLE[40] self.assertEqual(game.drill_per_action(self.p), 1.5) def test_drill_sergeant_doubles_drilling_for_a_while(self): world, p = self.world, self.p self.assertEqual(game.spend_favour(world, p, "sergeant", T0), "No raid to prepare for") world["next_event_at"] = T0 game.tick(world, T0, random.Random(2)) self.assertIsNone(game.spend_favour(world, p, "sergeant", T0)) self.assertEqual(game.defence(world)["parts"]["drilled"], 0) # a sergeant drills nobody by himself self.assertEqual(game.favours(world, T0)["sergeant"]["cost"], 225) game.start_action(world, p, "drill_militia", T0) game.process_player(world, p, T0 + 60 * 4 + 1, random.Random(1)) self.assertEqual(game.upcoming_raid(world)["drilled"], 8) self.assertEqual(p["xp"]["combat"], 96) self.assertEqual(world["city"]["drill_boost"], 26) self.assertEqual(p["favour"], 5000 - 150) def test_not_enough_favour(self): self.p["favour"] = 10 self.world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 0.0, "needed": 360} self.assertEqual(game.spend_favour(self.world, self.p, "builders", T0), "Needs 150 favour") class TierTest(unittest.TestCase): def test_steel_needs_coal_and_the_smithy(self): world = world_with(("kay", "miner")) 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", "warhammer", "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) self.assertIn(f"make_{key}_shield", game.ACTION_BY_ID) # builders make shields for *_, prefix, _label, _level in game.LEATHERS: for piece in ("cap", "legs", "body"): self.assertIn(f"{prefix}_{piece}", game.ITEMS) for *_, prefix, _label in game.CLOTHS: for piece in ("hood", "legs", "body"): self.assertIn(f"{prefix}_{piece}", game.ITEMS) self.assertIn(f"sew_{prefix}_{piece}", game.ACTION_BY_ID) self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses") def test_gear_needs_levels_to_wear(self): 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") 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_brackets"], 60) self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180) def test_dragons_drop_their_areas_reagents(self): 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") 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(sum(p["bank"].get(game.reagent(weight, 3), 0) for weight in game.WEIGHTS), 30) class PrayerTest(unittest.TestCase): def priest(self, level=1, combat=10): world = world_with(("pat", "commoner")) p = world["players"]["pat"] p["xp"]["prayer"] = game.XP_TABLE[level] p["xp"]["combat"] = game.XP_TABLE[combat] p["hp"] = game.max_hp(p) return world, p def test_burying_bones_gives_points_and_xp(self): world, p = self.priest() p["bank"] = {"bones": 5} game.start_action(world, p, "bury_bones", T0) game.process_player(world, p, T0 + 100, random.Random(1)) self.assertEqual(p["pp"], 10) self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[1] + 20) world["city"]["buildings"]["temple"]["level"] = 2 self.assertEqual(game.bone_points(world, game.ACTION_BY_ID["bury_bones"]), 4) def test_choosing_prayers(self): world, p = self.priest(level=10) self.assertEqual(game.set_prayers(p, ["steel_skin"]), "Steel Skin needs prayer level 20") self.assertEqual(game.set_prayers(p, ["thick_skin", "burst_of_strength", "rapid_heal"]), "At most 2 prayers at once") self.assertIsNone(game.set_prayers(p, ["thick_skin", "burst_of_strength"])) def test_prayers_cost_points_cut_damage_and_train_prayer(self): world, p = self.priest(level=10) scouts = game.ACTION_BY_ID["fight_scouts"] plain = game.fight_odds(p, scouts)["damage"] game.set_prayers(p, ["thick_skin", "burst_of_strength"]) p["pp"] = 4 blessed = game.blessing(p) self.assertEqual(blessed["cost"], 2) self.assertLess(game.fight_odds(p, scouts)["damage"], plain * 0.9) p["bank"] = {"bread": 200} game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 600, random.Random(1)) self.assertEqual(p["pp"], 0) self.assertEqual(summary["pp_spent"], 4) self.assertTrue(summary["prayer_out"]) self.assertEqual(p["xp"]["prayer"], game.XP_TABLE[10] + 4 * game.PRAYER_XP_PER_POINT) def test_second_wind_saves_one_retreat(self): world, p = self.priest(level=40, combat=10) game.set_prayers(p, ["second_wind"]) p["pp"] = 1000 game.start_action(world, p, "fight_scouts", T0) summary = game.process_player(world, p, T0 + 6 * 3600, random.Random(1)) self.assertEqual(summary["second_wind"], 1) self.assertTrue(summary["stopped"].startswith("Retreated")) def test_rapid_heal_mends_during_fights(self): world, p = self.priest(level=10, combat=10) scouts = game.ACTION_BY_ID["fight_scouts"] p["pp"], p["hp"] = 1000, 50 plain = game.simulate_fight(world, p, scouts, random.Random(3)) game.set_prayers(p, ["rapid_heal"]) mended = game.simulate_fight(world, p, scouts, random.Random(3)) swings = sum(1 for e in plain["events"] if e[1] == "you") top = game.max_hp(p) self.assertAlmostEqual(mended["hp"] - plain["hp"], swings * game.MEND * top * plain["interval"], places=6) self.assertLess(game.fight_odds(p, scouts)["damage"], game.fight_odds({**p, "prayers": []}, scouts)["damage"]) def test_offerings_ward_the_city_until_the_raid(self): world, p = self.priest(level=15) world["city"]["buildings"]["temple"]["level"] = 1 p["bank"] = {"big_bones": 10} game.start_action(world, p, "offer_big_bones", T0) game.process_player(world, p, T0 + 1000, random.Random(1)) self.assertEqual(world["city"]["devotion"], 10 * 6 * 1.25) self.assertEqual(game.defence(world)["parts"]["ward"], min(75 / 4, 15)) world["next_event_at"] = T0 game.tick(world, T0 + 1000, random.Random(2)) game.tick(world, game.upcoming_raid(world)["fire_at"], random.Random(1)) self.assertEqual(world["city"]["devotion"], 0) def test_offering_needs_a_temple(self): world, p = self.priest() p["bank"] = {"bones": 1} 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", "miner")) world["version"] = 4 p = world["players"]["fred"] del p["pp"], p["prayers"], p["xp"]["prayer"] del world["city"]["buildings"]["temple"] game.migrate(world, T0) self.assertEqual((p["pp"], p["prayers"], p["xp"]["prayer"]), (0, [], 0)) self.assertEqual(world["city"]["buildings"]["temple"]["level"], 0) class HerbloreTest(unittest.TestCase): def setUp(self): self.world = world_with(("hal", "commoner")) self.p = self.world["players"]["hal"] def test_herbs_to_potion(self): world, p = self.world, self.p game.start_action(world, p, "grow_guam_leaf", T0) game.process_player(world, p, T0 + 450, random.Random(1)) self.assertEqual(p["bank"]["guam_leaf"], 10) p["bank"]["logs"] = 3 self.assertIsNone(game.start_action(world, p, "brew_woodcutting_potion_1", T0 + 450)) # tier I needs no Herblore game.process_player(world, p, T0 + 1000, random.Random(1)) self.assertEqual(p["bank"]["woodcutting_potion_1"], 3) self.assertEqual(game.start_action(world, p, "brew_woodcutting_potion_2", T0 + 1000), "Needs level 15") def test_potion_tiers_climb_the_ladders(self): mining = [potion for potion in game.POTIONS if potion[0].startswith("mining_potion")] self.assertEqual([(herb, second) for _id, _name, _level, herb, second, *_rest in mining[:2]], [("guam_leaf", "copper_ore"), ("marrentill", "iron_ore")]) self.assertEqual([p[2] for p in mining], game.TIER_LEVELS) self.assertEqual([p[6]["speed"] for p in mining], game.SPEEDS) self.assertEqual(game.ITEMS["mining_potion_6"]["name"], "Mining potion VI") self.assertFalse(any("heal" in p[6] or "double" in p[6] for p in game.POTIONS)) def test_skill_potion_speeds_up_and_uses_charges(self): world, p = self.world, self.p plain = game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]) p["bank"] = {"woodcutting_potion_3": 1} self.assertIsNone(game.use_potion(p, "woodcutting_potion_3")) self.assertEqual(p["potions"]["woodcutting"], {"item": "woodcutting_potion_3", "charges": game.POTION_CHARGES}) self.assertAlmostEqual(game.action_time(world, p, game.ACTION_BY_ID["cut_logs"]), plain * 0.8) self.assertEqual(game.action_time(world, p, game.ACTION_BY_ID["mine_stone"]), plain) game.start_action(world, p, "cut_logs", T0) summary = game.process_player(world, p, T0 + 16 * 60, random.Random(1)) self.assertEqual(summary["potion_out"], ["woodcutting_potion_3"]) self.assertNotIn("woodcutting", p["potions"]) def test_empty_potion_is_replaced_from_the_bank(self): world, p = self.world, self.p p["bank"] = {"hunting_potion_1": 2} game.use_potion(p, "hunting_potion_1") p["potions"]["hunting"]["charges"] = 1 game.start_action(world, p, "hunt_birds", T0) summary = game.process_player(world, p, T0 + 25, random.Random(1)) self.assertEqual(summary["potions_drunk"], {"hunting_potion_1": 1}) self.assertEqual(p["potions"]["hunting"]["charges"], game.POTION_CHARGES) self.assertNotIn("hunting_potion_1", p["bank"]) def test_keep_chance_saves_materials(self): world, p = self.world, self.p p["bank"] = {"copper_ore": 100, "logs": 100, "smithing_potion_2": 1} game.use_potion(p, "smithing_potion_2") p["potions"]["smithing"]["charges"] = 1000 game.start_action(world, p, "smith_copper_bar", T0) summary = game.process_player(world, p, T0 + 20 * 100, random.Random(2)) self.assertGreater(summary["kept"], 5) self.assertEqual(p["bank"]["copper_ore"], 100 - (100 - summary["kept"])) def test_combat_potions_add_power_and_cut_damage(self): world, p = self.world, self.p p["xp"]["combat"] = game.XP_TABLE[20] p["hp"] = game.max_hp(p) p["bank"] = {"defence_potion_6": 1, "bread": 500} game.use_potion(p, "defence_potion_6") game.start_action(world, p, "fight_scouts", T0) scouts = game.ACTION_BY_ID["fight_scouts"] brewed = game.fight_odds(p, scouts)["damage"] active = p["potions"].pop("combat") self.assertAlmostEqual(brewed, game.fight_odds(p, scouts)["damage"] * 0.70, delta=0.2) p["potions"]["combat"] = active summary = game.process_player(world, p, T0 + 75 * 10, random.Random(1)) self.assertEqual(p["potions"]["combat"]["charges"], game.POTION_CHARGES - summary["done"]) def test_old_potions_become_their_tiers(self): world, p = self.world, self.p world["version"] = 10 p["bank"] = {"lumberjacks_tonic": 4, "woodcutting_potion_3": 1, "regeneration_potion": 2} p["potions"] = {"mining": {"item": "prospectors_draught", "charges": 12}} p["action"] = {"id": "brew_anglers_brew", "start": T0, "resume": "brew_smiths_focus"} world["city"]["stockpile"]["super_strength"] = 1 game.migrate(world, T0) self.assertEqual(p["bank"], {"woodcutting_potion_3": 5, "defence_potion_3": 2}) self.assertEqual(p["potions"]["mining"], {"item": "mining_potion_2", "charges": 12}) self.assertEqual((p["action"]["id"], p["action"]["resume"]), ("brew_fishing_potion_3", "brew_smithing_potion_2")) self.assertEqual(world["city"]["stockpile"]["strength_potion_4"], 1) for action in (p["action"]["id"], p["action"]["resume"]): self.assertIn(action, game.ACTION_BY_ID) def test_v5_worlds_get_herblore(self): world = world_with(("fred", "miner")) world["version"] = 5 p = world["players"]["fred"] del p["potions"], p["xp"]["herblore"] game.migrate(world, T0) self.assertEqual((p["potions"], p["xp"]["herblore"]), ({}, 0)) class ConstructionTest(unittest.TestCase): def site(self): 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 def test_players_build_with_their_hands(self): world = self.site() p = world["players"]["ann"] game.start_action(world, p, "labour", T0) summary = game.process_player(world, p, T0 + 30 * 100, random.Random(1)) self.assertEqual(summary["labour"], 100) self.assertEqual(world["city"]["construction"]["labour"], 100) self.assertGreater(p["xp"]["construction"], 0) def test_finishing_the_site_raises_the_building_and_stops_the_work(self): world = self.site() world["city"]["construction"]["labour"] = 355 p = world["players"]["ann"] game.start_action(world, p, "labour", T0) summary = game.process_player(world, p, T0 + 30 * 10, random.Random(1)) self.assertEqual(world["city"]["buildings"]["walls"]["level"], 1) self.assertIsNone(world["city"]["construction"]) self.assertEqual(summary["stopped"], "Nothing is being built") def test_townsfolk_trickle_and_levels_help(self): world = self.site() world["city"]["population"] = 25.0 # houses full, so the workforce stays put game.tick(world, T0 + 10 * game.HOUR, random.Random(1)) self.assertAlmostEqual(world["city"]["construction"]["labour"], 10 * 25 * game.TOWNSFOLK_LABOUR, delta=5) p = world["players"]["bob"] self.assertEqual(game.labour_per_action(p), 1) p["xp"]["construction"] = game.XP_TABLE[40] self.assertEqual(game.labour_per_action(p), 3) plain = game.action_time(world, p, game.ACTION_BY_ID["labour"]) p["equip"]["tool_construction"] = "iron_hammer" 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", "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): for plank, _name in game.PLANKS: self.assertIn(f"saw_{plank}", game.ACTION_BY_ID) for key, *_ in game.METALS: self.assertIn(f"smith_{key}_hammer", game.ACTION_BY_ID) self.assertIn("oak_plank", game.build_cost("houses", 3)) def test_v6_timer_becomes_labour(self): world = world_with(("fred", "miner")) world["version"] = 6 p = world["players"]["fred"] del p["xp"]["construction"], p["equip"]["tool_construction"] world["city"]["construction"] = {"building": "houses", "repair": False, "start": T0, "done_at": T0 + 3 * 3600} game.migrate(world, T0 + 3600) work = world["city"]["construction"] self.assertEqual(work["needed"], game.LABOUR_PER_LEVEL) self.assertAlmostEqual(work["labour"], game.LABOUR_PER_LEVEL / 3) self.assertNotIn("done_at", work) self.assertIsNone(p["equip"]["tool_construction"]) class HappeningsTest(unittest.TestCase): def setUp(self): 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 self.city = self.world["city"] self.rng = random.Random(4) def test_weather_rolls_and_seasons_turn(self): seen = set() for hour in range(1, 24 * 14): game.tick(self.world, T0 + hour * game.HOUR, self.rng) seen.add(self.city["weather"]["kind"]) self.assertGreaterEqual(len(seen), 3) self.assertEqual(game.season(self.world, T0)["name"], "Spring") self.assertEqual(game.season(self.world, T0 + 8 * game.DAY)["name"], "Summer") self.assertEqual(game.season(self.world, T0 + 22 * game.DAY)["name"], "Winter") def test_weather_changes_on_time_and_always_to_something_new(self): weather = self.city["weather"] weather.update(kind="rain", next="clear", until=T0 + 10 * 60) self.city["last_tick"] = T0 game.tick(self.world, T0 + 11 * 60, self.rng) # well inside the hour self.assertEqual(weather["kind"], "clear") for _ in range(50): spell = weather["until"] game.tick(self.world, spell, self.rng) self.assertNotEqual(weather["next"], weather["kind"]) self.assertGreater(weather["until"], spell) def test_days_count_midnights_not_24_hour_stretches(self): from datetime import datetime late = datetime(2026, 10, 18, 23, 0).timestamp() world = game.new_world(late) self.assertEqual(game.calendar_day(world, late + 1800), 0) # 23:30 the same evening self.assertEqual(game.calendar_day(world, late + 2 * 3600), 1) # 01:00 the next morning self.assertEqual(game.season(world, late + 2 * 3600)["day"], 2) def test_weather_changes_skill_speed(self): p = self.world["players"]["ann"] farm = game.ACTION_BY_ID["farm_wheat"] plain = game.action_time(self.world, p, farm) self.city["weather"]["kind"] = "heat" self.assertAlmostEqual(game.action_time(self.world, p, farm), plain * 0.85) self.assertAlmostEqual(game.city_rate(self.world, "food"), 1.15) def test_sickness_makes_the_city_eat_more(self): game._hardship(self.world, T0, self.rng, "sickness") self.assertAlmostEqual(game.city_rate(self.world, "food"), 2.0) self.assertEqual(self.world["events"][-1]["report"]["icon"], "🤒") def test_locusts_and_rot_take_from_stores_and_buildings_protect(self): self.city["stockpile"] = {"bread": 100, "wheat": 100, "logs": 100, "stone": 100} game._hardship(self.world, T0, self.rng, "locusts") self.assertEqual(self.city["stockpile"]["bread"], 60) self.assertEqual(self.city["stockpile"]["stone"], 100) self.city["buildings"]["lumber_shed"]["level"] = 2 game._hardship(self.world, T0, self.rng, "rot") self.assertEqual(self.city["stockpile"]["logs"], 85) def test_storm_damages_a_building(self): self.city["buildings"]["walls"]["level"] = 1 game._hardship(self.world, T0, self.rng, "storm") self.assertTrue(self.city["buildings"]["walls"]["damaged"]) def test_fire_is_put_out_by_water_barrels(self): self.city["stockpile"] = {"water_barrel": 6, "logs": 100} game._hardship(self.world, T0, self.rng, "fire") self.assertEqual(self.city["stockpile"], {"water_barrel": 1, "logs": 100}) self.assertIn("the fire brigade", game.city_uses("water_barrel")) def test_winter_burns_firewood_or_the_city_goes_cold(self): game._hardship(self.world, T0, self.rng, "winter") self.city["stockpile"] = {"logs": 3} game.tick(self.world, T0 + 4 * game.HOUR, self.rng) self.assertNotIn("logs", self.city["stockpile"]) self.assertTrue(self.city["cold"]) def test_wolves_slow_farming_until_fighters_clear_them(self): game._minor_threat(self.world, T0, self.rng, "wolves") p = self.world["players"]["ann"] self.assertEqual(game.skill_mods(self.world, game.ACTION_BY_ID["farm_wheat"])["speed"], 2.0) p["xp"]["combat"] = game.XP_TABLE[30] p["hp"] = game.max_hp(p) self.assertIsNone(game.start_action(self.world, p, "clear_wolves", T0)) summary = game.process_player(self.world, p, T0 + 3600, random.Random(1)) self.assertIsNone(game.active_problem(self.world, "wolves")) self.assertEqual(summary["stopped"], "That's been dealt with") def test_collapse_blocks_ore_until_planks_shore_it_up(self): game._minor_threat(self.world, T0, self.rng, "collapse") p = self.world["players"]["ann"] self.assertIn("collapsed", game.start_action(self.world, p, "mine_copper", T0)) self.assertIsNone(game.start_action(self.world, p, "mine_stone", T0)) p["bank"] = {"plank": 50} self.assertIsNone(game.aid_problem(self.world, p, "collapse", "plank", "bank", T0)) self.assertEqual(p["bank"], {"plank": 20}) self.assertIsNone(game.active_problem(self.world, "collapse")) def test_flood_recedes_on_its_own(self): game._minor_threat(self.world, T0, self.rng, "flood") game.tick(self.world, T0 + 37 * game.HOUR, self.rng) self.assertIsNone(game.active_problem(self.world, "flood")) def test_goblins_steal_every_hour(self): self.city["stockpile"] = {"stone": 1000} game._minor_threat(self.world, T0, self.rng, "goblins") game.tick(self.world, T0 + 5 * game.HOUR, self.rng) self.assertLess(self.city["stockpile"]["stone"], 1000) def test_fortunes(self): game._fortune(self.world, T0, self.rng, "harvest") self.assertEqual(game.skill_mods(self.world, game.ACTION_BY_ID["farm_wheat"])["yield"], 2) game._fortune(self.world, T0, self.rng, "master") self.assertTrue(any(e["kind"] == "master" for e in self.city["effects"])) before = self.city["treasury"] game._fortune(self.world, T0, self.rng, "noble") self.assertGreater(self.city["treasury"], before) game._fortune(self.world, T0, self.rng, "pilgrims") self.assertEqual(self.world["players"]["ann"]["bank"]["bones"], 10) game.tick(self.world, T0 + 13 * game.HOUR, self.rng) self.assertFalse(any(e["kind"] == "harvest" for e in self.city["effects"])) def test_festival_boosts_favour(self): game._fortune(self.world, T0, self.rng, "festival") p = self.world["players"]["ann"] p["bank"] = {"stone": 100} game.donate(self.world, p, "stone", 100, T0) self.assertEqual(p["favour"], 150) def test_mercenaries_join_the_next_defence(self): self.world["next_event_at"] = T0 game.tick(self.world, T0, self.rng) game._fortune(self.world, T0, self.rng, "mercenaries") p = self.world["players"]["ann"] self.city["treasury"] = 5000 before = game.defence(self.world)["total"] self.assertIsNone(game.hire_mercenaries(self.world, p, T0)) self.assertEqual(game.defence(self.world)["total"], before + 25) def test_dragons_burn_when_they_win(self): self.city["buildings"]["houses"]["level"] = 1 self.city["stockpile"] = {"logs": 500, "bread": 200} self.world["events"].append({"id": 99, "kind": "dragon", "status": "upcoming", "announced": T0, "fire_at": T0 + 10, "recon": 0, "sabotage": 0.0, "jitter": 0, "strength": 1000, "group": "a green dragon", "trait": "x"}) self.assertEqual(game.upcoming_raid(self.world)["kind"], "dragon") game.tick(self.world, T0 + 20, self.rng) report = self.world["events"][-1]["report"] self.assertIn("ablaze", report["title"]) self.assertTrue(self.city["buildings"]["houses"]["damaged"]) self.assertLess(self.city["stockpile"]["logs"], 500) def test_tracks_fire_on_their_own_cooldowns(self): self.world["next_fortune_at"] = T0 self.world["next_hardship_at"] = T0 game.tick(self.world, T0 + 1, self.rng) tracks = [e.get("track") for e in self.world["events"]] self.assertIn("fortune", tracks) self.assertIn("hardship", tracks) self.assertGreater(self.world["next_fortune_at"], T0 + 30 * game.HOUR) def test_v7_worlds_get_weather_and_tracks(self): world = world_with() world["version"] = 7 for key in ("weather", "effects", "problems", "hired"): del world["city"][key] for key in ("granary", "lumber_shed", "well"): del world["city"]["buildings"][key] for track in ("minor", "hardship", "fortune"): del world[f"next_{track}_at"] game.migrate(world, T0) self.assertEqual(world["city"]["weather"]["kind"], "clear") self.assertIn("granary", world["city"]["buildings"]) self.assertIsNotNone(world["next_fortune_at"]) class WearTest(unittest.TestCase): def setUp(self): self.world = world_with(("fred", "commoner")) self.city = self.world["city"] self.city["population"] = 40.0 # 20 defenders def test_wear_follows_how_close_the_fight_was(self): self.assertAlmostEqual(game.wear_share(3), 0.1) self.assertAlmostEqual(game.wear_share(1.5), 0.2) self.assertAlmostEqual(game.wear_share(1), 0.3) self.assertAlmostEqual(game.wear_share(0.5), 0.4) def test_only_issued_gear_breaks_worst_first(self): self.city["stockpile"] = {"iron_sword": 10, "copper_sword": 30} rng = random.Random() rng.random = lambda: 0.0 broken = game.wear_kit(self.world, 0.4, rng) # 20 defenders take 10 iron and 10 copper; 8 break, all copper. self.assertEqual(broken, {"copper_sword": 8}) self.assertEqual(self.city["stockpile"], {"iron_sword": 10, "copper_sword": 22}) def test_a_won_raid_breaks_some_kit_and_reports_it(self): self.city["stockpile"] = {"copper_sword": 20, "arrows": 100} event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"} game._resolve_raid(self.world, event, T0, random.Random(1)) self.assertLess(self.city["stockpile"]["copper_sword"], 20) self.assertEqual(self.city["stockpile"]["arrows"], 70) text = " ".join(event["report"]["lines"]) self.assertIn("used 30 arrows", text) 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 TradeSkillsTest(unittest.TestCase): def test_masonry_is_part_of_construction(self): self.assertNotIn("masonry", game.SKILL_IDS) bricks = game.ACTION_BY_ID["mason_bricks"] self.assertEqual((bricks["skill"], bricks["group"]), ("construction", "Masonry")) world = world_with(("swa", "builder")) p = world["players"]["swa"] world["version"] = 12 p["xp"]["masonry"], p["xp"]["construction"] = 5000, 1000 p["bank"] = {"masonry_potion_2": 3} p["potions"] = {"masonry": {"item": "masonry_potion_1", "charges": 20}} p["action"] = {"id": "brew_masonry_potion_3", "start": T0} game.migrate(world, T0) self.assertEqual(p["xp"]["construction"], 6000) self.assertNotIn("masonry", p["xp"]) self.assertEqual(p["bank"], {"construction_potion_2": 3}) self.assertEqual(p["potions"], {"construction": {"item": "construction_potion_1", "charges": 20}}) self.assertEqual(p["action"]["id"], "brew_construction_potion_3") def test_smiths_make_hardware_for_builders(self): nails = game.ACTION_BY_ID["smith_iron_nails"] self.assertEqual((nails["inputs"], nails["outputs"], nails["group"]), ({"iron_bar": 1}, {"iron_nails": 5}, "Hardware")) self.assertIn("iron_nails", game.ACTION_BY_ID["mason_beam"]["inputs"]) self.assertIn("rune_lock", game.ACTION_BY_ID["make_enchanted_chests"]["inputs"]) self.assertNotIn("iron_bar", game.build_cost("houses", 2)) self.assertIn("iron_nails", game.build_cost("houses", 2)) def test_homesteader_focuses_or_tends_two_plots(self): world = world_with(("hom", "homesteader"), ("com", "commoner")) h, c = world["players"]["hom"], world["players"]["com"] h["xp"]["farming"] = game.XP_TABLE[10] wheat = game.ACTION_BY_ID["farm_wheat"] self.assertAlmostEqual(game.action_time(world, h, wheat), wheat["time"] * 0.7) # focused self.assertEqual(game.plant_second_plot(world, c, "grow_thatch"), "Only homesteaders tend two plots") self.assertEqual(game.plant_second_plot(world, h, "grow_thatch"), "Plant your first plot first") game.start_action(world, h, "farm_wheat", T0) self.assertEqual(game.plant_second_plot(world, h, "farm_wheat"), "That's already growing in your first plot") self.assertIsNone(game.plant_second_plot(world, h, "grow_thatch")) self.assertAlmostEqual(game.action_time(world, h, wheat), wheat["time"] * 0.8) summary = game.process_player(world, h, T0 + 0.8 * (25 + 30) * 10 + 1, random.Random(1)) self.assertEqual(summary["done"], 20) self.assertEqual((h["bank"]["wheat"], h["bank"]["thatch"]), (20, 30)) # ten harvests from each plot # Replanting the first plot keeps the second; a "make one" elsewhere comes back to both. game.start_action(world, h, "grow_guam_leaf", T0 + 1000) self.assertEqual(h["action"]["plot2"], "grow_thatch") h["bank"].update(wheat=10, logs=10) game.start_action(world, h, "cook_bread", T0 + 1000, once=True) game.process_player(world, h, T0 + 1000 + 30, random.Random(1)) self.assertEqual((h["action"]["id"], h["action"]["plot2"]), ("grow_guam_leaf", "grow_thatch")) game.start_action(world, h, "mine_stone", T0 + 2000) self.assertNotIn("plot2", h["action"]) class WeaponStyleTest(unittest.TestCase): def armed(self, cls, weapon, shield=None, level=12): world = world_with(("pat", cls)) p = world["players"]["pat"] p["xp"]["combat"] = game.XP_TABLE[level] p["hp"] = game.max_hp(p) p["equip"].update(weapon=weapon, shield=shield, body="iron_body", helmet="iron_helmet", legs="iron_legs") return world, p def test_no_trade_is_funnelled_into_one_race(self): # Every race mixes its weaknesses across its variants, so no weapon has one easy target. styles = {"miner": "iron_warhammer", "builder": "iron_sword", "homesteader": "oak_staff", "ranger": "oak_bow"} races = ["fight_clay_golems", "fight_cave_trolls", "fight_restless_dead", "fight_harpies"] for cls, weapon in styles.items(): _world, p = self.armed(cls, weapon, "iron_shield" if cls == "builder" else None) times = [game.fight_odds(p, game.ACTION_BY_ID[race])["kill_time"] for race in races] self.assertLess(max(times) / min(times), 1.45, cls) def test_style_traits(self): _w, swordsman = self.armed("builder", "iron_sword", "iron_shield") _w, smith = self.armed("miner", "iron_warhammer") _w, archer = self.armed("ranger", "oak_bow") brute = game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"][0] # vulnerable to blunt, resists piercing spec = lambda p: game.fighter(p, game.NO_BLESSING, {}, brute) self.assertEqual(spec(swordsman)["block"], game.BLOCK_CHANCE) self.assertEqual(spec(smith)["crit"], 0.10) self.assertEqual(spec(smith)["affinity"], game.VULNERABLE) self.assertEqual(spec(archer)["affinity"], game.RESISTANT) self.assertEqual(spec(swordsman)["affinity"], 1) _w, other = self.armed("builder", "iron_warhammer") self.assertGreater(spec(smith)["max_hit"], spec(other)["max_hit"]) # the trade's own weapon hits harder world, archer = self.armed("ranger", "oak_bow") fight = game.simulate_fight(world, archer, game.ACTION_BY_ID["fight_harpies"], random.Random(1)) self.assertEqual(fight["events"][0][1], "you") # the bow strikes first def test_two_handed_weapons_leave_no_room_for_a_shield(self): _w, p = self.armed("miner", "iron_warhammer") self.assertEqual(game.equip_problem(p, "iron_shield"), "Your iron warhammer takes both hands") self.assertTrue(game.ITEMS["oak_staff"]["two_handed"]) self.assertIsNone(game.equip_problem(self.armed("builder", "iron_sword")[1], "iron_shield")) def test_weapons_past_copper_need_trophies(self): for action_id, trophy in (("smith_iron_warhammer", "clay_core"), ("smith_iron_sword", "cave_troll_hide"), ("make_iron_shield", "cave_troll_hide"), ("brew_oak_staff", "grave_dust"), ("lw_oak_bow", "harpy_sinew"), ("smith_rune_warhammer", "magma_core")): self.assertIn(trophy, game.ACTION_BY_ID[action_id]["inputs"], action_id) for action_id in ("smith_copper_warhammer", "smith_copper_sword", "make_copper_shield", "brew_wooden_staff", "lw_wooden_bow"): self.assertFalse(any(item in game.TROPHIES for item in game.ACTION_BY_ID[action_id]["inputs"]), action_id) def test_armour_past_copper_needs_its_areas_reagent(self): self.assertEqual(game.ACTION_BY_ID["smith_iron_body"]["inputs"], {"iron_bar": 5, "watchtower_plating": 3}) self.assertEqual(game.ACTION_BY_ID["lw_hard_leather_cap"]["inputs"], {"hard_leather": 2, "watchtower_lacing": 1}) self.assertEqual(game.ACTION_BY_ID["sew_moonweave_legs"]["inputs"], {"moonweave": 3, "ironhill_thread": 2}) for action_id in ("smith_copper_body", "lw_cap", "sew_linen_body"): self.assertFalse(any(item in game.REAGENTS for item in game.ACTION_BY_ID[action_id]["inputs"]), action_id) harpies = game.ACTION_BY_ID["fight_harpies"]["drops"] self.assertIn(["watchtower_thread", 1, game.REAGENT_ODDS], harpies) def test_heavy_stops_blows_light_stops_magic(self): _w, plate = self.armed("miner", "iron_warhammer") _w, cloth = self.armed("miner", "iron_warhammer") cloth["equip"].update(helmet="cotton_hood", body="cotton_body", legs="cotton_legs") self.assertGreater(game.armour(plate), game.armour(cloth)) self.assertGreater(game.magic_ward(cloth), game.magic_ward(plate)) shaper = next(v for v in game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"] if v["name"] == "Mud shaper") brute = game.ACTION_BY_ID["fight_clay_golems"]["enemy"]["variants"][0] hurt = lambda p, foe: game._variant_odds(p, foe, game.NO_BLESSING, {})["damage"] self.assertLess(hurt(cloth, shaper), hurt(plate, shaper)) self.assertLess(hurt(plate, brute), hurt(cloth, brute)) def test_each_kill_is_one_of_the_variants(self): world, p = self.armed("miner", "iron_warhammer", level=30) seen = {game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(seed))["enemy"] for seed in range(60)} self.assertEqual(seen, {"Goblin brute", "Goblin rogue", "Goblin warlock"}) def test_shield_smiths_stop_after_the_move(self): world = world_with(("kay", "miner")) world["version"] = 13 world["players"]["kay"]["action"] = {"id": "smith_iron_shield", "start": T0} game.migrate(world, T0) self.assertIsNone(world["players"]["kay"]["action"]) class FittingTest(unittest.TestCase): def setUp(self): self.world = world_with(("fred", "miner")) self.p = self.world["players"]["fred"] self.buildings = self.world["city"]["buildings"] def test_install_needs_the_building_level(self): self.p["bank"] = {"shelving": 1} self.buildings["storehouse"]["level"] = 1 self.assertEqual(game.install(self.world, self.p, "shelving", "bank", T0), "Needs Storehouse level 2") self.buildings["storehouse"]["level"] = 2 slots = game.bank_slots(self.world, self.p) self.assertIsNone(game.install(self.world, self.p, "shelving", "bank", T0)) self.assertEqual(game.bank_slots(self.world, self.p), slots + 1) self.assertNotIn("shelving", self.p["bank"]) self.assertGreater(self.p["favour"], 0) self.p["bank"] = {"shelving": 1} self.assertEqual(game.install(self.world, self.p, "shelving", "bank", T0), "The storehouse already has that") def test_install_from_the_stockpile(self): self.world["city"]["stockpile"] = {"hoardings": 1} self.buildings["walls"]["level"] = 3 walls = game.defence(self.world)["parts"]["walls"] self.assertIsNone(game.install(self.world, self.p, "hoardings", "city", T0)) self.assertEqual(game.defence(self.world)["parts"]["walls"], walls + 25) self.assertEqual(self.p["favour"], 0) self.assertIn("Walls", game.city_uses("hoardings")) def test_damage_pauses_a_fitting(self): self.buildings["smithy"].update(level=3, fittings=["bellows"]) action = game.ACTION_BY_ID["smith_copper_bar"] fitted = game.action_time(self.world, self.p, action) self.buildings["smithy"]["damaged"] = True self.assertFalse(game.fitted(self.world, "bellows")) self.assertGreater(game.action_time(self.world, self.p, action), fitted) def test_pump_can_put_out_a_fire(self): self.buildings["well"].update(level=2, fittings=["well_pump"]) self.world["city"]["stockpile"] = {"logs": 100} rng = random.Random() rng.random = lambda: 0.1 game._hardship(self.world, T0, rng, "fire") self.assertEqual(self.world["city"]["stockpile"], {"logs": 100}) def test_every_fitting_can_be_made(self): for fitting in game.FITTINGS: action = game.ACTION_BY_ID[f"make_{fitting[0]}"] self.assertEqual(action["skill"], "construction") self.assertEqual(action["outputs"], {fitting[0]: 1}) self.assertIn(fitting[2], game.BUILDINGS) def test_migration_adds_fittings(self): world = game.new_world(T0) world["version"] = 9 for building in world["city"]["buildings"].values(): del building["fittings"] game.migrate(world, T0) self.assertEqual(world["city"]["buildings"]["houses"]["fittings"], []) class PinTest(unittest.TestCase): def test_pin_toggles(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] self.assertIsNone(game.toggle_pin(p, "mining")) self.assertEqual(p["pinned"], ["mining"]) self.assertIsNone(game.toggle_pin(p, "mining")) self.assertEqual(p["pinned"], []) self.assertIsNotNone(game.toggle_pin(p, "juggling")) def test_old_players_get_pins(self): world = world_with(("fred", "commoner")) del world["players"]["fred"]["pinned"] world["version"] = 18 game.migrate(world, T0) self.assertEqual(world["players"]["fred"]["pinned"], []) def test_track_toggles_one_recipe(self): world = world_with(("fred", "commoner")) p = world["players"]["fred"] recipes = [a["id"] for a in game.ACTIONS if a["inputs"]][:2] self.assertIsNone(game.toggle_track(p, recipes[0])) self.assertEqual(p["tracked"], recipes[0]) self.assertIsNone(game.toggle_track(p, recipes[1])) self.assertEqual(p["tracked"], recipes[1]) self.assertIsNone(game.toggle_track(p, recipes[1])) self.assertIsNone(p["tracked"]) gather = next(a["id"] for a in game.ACTIONS if not a["inputs"]) self.assertIsNotNone(game.toggle_track(p, gather)) self.assertIsNotNone(game.toggle_track(p, "juggling")) def test_old_players_get_tracking(self): world = world_with(("fred", "commoner")) del world["players"]["fred"]["tracked"] world["version"] = 21 game.migrate(world, T0) self.assertIsNone(world["players"]["fred"]["tracked"]) class IconTest(unittest.TestCase): def test_everything_has_a_glyph_in_the_sprite(self): sprite = (Path(__file__).resolve().parent.parent / "static" / "icons.svg").read_text() missing = [name for name in game.all_glyphs() if f'id="{name}"' not in sprite] self.assertEqual(missing, [], "run tools/build_icons.py") for item, spec in game.ITEMS.items(): self.assertIn("glyph", spec, item) if __name__ == "__main__": unittest.main() class CombatReworkMigrationTest(unittest.TestCase): def test_old_monsters_trophies_and_dragonhide_carry_over(self): world = world_with(("kay", "ranger")) world["version"] = 15 p = world["players"]["kay"] del p["xp"]["tailoring"] p["bank"] = {"bat_sinew": 3, "harpy_sinew": 2, "green_dragonhide": 4, "red_dragon_leather": 1} p["equip"]["body"] = "blue_dhide_body" p["action"] = {"id": "fight_giant_bat", "start": T0} world["players"]["kay"] = p game.migrate(world, T0) self.assertEqual(p["bank"], {"harpy_sinew": 3, "spider_silk": 2, "ironhill_lacing": 4, "mammoth_leather": 1}) self.assertEqual(p["equip"]["body"], "sabrecat_leather_body") self.assertEqual(p["action"]["id"], "fight_harpies") self.assertEqual(p["xp"]["tailoring"], 0) def test_homesteaders_tailor(self): self.assertIn("tailoring", game.CLASSES["homesteader"]["fast"]) self.assertIn("tailoring", game.CLASSES["homesteader"]["craft"]) class WorkOrderTest(unittest.TestCase): def setUp(self): self.world = world_with(("mike", "ranger"), ("fred", "miner"), ("dave", "builder")) self.mike, self.fred, self.dave = (self.world["players"][uid] for uid in ("mike", "fred", "dave")) self.fred["xp"]["smithing"] = game.XP_TABLE[25] self.dave["xp"]["smithing"] = game.XP_TABLE[25] self.world["city"]["buildings"]["smithy"]["level"] = 1 def post_hammer(self): self.mike["gold"] = 200 self.mike["bank"] = {"iron_bar": 3, "clay_core": 3} return game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, ["iron_bar", "clay_core"], 140, T0) def test_posting_holds_the_reagents_and_reward(self): self.assertIsNone(self.post_hammer()) order = self.world["orders"][0] self.assertEqual(order["provided"], {"iron_bar": 3, "clay_core": 3}) self.assertEqual(self.mike["bank"], {}) self.assertEqual(self.mike["gold"], 60) def test_a_claimed_order_is_crafted_and_sent_to_the_requester(self): self.post_hammer() order = self.world["orders"][0] self.fred["bank"] = {"oak_plank": 1} self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) self.assertEqual(game.claim_order(self.world, self.dave, order["id"]), "Someone else has it") game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) self.assertEqual(self.mike["bank"].get("iron_warhammer"), 1) self.assertEqual(self.fred["gold"], 140) self.assertEqual(order["status"], "done") self.assertNotIn("oak_plank", self.fred["bank"]) # the crafter brings what wasn't provided self.assertIsNone(self.fred["action"]) def test_cancelling_refunds_what_is_left(self): self.post_hammer() order = self.world["orders"][0] self.assertEqual(game.claim_order(self.world, self.mike, order["id"]), "That's your own order") self.assertIsNone(game.cancel_order(self.world, self.mike, order["id"], T0)) self.assertEqual(self.mike["bank"], {"iron_bar": 3, "clay_core": 3}) self.assertEqual(self.mike["gold"], 200) def test_materials_requests_are_open_and_pay_per_item(self): self.mike["gold"] = 400 self.assertIsNone(game.post_order(self.world, self.mike, "gather", "oak_logs", 200, None, 2, T0)) order = self.world["orders"][0] self.fred["bank"] = {"oak_logs": 40} self.dave["bank"] = {"oak_logs": 500} self.assertIsNone(game.deliver_order(self.world, self.fred, order["id"], T0)) self.assertIsNone(game.deliver_order(self.world, self.dave, order["id"], T0)) self.assertEqual(self.fred["gold"], 80) self.assertEqual(self.dave["gold"], 320) self.assertEqual(self.dave["bank"]["oak_logs"], 340) self.assertEqual(self.mike["bank"]["oak_logs"], 200) self.assertEqual(order["status"], "done") def test_orders_expire_and_refund(self): self.post_hammer() game.expire_orders(self.world, T0 + 4 * game.DAY) self.assertEqual(self.mike["gold"], 200) self.assertEqual(self.mike["bank"]["iron_bar"], 3) def test_finished_orders_can_be_removed(self): self.post_hammer() order = self.world["orders"][0] self.assertEqual(game.remove_orders(self.world, self.mike, order["id"]), "No such order") # still open game.cancel_order(self.world, self.mike, order["id"], T0) self.assertEqual(game.remove_orders(self.world, self.fred, order["id"]), "No such order") # not his self.assertIsNone(game.remove_orders(self.world, self.mike)) self.assertEqual(self.world["orders"], []) def test_requester_can_fill_a_slot_after_posting(self): self.mike["gold"] = 200 self.mike["bank"] = {"iron_bar": 3, "clay_core": 3} game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0) order = self.world["orders"][0] self.assertIsNone(game.provide_reagent(self.world, self.mike, order["id"], "iron_bar")) self.assertEqual(order["provided"], {"iron_bar": 3}) self.fred["bank"] = {"oak_plank": 1, "clay_core": 3} self.assertIsNone(game.work_order(self.world, self.fred, order["id"], T0)) game.process_player(self.world, self.fred, T0 + 600, random.Random(1)) self.assertEqual(self.mike["bank"].get("iron_warhammer"), 1) self.assertEqual(self.fred["bank"], {}) # used his own plank and cores, never needed bars class Always: """An rng where every roll comes up: a gem, a drop, whatever's on offer.""" def random(self): return 0.0 def choices(self, population, weights=None, k=1): return [population[0]] * k def randint(self, a, b): return a class JewelcraftingTest(unittest.TestCase): def setUp(self): self.world = world_with(("ann", "commoner")) self.ann = self.world["players"]["ann"] def test_recipes_take_a_band_a_gem_and_past_quartz_a_shard(self): self.assertEqual(game.ACTION_BY_ID["jc_quartz_haste"]["inputs"], {"copper_bar": 1, "quartz": 1}) self.assertEqual(game.ACTION_BY_ID["jc_garnet_vigour"]["inputs"], {"silver_bar": 2, "garnet": 1, "watchtower_shard": 2}) self.assertEqual(game.ACTION_BY_ID["jc_diamond_charm"]["inputs"], {"gold_bar": 1, "diamond": 2, "dragonspine_shard": 1}) self.assertIn(["watchtower_shard", 1, game.REAGENT_ODDS], game.ACTION_BY_ID["fight_harpies"]["drops"]) def test_mining_turns_up_gems(self): self.ann["xp"]["mining"] = game.XP_TABLE[15] game.start_action(self.world, self.ann, "mine_iron", T0) rock = game.ACTION_BY_ID["mine_iron"] game.process_player(self.world, self.ann, T0 + game.action_time(self.world, self.ann, rock) + 0.1, Always()) self.assertEqual(self.ann["bank"]["garnet"], 1) self.assertEqual(self.ann["bank"]["iron_ore"], 1) def test_prospecting_cracks_ore_for_gems(self): self.ann["xp"]["jewelcrafting"] = game.XP_TABLE[15] self.ann["bank"] = {"iron_ore": 7} game.start_action(self.world, self.ann, "prospect_garnet", T0) summary = game.process_player(self.world, self.ann, T0 + 1000, Always()) self.assertEqual(self.ann["bank"], {"iron_ore": 2, "garnet": 1}) self.assertEqual(summary["stopped"], "Out of iron ore") self.assertGreater(self.ann["xp"]["jewelcrafting"], game.XP_TABLE[15]) def test_jewellery_bonuses(self): p = self.ann p["xp"]["combat"] = game.XP_TABLE[20] before = game.fighter(p, game.NO_BLESSING, {}) hp = game.max_hp(p) p["equip"].update(ring="garnet_haste", amulet="garnet_vigour", trinket="garnet_hourglass") after = game.fighter(p, game.NO_BLESSING, {}) self.assertAlmostEqual(after["interval"], round(before["interval"] * 0.93, 2)) self.assertEqual(game.max_hp(p), hp + 10) game.gain_xp(self.world, p, "mining", 100, T0) self.assertAlmostEqual(p["xp"]["mining"], 103) p["equip"]["ring"] = "garnet_fury" self.assertAlmostEqual(game.fighter(p, game.NO_BLESSING, {})["crit"], before["crit"] + 0.03) p["equip"]["ring"] = "garnet_might" self.assertEqual(game.combat_power(p), game.combat_power({**p, "equip": {**p["equip"], "ring": None}}) + 4) def test_miners_cut_jewels(self): self.assertIn("jewelcrafting", game.CLASSES["miner"]["fast"]) self.assertIn("jewelcrafting", game.CLASSES["miner"]["craft"]) def test_gems_can_be_asked_for(self): self.assertEqual(game.ORDER_RAW["sapphire"], "mine_mithril") self.assertEqual(game.ORDER_RECIPES["emerald_warding"], "jc_emerald_warding") def test_migration_adds_the_skill_and_slots(self): self.world["version"] = 17 del self.ann["xp"]["jewelcrafting"] for slot in ("ring", "amulet", "trinket"): del self.ann["equip"][slot] game.migrate(self.world, T0) self.assertEqual(self.ann["xp"]["jewelcrafting"], 0) self.assertEqual({slot: self.ann["equip"][slot] for slot in ("ring", "amulet", "trinket")}, {"ring": None, "amulet": None, "trinket": None}) def test_rings_of_precision_became_haste(self): self.world["version"] = 19 self.ann["bank"] = {"garnet_precision": 2, "garnet": 1} self.ann["equip"]["ring"] = "quartz_precision" self.ann["action"] = {"id": "jc_garnet_precision", "start": T0} game.migrate(self.world, T0) self.assertEqual(self.ann["bank"], {"garnet_haste": 2, "garnet": 1}) self.assertEqual(self.ann["equip"]["ring"], "quartz_haste") self.assertEqual(self.ann["action"]["id"], "jc_garnet_haste") class StationAndStorageTest(unittest.TestCase): def setUp(self): self.world = world_with(("bob", "commoner")) self.bob = self.world["players"]["bob"] def test_recipes_pull_in_other_trades(self): self.assertEqual(game.ACTION_BY_ID["make_oak_loom"]["inputs"], {"oak_plank": 3, "iron_nails": 5, "hard_leather": 2}) self.assertEqual(game.ACTION_BY_ID["make_wooden_hearth"]["inputs"], {"plank": 3, "copper_rivets": 5, "copper_bar": 2}) self.assertEqual(game.ACTION_BY_ID["make_oak_strongbox"]["inputs"], {"oak_plank": 4, "iron_nails": 10, "hard_leather": 2}) self.assertEqual(game.ACTION_BY_ID["sew_cotton_bag"]["inputs"], {"cotton_cloth": 4, "hard_leather": 1}) self.assertEqual(game.ACTION_BY_ID["sew_cotton_bag"]["skill"], "tailoring") def test_a_station_speeds_its_craft(self): smelt = game.ACTION_BY_ID["smith_copper_bar"] bare = game.action_time(self.world, self.bob, smelt) self.bob["equip"]["station_smithing"] = "oak_anvil_stand" self.assertAlmostEqual(game.action_time(self.world, self.bob, smelt), bare * 0.7) self.bob["equip"]["tool_smithing"] = "copper_chisel" self.assertAlmostEqual(game.action_time(self.world, self.bob, smelt), bare * 0.7 * 0.85) def test_every_crafting_skill_has_a_tool_and_a_station(self): for skill in game.CRAFTING: self.assertIn(f"tool_{skill}", game.EQUIP_SLOTS) self.assertIn(f"station_{skill}", game.EQUIP_SLOTS) self.assertEqual(game.ACTION_BY_ID["smith_iron_mortar_and_pestle"]["inputs"], {"iron_bar": 2, "oak_logs": 1}) self.assertEqual(game.ACTION_BY_ID["make_oak_workbench"]["skill"], "construction") def test_a_workbench_doesnt_speed_the_building_site(self): labour = game.ACTION_BY_ID["labour"] bare = game.action_time(self.world, self.bob, labour) self.bob["equip"]["station_construction"] = "magic_workbench" self.assertEqual(game.action_time(self.world, self.bob, labour), bare) def test_stations_move_out_of_the_tool_slot(self): self.world["version"] = game.WORLD_VERSION - 1 self.bob["equip"]["tool_smithing"] = "oak_anvil_stand" del self.bob["equip"]["station_smithing"] game.migrate(self.world, T0) self.assertEqual((self.bob["equip"]["tool_smithing"], self.bob["equip"]["station_smithing"]), (None, "oak_anvil_stand")) def test_strongbox_adds_slots_and_bag_a_bigger_stack(self): slots, stack = game.bank_slots(self.world, self.bob), game.stack_size(self.world, self.bob) self.bob["bank"] = {"stone": 1200} self.assertEqual(game.slots_used(self.world, self.bob), 2) self.bob["equip"].update(storage="oak_strongbox", bag="linen_bag") self.assertEqual(game.bank_slots(self.world, self.bob), slots + 4) self.assertEqual(game.stack_size(self.world, self.bob), stack + 250) self.assertEqual(game.slots_used(self.world, self.bob), 1) def test_no_smaller_strongbox_while_the_bank_is_full(self): import types import app handler = types.SimpleNamespace(store=types.SimpleNamespace(world=self.world)) handler._equip = lambda *args: app.Handler._equip(handler, *args) self.bob["equip"]["storage"] = "oak_strongbox" self.bob["bank"] = {f"{wood}_plank" if wood != "wooden" else "plank": 1 for wood in game.WOODS} self.bob["bank"].update({item: 1 for item in ("stone", "logs", "coal", "bones", "wheat", "flax", "rope", "bricks", "thatch", "wooden_strongbox")}) self.assertEqual(game.overfull(self.world, self.bob), 0) # 16 items in 12 + 4 slots: room for the swap, not after self.assertEqual(app.Handler.equip(handler, self.bob, "wooden_strongbox", None), "Bank too full for that") self.assertEqual(self.bob["equip"]["storage"], "oak_strongbox") self.assertEqual(self.bob["bank"]["wooden_strongbox"], 1)