2026-10-18 13:48:56 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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)
|
|
|
|
|
|
2026-10-18 19:35:21 +01:00
|
|
|
def test_make_one_then_back_to_work(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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"])
|
|
|
|
|
|
2026-10-18 13:48:56 +01:00
|
|
|
def test_class_speed_and_lucky_yield(self):
|
|
|
|
|
world = world_with(("pia", "pioneer"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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_level_and_city_requirements(self):
|
|
|
|
|
world = world_with(("ann", "warrior"))
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
def test_full_bank_overflows_to_city(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
p = world["players"]["fred"]
|
|
|
|
|
p["bank"] = {"stone": game.BASE_STACK - 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)
|
|
|
|
|
self.assertEqual(summary["overflow"], {"stone": 3})
|
|
|
|
|
self.assertEqual(world["city"]["stockpile"]["stone"], 3)
|
2026-10-18 19:54:08 +01:00
|
|
|
self.assertEqual(p["favour"], 1.5) # half favour for overflow
|
|
|
|
|
self.assertEqual(p["donated"], 3)
|
2026-10-18 13:48:56 +01:00
|
|
|
|
2026-10-18 22:40:36 +01:00
|
|
|
def test_full_bank_sells_what_the_city_cannot_use(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
p = world["players"]["fred"]
|
|
|
|
|
p["bank"] = {"copper_ore": game.BASE_STACK - 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", "warrior"))
|
|
|
|
|
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"])
|
|
|
|
|
|
2026-10-18 13:48:56 +01:00
|
|
|
def test_full_bank_can_stop_instead(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
p = world["players"]["wil"]
|
2026-10-18 19:27:56 +01:00
|
|
|
p["bank"] = {"bread": 500}
|
2026-10-18 13:48:56 +01:00
|
|
|
game.start_action(world, p, "fight_wolves", T0)
|
|
|
|
|
summary = game.process_player(world, p, T0 + 48 * 100, random.Random(5))
|
|
|
|
|
self.assertEqual(summary["done"], 100)
|
|
|
|
|
self.assertEqual(summary["wins"] + summary["losses"], 100)
|
|
|
|
|
self.assertEqual(p["gold"], summary["wins"] * 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GiftTest(unittest.TestCase):
|
|
|
|
|
def test_give_moves_items_within_bank_room(self):
|
|
|
|
|
world = world_with(("fred", "pioneer"), ("kay", "artisan"))
|
|
|
|
|
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):
|
2026-10-18 19:20:52 +01:00
|
|
|
def test_fund_from_bank_and_stores_then_builds(self):
|
2026-10-18 13:48:56 +01:00
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
p = world["players"]["fred"]
|
2026-10-18 19:20:52 +01:00
|
|
|
city = world["city"]
|
|
|
|
|
p["bank"] = {"stone": 1500}
|
|
|
|
|
city["stockpile"] = {"bricks": 600}
|
|
|
|
|
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.assertIsNone(game.fund(world, p, "walls", "bricks", 500, "city", T0))
|
|
|
|
|
self.assertEqual(city["stockpile"], {"bricks": 100})
|
|
|
|
|
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"], {})
|
2026-10-19 09:24:45 +01:00
|
|
|
self.assertEqual(city["construction"]["needed"], game.LABOUR_PER_LEVEL)
|
|
|
|
|
city["construction"]["labour"] = game.LABOUR_PER_LEVEL
|
|
|
|
|
game.tick(world, T0 + 1, random.Random(1))
|
2026-10-18 19:20:52 +01:00
|
|
|
self.assertEqual(city["buildings"]["walls"]["level"], 1)
|
2026-10-18 13:48:56 +01:00
|
|
|
self.assertEqual(game.defence(world)["parts"]["walls"], 20)
|
|
|
|
|
|
2026-10-18 19:20:52 +01:00
|
|
|
def test_funded_buildings_queue_behind_the_builders(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
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"])
|
2026-10-19 09:24:45 +01:00
|
|
|
city["construction"]["labour"] = city["construction"]["needed"]
|
|
|
|
|
game.tick(world, T0 + 1, random.Random(1))
|
2026-10-18 19:20:52 +01:00
|
|
|
self.assertEqual(city["construction"]["building"], "houses")
|
|
|
|
|
self.assertEqual(city["queue"], [])
|
|
|
|
|
|
|
|
|
|
def test_raids_never_touch_funds(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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"], {})
|
|
|
|
|
|
2026-10-18 22:41:11 +01:00
|
|
|
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")
|
|
|
|
|
|
2026-10-18 19:20:52 +01:00
|
|
|
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
|
|
|
|
|
|
2026-10-18 13:48:56 +01:00
|
|
|
def test_people_eat_and_go_hungry(self):
|
|
|
|
|
world = world_with()
|
|
|
|
|
city = world["city"]
|
|
|
|
|
city["stockpile"] = {"bread": 5}
|
2026-10-18 19:20:52 +01:00
|
|
|
game.tick(world, T0 + 4 * game.DAY, random.Random(1))
|
2026-10-18 13:48:56 +01:00
|
|
|
self.assertTrue(city["starving"])
|
|
|
|
|
self.assertNotIn("bread", city["stockpile"])
|
|
|
|
|
self.assertLess(city["population"], 20)
|
|
|
|
|
|
|
|
|
|
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", "warrior", 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_spymaster_and_recon_sharpen_intel(self):
|
|
|
|
|
world = world_with(("sam", "scout"))
|
|
|
|
|
raid = self.announce(world)
|
|
|
|
|
world["city"]["buildings"]["spymaster"]["level"] = 1
|
|
|
|
|
self.assertIn("size", game.event_view(world, raid, T0))
|
|
|
|
|
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 + 8 * 72, random.Random(1))
|
|
|
|
|
view = game.event_view(world, raid, T0)
|
|
|
|
|
self.assertEqual(view["intel"], 3)
|
|
|
|
|
self.assertEqual(view["exact"], raid["fire_at"])
|
|
|
|
|
self.assertIn("strength", view)
|
|
|
|
|
self.assertTrue(view["camp"])
|
|
|
|
|
|
|
|
|
|
def test_victory_rewards_garrison(self):
|
|
|
|
|
world = world_with(("wil", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
p = world["players"]["wil"]
|
|
|
|
|
p["xp"]["combat"] = game.XP_TABLE[40]
|
2026-10-18 19:27:56 +01:00
|
|
|
p["hp"] = game.max_hp(p)
|
2026-10-18 13:48:56 +01:00
|
|
|
raid = self.announce(world)
|
|
|
|
|
self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located")
|
|
|
|
|
raid["recon"] = 3 * game.RECON_PER_INTEL
|
|
|
|
|
self.assertIsNone(game.start_action(world, p, "sabotage", T0))
|
|
|
|
|
game.process_player(world, p, T0 + 96 * 20, random.Random(1))
|
|
|
|
|
self.assertGreater(raid["sabotage"], 0)
|
|
|
|
|
self.assertLessEqual(raid["sabotage"], 0.3)
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 19:27:56 +01:00
|
|
|
|
|
|
|
|
class CombatTest(unittest.TestCase):
|
|
|
|
|
def fighter(self, level=10, **equip):
|
|
|
|
|
world = world_with(("wil", "warrior"))
|
|
|
|
|
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()
|
2026-10-18 23:09:17 +01:00
|
|
|
p["bank"] = {"bread": 100}
|
2026-10-18 19:27:56 +01:00
|
|
|
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)
|
2026-10-18 23:09:17 +01:00
|
|
|
self.assertEqual(p["bank"]["bread"], 100 - summary["eaten"]["bread"])
|
2026-10-18 19:27:56 +01:00
|
|
|
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)
|
|
|
|
|
wolves = game.ACTION_BY_ID["fight_scouts"]
|
|
|
|
|
self.assertLess(game.fight_damage(armoured, wolves), game.fight_damage(bare, wolves) * 0.7)
|
|
|
|
|
self.assertLess(game.fight_damage(veteran, wolves), game.fight_damage(bare, wolves))
|
|
|
|
|
|
|
|
|
|
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", "warrior"))
|
|
|
|
|
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", "scout"))
|
|
|
|
|
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)
|
|
|
|
|
self.assertIn("cooked_shrimp", game.FOOD_ORDER)
|
|
|
|
|
|
|
|
|
|
def test_hides_to_armour(self):
|
|
|
|
|
world = world_with(("kay", "artisan"))
|
|
|
|
|
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", "pioneer"))
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 19:48:22 +01:00
|
|
|
|
|
|
|
|
class FavourTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.world = world_with(("fred", "pioneer"))
|
|
|
|
|
self.p = self.world["players"]["fred"]
|
|
|
|
|
self.p["favour"] = 5000
|
|
|
|
|
|
2026-10-19 09:24:45 +01:00
|
|
|
def test_builders_do_half_the_work_left(self):
|
2026-10-18 19:48:22 +01:00
|
|
|
world, p = self.world, self.p
|
|
|
|
|
self.assertEqual(game.spend_favour(world, p, "builders", T0), "Nothing is being built")
|
2026-10-19 09:24:45 +01:00
|
|
|
world["city"]["construction"] = {"building": "walls", "repair": False, "start": T0, "labour": 60.0, "needed": 360}
|
2026-10-18 19:48:22 +01:00
|
|
|
cost = game.favours(world, T0)["builders"]["cost"]
|
2026-10-19 09:24:45 +01:00
|
|
|
self.assertEqual(cost, 101) # 150 labour at 0.67 favour each
|
2026-10-18 19:48:22 +01:00
|
|
|
self.assertIsNone(game.spend_favour(world, p, "builders", T0))
|
2026-10-19 09:24:45 +01:00
|
|
|
self.assertEqual(world["city"]["construction"]["labour"], 210)
|
|
|
|
|
self.assertEqual(p["favour"], 5000 - 101)
|
2026-10-18 19:48:22 +01:00
|
|
|
|
|
|
|
|
def test_informant_raises_intel(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_event(world)
|
|
|
|
|
for level in (1, 2, 3):
|
|
|
|
|
self.assertIsNone(game.spend_favour(world, p, "informant", T0))
|
|
|
|
|
self.assertEqual(game.intel_level(world, event), level)
|
|
|
|
|
self.assertEqual(game.spend_favour(world, p, "informant", T0), "Nothing more to learn")
|
|
|
|
|
|
|
|
|
|
def test_rallies_cost_more_each_time_and_end_with_the_raid(self):
|
|
|
|
|
world, p = self.world, self.p
|
|
|
|
|
self.assertEqual(game.spend_favour(world, p, "militia", T0), "No raid to prepare for")
|
|
|
|
|
world["next_event_at"] = T0
|
|
|
|
|
game.tick(world, T0, random.Random(2))
|
|
|
|
|
before = game.defence(world)["total"]
|
|
|
|
|
game.spend_favour(world, p, "militia", T0)
|
|
|
|
|
self.assertEqual(game.favours(world, T0)["militia"]["cost"], 300)
|
|
|
|
|
game.spend_favour(world, p, "militia", T0)
|
|
|
|
|
self.assertEqual(game.defence(world)["total"], before + 20)
|
|
|
|
|
self.assertEqual(p["favour"], 5000 - 150 - 300)
|
|
|
|
|
game.tick(world, game.upcoming_raid(world)["fire_at"], random.Random(1))
|
|
|
|
|
self.assertEqual(game.defence(world)["parts"]["rallied"], 0)
|
|
|
|
|
self.assertEqual(game.favours(world, T0)["militia"]["cost"], 150)
|
|
|
|
|
|
|
|
|
|
def test_not_enough_favour(self):
|
|
|
|
|
self.p["favour"] = 10
|
2026-10-19 09:24:45 +01:00
|
|
|
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 121 favour")
|
2026-10-18 19:48:22 +01:00
|
|
|
|
|
|
|
|
|
2026-10-18 22:13:50 +01:00
|
|
|
|
|
|
|
|
class TierTest(unittest.TestCase):
|
|
|
|
|
def test_steel_needs_coal_and_the_smithy(self):
|
|
|
|
|
world = world_with(("kay", "artisan"))
|
|
|
|
|
p = world["players"]["kay"]
|
|
|
|
|
p["xp"]["smithing"] = game.XP_TABLE[30]
|
|
|
|
|
p["bank"] = {"iron_ore": 5, "coal": 10}
|
|
|
|
|
self.assertEqual(game.start_action(world, p, "smith_steel_bar", T0), "Needs Smithy level 2")
|
|
|
|
|
world["city"]["buildings"]["smithy"]["level"] = 2
|
|
|
|
|
self.assertIsNone(game.start_action(world, p, "smith_steel_bar", T0))
|
|
|
|
|
game.process_player(world, p, T0 + 1000, random.Random(1))
|
|
|
|
|
self.assertGreaterEqual(p["bank"]["steel_bar"], 5)
|
|
|
|
|
self.assertNotIn("iron_ore", p["bank"])
|
|
|
|
|
|
|
|
|
|
def test_every_tier_has_full_kit(self):
|
|
|
|
|
for key, *_ in game.METALS:
|
|
|
|
|
for piece in ("sword", "shield", "helmet", "legs", "body", "pickaxe", "axe", "hoe", "fishing_rod", "hunting_knife"):
|
|
|
|
|
self.assertIn(f"{key}_{piece}", game.ITEMS)
|
|
|
|
|
self.assertIn(f"smith_{key}_{piece}", game.ACTION_BY_ID)
|
|
|
|
|
for *_, prefix, _label, _level, _armour in game.LEATHERS:
|
|
|
|
|
for piece in ("cap", "legs", "body"):
|
|
|
|
|
self.assertIn(f"{prefix}_{piece}", game.ITEMS)
|
|
|
|
|
self.assertEqual(game.ITEMS["rune_body"]["plural"], "Rune cuirasses")
|
|
|
|
|
|
|
|
|
|
def test_gear_needs_levels_to_wear(self):
|
|
|
|
|
world = world_with(("wil", "warrior"))
|
|
|
|
|
p = world["players"]["wil"]
|
|
|
|
|
self.assertEqual(game.equip_problem(p, "rune_sword"), "Needs combat level 50")
|
|
|
|
|
self.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 20")
|
|
|
|
|
self.assertIsNone(game.equip_problem(p, "copper_sword"))
|
|
|
|
|
p["xp"]["combat"] = game.XP_TABLE[50]
|
|
|
|
|
self.assertIsNone(game.equip_problem(p, "rune_sword"))
|
|
|
|
|
|
|
|
|
|
def test_militia_takes_the_best_weapons_first(self):
|
|
|
|
|
world = world_with()
|
|
|
|
|
world["city"]["stockpile"] = {"copper_sword": 50, "rune_sword": 2, "iron_shield": 3}
|
|
|
|
|
parts = game.defence(world)["parts"]
|
|
|
|
|
armed = int(world["city"]["population"] // 2) # 10
|
|
|
|
|
self.assertEqual(parts["weapons"], round(0.35 * (2 * 35 + (armed - 2) * 4), 1))
|
|
|
|
|
self.assertEqual(parts["shields"], 0.5 * 3 * 4)
|
|
|
|
|
|
|
|
|
|
def test_higher_building_levels_need_higher_tiers(self):
|
|
|
|
|
self.assertNotIn("steel_bar", game.build_cost("walls", 2))
|
|
|
|
|
self.assertEqual(game.build_cost("walls", 3)["steel_bar"], 60)
|
|
|
|
|
self.assertEqual(game.build_cost("walls", 5)["mithril_bar"], 40)
|
|
|
|
|
self.assertEqual(game.build_cost("walls", 5)["steel_bar"], 180)
|
|
|
|
|
|
|
|
|
|
def test_dragons_drop_dragonhide(self):
|
|
|
|
|
world = world_with(("wil", "warrior"))
|
|
|
|
|
p = world["players"]["wil"]
|
|
|
|
|
p["xp"]["combat"] = game.XP_TABLE[80]
|
|
|
|
|
p["equip"].update(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body", legs="rune_legs")
|
|
|
|
|
p["hp"] = game.max_hp(p)
|
|
|
|
|
p["bank"] = {"cooked_swordfish": 200}
|
|
|
|
|
game.start_action(world, p, "fight_green_dragon", T0)
|
|
|
|
|
game.process_player(world, p, T0 + 3600 * 2, random.Random(3))
|
|
|
|
|
self.assertGreater(p["bank"].get("green_dragonhide", 0), 30)
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 23:09:17 +01:00
|
|
|
|
|
|
|
|
class PrayerTest(unittest.TestCase):
|
|
|
|
|
def priest(self, level=1, combat=10):
|
|
|
|
|
world = world_with(("pat", "warrior"))
|
|
|
|
|
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_damage(p, scouts)
|
|
|
|
|
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_damage(p, scouts, blessed), 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_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", "pioneer"))
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 23:12:57 +01:00
|
|
|
|
|
|
|
|
class HerbloreTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.world = world_with(("hal", "warrior"))
|
|
|
|
|
self.p = self.world["players"]["hal"]
|
|
|
|
|
|
|
|
|
|
def test_herbs_to_potion(self):
|
|
|
|
|
world, p = self.world, self.p
|
|
|
|
|
p["xp"]["farming"] = game.XP_TABLE[5]
|
|
|
|
|
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["xp"]["herblore"] = game.XP_TABLE[6]
|
|
|
|
|
p["bank"]["logs"] = 3
|
|
|
|
|
game.start_action(world, p, "brew_lumberjacks_tonic", T0 + 450)
|
|
|
|
|
game.process_player(world, p, T0 + 1000, random.Random(1))
|
|
|
|
|
self.assertEqual(p["bank"]["lumberjacks_tonic"], 3)
|
|
|
|
|
|
|
|
|
|
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"] = {"lumberjacks_tonic": 1}
|
|
|
|
|
self.assertIsNone(game.use_potion(p, "lumberjacks_tonic"))
|
|
|
|
|
self.assertEqual(p["potions"]["woodcutting"], {"item": "lumberjacks_tonic", "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"], ["lumberjacks_tonic"])
|
|
|
|
|
self.assertNotIn("woodcutting", p["potions"])
|
|
|
|
|
|
|
|
|
|
def test_empty_potion_is_replaced_from_the_bank(self):
|
|
|
|
|
world, p = self.world, self.p
|
|
|
|
|
p["bank"] = {"trackers_tincture": 2}
|
|
|
|
|
game.use_potion(p, "trackers_tincture")
|
|
|
|
|
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"], {"trackers_tincture": 1})
|
|
|
|
|
self.assertEqual(p["potions"]["hunting"]["charges"], game.POTION_CHARGES)
|
|
|
|
|
self.assertNotIn("trackers_tincture", p["bank"])
|
|
|
|
|
|
|
|
|
|
def test_keep_chance_saves_materials(self):
|
|
|
|
|
world, p = self.world, self.p
|
|
|
|
|
p["bank"] = {"copper_ore": 100, "logs": 100, "smiths_focus": 1}
|
|
|
|
|
game.use_potion(p, "smiths_focus")
|
|
|
|
|
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": 1, "bread": 500}
|
|
|
|
|
game.use_potion(p, "defence_potion")
|
|
|
|
|
game.start_action(world, p, "fight_scouts", T0)
|
|
|
|
|
scouts = game.ACTION_BY_ID["fight_scouts"]
|
|
|
|
|
brewed = game.with_potion(game.NO_BLESSING, game.potion_effect(p, "combat"))
|
|
|
|
|
self.assertAlmostEqual(game.fight_damage(p, scouts, brewed), game.fight_damage(p, scouts) * 0.85)
|
|
|
|
|
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_v5_worlds_get_herblore(self):
|
|
|
|
|
world = world_with(("fred", "pioneer"))
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 09:24:45 +01:00
|
|
|
|
|
|
|
|
class ConstructionTest(unittest.TestCase):
|
|
|
|
|
def site(self):
|
|
|
|
|
world = world_with(("bob", "pioneer"), ("ann", "warrior"))
|
|
|
|
|
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()
|
|
|
|
|
game.tick(world, T0 + 10 * game.HOUR, random.Random(1))
|
|
|
|
|
self.assertAlmostEqual(world["city"]["construction"]["labour"], 10 * 20 * 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", "warrior"))
|
|
|
|
|
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", "pioneer"))
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 09:34:18 +01:00
|
|
|
|
|
|
|
|
class HappeningsTest(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.world = world_with(("ann", "warrior"), ("sam", "scout"))
|
|
|
|
|
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")
|
|
|
|
|
|
2026-10-19 09:42:36 +01:00
|
|
|
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)
|
|
|
|
|
|
2026-10-19 09:34:18 +01:00
|
|
|
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"), 1.5)
|
|
|
|
|
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_halve_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"])["yield"], 0.5)
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 13:48:56 +01:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|