guildhall/tests/test_game.py
rope 387f4c201b Raid replay plays like a fight; fight-once can be joined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:54:37 +00:00

2922 lines
154 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
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}
p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs")
game.start_action(world, p, "fight_wolves", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(5))
self.assertGreater(summary["done"], 30) # 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"] - 25 * 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_threats_land_every_two_days_and_the_camp_opens_a_day_out(self):
world = world_with(("sam", "ranger"))
raid = self.announce(world)
self.assertLessEqual(abs(raid["fire_at"] - (T0 + 48 * game.HOUR)), 2 * game.HOUR)
raid["recon"] = game.intel_costs(raid)[-1]
self.assertEqual(game.intel_level(world, raid, raid["fire_at"] - 24 * game.HOUR - 1), 2)
self.assertEqual(game.intel_level(world, raid, raid["fire_at"] - 24 * game.HOUR), 3)
raid["strength"] = 0
game.tick(world, raid["fire_at"], random.Random(3))
following = game.upcoming_raid(world)
self.assertIsNotNone(following) # rumoured the moment the last one lands
self.assertEqual(following["slot"], raid["slot"] + 48 * game.HOUR)
def test_v26_worlds_get_livelier_tracks(self):
world = world_with()
world["version"] = 26
world["next_fortune_at"] = T0 + 70 * game.HOUR
world["next_event_at"] = T0 + 20 * game.HOUR
game.migrate(world, T0)
self.assertEqual(world["next_fortune_at"], T0 + 20 * game.HOUR)
self.assertEqual(world["next_event_at"], T0)
def test_only_paid_guards_stand_and_hold_kit(self):
world = world_with(("sam", "commoner"))
city = world["city"]
city["population"] = 100
city["buildings"]["barracks"]["level"] = 2
city["stockpile"] = {"copper_sword": 100}
self.assertEqual(game.guard_posts(world), 50)
self.assertEqual(game.defence(world)["parts"]["weapons"], 0)
p = world["players"]["sam"]
p["gold"] = 1000
self.assertIsNone(game.pay_wages(world, p, 600, "bank", T0))
self.assertEqual(game.guards_on_duty(world), 10)
self.assertEqual(p["favour"], 600)
parts = game.defence(world)["parts"]
self.assertEqual(parts["weapons"], round(0.35 * 10 * 4, 1))
self.assertEqual(parts["militia"], round(0.3 * 2 * 100 * 10 / 50, 1))
city["treasury"] = 10_000
self.assertIsNone(game.pay_wages(world, p, "all", "city", T0))
self.assertEqual(city["wages"], 50 * game.GUARD_WAGE) # never more than every post
self.assertEqual(game.pay_wages(world, p, 1, "bank", T0), "Every post is paid for")
event = {"strength": 1, "sabotage": 0, "group": "the Red Hand"}
game._resolve_raid(world, event, T0, random.Random(1))
self.assertEqual(event["report"]["guards"], 50)
self.assertEqual(city["wages"], 0)
def test_threat_size_is_against_standing_defence(self):
self.assertEqual(game.threat_size({"standing": 100}, 110), 0)
self.assertEqual(game.threat_size({"standing": 100}, 260), 3)
self.assertEqual(game.threat_size({"strength": 20}, 20), 0) # rumoured before v29
def test_v28_worlds_pay_the_coming_threats_guards(self):
world = world_with()
world["version"] = 28
game.migrate(world, T0)
self.assertEqual(game.guards_on_duty(world), game.guard_posts(world))
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_three_knockouts_in_a_row_stop_you_and_nothing_is_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.assertEqual(summary["stopped"], "Knocked out 3 times in a row")
self.assertEqual(summary["losses"], 3)
self.assertEqual(p["equip"]["weapon"], "copper_sword")
self.assertEqual(p["bank"]["stone"], 10)
self.assertAlmostEqual(p["hp"], game.max_hp(p) * game.KNOCKED_OUT_HP)
self.assertIsNone(game.start_action(world, p, "fight_scouts", T0 + 6 * 3600)) # you can always go again
def test_a_win_resets_the_knockouts(self):
world, p = self.fighter(level=1, weapon="copper_sword")
game.start_action(world, p, "fight_wolves", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(1))
self.assertGreater(summary["losses"], 2) # knocked out now and then, never three in a row
self.assertEqual(p["action"]["id"], "fight_wolves")
self.assertGreater(summary["wins"], 20)
def test_food_shortens_the_rest_between_fights(self):
world, p = self.fighter(level=30)
p["eat_below"] = 1.0 # eat whenever a bite is quicker
top = game.max_hp(p)
p["bank"] = {}
resting, eaten = game.rest_plan(world, p, top / 2)
self.assertEqual(eaten, {})
self.assertAlmostEqual(resting, top / 2 / (top * game.REST_RATE))
p["bank"] = {"bread": 100}
bread, eaten = game.rest_plan(world, p, top / 2)
self.assertLess(bread, resting)
self.assertEqual(eaten, {"bread": 10}) # 80 HP back in 8 HP bites
p["bank"] = {"cooked_salmon": 100}
salmon, eaten = game.rest_plan(world, p, top / 2)
self.assertLess(salmon, bread) # better food, shorter rest
self.assertEqual(eaten, {"cooked_salmon": 3})
self.assertEqual(game.rest_plan(world, p, top)[0], game.RESPAWN) # at full: a breather
p["xp"]["combat"] = game.XP_TABLE[99]
p["bank"] = {"bread": 100}
self.assertEqual(game.rest_plan(world, p, 100)[1], {}) # a bite of bread is no faster than resting
def test_you_choose_how_hurt_you_eat_at(self):
world, p = self.fighter(level=30)
top = game.max_hp(p)
p["bank"] = {"cooked_salmon": 100}
self.assertEqual(p["eat_below"], 0.5)
self.assertEqual(game.rest_plan(world, p, top * 0.95)[1], {}) # a scratch: just rest
_seconds, eaten = game.rest_plan(world, p, top * 0.2)
self.assertEqual(eaten, {"cooked_salmon": 2}) # back over half, then rest
self.assertIsNone(game.set_eat_below(p, 0))
self.assertEqual(game.rest_plan(world, p, top * 0.2)[1], {}) # never
self.assertIsNone(game.set_eat_below(p, 1.0))
self.assertEqual(game.rest_plan(world, p, top * 0.2)[1], {"cooked_salmon": 5}) # all the way
self.assertEqual(game.set_eat_below(p, 0.3), "Pick one of the choices")
def test_food_is_eaten_resting_not_fighting(self):
world, p = self.fighter(level=15, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
p["bank"] = {"cooked_trout": 100}
p["hp"] = 10
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(3))
mine = game.member_of(fight, p["id"])
self.assertGreater(sum(mine["eaten"].values()), 0)
self.assertGreater(fight["rest"], game.RESPAWN)
self.assertEqual(mine["hp_in"], game.max_hp(p)) # every fight starts rested
self.assertTrue(all(e[0] >= fight["rest"] for e in fight["events"]))
self.assertFalse(any(e[3] == "eat" for e in fight["events"]))
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_slice_by_slice(self):
world, p = self.fighter(level=20, weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
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")
kinds = {"hit", "crit", "tick", "miss", "block", "absorb", "heal", "mend", "cast", "use", "stop", "cc", "free",
"die", "enrage", "wind", "down", "left"}
self.assertTrue(all(e[3] in kinds for e in fight["events"]))
self.assertTrue(all(e[1] in (p["id"], None) or e[1].startswith("e") for e in fight["events"]))
dead = [e[2] for e in fight["events"] if e[3] == "die"]
self.assertEqual(sorted(dead), sorted(f"e{i}" for i in range(len(fight["pack"])))) # the last of them 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"], game.member_of(fight, p["id"])["hp"], delta=0.1)
self.assertAlmostEqual(p["action"]["start"], T0 + fight["duration"]) # straight on to the next rest
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_fight_starts_with_every_swing_timer_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"]) # the rest starts at the kill
second = p["action"]["fight"]
swing = next(e[0] for e in second["events"] if e[1] == p["id"] and e[6] in game.DAMAGE_TYPES)
self.assertAlmostEqual(swing, second["rest"] + 2.5) # a full swing from scratch, to the slice
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 AbilityTest(unittest.TestCase):
def fighter(self, cls, level, tier=1, **equip):
world = world_with(("ash", cls))
p = world["players"]["ash"]
p["xp"]["combat"] = game.XP_TABLE[level]
p["equip"].update(equip)
p["hp"] = game.max_hp(p)
return world, p
def test_the_weapon_gives_four_abilities_and_the_trade_one(self):
known = lambda style, cls, level, shield=False: game.combat.known_abilities(style, cls, level, shield)
self.assertEqual(set(known("hammer", "miner", 1)), {"crushing_blow", "find_the_seam"})
self.assertEqual(set(known("hammer", "miner", 45)), {"crushing_blow", "concuss", "earthshaker", "unstoppable",
"find_the_seam"})
self.assertEqual(set(known("bow", "miner", 15)), {"aimed_shot", "pinning_shot", "find_the_seam"}) # any weapon
self.assertEqual(set(known("sword", "builder", 45)), {"challenge", "sweeping_cut", "barricade"}) # no shield
self.assertEqual(set(known("sword", "builder", 45, True)), {"shield_bash", "challenge", "sweeping_cut",
"shield_wall", "barricade"})
self.assertEqual(known("staff", "homesteader", 30), ["mend", "ward", "hearty_meal", "bolt"]) # auto's order
self.assertEqual(game.combat.role("sword", True), "tank")
self.assertEqual(game.combat.role("sword", False), "damage")
self.assertEqual(game.combat.role("staff", False), "healer")
def test_abilities_are_used_in_the_fight(self):
world, p = self.fighter("ranger", 30, weapon="willow_bow")
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_clay_golems"], random.Random(1))
used = {e[6] for e in fight["events"] if e[1] == p["id"] and e[6] in game.combat.ABILITIES}
self.assertIn("aimed_shot", used)
casts = [e for e in fight["events"] if e[1] == p["id"] and e[3] == "cast"]
self.assertTrue(casts)
def test_casters_get_stopped(self):
world, p = self.fighter("ranger", 30, weapon="willow_bow")
harpies = game.ACTION_BY_ID["fight_harpies"]
stops = 0
for seed in range(20):
fight = game.simulate_fight(world, p, harpies, random.Random(seed))
stops += sum(1 for e in fight["events"] if e[3] == "stop" and e[6] == "hex_bolt")
self.assertGreater(stops, 0) # Pinning Shot stops a windcaster's hex
def test_a_health_potion_once_a_fight_when_low(self):
world, p = self.fighter("miner", 10, weapon="copper_warhammer")
p["bank"] = {"health_potion_1": 5, "health_potion_2": 5}
self.assertEqual(game.health_potion(p), "health_potion_2")
game.start_action(world, p, "fight_scouts", T0)
summary = game.process_player(world, p, T0 + 3600, random.Random(1))
drunk = summary["used"].get("health_potion_2", 0)
self.assertGreater(drunk, 0)
self.assertLessEqual(drunk, summary["done"])
self.assertEqual(p["bank"].get("health_potion_2", 0), 5 - drunk)
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(3))
self.assertLessEqual(sum(1 for e in fight["events"] if e[3] == "heal" and e[6] == "potion"), 1)
def test_health_potions_are_brewed(self):
brew = game.ACTION_BY_ID["brew_health_potion_3"]
self.assertEqual((brew["skill"], brew["level"]), ("herblore", 30))
self.assertEqual(brew["outputs"], {"health_potion_3": 1})
self.assertIn("tarromin", brew["inputs"])
self.assertIn("cooked_salmon", brew["inputs"])
self.assertEqual(game.ITEMS["health_potion_3"]["drink"], game.HEALTH_HEAL[2])
self.assertNotIn("health_potion_3", game.edible({"bank": {"health_potion_3": 1}, "food": None}))
def test_volley_needs_no_arrows(self):
world, p = self.fighter("ranger", 45, weapon="maple_bow")
p["bank"] = {}
fight = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_goblins"], random.Random(2))
self.assertTrue(any(e[1] == p["id"] and e[6] == "volley" for e in fight["events"]))
def test_a_party_rests_as_long_as_its_slowest(self):
world = world_with(("wil", "builder"), ("kay", "builder"))
wil, kay = world["players"]["wil"], world["players"]["kay"]
for p in (wil, kay):
p["xp"]["combat"] = game.XP_TABLE[20]
wil["hp"], kay["hp"] = game.max_hp(wil), 10
fight = game.draw_fight(world, [wil, kay], game.ACTION_BY_ID["fight_scouts"], random.Random(1))
self.assertAlmostEqual(fight["rest"], game.rest_plan(world, kay)[0])
def test_fights_in_progress_are_drawn_again_after_the_update(self):
world = world_with(("wil", "builder"))
wil = world["players"]["wil"]
game.start_action(world, wil, "fight_wolves", T0)
wil["action"]["fight"] = {"old": True}
world["parties"]["1"] = {"id": "1", "action": "fight_wolves", "members": [], "start": T0, "fight": {"old": True}}
world["version"] = 25
game.migrate(world, T0)
self.assertNotIn("fight", wil["action"])
self.assertIsNone(world["parties"]["1"]["fight"])
class ActivePlayTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("ash", "miner"))
self.p = self.world["players"]["ash"]
self.p["xp"]["combat"] = game.XP_TABLE[30]
self.p["equip"].update(weapon="steel_warhammer", helmet="steel_helmet", body="steel_body", legs="steel_legs")
self.p["hp"] = game.max_hp(self.p)
self.p["last_active"] = T0
game.start_action(self.world, self.p, "fight_stone_guardians", T0)
game.process_player(self.world, self.p, T0, random.Random(3)) # two stone guardians
self.fight = self.p["action"]["fight"]
def at(self, t):
"""The real moment `t` seconds into the fight."""
return T0 + (self.fight["rest"] + t) / self.fight["pace"]
def mine(self, label, kinds=("cast", "use", "hit", "crit")):
return [e for e in self.fight["events"] if e[1] == "ash" and e[6] == label and e[3] in kinds]
def test_switching_and_pressing(self):
self.assertEqual(game.press(self.world, self.p, "crushing_blow", self.at(5)), "That one's on auto")
self.assertIsNone(game.set_manual(self.world, self.p, "crushing_blow", True, T0)) # yours from the start
self.assertEqual(self.p["manual"], ["crushing_blow"])
watch = (self.fight["rest"] + game.member_of(self.fight, "ash")["watch"]) / self.fight["pace"]
self.assertTrue(all(e[0] >= watch for e in self.mine("crushing_blow", ("cast",)))) # back on auto once you look away
before = [e for e in self.fight["events"] if e[0] < self.at(5) - T0]
self.assertIsNone(game.press(self.world, self.p, "crushing_blow", self.at(5)))
self.assertEqual([e for e in self.fight["events"] if e[0] < self.at(5) - T0], before) # the past stands
casts = [e[0] for e in self.mine("crushing_blow", ("cast",)) if e[0] >= self.at(5) - T0]
self.assertTrue(casts)
self.assertLessEqual(casts[0], self.at(5 + combat_queue()) - T0) # as soon as it's ready
def test_while_watching_auto_leaves_your_abilities_alone(self):
game.set_manual(self.world, self.p, "crushing_blow", True, self.at(1))
watch = game.member_of(self.fight, "ash")["watch"]
uses = [e[0] for e in self.mine("crushing_blow", ("cast",))]
self.assertTrue(all(u >= (self.fight["rest"] + watch) / self.fight["pace"] for u in uses if u > self.at(1) - T0))
# Look again and again: still nothing on auto until you look away.
for t in (5, 10, 15):
game.watch_fight(self.world, self.p, self.at(t))
self.assertGreater(game.member_of(self.fight, "ash")["watch"], 25)
def test_tapping_an_enemy_turns_you_on_it(self):
self.assertIsNone(game.set_fight_target(self.world, self.p, "e1", self.at(2)))
swings = [e for e in self.fight["events"] if e[1] == "ash" and e[3] in ("hit", "crit", "miss")
and e[6] in game.DAMAGE_TYPES and e[0] > self.at(2.5) - T0]
dead = {e[2]: e[0] for e in self.fight["events"] if e[3] == "die"}
self.assertTrue(all(e[2] == "e1" for e in swings if e[0] < dead.get("e1", 1e9)))
def test_a_press_lands_on_the_enemy_it_names(self):
game.set_manual(self.world, self.p, "concuss", True, T0)
self.p["xp"]["combat"] = game.XP_TABLE[30]
self.assertIsNone(game.press(self.world, self.p, "concuss", self.at(3), "e1")) # not the one you're hitting
used = [e for e in self.fight["events"] if e[1] == "ash" and e[6] == "concuss" and e[0] >= self.at(3) - T0]
self.assertEqual(used[0][2], "e1")
def test_no_presses_while_resting_or_out_of_a_fight(self):
game.set_manual(self.world, self.p, "crushing_blow", True, T0)
self.assertEqual(game.press(self.world, self.p, "crushing_blow", T0), "Still resting")
later = T0 + self.fight["duration"] + 100
self.p["action"] = None
self.assertEqual(game.press(self.world, self.p, "crushing_blow", later), "You're not fighting")
def test_old_worlds_get_manual(self):
del self.p["manual"]
self.world["version"] = 27
game.migrate(self.world, T0)
self.assertEqual(self.p["manual"], [])
def combat_queue():
return game.combat.QUEUE + game.combat.GCD
class DungeonTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("wil", "builder"), ("kay", "homesteader"), ("ann", "miner"))
gear = {"builder": dict(weapon="rune_sword", shield="rune_shield", helmet="rune_helmet", body="rune_body",
legs="rune_legs"),
"homesteader": dict(weapon="magic_staff", helmet="rune_helmet", body="rune_body", legs="rune_legs"),
"miner": dict(weapon="rune_warhammer", helmet="rune_helmet", body="rune_body", legs="rune_legs")}
for p in self.world["players"].values():
p["xp"]["combat"] = game.XP_TABLE[60] # well past it: these are about the run, not the fights
p["equip"].update(gear[p["cls"]])
p["hp"] = game.max_hp(p)
p["last_active"] = T0
self.wil, self.kay, self.ann = (self.world["players"][uid] for uid in ("wil", "kay", "ann"))
def online(self, now, players=None):
for p in players or self.world["players"].values():
p["last_active"] = now
game.catch_up(self.world, now, random.Random(1))
def party(self):
return game.party_of(self.world, self.wil)
def test_a_run_goes_pull_by_pull_and_pays_on_the_way_out(self):
self.assertIsNone(game.start_action(self.world, self.wil, "dungeon_watchtower", T0))
self.online(T0 + 600)
self.assertNotIn("fight", self.wil["action"]) # alone, it waits
self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0 + 600))
self.assertIsNone(game.start_run(self.world, self.wil, T0 + 600))
names = []
now = T0 + 600
while game.party_of(self.world, self.wil):
fight = self.party()["fight"] or {}
if fight.get("stage"):
names.append(fight["enemy"])
now += 2
self.online(now)
self.assertLess(now, T0 + 6 * 3600)
pulls = game.ACTION_BY_ID["dungeon_watchtower"]["pulls"]
self.assertEqual(list(dict.fromkeys(names)), pulls)
for p in (self.wil, self.kay):
self.assertIsNone(p["action"])
self.assertEqual(p["stopped"], "Cleared Watchtower Depths")
self.assertGreaterEqual(p["gold"], game.DUNGEONS["dungeon_watchtower"]["bonus_gold"])
self.assertIn("cleared Watchtower Depths", self.world["ledger"][-1]["text"])
def test_a_group_waits_for_its_leader_and_holds_four(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
self.assertIsNone(game.join_fight(self.world, self.kay, "wil", T0))
self.online(T0 + 600)
self.assertIsNone(self.party()["fight"]) # two in: still waiting for more
self.assertFalse(game.run_of(self.world, self.kay)["started"])
self.assertIsNone(game.join_fight(self.world, self.ann, "wil", T0 + 600))
extra = game.new_player("bo", "Bo", "ranger", T0)
extra["xp"]["combat"] = game.XP_TABLE[60]
self.world["players"]["bo"] = extra
self.assertIsNone(game.join_fight(self.world, extra, "wil", T0 + 600))
self.assertEqual(len(self.party()["members"]), 4)
self.assertFalse(game.joinable(self.world, self.wil))
self.assertEqual(game.start_run(self.world, self.kay, T0 + 601), "Only the leader starts")
self.assertIsNone(game.start_run(self.world, self.wil, T0 + 601))
self.online(T0 + 602)
self.assertEqual(len(self.party()["fight"]["members"]), 4)
game.leave_party(self.world, extra, self.party()["id"], T0 + 603)
extra["action"] = None
self.assertFalse(game.joinable(self.world, self.wil))
self.assertEqual(game.join_fight(self.world, extra, "wil", T0 + 604), "The run has started")
def test_no_run_alone(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
self.assertEqual(game.start_run(self.world, self.wil, T0), "Not in a dungeon")
def test_a_run_waits_for_whoever_looked_away(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
game.start_run(self.world, self.wil, T0)
self.online(T0 + 300, [self.wil]) # kay went to make tea
self.online(T0 + 1200, [self.wil])
self.assertIsNone(self.party()["fight"])
self.assertEqual(game.run_of(self.world, self.wil)["away"], ["Kay"])
self.online(T0 + 1210)
self.assertIsNotNone(self.party()["fight"])
def test_three_wipes_on_a_pull_end_the_run(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
for p in (self.wil, self.kay):
p["equip"] = {slot: None for slot in p["equip"]}
p["xp"]["combat"] = game.XP_TABLE[20]
self.party()["run"] = {"pull": 7, "wipes": 0} # the ogre, naked
now = T0
while game.party_of(self.world, self.wil):
now += 30
self.online(now)
self.assertLess(now, T0 + 3 * 3600)
self.assertEqual(self.wil["stopped"], "Wiped 3 times at Watchtower ogre")
def test_the_leader_can_pull_early(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
game.start_run(self.world, self.wil, T0)
self.kay["hp"] = 1
self.online(T0 + 0.5)
fight = self.party()["fight"]
self.assertGreater(fight["rest"], 10)
self.assertEqual(game.pull_now(self.world, self.kay, T0 + 3), "Only the leader pulls")
self.assertIsNone(game.pull_now(self.world, self.wil, T0 + 3))
self.assertLessEqual(fight["rest"], 3)
self.assertLess(game.member_of(fight, "kay")["hp_in"], game.max_hp(self.kay) / 2)
def test_a_pull_early_heals_each_at_their_own_pace(self):
game.start_action(self.world, self.wil, "dungeon_watchtower", T0)
game.join_fight(self.world, self.kay, "wil", T0)
game.start_run(self.world, self.wil, T0)
self.wil["hp"], self.kay["hp"] = game.max_hp(self.wil) - 5, 1
self.online(T0 + 0.5)
fight = self.party()["fight"]
wil = game.member_of(fight, "wil")
self.assertLess(wil["rest"], fight["rest"]) # done resting first, waiting on Kay
self.assertIsNone(game.pull_now(self.world, self.wil, T0 + wil["rest"] / fight["pace"] + 1))
self.assertEqual(wil["hp_in"], game.max_hp(self.wil))
self.assertLess(game.member_of(fight, "kay")["hp_in"], game.max_hp(self.kay))
def test_adds_join_halfway_and_never_count_if_they_dont(self):
foes = game.DUNGEONS["dungeon_watchtower"]["pulls"][7][1]
self.assertTrue(foes[1]["dormant"] and foes[2]["dormant"])
players = [self.wil, self.kay]
fight = game.draw_pull(self.world, players, game.ACTION_BY_ID["dungeon_watchtower"], {"pull": 7, "wipes": 0},
random.Random(2), T0)
joined = [e for e in fight["events"] if e[3] == "join"]
yell = next(e for e in fight["events"] if e[3] == "yell")
self.assertEqual(yell[6], "Crush them!")
self.assertEqual({e[1] for e in joined}, {"e1", "e2"})
self.assertGreaterEqual(joined[0][0], yell[0])
class LootTest(unittest.TestCase):
def smith(self, level=30):
world = world_with(("sam", "miner"))
p = world["players"]["sam"]
p["xp"]["smithing"] = game.XP_TABLE[level]
return world, p
def test_every_dungeon_has_relic_patterns_and_a_unique(self):
for key in game.DUNGEONS:
loot = game.ACTION_BY_ID[key]["loot"]
self.assertEqual(len(loot["patterns"]), 7)
self.assertTrue(game.ITEMS[loot["unique"]]["mods"])
for pattern in loot["patterns"]:
made = game.ITEMS[game.ITEMS[pattern]["pattern"]]
self.assertEqual(made["req"], ["combat", game.DUNGEONS[key]["level"]])
base = game.ITEMS[made["base"]]
for stat in ("power", "armour"):
if base.get(stat):
self.assertGreater(made[stat], base[stat])
def test_bosses_drop_relics_and_patterns(self):
ogre = game.DUNGEONS["dungeon_watchtower"]["pulls"][7][1]
fight = {"pack": [dict(ogre[0])], "kills": ["e0"]}
found = {}
for seed in range(200):
for item, qty in game.pack_loot(game.ACTION_BY_ID["dungeon_watchtower"], fight, random.Random(seed))[1].items():
found[item] = found.get(item, 0) + qty
self.assertEqual(found["watchtower_relic"], 400) # two, every time
patterns = sum(qty for item, qty in found.items() if item.startswith("pattern_"))
self.assertTrue(40 < patterns < 100) # about 35%
self.assertGreater(found.get("ogres_knuckle", 0), 0)
def test_learn_a_pattern_and_reforge(self):
world, p = self.smith()
reforge = game.ACTION_BY_ID["reforge_ogre_forged_warhammer"]
p["bank"] = {"iron_warhammer": 1, "watchtower_relic": 3}
self.assertEqual(game.can_start(world, p, reforge), "Learn its pattern first")
self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "You don't have it")
p["bank"]["pattern_ogre_forged_warhammer"] = 1
p["xp"]["smithing"] = game.XP_TABLE[20]
self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "Needs smithing level 29")
p["xp"]["smithing"] = game.XP_TABLE[30]
self.assertIsNone(game.learn_pattern(p, "pattern_ogre_forged_warhammer"))
self.assertNotIn("pattern_ogre_forged_warhammer", p["bank"])
self.assertEqual(game.learn_pattern(p, "pattern_ogre_forged_warhammer"), "You don't have it")
world["city"]["buildings"]["smithy"]["level"] = 5
self.assertIsNone(game.start_action(world, p, "reforge_ogre_forged_warhammer", T0, once=True))
game.process_player(world, p, T0 + 3600, random.Random(1))
self.assertEqual(p["bank"].get("ogre_forged_warhammer"), 1)
self.assertNotIn("watchtower_relic", p["bank"])
def ranger(self, *uniques):
world = world_with(("ash", "ranger"))
p = world["players"]["ash"]
p["xp"]["combat"] = game.XP_TABLE[85]
p["equip"].update(weapon="magic_bow", helmet="mammoth_leather_cap", body="mammoth_leather_body",
legs="mammoth_leather_legs")
for unique in uniques:
p["equip"][game.ITEMS[unique]["slot"]] = unique
p["hp"] = game.max_hp(p)
p["bank"] = {"arrows": 1000}
return world, p
def test_a_unique_changes_an_ability(self):
world, p = self.ranger()
self.assertEqual(game.fighter(p, game.NO_BLESSING, {})["mods"], {})
plain = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
world, p = self.ranger("wyrmscale_quiver")
mods = game.fighter(p, game.NO_BLESSING, {})["mods"]
self.assertEqual(mods["volley"], {"c": 1.4})
quivered = game.simulate_fight(world, p, game.ACTION_BY_ID["fight_scouts"], random.Random(4))
aims = lambda f: [e[0] for e in f["events"] if e[1] == "ash" and e[6] == "aimed_shot" and e[3] in ("cast", "hit", "crit")]
cast, landed = aims(quivered)[:2]
self.assertAlmostEqual(landed - cast, 1.5, places=1) # 2s of aiming, a quarter shorter
def test_a_pendant_mends_a_second_ally(self):
world = world_with(("kay", "homesteader"), ("wil", "builder"), ("bo", "miner"))
players = list(world["players"].values())
for p in players:
p["xp"]["combat"] = game.XP_TABLE[40]
p["hp"] = game.max_hp(p)
kay = world["players"]["kay"]
kay["equip"].update(weapon="willow_staff", amulet="barrow_kings_pendant")
ogre = game.ACTION_BY_ID["fight_ruins"]
heals = 0
for seed in range(5):
fight = game.draw_fight(world, players, ogre, random.Random(seed))
mends = [e for e in fight["events"] if e[1] == "kay" and e[3] == "heal" and e[6] == "mend"]
heals += sum(1 for a, b in zip(mends, mends[1:]) if a[0] == b[0] and a[2] != b[2])
self.assertGreater(heals, 0) # one cast, two people healed
def test_old_worlds_get_patterns(self):
world, p = self.smith()
del p["patterns"]
world["version"] = 29
game.migrate(world, T0)
self.assertEqual(p["patterns"], [])
def test_achievements_read_what_you_have(self):
world, p = self.smith()
card = lambda name: next(c for s in game.achievements(p) for c in s["cards"] if c["name"] == name)
done = lambda name: [e["name"] for e in card(name)["entries"] if e["done"]]
self.assertEqual(len(card("Watchtower Depths")["entries"]), 7)
self.assertEqual(done("Watchtower Depths"), [])
p["patterns"].append("pattern_ogre_forged_warhammer")
self.assertEqual(done("Watchtower Depths"), ["Ogre-forged warhammer"])
# A unique stays found after it's gone.
game.bank_add(world, p, "ogres_knuckle", 1, T0, game.empty_summary())
game.bank_take(p, "ogres_knuckle", 1)
self.assertEqual(done("Uniques"), ["Ogre's knuckle"])
self.assertEqual(done("Masters"), [])
p["xp"]["mining"] = game.XP_TABLE[50]
self.assertEqual(done("Masters"), ["Master miner"])
def test_old_worlds_find_the_uniques_they_hold(self):
world, p = self.smith()
del p["found"]
p["equip"]["ring"] = "ogres_knuckle"
world["version"] = 30
game.migrate(world, T0)
self.assertEqual(p["found"], ["ogres_knuckle"])
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, "willow_logs": 5}
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 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 75")
self.assertEqual(game.equip_problem(p, "steel_pickaxe"), "Needs mining level 30")
self.assertIsNone(game.equip_problem(p, "copper_sword"))
p["xp"]["combat"] = game.XP_TABLE[75]
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}
world["city"]["wages"] = 10_000
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"), ("kay", "commoner"))
for p in world["players"].values():
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}
p["last_active"] = T0 + 3600 * 2
p = world["players"]["wil"]
game.start_action(world, p, "fight_green_dragon", T0) # a group fight
game.join_fight(world, world["players"]["kay"], "wil", T0)
game.catch_up(world, 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_knockout(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.assertEqual(summary["stopped"], "Knocked out 3 times in a row")
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"] = 1000
p["equip"].update(weapon="copper_sword", helmet="copper_helmet", body="copper_body", legs="copper_legs")
plain = game.simulate_fight(world, p, scouts, random.Random(2))
game.set_prayers(p, ["rapid_heal"])
mended = game.simulate_fight(world, p, scouts, random.Random(2))
self.assertEqual([e[:4] for e in plain["events"] if e[1] == p["id"]][:5],
[e[:4] for e in mended["events"] if e[1] == p["id"]][:5]) # the same fight
self.assertGreater(game.member_of(mended, p["id"])["hp"], game.member_of(plain, p["id"])["hp"])
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"] = 6 # one a potion, one to burn under the still
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"]
per_second = lambda odds: odds["damage"] / odds["kill_time"] # less eating also shortens the fight
brewed = per_second(game.fight_odds(p, scouts))
active = p["potions"].pop("combat")
self.assertAlmostEqual(brewed, per_second(game.fight_odds(p, scouts)) * 0.70, delta=0.02)
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_new_happenings(self):
game._fortune(self.world, T0, self.rng, "masons")
self.assertEqual(game.skill_mods(self.world, game.ACTION_BY_ID["clear_rockslide"])["speed"], 0.75)
self.city["treasury"] = 1000
game._hardship(self.world, T0, self.rng, "taxman")
self.assertEqual(self.city["treasury"], 850)
game._minor_threat(self.world, T0, self.rng, "smugglers")
game.tick(self.world, T0 + 2 * game.HOUR + 1, self.rng)
self.assertLess(self.city["treasury"], 850)
game._minor_threat(self.world, T0, self.rng, "bridge")
p = self.world["players"]["ann"]
hunt = next(a for a in game.ACTIONS if a["skill"] == "hunting" and not a.get("special"))
self.assertTrue(game.can_start(self.world, p, hunt))
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 = next(e for e in self.world["events"] if e["id"] == 99)["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 + 8 * 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}
self.city["buildings"]["barracks"]["level"] = 1
self.city["wages"] = 10_000
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}
self.city["wages"] = 10_000
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 GoldTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("ann", "commoner"), ("bob", "commoner"))
self.ann, self.bob = self.world["players"]["ann"], self.world["players"]["bob"]
self.ann["gold"] = self.bob["gold"] = 200_000
def caravan(self, wares):
self.world["caravan"] = {"id": 7, "at": T0 - 1, "until": T0 + 3600, "wares": wares, "look": "e_wolf"}
def test_rented_slots_cost_more_each_time(self):
before = game.bank_slots(self.world, self.ann)
self.assertEqual([game.rent_cost({"rented": n}) for n in range(4)], [1000, 1350, 1800, 2450])
self.assertIsNone(game.rent_slot(self.ann))
self.assertIsNone(game.rent_slot(self.ann))
self.assertEqual(game.bank_slots(self.world, self.ann), before + 2)
self.assertEqual(self.ann["gold"], 200_000 - 2350)
self.ann["gold"] = 0
self.assertEqual(game.rent_slot(self.ann), "Needs 1,800 gold")
def test_caravan_comes_on_the_prep_day_and_sells_once_per_visit(self):
self.world["next_event_at"] = T0
game.tick(self.world, T0, random.Random(4))
caravan = self.world["caravan"]
self.assertTrue(T0 + 2 * game.HOUR <= caravan["at"] <= T0 + 16 * game.HOUR)
self.assertIsNone(game.caravan_view(self.world, self.ann, T0))
game.tick(self.world, caravan["at"] + 1, random.Random(4))
view = game.caravan_view(self.world, self.ann, caravan["at"] + 1)
self.assertEqual(len(view["wares"]), game.CARAVAN_WARES)
self.assertIsNone(game.caravan_view(self.world, self.ann, caravan["until"] + 1))
def test_wares_boost_your_own_work_for_their_charges(self):
self.caravan(["prospector", "tutor"])
self.assertIsNone(game.buy_ware(self.world, self.ann, "prospector", None, T0))
self.assertEqual(game.buy_ware(self.world, self.ann, "prospector", None, T0), "One of each per visit")
self.assertEqual(self.ann["gold"], 200_000 - 1550) # mining 1: 1,500 × (1 + 1/30)
self.assertEqual(game.ware(self.ann, "yield", "mining"), 1.5)
self.assertEqual(game.ware(self.bob, "yield", "mining"), 1)
self.ann["boosts"][0]["left"] = 1
game.ware(self.ann, "yield", "mining", spend=True)
self.assertEqual(self.ann["boosts"], [])
self.assertEqual(game.buy_ware(self.world, self.ann, "tutor", None, T0), "Pick a skill")
self.assertIsNone(game.buy_ware(self.world, self.ann, "tutor", "smithing", T0))
self.assertEqual(game.ware(self.ann, "xp", "smithing"), 1.5)
def test_ware_prices_rise_with_your_level(self):
self.ann["xp"]["mining"] = game.XP_TABLE[60]
self.assertEqual(game.ware_price(self.ann, "prospector", "mining"), 4500)
def test_threat_wares_end_with_the_threat(self):
self.caravan(["spyglass"])
self.assertEqual(game.buy_ware(self.world, self.ann, "spyglass", None, T0), "There's no threat coming")
self.world["next_event_at"] = T0
game.tick(self.world, T0, random.Random(4))
self.caravan(["spyglass"])
self.assertIsNone(game.buy_ware(self.world, self.ann, "spyglass", None, T0))
self.assertEqual(game.ware(self.ann, "recon", "reconnaissance"), 1.5)
raid = game.upcoming_raid(self.world)
raid["strength"] = 0
game.tick(self.world, raid["fire_at"], random.Random(4))
self.assertEqual(game.ware(self.ann, "recon", "reconnaissance"), 1)
def test_looks_need_owning_or_earning(self):
look = {"field": "navy", "trim": "brass", "emblem": None, "title": None}
self.assertIsNone(game.set_look(self.ann, look))
self.assertEqual(game.set_look(self.ann, {**look, "emblem": "e_wolf"}), "You don't own that")
self.caravan([])
self.assertIsNone(game.buy_ware(self.world, self.ann, "e_wolf", None, T0))
self.assertIsNone(game.set_look(self.ann, {**look, "emblem": "e_wolf"}))
self.assertEqual(game.look_view(self.ann)["emblem"], "wolf-head")
self.assertEqual(game.set_look(self.ann, {**look, "title": "Master smith"}), "You haven't earned that title")
self.ann["xp"]["smithing"] = game.XP_TABLE[50]
self.assertIsNone(game.set_look(self.ann, {**look, "title": "Master smith"}))
self.assertNotIn("Master baiter", game.titles(self.ann))
self.ann["xp"]["fishing"] = game.XP_TABLE[50]
self.assertIn("Master angler", game.titles(self.ann))
self.assertIn("Master baiter", game.titles(self.ann))
self.assertIsNone(game.upgrade_home(self.ann))
self.assertEqual(game.look_view(self.ann)["home"], "Cottage")
def test_monuments_take_gold_and_carve_the_donors_in(self):
city = self.world["city"]
self.assertIsNone(game.give_monument(self.world, self.ann, "fountain", 20_000, T0))
self.assertIsNone(game.give_monument(self.world, self.bob, "fountain", "all", T0))
self.assertEqual(self.bob["gold"], 200_000 - 5_000)
self.assertEqual(city["built"][0]["donors"], {"ann": 20_000, "bob": 5_000})
self.assertEqual(game.give_monument(self.world, self.ann, "fountain", 1, T0), "It's already built")
self.assertEqual(self.ann["favour"], 0)
game.give_monument(self.world, self.ann, "statue", 30_000, T0)
game.give_monument(self.world, self.bob, "statue", 20_000, T0)
self.assertEqual(city["carving"]["by"], "ann")
self.assertEqual(game.carve_statue(self.world, self.bob, "bob", T0), "It isn't yours to pick")
self.assertIsNone(game.carve_statue(self.world, self.ann, "bob", T0))
self.assertEqual(city["built"][-1]["of"], "bob")
def test_v28_worlds_get_gold_sinks(self):
world = world_with(("ann", "commoner"))
world["version"] = 28
for key in ("rented", "boosts", "bought", "deeds", "owned", "look"):
del world["players"]["ann"][key]
del world["caravan"]
game.migrate(world, T0)
self.assertEqual(world["players"]["ann"]["look"], game.DEFAULT_LOOK)
self.assertIsNone(world["caravan"])
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=15):
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], archer["id"]) # 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_fight_is_a_pack_of_the_variants(self):
world, p = self.armed("miner", "iron_warhammer", level=30)
goblins = game.ACTION_BY_ID["fight_goblins"]
packs = [game.simulate_fight(world, p, goblins, random.Random(seed))["pack"] for seed in range(60)]
self.assertEqual({foe["name"] for pack in packs for foe in pack}, {"Goblin brute", "Goblin rogue", "Goblin warlock"})
self.assertEqual({len(pack) for pack in packs}, {1, 2})
for pack in packs:
share = game.OUTNUMBERED[0] if len(pack) == 2 else 1
self.assertTrue(all(foe["share"] == share for foe in pack)) # two of them, each weaker
scouts = game.ACTION_BY_ID["fight_scouts"]
self.assertEqual({len(game.draw_pack(scouts, 3, random.Random(seed))) for seed in range(60)}, {3, 4})
def test_a_boss_comes_alone(self):
ogre = game.ACTION_BY_ID["fight_ruins"]
foe = ogre["enemy"]["variants"][0]
pair, four = game.draw_pack(ogre, 2, random.Random(1)), game.draw_pack(ogre, 4, random.Random(1))
self.assertEqual((len(pair), len(four)), (1, 1))
self.assertEqual(pair[0]["hp"], round(foe["hp"] * game.BOSS_HP * 2)) # tougher the more of you there are
self.assertEqual(four[0]["hp"], round(foe["hp"] * game.BOSS_HP * 4))
self.assertEqual(pair[0]["max_hit"], round(foe["max_hit"] * game.BOSS_HITS[0]))
self.assertGreater(four[0]["max_hit"], pair[0]["max_hit"])
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)
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
def test_anyone_can_put_reagents_in_and_gets_a_share(self):
self.mike["gold"] = 200
game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0)
order = self.world["orders"][0]
self.dave["bank"] = {"iron_bar": 2}
self.mike["bank"] = {"iron_bar": 5}
self.assertIsNone(game.provide_reagent(self.world, self.dave, order["id"], "iron_bar"))
self.assertIsNone(game.provide_reagent(self.world, self.mike, order["id"], "iron_bar"))
self.assertEqual(order["provided"], {"iron_bar": 3}) # only what's needed
self.assertEqual(self.mike["bank"], {"iron_bar": 4})
self.assertEqual(game.provide_reagent(self.world, self.dave, order["id"], "iron_bar"), "Already provided")
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))
# Reagents are worth 84 a run; Dave's 2 bars (20) earn 20/84 of half the reward.
self.assertEqual(self.dave["gold"], 17)
self.assertEqual(self.fred["gold"], 123) # the requester's own bar leaves its share with the crafter
self.assertEqual(order["earned"], {"dave": 17})
def test_crafter_tops_up_a_short_pool(self):
self.mike["gold"] = 200
game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0)
order = self.world["orders"][0]
self.dave["bank"] = {"iron_bar": 1}
game.provide_reagent(self.world, self.dave, order["id"], "iron_bar")
self.fred["bank"] = {"iron_bar": 1, "oak_plank": 1, "clay_core": 3}
self.assertEqual(game.work_order(self.world, self.fred, order["id"], T0), "Out of iron bar")
self.fred["bank"]["iron_bar"] = 2
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.assertNotIn("iron_bar", self.fred["bank"])
def test_pooled_reagents_go_back_to_whoever_gave_them(self):
self.mike["gold"] = 200
game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0)
order = self.world["orders"][0]
self.dave["bank"] = {"clay_core": 3}
game.provide_reagent(self.world, self.dave, order["id"], "clay_core")
game.cancel_order(self.world, self.mike, order["id"], T0)
self.assertEqual(self.dave["bank"], {"clay_core": 3})
self.assertEqual(self.mike["gold"], 200)
def test_leftover_reagents_return_when_filled_from_a_bank(self):
self.mike["gold"] = 200
game.post_order(self.world, self.mike, "craft", "iron_warhammer", 1, [], 140, T0)
order = self.world["orders"][0]
self.dave["bank"] = {"clay_core": 3}
game.provide_reagent(self.world, self.dave, order["id"], "clay_core")
self.fred["bank"] = {"iron_warhammer": 1}
self.assertIsNone(game.deliver_order(self.world, self.fred, order["id"], T0))
self.assertEqual(order["status"], "done")
self.assertEqual(self.dave["bank"], {"clay_core": 3})
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_loupe_doubles_any_class_crafts(self):
self.ann["xp"]["jewelcrafting"] = game.XP_TABLE[15]
for loupe, rings in ((None, 1), ("quartz_loupe", 2)):
self.ann["equip"]["trinket"] = loupe
self.ann["bank"] = {"copper_bar": 1, "quartz": 1}
game.start_action(self.world, self.ann, "jc_quartz_haste", T0)
game.process_player(self.world, self.ann, T0 + 1000, Always())
self.assertEqual(self.ann["bank"], {"quartz_haste": rings})
def test_jewellery_tabs_by_slot(self):
self.assertEqual(game.ACTION_BY_ID["jc_garnet_haste"]["group"], "Rings")
self.assertEqual(game.ACTION_BY_ID["jc_diamond_warding"]["group"], "Amulets")
self.assertEqual(game.ACTION_BY_ID["jc_quartz_loupe"]["group"], "Trinkets")
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_speeds_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"
speed = game.ITEMS["magic_workbench"]["speed"]
self.assertAlmostEqual(game.action_time(self.world, self.bob, labour), bare * (1 - speed))
def test_stations_move_out_of_the_tool_slot(self):
self.world["version"] = 22
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)
class FirekeepingTest(unittest.TestCase):
def setUp(self):
self.world = world_with(("kai", "commoner"), ("sam", "commoner"))
self.kai, self.sam = self.world["players"]["kai"], self.world["players"]["sam"]
self.fires = self.world["city"]["fires"]
def tend(self, player, fire, log, at, seconds):
self.assertIsNone(game.start_action(self.world, player, f"tend_{fire}_{log}", at))
return game.process_player(self.world, player, at + seconds, random.Random(1))
def test_tending_feeds_only_that_fire_and_pays_by_the_minute(self):
self.kai["bank"] = {"logs": 3}
self.tend(self.kai, "forge", "logs", T0, 95)
level = game.skill_level(self.kai, "firekeeping")
self.assertEqual(self.kai["bank"].get("logs", 0), 0)
self.assertEqual(game.heat(self.world, "forge"), 0)
self.assertEqual(game.heat(self.world, "kitchen"), -1)
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 3 * game.TEND_XP[0] * game.fire_minutes(level))
def test_better_logs_need_the_level(self):
self.kai["bank"] = {"maple_logs": 3}
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_maple_logs", T0), "Needs level 45")
def test_a_full_fire_stops_tending_and_pays_nothing(self):
self.kai["bank"] = {"logs": 100}
summary = self.tend(self.kai, "forge", "logs", T0, 3000)
self.assertEqual(summary["stopped"], "Your pile in the forge is full")
left = sum(chunk["left"] for chunk in self.fires["forge"]["fuel"])
self.assertGreater(left + game.fire_minutes(1) * 60, game.fire_bank(1) * 60) # no room for another log
xp = self.kai["xp"]["firekeeping"]
self.assertEqual(game.start_action(self.world, self.kai, "tend_forge_logs", T0 + 3000), "Your pile in the forge is full")
self.assertEqual(self.kai["xp"]["firekeeping"], xp)
def test_a_better_log_pushes_your_cheaper_fuel_out_but_not_the_reverse(self):
bank = game.fire_bank(15) * 60
self.fires["kiln"].update(fuel=[{"tier": 0, "left": bank, "by": "kai"}], at=T0) # kai's pile: full of plain logs
self.kai["xp"]["firekeeping"] = game.XP_TABLE[15]
self.kai["bank"] = {"oak_logs": 1, "logs": 1}
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "Your pile in the kiln is full")
self.assertIsNone(game.start_action(self.world, self.kai, "tend_kiln_oak_logs", T0)) # a better log still fits
game.feed_fire(self.world, self.kai, "kiln", 1)
self.assertEqual(game.heat(self.world, "kiln"), 1)
self.assertAlmostEqual(sum(chunk["left"] for chunk in self.fires["kiln"]["fuel"]), bank)
self.assertEqual(game.start_action(self.world, self.kai, "tend_kiln_logs", T0), "Your pile in the kiln is full")
def test_a_beginner_can_always_feed_a_fire_a_veteran_filled(self):
self.fires["forge"].update(fuel=[{"tier": 5, "left": 100 * 3600, "by": "kai"}], at=T0)
self.sam["bank"] = {"logs": 5}
self.tend(self.sam, "forge", "logs", T0, 95)
self.assertEqual(self.sam["bank"]["logs"], 2)
self.assertGreater(self.sam["xp"]["firekeeping"], 0)
self.assertEqual(game.heat(self.world, "forge"), 5)
def test_piles_share_the_burn_and_the_time_adds_up(self):
fire = self.fires["forge"]
fire.update(fuel=[{"tier": 5, "left": 600, "by": "kai"}, {"tier": 0, "left": 600, "by": "sam"}], at=T0)
game.burn_fires(self.world, T0 + 600) # half each
self.assertEqual([round(chunk["left"]) for chunk in fire["fuel"]], [300, 300])
self.assertEqual(game.heat(self.world, "forge"), 5) # the beginner's logs keep the violet going longer
game.burn_fires(self.world, T0 + 1199)
self.assertEqual(game.heat(self.world, "forge"), 5)
game.burn_fires(self.world, T0 + 1200) # 20 minutes of fuel, 20 minutes of fire
self.assertEqual(fire["fuel"], [])
def test_a_pile_burns_its_hottest_fuel_first_on_game_time(self):
fire = self.fires["still"]
fire["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}, {"tier": 2, "left": 300, "by": "kai"}]
fire["at"] = T0
game.burn_fires(self.world, T0 + 200)
self.assertEqual(game.heat(self.world, "still"), 2)
game.burn_fires(self.world, T0 + 400)
self.assertEqual(game.heat(self.world, "still"), 0)
self.assertAlmostEqual(fire["fuel"][0]["left"], 500)
game.burn_fires(self.world, T0 + 100) # never backwards
self.assertEqual(fire["at"], T0 + 400)
def test_a_hot_forge_pays_the_log_and_a_coal_but_never_the_last(self):
steel, copper = game.ACTION_BY_ID["smith_steel_bar"], game.ACTION_BY_ID["smith_copper_bar"]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1})
self.fires["forge"]["fuel"] = [{"tier": 1, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 2, "willow_logs": 1}) # too cool
self.assertEqual(game.fire_inputs(self.world, copper), {"copper_ore": 1})
self.fires["forge"]["fuel"] = [{"tier": 3, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, steel), {"iron_ore": 1, "coal": 1})
self.assertEqual(game.fire_inputs(self.world, game.ACTION_BY_ID["smith_mithril_bar"]), {"mithril_ore": 1, "coal": 2})
def test_a_potion_brewed_from_logs_keeps_its_ingredient_log(self):
brew = game.ACTION_BY_ID["brew_woodcutting_potion_1"]
self.fires["still"]["fuel"] = [{"tier": 0, "left": 600, "by": "kai"}]
self.assertEqual(game.fire_inputs(self.world, brew), {"guam_leaf": 1, "logs": 1})
def test_a_lit_fire_pays_for_work_orders_too(self):
self.fires["forge"].update(fuel=[{"tier": 0, "left": 60000, "by": "kai"}], at=T0)
self.world["players"]["mo"] = mo = game.new_player("mo", "Mo", "commoner", T0)
mo["gold"], mo["bank"] = 100, {"copper_ore": 5, "logs": 5}
self.assertEqual(game.post_order(self.world, mo, "craft", "copper_bar", 5, ["copper_ore", "logs"], 50, T0),
"The forge pays that")
self.assertIsNone(game.post_order(self.world, mo, "craft", "copper_bar", 5, ["copper_ore"], 50, T0))
order = self.world["orders"][0]
self.assertEqual(game.provide_reagent(self.world, mo, order["id"], "logs"), "The forge pays that")
self.sam["bank"] = {}
self.assertIsNone(game.work_order(self.world, self.sam, order["id"], T0))
game.process_player(self.world, self.sam, T0 + 600, random.Random(1))
self.assertEqual(order["status"], "done")
self.assertEqual(mo["bank"], {"copper_bar": 5, "logs": 5})
self.assertEqual(self.fires["forge"]["saved"], 5)
def test_warmth_goes_to_whoever_fed_the_fire(self):
self.fires["kitchen"]["fuel"] = [{"tier": 0, "left": 6000, "by": "kai"}]
self.fires["kitchen"]["at"] = T0
self.sam["bank"] = {"raw_shrimp": 4}
game.start_action(self.world, self.sam, "cook_shrimp", T0)
game.process_player(self.world, self.sam, T0 + 85, random.Random(1))
self.assertEqual(self.sam["bank"]["cooked_shrimp"], 4)
self.assertNotIn("logs", self.sam["bank"])
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
self.assertAlmostEqual(self.kai["away"]["xp"]["firekeeping"], 4 * 6 * game.WARMTH)
self.assertEqual(self.fires["kitchen"]["saved"], 4)
self.assertEqual(self.sam["xp"]["firekeeping"], 0)
def test_warmth_is_shared_by_every_pile_hot_enough_for_the_craft(self):
self.world["players"]["ash"] = ash = game.new_player("ash", "Ash", "commoner", T0)
ash["xp"]["cooking"] = game.XP_TABLE[15]
self.fires["kitchen"].update(at=T0, fuel=[{"tier": 3, "left": 6000, "by": "kai"},
{"tier": 0, "left": 6000, "by": "sam"}])
ash["bank"] = {"raw_shrimp": 1, "raw_trout": 1}
game.start_action(self.world, ash, "cook_shrimp", T0, once=True)
game.process_player(self.world, ash, T0 + 25, random.Random(1))
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 6 * game.WARMTH / 2)
self.assertAlmostEqual(self.sam["xp"]["firekeeping"], 6 * game.WARMTH / 2)
game.start_action(self.world, ash, "cook_trout", T0 + 25, once=True)
game.process_player(self.world, ash, T0 + 60, random.Random(1))
self.assertAlmostEqual(self.kai["xp"]["firekeeping"], 6 * game.WARMTH / 2 + 15 * game.WARMTH) # trout: kai's alone
self.assertAlmostEqual(self.sam["xp"]["firekeeping"], 6 * game.WARMTH / 2)
def test_catch_up_runs_everyone_and_keeps_what_they_missed(self):
game.start_action(self.world, self.sam, "mine_stone", T0)
self.assertTrue(game.catch_up(self.world, T0 + 100, random.Random(1)))
game.catch_up(self.world, T0 + 200, random.Random(1))
self.assertEqual(self.sam["away"]["done"], 10)
summary = game.take_summary(self.sam, game.process_player(self.world, self.sam, T0 + 220, random.Random(1)))
self.assertEqual(summary["done"], 11)
self.assertEqual(summary["items"], {"stone": 11})
self.assertNotIn("away", self.sam)
def test_old_worlds_get_their_fires(self):
del self.world["city"]["fires"]
del self.kai["xp"]["firekeeping"]
self.world["version"] = 23
game.migrate(self.world, T0)
self.assertEqual(set(self.world["city"]["fires"]), set(game.FIRES))
self.assertEqual(self.kai["xp"]["firekeeping"], 0)
self.assertIn("tool_firekeeping", self.kai["equip"])
class PatchNotesTest(unittest.TestCase):
def test_newest_first_and_known_kinds(self):
dates = [release["date"] for release in game.PATCH_NOTES]
self.assertEqual(dates, sorted(dates, reverse=True))
for release in game.PATCH_NOTES:
self.assertTrue(release["notes"])
for kind, text in release["notes"]:
self.assertIn(kind, ("new", "balance", "fix"))
self.assertTrue(text)
class PartyTest(unittest.TestCase):
def pair(self, level=12):
world = world_with(("wil", "commoner"), ("kay", "commoner"))
for p in world["players"].values():
p["xp"]["combat"] = game.XP_TABLE[level]
p["hp"] = game.max_hp(p)
p["equip"].update(weapon="iron_sword", helmet="iron_helmet", body="iron_body", legs="iron_legs")
p["bank"] = {"bread": 300}
return world, world["players"]["wil"], world["players"]["kay"]
def online(self, world, now, rng):
"""Everyone playing: catch up to `now` as their pages would."""
for p in world["players"].values():
p["last_active"] = now
game.catch_up(world, now, rng)
def test_tagging_in_keeps_what_already_happened(self):
world, wil, kay = self.pair()
rng = random.Random(1)
game.start_action(world, wil, "fight_scouts", T0)
self.online(world, T0 + 0.1, rng)
before = json.loads(json.dumps(wil["action"]["fight"]))
k = len(before["events"]) // 2
at = (before["events"][k - 1][0] + before["events"][k][0]) / 2
self.online(world, T0 + at, rng)
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + at))
fight = game.current_fight(world, wil)
self.assertIs(fight, game.current_fight(world, kay)) # both watch the one fight
self.assertEqual(fight["events"][:k], before["events"][:k])
theirs = [e for e in fight["events"] if e[1] == "kay"]
self.assertTrue(theirs)
self.assertGreater(theirs[0][0], at)
self.assertEqual(kay["action"]["start"], wil["action"]["start"])
def test_kills_go_faster_and_everyone_gets_the_xp(self):
world, wil, kay = self.pair()
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(2))
together = wil["away"]
self.assertEqual(together["done"], kay["away"]["done"])
self.assertEqual(together["with"], {"Kay": together["done"]})
self.assertEqual(wil["xp"]["combat"], kay["xp"]["combat"]) # the same kills, the full XP each
self.assertGreater(wil["gold"] + kay["gold"], 0)
self.assertLessEqual(abs(wil["gold"] - kay["gold"]), together["wins"] * 4) # split evenly, odd coins aside
solo, alone = self.pair()[0:2]
game.start_action(solo, alone, "fight_scouts", T0)
self.online(solo, T0 + 3600, random.Random(2))
self.assertGreater(together["xp"]["combat"], alone["away"]["xp"]["combat"] * 1.3)
def test_leaving_lets_the_other_carry_on(self):
world, wil, kay = self.pair()
rng = random.Random(3)
game.start_action(world, wil, "fight_scouts", T0)
game.join_fight(world, kay, "wil", T0)
self.online(world, T0 + 5, rng)
self.assertEqual(len(world["parties"]), 1)
self.assertIsNone(game.start_action(world, kay, "mine_stone", T0 + 5))
self.assertEqual(world["parties"], {})
self.assertNotIn("party", wil["action"])
left = game.member_of(wil["action"]["fight"], "kay") # the fight goes on without her
self.assertEqual(left["result"], "left")
self.online(world, T0 + 1200, rng)
self.assertGreater(wil["away"]["wins"], 3)
def test_away_too_long_steps_out(self):
world, wil, kay = self.pair(level=40)
for p in (wil, kay):
p["bank"] = {"bread": 100_000}
game.start_action(world, wil, "fight_scouts", T0)
game.join_fight(world, kay, "wil", T0)
now = T0 + game.OFFLINE_CAP + 3600
wil["last_active"] = now # wil kept playing, kay went to bed
game.catch_up(world, now, random.Random(4))
self.assertEqual(world["parties"], {})
self.assertEqual(kay["action"]["id"], "fight_scouts")
self.assertNotIn("party", kay["action"])
self.assertGreater(wil["away"]["done"], kay["away"]["done"]) # wil carried on alone for the last hour
def test_knocked_out_in_a_won_fight_still_shares_the_win(self):
world, wil, kay = self.pair()
game.start_action(world, wil, "fight_scouts", T0)
self.online(world, T0 + 0.1, random.Random(0))
kay["hp"] = 1 # tags in as she is
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 0.1))
fight = game.current_fight(world, wil)
mine = game.member_of(fight, "kay")
self.assertTrue(mine["down"])
self.assertEqual((fight["result"], mine["result"]), ("won", "won"))
self.online(world, T0 + fight["duration"] + 0.01, random.Random(5))
self.assertEqual(kay["away"]["wins"], 1)
self.assertAlmostEqual(kay["hp"], game.max_hp(kay) * game.KNOCKED_OUT_HP)
self.assertEqual(kay["action"]["id"], "fight_scouts")
def test_a_boss_needs_a_group(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0))
self.assertIn("Wil is looking for a group", world["ledger"][-1]["text"])
self.online(world, T0 + 3600, random.Random(1))
self.assertNotIn("fight", wil["action"]) # alone, nothing happens
self.assertEqual(wil["away"]["done"], 0) if "away" in wil else None
self.assertEqual(wil["action"]["start"], T0 + 3600)
self.assertIsNone(game.join_fight(world, kay, "wil", T0 + 3600))
self.online(world, T0 + 7200, random.Random(1))
self.assertGreater(wil["away"]["wins"], 0)
self.assertEqual(wil["away"]["wins"], kay["away"]["wins"])
self.assertIsNone(game.start_action(world, kay, "mine_stone", T0 + 7200))
self.online(world, T0 + 9000, random.Random(1))
wins = wil["away"]["wins"]
self.online(world, T0 + 12600, random.Random(1))
self.assertEqual(wil["away"]["wins"], wins) # back to waiting
self.assertEqual(wil["action"]["id"], "fight_ruins")
def test_who_can_join(self):
world, wil, kay = self.pair()
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Wil isn't fighting")
game.start_action(world, wil, "mine_stone", T0)
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Wil isn't fighting")
self.assertEqual(game.join_fight(world, kay, "kay", T0), "No one to join")
wil["xp"]["combat"] = game.XP_TABLE[60]
game.start_action(world, wil, "fight_ruins", T0)
self.assertEqual(game.join_fight(world, kay, "wil", T0), "Needs level 20")
game.start_action(world, wil, "fight_scouts", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.assertEqual(game.join_fight(world, wil, "kay", T0), "Already fighting together")
def test_a_boss_fought_once_can_be_joined(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
p["bank"] = {"cooked_salmon": 5000}
game.start_action(world, wil, "mine_stone", T0)
self.assertIsNone(game.start_action(world, wil, "fight_ruins", T0, once=True))
self.assertTrue(game.joinable(world, wil))
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(6))
self.assertEqual(wil["away"]["with"], {"Kay": 1})
self.assertEqual(wil["action"]["id"], "mine_stone") # one kill, then back to mining
self.assertEqual(kay["action"]["id"], "fight_ruins") # waiting for the next group
self.assertEqual(world["parties"], {})
def test_bosses_give_everyone_their_own_loot(self):
world, wil, kay = self.pair(level=30)
for p in (wil, kay):
p["equip"].update(weapon="steel_sword", shield="steel_shield", helmet="steel_helmet", body="steel_body",
legs="steel_legs")
p["bank"] = {"cooked_salmon": 5000}
game.start_action(world, wil, "fight_ruins", T0)
self.assertIsNone(game.join_fight(world, kay, "wil", T0))
self.online(world, T0 + 3600, random.Random(6))
wins = wil["away"]["wins"]
self.assertGreater(wins, 10)
low, high = game.ACTION_BY_ID["fight_ruins"]["gold"]
for p in (wil, kay):
self.assertTrue(wins * low <= p["away"]["gold"] <= wins * high) # a whole purse each
bones = p["away"]["items"].get("big_bones", 0) + p["away"]["overflow"].get("big_bones", 0)
self.assertEqual(bones, wins) # the sure drop, for each of them
def test_loot_is_shared_out(self):
shares = game.share_loot((7, {"bones": 1, "arrows": 5}), ["a", "b"], random.Random(1))
self.assertEqual(sorted(gold for gold, _ in shares.values()), [3, 4])
self.assertEqual(sum(got.get("arrows", 0) for _, got in shares.values()), 5)
self.assertEqual(sum(got.get("bones", 0) for _, got in shares.values()), 1)
def test_a_fight_in_progress_is_drawn_again_after_the_update(self):
world, wil, _ = self.pair()
game.start_action(world, wil, "fight_scouts", T0)
wil["action"]["fight"] = {"old": True}
world["version"] = 24
del world["parties"], world["next_party_id"]
game.migrate(world, T0)
self.assertNotIn("fight", wil["action"])
self.assertEqual(world["parties"], {})
if __name__ == "__main__":
unittest.main()