diff --git a/README.md b/README.md index 1893834..305234b 100644 --- a/README.md +++ b/README.md @@ -27,15 +27,18 @@ Prototype. The first slice: levels 1–20, two zones, one dungeon. - **Questing:** 24 quests with Classic's names and chains, from Kobold Camp Cleanup to The Defias Brotherhood. Kill, loot (quest items only drop while you're on the quest) and travel objectives; rewards are a choice of gear. -- **Travel:** places are joined by roads that take real minutes. Hunts are - where the mobs are, quest givers stand where they stood in Classic, and the - dungeon door is in Moonbrook. +- **Zones, not maps:** a zone is one page. Every hunt, quest giver and vendor + in Elwynn is a tap away, grouped by subzone (Northshire, Fargodeep Mine, + Forest's Edge…). Getting to Westfall is a journey: a fight at each stop on the + Westbrook road (the garrison, the bridge, the Jansen Stead), with a chance of + meeting Mother Fang. Die on the road and you wake where it began. Once you've + made it on foot, the flight master takes you back and forth for 50c. - **Idle:** start a hunt and leave. You fight, rest and fight again for up to 12 hours, finishing quest objectives as you go. Three deaths in a row and you stop. - **Dungeons:** the Deadmines, nine pulls and five bosses, for a group of - 2–5 (packs shrink for smaller groups). Everyone meets at the door; the leader - leads you in and can pull early. Bosses give every player their own loot roll + 2–5 (packs shrink for smaller groups). The leader summons everyone from the + meeting stone in Westfall and can pull early. Bosses give every player their own loot roll at gear they can use. - **Gear:** seven slots, cloth/leather/mail by class, Classic-style generated greens (`Rough Leather Boots of the Monkey`) and named blues from the diff --git a/app.py b/app.py index 2233276..47fd6e4 100644 --- a/app.py +++ b/app.py @@ -59,7 +59,7 @@ class Store: with closing(sqlite3.connect(path)) as db, db: db.execute("CREATE TABLE IF NOT EXISTS world (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)") row = db.execute("SELECT data FROM world WHERE id = 1").fetchone() - self.world = json.loads(row[0]) if row else game.new_world(time.time()) + self.world = game.migrate(json.loads(row[0])) if row else game.new_world(time.time()) self.world["config"]["dev"] = dev self.save() @@ -99,7 +99,8 @@ def static_content() -> dict: referenced.update(quest.get("choice", [])) return { "classes": content.CLASSES, "specs": content.SPECS, "abilities": content.ABILITIES, "unlock": content.UNLOCK, - "places": content.PLACES, "quests": content.QUESTS, "dungeons": content.DUNGEONS, + "places": content.PLACES, "zones": content.ZONES, "roads": content.ROADS, "flight_cost": content.FLIGHT_COST, + "quests": content.QUESTS, "dungeons": content.DUNGEONS, "mobs": {k: {"name": m["name"], "lv": m["lv"], "rare": m.get("rare", False), "boss": m.get("boss", False), "elite": m.get("elite", False)} for k, m in content.MOBS.items()}, "enemy_abilities": content.ENEMY_ABILITIES, "slots": content.SLOTS, "slot_names": content.SLOT_NAMES, @@ -115,7 +116,7 @@ CONTENT = json.dumps(static_content(), separators=(",", ":")).encode() def player_view(world: dict, player: dict, now: float) -> dict: st = game.stats(player) hp, mana = game.hp_now(player, now) - view = {key: player[key] for key in ("id", "name", "cls", "level", "xp", "copper", "place", "activity", + view = {key: player[key] for key in ("id", "name", "cls", "level", "xp", "copper", "place", "zones", "activity", "quests", "done", "manual", "group", "dead_until", "next_pull")} view["hp"], view["mana"] = round(hp), round(mana, 3) view["stats"] = st @@ -290,8 +291,10 @@ class Handler(BaseHTTPRequestHandler): return game.start_hunt(world, player, arg, now) if path == "/api/stop": return game.stop(world, player, now) - if path == "/api/travel": - return game.travel(world, player, arg, now) + if path == "/api/journey": + return game.journey(world, player, arg, now) + if path == "/api/fly": + return game.fly(world, player, arg, now) if path == "/api/equip": return game.equip(player, arg) if path == "/api/unequip": @@ -351,6 +354,8 @@ class Handler(BaseHTTPRequestHandler): return "No such place" game.stop(world, player, now) player["place"] = str(body["id"]) + if game.zone_of(player["place"]) not in player["zones"]: + player["zones"].append(game.zone_of(player["place"])) return None return "Not found" diff --git a/content.py b/content.py index 25c9005..1a707e8 100644 --- a/content.py +++ b/content.py @@ -275,6 +275,9 @@ MOBS = { "princess": {"name": "Princess", "lv": (9, 9), "kind": "beast", "hp": 2.5, "dmg": 1.3, "rare": True, "junk": "Princess's Collar"}, "porcine_entourage": {"name": "Porcine Entourage", "lv": (6, 7), "kind": "beast", "junk": "Chunk of Boar Meat"}, + "young_wolf_pack": {"name": "Prowling Wolf", "lv": (9, 10), "kind": "beast", "hp": 0.7, "junk": "Wolf Fang"}, + "mother_fang": {"name": "Mother Fang", "lv": (10, 10), "kind": "beast", "hp": 2.5, "dmg": 1.3, "rare": True, + "junk": "Silky Spider Leg"}, "riverpaw_gnoll": {"name": "Riverpaw Gnoll", "lv": (9, 10), "kind": "humanoid", "junk": "Gnoll Tooth"}, "riverpaw_mystic": {"name": "Riverpaw Mystic", "lv": (10, 10), "kind": "humanoid", "hp": 0.8, "abilities": ["healing_wave"], "junk": "Mystic's Charm"}, @@ -343,68 +346,70 @@ BOSS_HP, BOSS_DMG = 5.0, 2.8 # on top of the mob's own multipliers # --- the world ------------------------------------------------------------- -# Hunts are fights you can repeat. `pulls` are the packs a hunt draws from. -PLACES = { - "northshire_abbey": {"name": "Northshire Abbey", "zone": "Elwynn Forest", "hub": True, "lv": (1, 5), - "links": {"northshire_valley": 1, "northshire_vineyards": 1, "echo_ridge": 1, - "goldshire": 4}}, - "northshire_valley": {"name": "Northshire Valley", "zone": "Elwynn Forest", "lv": (1, 3), - "hunts": {"young_wolf": [["young_wolf"]], "kobold_vermin": [["kobold_vermin"]]}, - "links": {"northshire_abbey": 1}}, - "northshire_vineyards": {"name": "Northshire Vineyards", "zone": "Elwynn Forest", "lv": (3, 5), - "hunts": {"defias_thug": [["defias_thug"]], - "garrick_padfoot": [["garrick_padfoot", "defias_thug"]]}, - "links": {"northshire_abbey": 1}}, - "echo_ridge": {"name": "Echo Ridge Mine", "zone": "Elwynn Forest", "lv": (3, 5), - "hunts": {"kobold_worker": [["kobold_worker"]], "kobold_laborer": [["kobold_laborer"]]}, - "links": {"northshire_abbey": 1}}, - "goldshire": {"name": "Goldshire", "zone": "Elwynn Forest", "hub": True, "lv": (5, 10), - "links": {"northshire_abbey": 4, "fargodeep": 2, "crystal_lake": 2, "brackwell": 3, - "forests_edge": 3, "sentinel_hill": 7}}, - "fargodeep": {"name": "Fargodeep Mine", "zone": "Elwynn Forest", "lv": (6, 8), - "hunts": {"kobold_tunneler": [["kobold_tunneler"]], - "kobold_miner": [["kobold_miner"], ["kobold_miner", "kobold_tunneler"]]}, - "links": {"goldshire": 2}}, - "crystal_lake": {"name": "Crystal Lake", "zone": "Elwynn Forest", "lv": (7, 9), - "hunts": {"murloc_streamrunner": [["murloc_streamrunner"]], - "murloc_forager": [["murloc_forager"], ["murloc_forager", "murloc_streamrunner"]]}, - "links": {"goldshire": 2}}, - "brackwell": {"name": "Brackwell Pumpkin Patch", "zone": "Elwynn Forest", "lv": (8, 10), - "hunts": {"defias_bandit": [["defias_bandit"]], - "princess": [["princess", "porcine_entourage", "porcine_entourage"]]}, - "links": {"goldshire": 3, "forests_edge": 2}}, - "forests_edge": {"name": "Forest's Edge", "zone": "Elwynn Forest", "lv": (9, 11), - "hunts": {"riverpaw_gnoll": [["riverpaw_gnoll"], ["riverpaw_gnoll", "riverpaw_mystic"]], - "hogger": [["hogger"]]}, - "links": {"goldshire": 3, "brackwell": 2, "sentinel_hill": 5}}, - "sentinel_hill": {"name": "Sentinel Hill", "zone": "Westfall", "hub": True, "lv": (10, 20), - "links": {"goldshire": 7, "forests_edge": 5, "saldeans_farm": 2, "molsen_farm": 2, - "dust_plains": 3, "gold_coast": 3, "moonbrook": 3}}, - "saldeans_farm": {"name": "Saldean's Farm", "zone": "Westfall", "lv": (10, 13), - "hunts": {"harvest_watcher": [["harvest_watcher"]], - "young_fleshripper": [["young_fleshripper"]], "goretusk": [["goretusk"]]}, - "links": {"sentinel_hill": 2}}, - "molsen_farm": {"name": "The Molsen Farm", "zone": "Westfall", "lv": (12, 14), - "hunts": {"defias_trapper": [["defias_trapper"]], - "defias_smuggler": [["defias_smuggler"], ["defias_smuggler", "defias_trapper"]]}, - "links": {"sentinel_hill": 2}}, - "dust_plains": {"name": "The Dust Plains", "zone": "Westfall", "lv": (14, 16), - "hunts": {"riverpaw_brute": [["riverpaw_brute"], ["riverpaw_brute", "riverpaw_herbalist"]]}, - "links": {"sentinel_hill": 3, "dagger_hills": 2}}, - "gold_coast": {"name": "Gold Coast Quarry", "zone": "Westfall", "lv": (15, 16), - "hunts": {"murloc_coastrunner": [["murloc_coastrunner"], ["murloc_coastrunner", "murloc_tidecaller"]]}, - "links": {"sentinel_hill": 3}}, - "moonbrook": {"name": "Moonbrook", "zone": "Westfall", "lv": (16, 18), "dungeon": "deadmines", - "hunts": {"defias_pillager": [["defias_pillager"], ["defias_looter", "defias_pillager"]], - "defias_looter": [["defias_looter"]]}, - "links": {"sentinel_hill": 3, "dagger_hills": 2}}, - "dagger_hills": {"name": "The Dagger Hills", "zone": "Westfall", "lv": (17, 19), - "hunts": {"defias_pathstalker": [["defias_pathstalker"]], - "defias_highwayman": [["defias_highwayman"], ["defias_highwayman", "defias_pathstalker"]]}, - "links": {"moonbrook": 2, "dust_plains": 2}}, +# A zone is one page: every hunt, quest giver and vendor in it is a tap away. +ZONES = { + "elwynn": {"name": "Elwynn Forest", "lv": (1, 10), "hub": "goldshire", "start": "northshire_abbey"}, + "westfall": {"name": "Westfall", "lv": (10, 20), "hub": "sentinel_hill"}, +} + +# Crossing between zones is a journey: a fight at each stop along the road, in +# order (reversed going the other way), each with a chance of the road's rare. +ROADS = { + "westbrook": { + "name": "The Westbrook road", "zones": ("elwynn", "westfall"), "rare": "mother_fang", "rare_odds": 0.1, + "stops": [ + {"name": "Westbrook Garrison", "pulls": [["riverpaw_gnoll"], ["young_wolf_pack", "young_wolf_pack"]]}, + {"name": "The Westbrook bridge", "pulls": [["murloc_forager", "murloc_streamrunner"], + ["riverpaw_gnoll", "riverpaw_mystic"]]}, + {"name": "The Jansen Stead", "pulls": [["young_fleshripper"], ["harvest_watcher"]]}, + ], + }, +} +FLIGHT_COST = 50 # copper to fly to a zone you've reached on foot + +# Places are a zone's subzones. Hunts are fights you can repeat; `pulls` are the +# packs a hunt draws from. +PLACES = { + "northshire_abbey": {"name": "Northshire Abbey", "zone": "elwynn", "hub": True, "lv": (1, 5)}, + "northshire_valley": {"name": "Northshire Valley", "zone": "elwynn", "lv": (1, 3), + "hunts": {"young_wolf": [["young_wolf"]], "kobold_vermin": [["kobold_vermin"]]}}, + "northshire_vineyards": {"name": "Northshire Vineyards", "zone": "elwynn", "lv": (3, 5), + "hunts": {"defias_thug": [["defias_thug"]], + "garrick_padfoot": [["garrick_padfoot", "defias_thug"]]}}, + "echo_ridge": {"name": "Echo Ridge Mine", "zone": "elwynn", "lv": (3, 5), + "hunts": {"kobold_worker": [["kobold_worker"]], "kobold_laborer": [["kobold_laborer"]]}}, + "goldshire": {"name": "Goldshire", "zone": "elwynn", "hub": True, "lv": (5, 10)}, + "fargodeep": {"name": "Fargodeep Mine", "zone": "elwynn", "lv": (6, 8), + "hunts": {"kobold_tunneler": [["kobold_tunneler"]], + "kobold_miner": [["kobold_miner"], ["kobold_miner", "kobold_tunneler"]]}}, + "crystal_lake": {"name": "Crystal Lake", "zone": "elwynn", "lv": (7, 9), + "hunts": {"murloc_streamrunner": [["murloc_streamrunner"]], + "murloc_forager": [["murloc_forager"], ["murloc_forager", "murloc_streamrunner"]]}}, + "brackwell": {"name": "Brackwell Pumpkin Patch", "zone": "elwynn", "lv": (8, 10), + "hunts": {"defias_bandit": [["defias_bandit"]], + "princess": [["princess", "porcine_entourage", "porcine_entourage"]]}}, + "forests_edge": {"name": "Forest's Edge", "zone": "elwynn", "lv": (9, 11), + "hunts": {"riverpaw_gnoll": [["riverpaw_gnoll"], ["riverpaw_gnoll", "riverpaw_mystic"]], + "hogger": [["hogger"]]}}, + "sentinel_hill": {"name": "Sentinel Hill", "zone": "westfall", "hub": True, "lv": (10, 20)}, + "saldeans_farm": {"name": "Saldean's Farm", "zone": "westfall", "lv": (10, 13), + "hunts": {"harvest_watcher": [["harvest_watcher"]], + "young_fleshripper": [["young_fleshripper"]], "goretusk": [["goretusk"]]}}, + "molsen_farm": {"name": "The Molsen Farm", "zone": "westfall", "lv": (12, 14), + "hunts": {"defias_trapper": [["defias_trapper"]], + "defias_smuggler": [["defias_smuggler"], ["defias_smuggler", "defias_trapper"]]}}, + "dust_plains": {"name": "The Dust Plains", "zone": "westfall", "lv": (14, 16), + "hunts": {"riverpaw_brute": [["riverpaw_brute"], ["riverpaw_brute", "riverpaw_herbalist"]]}}, + "gold_coast": {"name": "Gold Coast Quarry", "zone": "westfall", "lv": (15, 16), + "hunts": {"murloc_coastrunner": [["murloc_coastrunner"], ["murloc_coastrunner", "murloc_tidecaller"]]}}, + "moonbrook": {"name": "Moonbrook", "zone": "westfall", "lv": (16, 18), "dungeon": "deadmines", + "hunts": {"defias_pillager": [["defias_pillager"], ["defias_looter", "defias_pillager"]], + "defias_looter": [["defias_looter"]]}}, + "dagger_hills": {"name": "The Dagger Hills", "zone": "westfall", "lv": (17, 19), + "hunts": {"defias_pathstalker": [["defias_pathstalker"]], + "defias_highwayman": [["defias_highwayman"], ["defias_highwayman", "defias_pathstalker"]]}}, } -GRAVEYARD = {"Elwynn Forest": "goldshire", "Westfall": "sentinel_hill"} DUNGEONS = { "deadmines": { @@ -458,7 +463,7 @@ QUESTS = { "name": "Report to Goldshire", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 5, "req": ["investigate_echo_ridge"], "ender": "goldshire", "ender_npc": "Marshal Dughan", "text": "Take word of the kobolds to Marshal Dughan in Goldshire.", - "obj": [{"visit": "goldshire"}], "xp": 200, "copper": 50}, + "obj": [], "xp": 200, "copper": 50}, "gold_dust_exchange": { "name": "Gold Dust Exchange", "giver": "goldshire", "npc": "Remy \"Two Times\"", "lv": 7, "text": "The kobolds at Fargodeep pan for gold dust. Bring me some, and no questions.", @@ -497,7 +502,7 @@ QUESTS = { "name": "Report to Gryan Stoutmantle", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 10, "req": ["riverpaw_gnoll_bounty"], "ender": "sentinel_hill", "ender_npc": "Gryan Stoutmantle", "text": "The People's Militia at Sentinel Hill needs every sword it can get.", - "obj": [{"visit": "sentinel_hill"}], "xp": 400, "copper": 100}, + "obj": [{"visit": "westfall"}], "xp": 400, "copper": 100}, "the_killing_fields": { "name": "The Killing Fields", "giver": "saldeans_farm", "npc": "Farmer Saldean", "lv": 12, "text": "Harvest Watchers turned on us. Smash them.", diff --git a/game.py b/game.py index 51d0463..69d9e07 100644 --- a/game.py +++ b/game.py @@ -7,15 +7,15 @@ adventuring for up to OFFLINE_CAP. from __future__ import annotations -import heapq import random import combat from content import (ABILITIES, ARMOUR_TYPES, BASE_NAMES, BOSS_LOOT, BOSS_LOOT_CHANCE, CLASSES, DUNGEONS, - GRAVEYARD, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, SLOT_ARMOUR, SLOT_BUDGET, - SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, XP_TO_NEXT) + FLIGHT_COST, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, ROADS, SLOT_ARMOUR, + SLOT_BUDGET, SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, + XP_TO_NEXT, ZONES) -VERSION = 1 +VERSION = 2 MINUTE = 60 HOUR = 3600 OFFLINE_CAP = 12 * HOUR @@ -160,7 +160,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict: armour = spec["armour"] player = { "id": uid, "name": name, "cls": cls, "level": 1, "xp": 0, "copper": 0, - "place": "northshire_abbey", "activity": None, "fight": None, "group": None, + "place": "northshire_abbey", "zones": ["elwynn"], "activity": None, "fight": None, "group": None, "rest": {"at": now, "hp": 1e9, "mana": 1.0}, "dead_until": 0, "next_pull": 0, "deaths": 0, "quests": {}, "done": [], "bags": {}, "manual": [], "equip": {slot: None for slot in SLOTS}, @@ -269,6 +269,26 @@ def new_world(now: float) -> dict: "config": {"clock_offset": 0}, "created": now} +def migrate(world: dict) -> dict: + """Bring a saved world up to this version.""" + if world.get("version", 1) < 2: + # Places became subzones of zones; road travel became journeys. + for player in world["players"].values(): + activity = player["activity"] + if activity and activity["kind"] == "travel": + player["place"], player["activity"] = activity["to"], None + player["zones"] = [zone_of(player["place"])] + for qid, progress in player["quests"].items(): + need = len(QUESTS[qid]["obj"]) + player["quests"][qid] = (progress + [0] * need)[:need] + world["version"] = 2 + return world + + +def zone_of(place: str) -> str: + return PLACES[place]["zone"] + + def _next_id(world: dict, prefix: str) -> str: world["next"] += 1 return f"{prefix}{world['next']}" @@ -296,31 +316,36 @@ def _process_player(world: dict, player: dict, now: float, rng: random.Random) - combat.advance(fight, now, watch_map(world)) if fight["state"] == "on": return - _end_hunt_fight(world, player, fight, rng) + _end_field_fight(world, player, fight, rng) continue - if not activity: + if not activity or activity["kind"] not in ("hunt", "journey"): + return + start = max(player["next_pull"], player["dead_until"]) + if start > now: + return + if start > player["last_active"] + OFFLINE_CAP: + player["activity"] = None + log(player, start, "You made camp. Come back to keep going.", "info") return - if activity["kind"] == "travel": - if now < activity["end"]: - return - _arrive(world, player, activity["to"], activity["end"]) - continue if activity["kind"] == "hunt": - start = max(player["next_pull"], player["dead_until"]) - if start > now: - return - if start > player["last_active"] + OFFLINE_CAP: - player["activity"] = None - log(player, start, "You made camp. Come back to keep going.", "info") - return - _start_hunt_fight(world, player, activity["hunt"], start, rng) - continue - return + pull = rng.choice(PLACES[player["place"]]["hunts"][activity["hunt"]]) + _start_field_fight(world, player, pull, "hunt", start, rng) + else: + road = ROADS[activity["road"]] + stop = _stops(activity)[activity["leg"]] + pull = rng.choice(stop["pulls"]) + if rng.random() < road["rare_odds"]: + pull = [road["rare"]] + _start_field_fight(world, player, pull, "journey", start, rng, stop["name"]) -def _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: random.Random) -> None: - pulls = PLACES[player["place"]]["hunts"][hunt] - pull = rng.choice(pulls) +def _stops(journey: dict) -> list[dict]: + road = ROADS[journey["road"]] + return road["stops"] if journey["to"] == road["zones"][1] else road["stops"][::-1] + + +def _start_field_fight(world: dict, player: dict, pull: list, kind: str, at: float, rng: random.Random, + where: str | None = None) -> None: enemies = [] for index, mob_id in enumerate(pull): low, high = MOBS[mob_id]["lv"] @@ -329,12 +354,14 @@ def _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: rand enemies.append(unit) fid = _next_id(world, "f") name = MOBS[pull[0]]["name"] if len(pull) == 1 else f"{MOBS[pull[0]]['name']} and friends" + if where: + name = f"{where}: {name}" world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, player_unit(player, at, False), - enemies, "hunt", name) + enemies, kind, name) player["fight"] = fid -def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None: +def _end_field_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None: end = fight["ended"] me = fight["units"][player["id"]] player["fight"] = None @@ -348,13 +375,24 @@ def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random) hp, mana = hp_now(player, end) missing = max(1 - hp / st["max_hp"], 1 - mana) player["next_pull"] = end + max(2.0, missing / REST * 0.85) + activity = player["activity"] + if activity and activity["kind"] == "journey": + activity["leg"] += 1 + if activity["leg"] >= len(_stops(activity)): + _arrive(world, player, activity["to"], end) else: player["deaths"] += 1 player["dead_until"] = end + REVIVE player["rest"] = {"at": end + REVIVE, "hp": st["max_hp"] * 0.5, "mana": 0.5} player["next_pull"] = end + REVIVE + 0.5 / REST log(player, end, f"Killed by {fight['name']}", "death") - if player["deaths"] >= DEATHS_TO_STOP: + activity = player["activity"] + if activity and activity["kind"] == "journey": + # Driven back: you wake where the road began. + player["activity"] = None + player["deaths"] = 0 + log(player, end, f"You never made it to {ZONES[activity['to']]['name']}", "death") + elif player["deaths"] >= DEATHS_TO_STOP: player["activity"] = None player["deaths"] = 0 log(player, end, "Died three times in a row. You stopped hunting here.", "death") @@ -393,13 +431,20 @@ def _reward_kills(world: dict, player: dict, fight: dict, share: float, now: flo gain_xp(world, player, xp, now) -def _arrive(world: dict, player: dict, place: str, at: float) -> None: - player["place"] = place +def _arrive(world: dict, player: dict, zone: str, at: float, flew: bool = False) -> None: + player["place"] = ZONES[zone]["hub"] player["activity"] = None - log(player, at, f"Arrived at {PLACES[place]['name']}", "travel") + if zone not in player["zones"]: + player["zones"].append(zone) + world_log(world, at, f"{player['name']} reached {ZONES[zone]['name']}", "travel", player["id"]) + log(player, at, f"{'Flew to' if flew else 'Arrived in'} {ZONES[zone]['name']}", "travel") + _visit(player, zone) + + +def _visit(player: dict, zone: str) -> None: for qid, progress in player["quests"].items(): for index, objective in enumerate(QUESTS[qid]["obj"]): - if objective.get("visit") == place: + if objective.get("visit") == zone: progress[index] = 1 @@ -418,12 +463,18 @@ def _leave_fight(world: dict, player: dict, now: float) -> None: "mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0} +def hunt_place(zone: str, hunt: str) -> str | None: + return next((pid for pid, p in PLACES.items() if p["zone"] == zone and hunt in p.get("hunts", {})), None) + + def start_hunt(world: dict, player: dict, hunt: str, now: float) -> str | None: if _busy(player): return _busy(player) - if hunt not in PLACES[player["place"]].get("hunts", {}): + place = hunt_place(zone_of(player["place"]), hunt) + if not place: return "Nothing to hunt like that here" _leave_fight(world, player, now) + player["place"] = place player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now} player["deaths"] = 0 player["next_pull"] = max(player["next_pull"], now) @@ -442,41 +493,46 @@ def _busy(player: dict) -> str | None: activity = player["activity"] if activity and activity["kind"] == "dungeon": return "You're in a dungeon" - if activity and activity["kind"] == "travel": + if activity and activity["kind"] == "journey": return "You're on the road" return None -def route(start: str, goal: str) -> tuple[float, list[str]] | None: - queue = [(0.0, start, [start])] - seen = set() - while queue: - cost, here, path = heapq.heappop(queue) - if here == goal: - return cost, path - if here in seen: - continue - seen.add(here) - for nxt, minutes in PLACES[here]["links"].items(): - if nxt not in seen: - heapq.heappush(queue, (cost + minutes, nxt, path + [nxt])) - return None +def road_between(a: str, b: str) -> str | None: + return next((rid for rid, road in ROADS.items() if set(road["zones"]) == {a, b}), None) -def travel(world: dict, player: dict, to: str, now: float) -> str | None: +def journey(world: dict, player: dict, to: str, now: float) -> str | None: + """Set out on foot: a fight at every stop along the road.""" if _busy(player): return _busy(player) - if to not in PLACES or to == player["place"]: - return "Pick somewhere else" - found = route(player["place"], to) - if not found: + here = zone_of(player["place"]) + road = road_between(here, to) if to in ZONES else None + if not road: return "No road there" if player["dead_until"] > now: return "You're dead" - minutes, path = found _leave_fight(world, player, now) - player["activity"] = {"kind": "travel", "from": player["place"], "to": to, "path": path, - "start": now, "end": now + minutes * MINUTE} + player["activity"] = {"kind": "journey", "road": road, "from": here, "to": to, "leg": 0, "since": now} + player["deaths"] = 0 + player["next_pull"] = max(player["next_pull"], now) + return None + + +def fly(world: dict, player: dict, to: str, now: float) -> str | None: + if _busy(player): + return _busy(player) + if to not in player["zones"]: + return "Reach it on foot first" + if to == zone_of(player["place"]): + return "You're already here" + if player["dead_until"] > now: + return "You're dead" + if player["copper"] < FLIGHT_COST: + return "Not enough money" + _leave_fight(world, player, now) + player["copper"] -= FLIGHT_COST + _arrive(world, player, to, now, flew=True) return None @@ -510,8 +566,6 @@ def unequip(player: dict, slot: str) -> str | None: def sell(player: dict, item_id: str, qty: int | str) -> str | None: - if not PLACES[player["place"]].get("hub"): - return "Find a vendor in a town" have = player["bags"].get(item_id, 0) if qty == "all": qty = have @@ -526,8 +580,6 @@ def sell(player: dict, item_id: str, qty: int | str) -> str | None: def sell_junk(player: dict) -> str | None: - if not PLACES[player["place"]].get("hub"): - return "Find a vendor in a town" junk = [i for i in player["bags"] if i.startswith("j/")] if not junk: return "No junk to sell" @@ -577,7 +629,7 @@ def shown_fight(world: dict, player: dict, now: float) -> dict | None: def available_quests(player: dict) -> list[str]: return [qid for qid, q in QUESTS.items() - if q["giver"] == player["place"] and qid not in player["done"] and qid not in player["quests"] + if zone_of(q["giver"]) == zone_of(player["place"]) and qid not in player["done"] and qid not in player["quests"] and all(req in player["done"] for req in q.get("req", []))] @@ -592,9 +644,7 @@ def accept(player: dict, qid: str) -> str | None: if len(player["quests"]) >= MAX_QUESTS: return f"Your quest log is full ({MAX_QUESTS})" player["quests"][qid] = [0] * len(QUESTS[qid]["obj"]) - for index, objective in enumerate(QUESTS[qid]["obj"]): - if objective.get("visit") == player["place"]: - player["quests"][qid][index] = 1 + _visit(player, zone_of(player["place"])) return None @@ -609,8 +659,9 @@ def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | No quest = QUESTS.get(qid) if not quest or qid not in player["quests"]: return "Not on that quest" - if player["place"] != quest.get("ender", quest["giver"]): - return f"Turn it in at {PLACES[quest.get('ender', quest['giver'])]['name']}" + ender = quest.get("ender", quest["giver"]) + if zone_of(player["place"]) != zone_of(ender): + return f"Turn it in at {PLACES[ender]['name']}, {ZONES[zone_of(ender)]['name']}" if not quest_done(player, qid): return "Not finished yet" options = quest.get("choice", []) @@ -724,20 +775,25 @@ def enter_dungeon(world: dict, player: dict, now: float) -> str | None: return "Only the leader can lead you in" if group["dungeon"]: return "You're already inside" - dungeon_id = PLACES[player["place"]].get("dungeon") + zone = zone_of(player["place"]) + dungeon_id = next((d for d, spec in DUNGEONS.items() if zone_of(spec["entrance"]) == zone), None) if not dungeon_id: - return "There's no dungeon here" + return "There's no dungeon in this zone" if len(group["members"]) < min_party(world): return f"Dungeons need at least {min_party(world)} players" members = [world["players"][m] for m in group["members"]] - away = [m["name"] for m in members if m["place"] != player["place"] or _busy(m)] + away = [m["name"] for m in members if _busy(m)] if away: - return f"Waiting on {', '.join(away)} to reach {PLACES[player['place']]['name']}" + return f"Waiting on {', '.join(away)}" dead = [m["name"] for m in members if m["dead_until"] > now] if dead: return f"{', '.join(dead)} is dead" for member in members: + # The meeting stone summons everyone, wherever they are. _leave_fight(world, member, now) + member["place"] = DUNGEONS[dungeon_id]["entrance"] + if zone not in member["zones"]: + member["zones"].append(zone) member["activity"] = {"kind": "dungeon", "group": group["id"]} log(member, now, f"Entered {DUNGEONS[dungeon_id]['name']}", "travel") group["dungeon"] = {"id": dungeon_id, "pull": 0, "state": "rest", "next": now + DUNGEON_REST, "fight": None, diff --git a/static/app.js b/static/app.js index 4207579..d8dc4a0 100644 --- a/static/app.js +++ b/static/app.js @@ -35,6 +35,7 @@ let failures = 0; let selected = null; // bag item let regCls = null; let pickReward = {}; // quest id -> reward index +let openQuest = null; // quest offer shown in full let seenEv = { id: null, t: -1 }; let barKey = ""; let seenChat = Number(remember("seenChat") || 0); @@ -289,12 +290,9 @@ function abilityRow(id, level, me) { function adventurePage() { const me = S.me; - const here = place(me.place); const act = me.activity; const parts = [statusCard()]; - if (!act || act.kind !== "dungeon") { - parts.push(placeCard(here)); - } + if (!act || act.kind !== "dungeon") parts.push(zoneCard(zoneOf(me.place))); parts.push(logCard()); if (S.dev) parts.push(devCard()); return parts.join(""); @@ -308,19 +306,21 @@ function statusCard() { return `
💀 You died. Running back ·
`; } if (!act) return ""; - if (act.kind === "travel") { + const waiting = !S.fight && me.next_pull > t ? `resting · ` : ""; + if (act.kind === "journey") { + const road = C.roads[act.road]; + const stops = act.to === road.zones[1] ? road.stops : road.stops.slice().reverse(); return `
-
🐎 On the road to ${esc(place(act.to).name)}
-
-
${act.path.map((p) => esc(place(p).name)).join(" → ")}
+
🛤️ On the road to ${esc(C.zones[act.to].name)}${waiting} +
+
${stops.map((stop, i) => `
${i < act.leg ? "✅" : i === act.leg ? "➡️" : "▫️"} ${esc(stop.name)}
`).join("")} +
${act.leg >= stops.length ? "✅" : "▫️"} ${esc(C.places[C.zones[act.to].hub].name)}
`; } if (act.kind === "hunt") { - const mob = C.mobs[act.hunt]; - const waiting = !S.fight && me.next_pull > t; return `
- 🎯 Hunting ${esc(mob.name)} - ${waiting ? `resting · ` : ""} + 🎯 Hunting ${esc(C.mobs[act.hunt].name)}${esc(place(me.place).name)} + ${waiting}
`; } @@ -357,46 +357,59 @@ function dungeonCard() { `; } -function placeCard(here) { +function zoneOf(placeId) { return C.places[placeId].zone; } + +function zoneDungeon(zone) { + return Object.keys(C.dungeons).find((id) => zoneOf(C.dungeons[id].entrance) === zone); +} + +function zoneCard(zone) { const me = S.me; + const z = C.zones[zone]; const act = me.activity; - const travelling = act && act.kind === "travel"; + const away = act && act.kind === "journey"; const hunting = act && act.kind === "hunt" ? act.hunt : null; - const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => { - const mob = C.mobs[id]; - const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`; - const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name); - const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id))); - return `
-
${esc(mob.name)} lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""} - ${hot ? '!' : ""}
- ${hunting === id ? `` - : ``} -
`; + const subzones = Object.entries(C.places).filter(([, p]) => p.zone === zone && p.hunts).map(([pid, p]) => { + const rows = Object.entries(p.hunts).map(([id, pulls]) => { + const mob = C.mobs[id]; + const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`; + const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name); + const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id))); + return `
+
${esc(mob.name)} lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""} + ${hot ? '!' : ""}
+ ${hunting === id ? `` + : ``} +
`; + }).join(""); + return `
${esc(p.name)} · ${p.lv[0]}-${p.lv[1]}
${rows}
`; }).join(""); - const offers = me.available.map((q) => questOffer(q)).join(""); - const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q)) + // Nearest first; quests far above you wait until they're within reach. + const offers = me.available.filter((q) => C.quests[q].lv <= me.level + 5) + .sort((a, b) => C.quests[a].lv - C.quests[b].lv).map((q) => questOffer(q)).join(""); + const turnIns = Object.keys(me.quests).filter((q) => zoneOf(C.quests[q].ender || C.quests[q].giver) === zone && questReady(q)) .map((q) => questTurnIn(q)).join(""); - const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : ""; + const dungeon = zoneDungeon(zone); const junk = me.bags.filter((b) => b.q === "poor"); const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0); - const vendor = here.hub ? `
🪙 Vendor -
` : ""; - const roads = Object.entries(here.links).map(([to, minutes]) => - ``).join(""); - const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id]) - .map(([id, p]) => ``).join(""); + const roads = Object.entries(C.roads).filter(([, r]) => r.zones.includes(zone)).map(([, r]) => { + const to = r.zones.find((x) => x !== zone); + return `
+
🛤️ ${esc(C.zones[to].name)} lv ${C.zones[to].lv[0]}-${C.zones[to].lv[1]} · ${esc(r.name)}, ${r.stops.length} fights
+ +
`; + }).join(""); + const flights = me.zones.filter((x) => x !== zone).map((to) => + ``).join(""); return `
-

