2026-10-23 22:52:42 +01:00
|
|
|
import random
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
|
|
|
|
|
|
import combat # noqa: E402
|
|
|
|
|
import content # noqa: E402
|
|
|
|
|
import game # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def world_with(*players, now=1000.0, dev=True):
|
|
|
|
|
world = game.new_world(now)
|
|
|
|
|
world["config"]["dev"] = dev
|
|
|
|
|
for uid, cls in players:
|
|
|
|
|
world["players"][uid] = game.new_player(uid, uid.title(), cls, now)
|
|
|
|
|
return world
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def solo_fight(cls, level, mob, seed=1, watch=None, manual=()):
|
|
|
|
|
player = game.new_player("p", "P", cls, 0)
|
|
|
|
|
game.dev_level({"config": {}}, player, level, 0)
|
|
|
|
|
player["manual"] = list(manual)
|
|
|
|
|
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False),
|
|
|
|
|
[combat.mob_unit("e0", mob, level)], "hunt")
|
|
|
|
|
return player, fight
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Content(unittest.TestCase):
|
|
|
|
|
def test_every_spec_has_four_known_abilities(self):
|
|
|
|
|
for spec in content.SPECS.values():
|
|
|
|
|
self.assertEqual(len(spec["abilities"]), 4)
|
|
|
|
|
self.assertEqual(sorted(spec["abilities"]), sorted(spec["priority"]))
|
|
|
|
|
for ab in spec["abilities"]:
|
|
|
|
|
self.assertIn(ab, content.ABILITIES)
|
|
|
|
|
|
2026-10-24 08:24:50 +01:00
|
|
|
def test_places_sit_in_zones_and_hunts_exist(self):
|
|
|
|
|
hunts = set()
|
2026-10-23 22:52:42 +01:00
|
|
|
for pid, place in content.PLACES.items():
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertIn(place["zone"], content.ZONES)
|
2026-10-23 22:52:42 +01:00
|
|
|
for hunt, pulls in place.get("hunts", {}).items():
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertNotIn((place["zone"], hunt), hunts, "hunts must be unique within a zone")
|
|
|
|
|
hunts.add((place["zone"], hunt))
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIn(hunt, content.MOBS)
|
|
|
|
|
for pull in pulls:
|
|
|
|
|
for mob in pull:
|
|
|
|
|
self.assertIn(mob, content.MOBS)
|
|
|
|
|
|
2026-10-24 08:24:50 +01:00
|
|
|
def test_roads_join_zones_through_real_mobs(self):
|
|
|
|
|
for road in content.ROADS.values():
|
|
|
|
|
for zone in road["zones"]:
|
|
|
|
|
self.assertIn(zone, content.ZONES)
|
|
|
|
|
self.assertIn(road["rare"], content.MOBS)
|
|
|
|
|
for stop in road["stops"]:
|
|
|
|
|
for pull in stop["pulls"]:
|
|
|
|
|
for mob in pull:
|
|
|
|
|
self.assertIn(mob, content.MOBS)
|
|
|
|
|
|
2026-10-23 22:52:42 +01:00
|
|
|
def test_quests_point_at_real_things(self):
|
|
|
|
|
for qid, quest in content.QUESTS.items():
|
|
|
|
|
self.assertIn(quest["giver"], content.PLACES)
|
|
|
|
|
for req in quest.get("req", []):
|
|
|
|
|
self.assertIn(req, content.QUESTS)
|
|
|
|
|
for objective in quest["obj"]:
|
|
|
|
|
if "kill" in objective:
|
|
|
|
|
self.assertIn(objective["kill"], content.MOBS)
|
|
|
|
|
for mob in objective.get("from", []):
|
|
|
|
|
self.assertIn(mob, content.MOBS)
|
|
|
|
|
for reward in quest.get("choice", []):
|
|
|
|
|
self.assertIsNotNone(game.item(reward), reward)
|
|
|
|
|
|
|
|
|
|
def test_loot_tables_resolve(self):
|
|
|
|
|
for items in list(content.BOSS_LOOT.values()) + [content.TRASH_LOOT]:
|
|
|
|
|
for item_id in items:
|
|
|
|
|
self.assertIsNotNone(game.item(item_id), item_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Items(unittest.TestCase):
|
|
|
|
|
def test_generated_green_has_name_and_stats(self):
|
|
|
|
|
green = game.item("g/chest/mail/bear/14")
|
|
|
|
|
self.assertEqual(green["q"], "uncommon")
|
|
|
|
|
self.assertIn("of the Bear", green["name"])
|
|
|
|
|
self.assertGreater(green["sta"], green["power"])
|
|
|
|
|
self.assertGreater(green["armor"], 0)
|
|
|
|
|
|
|
|
|
|
def test_armour_rules(self):
|
|
|
|
|
mage = game.new_player("m", "M", "mage", 0)
|
|
|
|
|
self.assertIsNotNone(game.can_wear(mage, game.item("g/chest/mail/bear/2")))
|
|
|
|
|
self.assertIsNone(game.can_wear(mage, game.item("g/chest/cloth/eagle/2")))
|
|
|
|
|
warrior = game.new_player("w", "W", "warrior", 0)
|
|
|
|
|
self.assertIsNone(game.can_wear(warrior, game.item("g/chest/cloth/eagle/2")))
|
|
|
|
|
self.assertIn("level", game.can_wear(warrior, game.item("g/chest/mail/bear/14")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Combat(unittest.TestCase):
|
|
|
|
|
def test_every_class_beats_an_even_mob_alone(self):
|
|
|
|
|
for cls in content.CLASSES:
|
|
|
|
|
_, fight = solo_fight(cls, 10, "riverpaw_gnoll")
|
|
|
|
|
combat.advance(fight, 300)
|
|
|
|
|
self.assertEqual(fight["state"], "won", cls)
|
|
|
|
|
|
|
|
|
|
def test_same_seed_same_fight(self):
|
|
|
|
|
a = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
|
|
|
|
|
b = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
|
|
|
|
|
combat.advance(a, 300)
|
|
|
|
|
combat.advance(b, 300)
|
|
|
|
|
self.assertEqual(a["t"], b["t"])
|
|
|
|
|
self.assertEqual(a["units"]["p"]["hp"], b["units"]["p"]["hp"])
|
|
|
|
|
|
|
|
|
|
def test_manual_ability_waits_for_the_player_while_watched(self):
|
|
|
|
|
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
|
|
|
|
|
combat.advance(fight, 6, watch={"p": 5})
|
|
|
|
|
self.assertNotIn("sunder", fight["units"]["p"]["cds"])
|
|
|
|
|
combat.press(fight, "p", "sunder", 6)
|
|
|
|
|
combat.advance(fight, 7, watch={"p": 6})
|
|
|
|
|
self.assertIn("sunder", fight["units"]["p"]["cds"])
|
|
|
|
|
|
|
|
|
|
def test_manual_ability_falls_back_to_auto_when_nobody_watches(self):
|
|
|
|
|
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
|
|
|
|
|
combat.advance(fight, 6)
|
|
|
|
|
self.assertIn("sunder", fight["units"]["p"]["cds"])
|
|
|
|
|
|
|
|
|
|
def test_pressed_cooldown_is_shorter_than_auto(self):
|
|
|
|
|
_, fight = solo_fight("shaman", 10, "riverpaw_gnoll", manual=["stormstrike"])
|
|
|
|
|
combat.press(fight, "p", "stormstrike", 0)
|
|
|
|
|
combat.advance(fight, 0.25, watch={"p": 0})
|
|
|
|
|
pressed = fight["units"]["p"]["cds"]["stormstrike"]
|
|
|
|
|
_, auto = solo_fight("shaman", 10, "riverpaw_gnoll")
|
|
|
|
|
combat.advance(auto, 3)
|
|
|
|
|
used = auto["units"]["p"]["cds"]["stormstrike"]
|
|
|
|
|
self.assertAlmostEqual(pressed - 0.25, content.ABILITIES["stormstrike"]["cd"], places=2)
|
|
|
|
|
self.assertGreater(used - pressed, content.ABILITIES["stormstrike"]["cd"] * 0.4)
|
|
|
|
|
|
|
|
|
|
def test_interrupt_stops_a_cast(self):
|
|
|
|
|
player = game.new_player("p", "P", "rogue", 0)
|
|
|
|
|
game.dev_level({"config": {}}, player, 16, 0)
|
|
|
|
|
player["manual"] = ["kick"]
|
|
|
|
|
fight = combat.new_fight("f", 3, 0, game.player_unit(player, 0, False),
|
|
|
|
|
[combat.mob_unit("e0", "defias_pillager", 16)], "hunt")
|
|
|
|
|
for step in range(40):
|
|
|
|
|
combat.advance(fight, (step + 1) * combat.STEP, watch={"p": step * combat.STEP})
|
|
|
|
|
if fight["units"]["e0"]["cast"]:
|
|
|
|
|
break
|
|
|
|
|
self.assertTrue(fight["units"]["e0"]["cast"], "the pillager should start a Fireball")
|
|
|
|
|
combat.press(fight, "p", "kick", fight["t"], "e0")
|
|
|
|
|
combat.advance(fight, fight["t"] + 0.5, watch={"p": fight["t"]})
|
|
|
|
|
self.assertIsNone(fight["units"]["e0"]["cast"])
|
|
|
|
|
self.assertTrue(any("interrupts" in line["text"] for line in fight["log"]))
|
|
|
|
|
|
|
|
|
|
def test_mob_turns_on_the_healer_and_taunt_takes_it_back(self):
|
|
|
|
|
tank = game.new_player("tank", "Tank", "warrior", 0)
|
|
|
|
|
healer = game.new_player("heal", "Heal", "priest", 0)
|
|
|
|
|
for p in (tank, healer):
|
|
|
|
|
game.dev_level({"config": {}}, p, 18, 0)
|
|
|
|
|
party = game.player_unit(tank, 0, True) + game.player_unit(healer, 0, True)
|
|
|
|
|
fight = combat.new_fight("f", 5, 0, party, [combat.mob_unit("e0", "defias_overseer", 18)], "dungeon")
|
|
|
|
|
mob = fight["units"]["e0"]
|
|
|
|
|
mob["threat"]["heal"] = 10000
|
|
|
|
|
combat.advance(fight, 0.5)
|
|
|
|
|
self.assertEqual(mob["target"], "heal")
|
|
|
|
|
combat.advance(fight, 4)
|
|
|
|
|
self.assertEqual(mob["target"], "tank")
|
|
|
|
|
self.assertTrue(any("Taunt" in line["text"] for line in fight["log"]))
|
|
|
|
|
|
|
|
|
|
def test_cc_breaks_on_damage_and_bosses_are_immune(self):
|
|
|
|
|
player = game.new_player("p", "P", "mage", 0)
|
|
|
|
|
game.dev_level({"config": {}}, player, 18, 0)
|
|
|
|
|
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
|
|
|
|
|
[combat.mob_unit("e0", "defias_miner", 18), combat.mob_unit("e1", "rhahkzor", 19)],
|
|
|
|
|
"dungeon")
|
|
|
|
|
rng = random.Random(1)
|
|
|
|
|
combat._apply(fight, fight["units"]["p"], "polymorph", "e1", 0, rng)
|
|
|
|
|
self.assertEqual(fight["units"]["e1"]["cc"], 0)
|
|
|
|
|
combat._apply(fight, fight["units"]["p"], "polymorph", "e0", 0, rng)
|
|
|
|
|
self.assertGreater(fight["units"]["e0"]["cc"], 0)
|
|
|
|
|
combat._damage(fight, fight["units"]["p"], fight["units"]["e0"], 10, "frost", 0.5, rng)
|
|
|
|
|
self.assertEqual(fight["units"]["e0"]["cc"], 0)
|
|
|
|
|
|
|
|
|
|
def test_boss_phase_summons_adds(self):
|
|
|
|
|
player = game.new_player("p", "P", "warrior", 0)
|
|
|
|
|
game.dev_level({"config": {}}, player, 18, 0)
|
|
|
|
|
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
|
|
|
|
|
[combat.mob_unit("e0", "edwin_vancleef", 21)], "dungeon")
|
|
|
|
|
boss = fight["units"]["e0"]
|
|
|
|
|
boss["hp"] = boss["max_hp"] * 0.49
|
|
|
|
|
combat.advance(fight, 0.25)
|
|
|
|
|
self.assertEqual(sum(1 for u in fight["units"].values() if u.get("mob") == "defias_blackguard"), 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class World(unittest.TestCase):
|
|
|
|
|
def test_hunting_earns_xp_and_quest_progress_while_away(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
self.assertIsNone(game.accept(fred, "kobold_camp_cleanup"))
|
|
|
|
|
self.assertIsNone(game.start_hunt(world, fred, "kobold_vermin", 1000))
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertEqual(fred["place"], "northshire_valley")
|
2026-10-23 22:52:42 +01:00
|
|
|
game.process(world, 1000 + 20 * 60, random.Random(1))
|
|
|
|
|
self.assertGreater(fred["level"], 1)
|
|
|
|
|
self.assertEqual(fred["quests"]["kobold_camp_cleanup"], [10])
|
|
|
|
|
|
|
|
|
|
def test_hunting_stops_after_the_offline_cap(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
game.start_hunt(world, fred, "kobold_vermin", 1000)
|
|
|
|
|
game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1))
|
|
|
|
|
self.assertIsNone(fred["activity"])
|
|
|
|
|
self.assertIsNone(fred["fight"])
|
|
|
|
|
|
|
|
|
|
def test_turn_in_needs_the_right_place_and_a_reward_pick(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
game.accept(fred, "kobold_camp_cleanup")
|
|
|
|
|
fred["quests"]["kobold_camp_cleanup"] = [10]
|
2026-10-24 08:24:50 +01:00
|
|
|
fred["place"] = "sentinel_hill"
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIn("Turn it in", game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
|
2026-10-24 08:24:50 +01:00
|
|
|
fred["place"] = "fargodeep" # anywhere in Elwynn will do
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertEqual(game.turn_in(world, fred, "kobold_camp_cleanup", None, 0), "Pick a reward")
|
|
|
|
|
self.assertIsNone(game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
|
|
|
|
|
self.assertIn("w/feet/mail/4", fred["bags"])
|
|
|
|
|
self.assertIn("investigate_echo_ridge", game.available_quests(fred))
|
|
|
|
|
|
2026-10-24 08:24:50 +01:00
|
|
|
def test_a_journey_is_a_fight_at_every_stop(self):
|
2026-10-23 22:52:42 +01:00
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
fred = world["players"]["fred"]
|
2026-10-24 08:24:50 +01:00
|
|
|
game.dev_level(world, fred, 14, 1000)
|
|
|
|
|
self.assertIsNone(game.accept(fred, "riverpaw_gnoll_bounty"))
|
|
|
|
|
fred["done"].append("riverpaw_gnoll_bounty")
|
|
|
|
|
fred["quests"].pop("riverpaw_gnoll_bounty")
|
|
|
|
|
self.assertIsNone(game.accept(fred, "report_to_gryan"))
|
|
|
|
|
self.assertIsNone(game.journey(world, fred, "westfall", 1000))
|
|
|
|
|
self.assertIn("road", game.start_hunt(world, fred, "kobold_vermin", 1000))
|
|
|
|
|
fights = 0
|
|
|
|
|
for step in range(2000):
|
|
|
|
|
game.process(world, 1000 + step, random.Random(step))
|
|
|
|
|
if fred["fight"]:
|
|
|
|
|
fights += step == 0 or world["fights"][fred["fight"]]["t"] <= 1
|
|
|
|
|
if not fred["activity"]:
|
|
|
|
|
break
|
|
|
|
|
self.assertEqual(fred["place"], "sentinel_hill")
|
|
|
|
|
self.assertEqual(fred["zones"], ["elwynn", "westfall"])
|
|
|
|
|
self.assertEqual(fred["quests"]["report_to_gryan"], [1])
|
|
|
|
|
self.assertGreaterEqual(fights, 3)
|
|
|
|
|
|
|
|
|
|
def test_dying_on_the_road_sends_you_back(self):
|
|
|
|
|
world = world_with(("fred", "mage"))
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
self.assertIsNone(game.journey(world, fred, "westfall", 1000))
|
|
|
|
|
game.process(world, 1000 + 600, random.Random(1))
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIsNone(fred["activity"])
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertEqual(game.zone_of(fred["place"]), "elwynn")
|
|
|
|
|
self.assertEqual(fred["zones"], ["elwynn"])
|
|
|
|
|
|
|
|
|
|
def test_flying_needs_the_zone_reached_and_the_fare(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
self.assertIn("on foot", game.fly(world, fred, "westfall", 0))
|
|
|
|
|
fred["zones"].append("westfall")
|
|
|
|
|
self.assertIn("money", game.fly(world, fred, "westfall", 0))
|
|
|
|
|
fred["copper"] = 100
|
|
|
|
|
self.assertIsNone(game.fly(world, fred, "westfall", 0))
|
|
|
|
|
self.assertEqual(fred["place"], "sentinel_hill")
|
|
|
|
|
self.assertEqual(fred["copper"], 100 - content.FLIGHT_COST)
|
|
|
|
|
|
|
|
|
|
def test_old_worlds_migrate(self):
|
|
|
|
|
world = world_with(("fred", "warrior"))
|
|
|
|
|
world["version"] = 1
|
|
|
|
|
fred = world["players"]["fred"]
|
|
|
|
|
del fred["zones"]
|
|
|
|
|
fred["activity"] = {"kind": "travel", "to": "sentinel_hill", "end": 0}
|
|
|
|
|
fred["quests"]["report_to_goldshire"] = [0]
|
|
|
|
|
game.migrate(world)
|
|
|
|
|
self.assertEqual((fred["place"], fred["activity"], fred["zones"]), ("sentinel_hill", None, ["westfall"]))
|
|
|
|
|
self.assertEqual(fred["quests"]["report_to_goldshire"], [])
|
2026-10-23 22:52:42 +01:00
|
|
|
|
|
|
|
|
def test_a_group_clears_the_deadmines(self):
|
|
|
|
|
world = world_with(*[(uid, cls) for uid, cls in
|
|
|
|
|
(("t", "warrior"), ("h", "priest"), ("m", "mage"), ("r", "rogue"), ("x", "hunter"))])
|
|
|
|
|
for uid, player in world["players"].items():
|
|
|
|
|
game.dev_level(world, player, 20, 1000)
|
2026-10-24 08:24:50 +01:00
|
|
|
world["players"]["t"]["place"] = "sentinel_hill"
|
2026-10-23 22:52:42 +01:00
|
|
|
leader = world["players"]["t"]
|
|
|
|
|
game.create_group(world, leader)
|
|
|
|
|
gid = leader["group"]
|
|
|
|
|
for uid in "hmrx":
|
|
|
|
|
self.assertIsNone(game.join_group(world, world["players"][uid], gid))
|
|
|
|
|
self.assertIsNone(game.enter_dungeon(world, leader, 1000))
|
|
|
|
|
game.process(world, 1000 + 3 * 3600, random.Random(4))
|
|
|
|
|
dungeon = world["groups"][gid]["dungeon"]
|
|
|
|
|
self.assertEqual(dungeon["state"], "done", dungeon)
|
|
|
|
|
loot = sum(len(p["bags"]) for p in world["players"].values())
|
|
|
|
|
self.assertGreater(loot, 0)
|
|
|
|
|
|
2026-10-24 08:24:50 +01:00
|
|
|
def test_the_meeting_stone_summons_the_group(self):
|
2026-10-23 22:52:42 +01:00
|
|
|
world = world_with(("a", "warrior"), ("b", "priest"), dev=False)
|
|
|
|
|
a, b = world["players"]["a"], world["players"]["b"]
|
|
|
|
|
game.create_group(world, a)
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertIn("no dungeon", game.enter_dungeon(world, a, 0))
|
|
|
|
|
a["place"] = "sentinel_hill"
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIn("at least 2", game.enter_dungeon(world, a, 0))
|
|
|
|
|
game.join_group(world, b, a["group"])
|
2026-10-24 08:24:50 +01:00
|
|
|
game.journey(world, b, "westfall", 0)
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIn("Waiting on B", game.enter_dungeon(world, a, 0))
|
2026-10-24 08:24:50 +01:00
|
|
|
game.stop(world, b, 0)
|
2026-10-23 22:52:42 +01:00
|
|
|
self.assertIsNone(game.enter_dungeon(world, a, 0))
|
2026-10-24 08:24:50 +01:00
|
|
|
self.assertEqual((b["activity"]["kind"], b["place"]), ("dungeon", "moonbrook"))
|
|
|
|
|
self.assertIn("westfall", b["zones"])
|
2026-10-23 22:52:42 +01:00
|
|
|
game.leave_group(world, b, 10)
|
|
|
|
|
self.assertIsNone(b["activity"])
|
|
|
|
|
self.assertEqual(b["place"], "moonbrook")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|