${esc(here.name)}

${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}
- ${hunts ? `

Hunt

${hunts}
` : ""} +

${esc(z.name)}

lv ${z.lv[0]}-${z.lv[1]} +
${turnIns ? `

Turn in

${turnIns}
` : ""} ${offers ? `

Quests

${offers}
` : ""} - ${dungeon} - ${vendor} -

Roads

-
${roads}
-
-
+ ${subzones ? `

Hunt

${subzones}` : ""} + ${dungeon ? dungeonEntrance(dungeon) : ""} + ${roads ? `

Roads

${roads}
` : ""} + ${flights ? `
Flight master${flights}
` : ""}
`; } @@ -405,22 +418,25 @@ function dungeonEntrance(id) { const g = S.group; let action; if (!g) action = `Form a group (👥) to go in`; - else if (g.leader !== S.uid) action = `Waiting for the leader`; - else action = ``; + else if (g.leader !== S.uid) action = `The leader summons you`; + else action = ``; return `

Dungeon

-
🕳️ ${esc(spec.name)} lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses
+
🕳️ ${esc(spec.name)} ${esc(place(spec.entrance).name)} · lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses +
The meeting stone summons your whole group, wherever they are.
${action}
`; } function questOffer(qid) { const q = C.quests[qid]; const tooHigh = q.lv > S.me.level + 3; - return `
-
!${esc(q.name)}lv ${q.lv}
-
${esc(q.npc)}: “${esc(q.text)}”
+ const open = openQuest === qid; + return `
+
!${esc(q.name)}lv ${q.lv} + ${open ? "" : ``}
+ ${open ? `
${esc(q.npc)}, ${esc(place(q.giver).name)}: “${esc(q.text)}”
${q.obj.map((o) => `
· ${objText(o)}
`).join("")}
${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""} -
+
` : ""}
`; } @@ -447,7 +463,7 @@ function objText(o, have) { const count = have === undefined ? "" : ` ${have} / ${need}`; if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`; if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}`; - if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`; + if (o.visit) return `Reach ${esc(C.zones[o.visit].name)}${count}`; return ""; } @@ -502,8 +518,7 @@ function questsPage() { return `
${ready ? '? ' : ""}${esc(q.name)}lv ${q.lv}
${q.obj.map((o, i) => `
· ${objText(o, me.quests[qid][i])}
`).join("")} -
${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`} - ${ready && me.place !== ender ? `` : ""} +
${ready ? "Turn in" : "Ends"} with ${esc(q.ender_npc || q.npc)}, ${esc(place(ender).name)}${zoneOf(ender) !== zoneOf(me.place) ? `, ${esc(C.zones[zoneOf(ender)].name)}` : ""}
`; }).join("")} @@ -552,14 +567,13 @@ function characterPage() { }).join(""); const bags = me.bags.map((it) => { const on = selected === it.id; - const atHub = place(me.place).hub; return `
${esc(it.name)}${it.qty > 1 ? ` ×${it.qty}` : ""}
${itemLine(it)}${it.req > me.level ? ` · requires ${it.req}` : ""}
${on && it.slot ? `
${compare(it)}
` : ""} ${on ? `
${it.slot ? `` : ""} -
` : ""} +
` : ""}
`; }).join(""); return `
@@ -586,7 +600,7 @@ function groupPage() { const g = S.group; let mine; if (g) { - const here = place(S.me.place); + const dungeon = zoneDungeon(zoneOf(S.me.place)); mine = `

Your group

${g.members.map((m) => `
${esc(m.name)}${m.id === g.leader ? " 👑" : ""} @@ -594,21 +608,21 @@ function groupPage() {
${bar(m.hp, m.max_hp, "thin")}
`).join("")}
- ${!g.dungeon && here.dungeon && g.leader === S.uid ? `` : ""} - ${!g.dungeon && !here.dungeon ? `Meet at Moonbrook for the Deadmines` : ""} + ${!g.dungeon && dungeon && g.leader === S.uid ? `` : ""} + ${!g.dungeon && !dungeon ? `The leader summons everyone from the meeting stone in the dungeon's zone` : ""}
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`; } else { const open = S.groups.filter((x) => !x.inside && x.size < 5); mine = `

Group

-

Dungeons take a group. Meet at the entrance; the leader leads you in.

+

Dungeons take a group. The leader summons everyone from the dungeon's meeting stone.

${open.map((x) => `
${esc(x.leader)}'s group ${x.size} / 5
`).join("")}
`; } const people = S.players.map((p) => `
${p.online ? "🟢" : "⚪"}${esc(p.name)} - ${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}
`).join(""); + ${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "journey" ? " · on the road" : ""}
`).join(""); return `${mine}

Adventurers

${people}
`; } @@ -762,7 +776,7 @@ function renderBar() { animate(); } -// Moves cast bars, cooldowns and travel along between polls. +// Moves cast bars, cooldowns and countdowns along between polls. function animate() { if (!S) return; const t = fightTime(); @@ -770,10 +784,6 @@ function animate() { const start = Number(el.dataset.start), end = Number(el.dataset.end); el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`; } - for (const el of document.querySelectorAll(".bar.road[data-from]")) { - const from = Number(el.dataset.from), to = Number(el.dataset.to); - el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`; - } for (const el of document.querySelectorAll("[data-until]")) { el.textContent = clock(Number(el.dataset.until) - now()); } @@ -863,9 +873,10 @@ document.addEventListener("click", async (ev) => { break; case "hunt": api("hunt", { id }); break; case "stop": api("stop"); break; - case "travel": api("travel", { id }); break; - case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break; + case "journey": api("journey", { id }); break; + case "fly": api("fly", { id }); break; case "accept": api("accept", { id }); break; + case "open-quest": openQuest = openQuest === id ? null : id; render(); break; case "abandon": if (confirm("Abandon this quest?")) api("abandon", { id }); break; case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break; case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break; diff --git a/static/styles.css b/static/styles.css index 6f2db59..87177a9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -85,7 +85,6 @@ main { max-width: 760px; margin: 0 auto; padding: 8px 12px; } .bar.low > i { background: var(--hp-low); } .bar.cast > i { background: var(--cast); transition: none; } .bar.cast.safe > i { background: #8b8b8b; } -.bar.road > i { background: #8a6a2a; transition: none; } /* fight */ .fight-head { display: flex; align-items: baseline; gap: 8px; } diff --git a/tests/test_game.py b/tests/test_game.py index af05368..242275e 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -35,16 +35,28 @@ class Content(unittest.TestCase): for ab in spec["abilities"]: self.assertIn(ab, content.ABILITIES) - def test_places_link_both_ways_and_hunts_exist(self): + def test_places_sit_in_zones_and_hunts_exist(self): + hunts = set() for pid, place in content.PLACES.items(): - for other in place["links"]: - self.assertIn(pid, content.PLACES[other]["links"], f"{pid} -> {other}") + self.assertIn(place["zone"], content.ZONES) for hunt, pulls in place.get("hunts", {}).items(): + self.assertNotIn((place["zone"], hunt), hunts, "hunts must be unique within a zone") + hunts.add((place["zone"], hunt)) self.assertIn(hunt, content.MOBS) for pull in pulls: for mob in pull: self.assertIn(mob, content.MOBS) + 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) + def test_quests_point_at_real_things(self): for qid, quest in content.QUESTS.items(): self.assertIn(quest["giver"], content.PLACES) @@ -181,8 +193,8 @@ class World(unittest.TestCase): world = world_with(("fred", "warrior")) fred = world["players"]["fred"] self.assertIsNone(game.accept(fred, "kobold_camp_cleanup")) - fred["place"] = "northshire_valley" self.assertIsNone(game.start_hunt(world, fred, "kobold_vermin", 1000)) + self.assertEqual(fred["place"], "northshire_valley") game.process(world, 1000 + 20 * 60, random.Random(1)) self.assertGreater(fred["level"], 1) self.assertEqual(fred["quests"]["kobold_camp_cleanup"], [10]) @@ -190,7 +202,6 @@ class World(unittest.TestCase): def test_hunting_stops_after_the_offline_cap(self): world = world_with(("fred", "warrior")) fred = world["players"]["fred"] - fred["place"] = "northshire_valley" game.start_hunt(world, fred, "kobold_vermin", 1000) game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1)) self.assertIsNone(fred["activity"]) @@ -201,33 +212,73 @@ class World(unittest.TestCase): fred = world["players"]["fred"] game.accept(fred, "kobold_camp_cleanup") fred["quests"]["kobold_camp_cleanup"] = [10] - fred["place"] = "goldshire" + fred["place"] = "sentinel_hill" self.assertIn("Turn it in", game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0)) - fred["place"] = "northshire_abbey" + fred["place"] = "fargodeep" # anywhere in Elwynn will do 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)) - def test_travel_takes_the_shortest_road(self): - minutes, path = game.route("northshire_abbey", "moonbrook") - self.assertEqual(path, ["northshire_abbey", "goldshire", "sentinel_hill", "moonbrook"]) - self.assertEqual(minutes, 14) + def test_a_journey_is_a_fight_at_every_stop(self): world = world_with(("fred", "warrior")) fred = world["players"]["fred"] - self.assertIsNone(game.travel(world, fred, "goldshire", 1000)) - game.process(world, 1000 + 4 * 60 - 1, random.Random(1)) - self.assertEqual(fred["place"], "northshire_abbey") - game.process(world, 1000 + 4 * 60, random.Random(1)) - self.assertEqual(fred["place"], "goldshire") + 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)) self.assertIsNone(fred["activity"]) + 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"], []) 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) - player["place"] = "moonbrook" + world["players"]["t"]["place"] = "sentinel_hill" leader = world["players"]["t"] game.create_group(world, leader) gid = leader["group"] @@ -240,17 +291,20 @@ class World(unittest.TestCase): loot = sum(len(p["bags"]) for p in world["players"].values()) self.assertGreater(loot, 0) - def test_dungeon_needs_everyone_at_the_door(self): + def test_the_meeting_stone_summons_the_group(self): world = world_with(("a", "warrior"), ("b", "priest"), dev=False) a, b = world["players"]["a"], world["players"]["b"] - a["place"] = "moonbrook" game.create_group(world, a) + self.assertIn("no dungeon", game.enter_dungeon(world, a, 0)) + a["place"] = "sentinel_hill" self.assertIn("at least 2", game.enter_dungeon(world, a, 0)) game.join_group(world, b, a["group"]) + game.journey(world, b, "westfall", 0) self.assertIn("Waiting on B", game.enter_dungeon(world, a, 0)) - b["place"] = "moonbrook" + game.stop(world, b, 0) self.assertIsNone(game.enter_dungeon(world, a, 0)) - self.assertEqual(b["activity"]["kind"], "dungeon") + self.assertEqual((b["activity"]["kind"], b["place"]), ("dungeon", "moonbrook")) + self.assertIn("westfall", b["zones"]) game.leave_group(world, b, 10) self.assertIsNone(b["activity"]) self.assertEqual(b["place"], "moonbrook")