Zones instead of road timers: journeys, flights, meeting stone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-24 08:24:50 +01:00
parent 7150b2f89c
commit 0e396ee5d4
7 changed files with 363 additions and 230 deletions

View file

@ -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 - **Questing:** 24 quests with Classic's names and chains, from Kobold Camp
Cleanup to The Defias Brotherhood. Kill, loot (quest items only drop while 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. 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 - **Zones, not maps:** a zone is one page. Every hunt, quest giver and vendor
where the mobs are, quest givers stand where they stood in Classic, and the in Elwynn is a tap away, grouped by subzone (Northshire, Fargodeep Mine,
dungeon door is in Moonbrook. 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 - **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 12 hours, finishing quest objectives as you go. Three deaths in a row and you
stop. stop.
- **Dungeons:** the Deadmines, nine pulls and five bosses, for a group of - **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 2–5 (packs shrink for smaller groups). The leader summons everyone from the
leads you in and can pull early. Bosses give every player their own loot roll meeting stone in Westfall and can pull early. Bosses give every player their own loot roll
at gear they can use. at gear they can use.
- **Gear:** seven slots, cloth/leather/mail by class, Classic-style generated - **Gear:** seven slots, cloth/leather/mail by class, Classic-style generated
greens (`Rough Leather Boots of the Monkey`) and named blues from the greens (`Rough Leather Boots of the Monkey`) and named blues from the

15
app.py
View file

@ -59,7 +59,7 @@ class Store:
with closing(sqlite3.connect(path)) as db, db: 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)") 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() 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.world["config"]["dev"] = dev
self.save() self.save()
@ -99,7 +99,8 @@ def static_content() -> dict:
referenced.update(quest.get("choice", [])) referenced.update(quest.get("choice", []))
return { return {
"classes": content.CLASSES, "specs": content.SPECS, "abilities": content.ABILITIES, "unlock": content.UNLOCK, "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), "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()}, "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, "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: def player_view(world: dict, player: dict, now: float) -> dict:
st = game.stats(player) st = game.stats(player)
hp, mana = game.hp_now(player, now) 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")} "quests", "done", "manual", "group", "dead_until", "next_pull")}
view["hp"], view["mana"] = round(hp), round(mana, 3) view["hp"], view["mana"] = round(hp), round(mana, 3)
view["stats"] = st view["stats"] = st
@ -290,8 +291,10 @@ class Handler(BaseHTTPRequestHandler):
return game.start_hunt(world, player, arg, now) return game.start_hunt(world, player, arg, now)
if path == "/api/stop": if path == "/api/stop":
return game.stop(world, player, now) return game.stop(world, player, now)
if path == "/api/travel": if path == "/api/journey":
return game.travel(world, player, arg, now) return game.journey(world, player, arg, now)
if path == "/api/fly":
return game.fly(world, player, arg, now)
if path == "/api/equip": if path == "/api/equip":
return game.equip(player, arg) return game.equip(player, arg)
if path == "/api/unequip": if path == "/api/unequip":
@ -351,6 +354,8 @@ class Handler(BaseHTTPRequestHandler):
return "No such place" return "No such place"
game.stop(world, player, now) game.stop(world, player, now)
player["place"] = str(body["id"]) 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 None
return "Not found" return "Not found"

View file

@ -275,6 +275,9 @@ MOBS = {
"princess": {"name": "Princess", "lv": (9, 9), "kind": "beast", "hp": 2.5, "dmg": 1.3, "rare": True, "princess": {"name": "Princess", "lv": (9, 9), "kind": "beast", "hp": 2.5, "dmg": 1.3, "rare": True,
"junk": "Princess's Collar"}, "junk": "Princess's Collar"},
"porcine_entourage": {"name": "Porcine Entourage", "lv": (6, 7), "kind": "beast", "junk": "Chunk of Boar Meat"}, "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_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, "riverpaw_mystic": {"name": "Riverpaw Mystic", "lv": (10, 10), "kind": "humanoid", "hp": 0.8,
"abilities": ["healing_wave"], "junk": "Mystic's Charm"}, "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 ------------------------------------------------------------- # --- the world -------------------------------------------------------------
# Hunts are fights you can repeat. `pulls` are the packs a hunt draws from. # A zone is one page: every hunt, quest giver and vendor in it is a tap away.
PLACES = { ZONES = {
"northshire_abbey": {"name": "Northshire Abbey", "zone": "Elwynn Forest", "hub": True, "lv": (1, 5), "elwynn": {"name": "Elwynn Forest", "lv": (1, 10), "hub": "goldshire", "start": "northshire_abbey"},
"links": {"northshire_valley": 1, "northshire_vineyards": 1, "echo_ridge": 1, "westfall": {"name": "Westfall", "lv": (10, 20), "hub": "sentinel_hill"},
"goldshire": 4}}, }
"northshire_valley": {"name": "Northshire Valley", "zone": "Elwynn Forest", "lv": (1, 3),
"hunts": {"young_wolf": [["young_wolf"]], "kobold_vermin": [["kobold_vermin"]]}, # Crossing between zones is a journey: a fight at each stop along the road, in
"links": {"northshire_abbey": 1}}, # order (reversed going the other way), each with a chance of the road's rare.
"northshire_vineyards": {"name": "Northshire Vineyards", "zone": "Elwynn Forest", "lv": (3, 5), ROADS = {
"hunts": {"defias_thug": [["defias_thug"]], "westbrook": {
"garrick_padfoot": [["garrick_padfoot", "defias_thug"]]}, "name": "The Westbrook road", "zones": ("elwynn", "westfall"), "rare": "mother_fang", "rare_odds": 0.1,
"links": {"northshire_abbey": 1}}, "stops": [
"echo_ridge": {"name": "Echo Ridge Mine", "zone": "Elwynn Forest", "lv": (3, 5), {"name": "Westbrook Garrison", "pulls": [["riverpaw_gnoll"], ["young_wolf_pack", "young_wolf_pack"]]},
"hunts": {"kobold_worker": [["kobold_worker"]], "kobold_laborer": [["kobold_laborer"]]}, {"name": "The Westbrook bridge", "pulls": [["murloc_forager", "murloc_streamrunner"],
"links": {"northshire_abbey": 1}}, ["riverpaw_gnoll", "riverpaw_mystic"]]},
"goldshire": {"name": "Goldshire", "zone": "Elwynn Forest", "hub": True, "lv": (5, 10), {"name": "The Jansen Stead", "pulls": [["young_fleshripper"], ["harvest_watcher"]]},
"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"]], FLIGHT_COST = 50 # copper to fly to a zone you've reached on foot
"kobold_miner": [["kobold_miner"], ["kobold_miner", "kobold_tunneler"]]},
"links": {"goldshire": 2}}, # Places are a zone's subzones. Hunts are fights you can repeat; `pulls` are the
"crystal_lake": {"name": "Crystal Lake", "zone": "Elwynn Forest", "lv": (7, 9), # packs a hunt draws from.
"hunts": {"murloc_streamrunner": [["murloc_streamrunner"]], PLACES = {
"murloc_forager": [["murloc_forager"], ["murloc_forager", "murloc_streamrunner"]]}, "northshire_abbey": {"name": "Northshire Abbey", "zone": "elwynn", "hub": True, "lv": (1, 5)},
"links": {"goldshire": 2}}, "northshire_valley": {"name": "Northshire Valley", "zone": "elwynn", "lv": (1, 3),
"brackwell": {"name": "Brackwell Pumpkin Patch", "zone": "Elwynn Forest", "lv": (8, 10), "hunts": {"young_wolf": [["young_wolf"]], "kobold_vermin": [["kobold_vermin"]]}},
"hunts": {"defias_bandit": [["defias_bandit"]], "northshire_vineyards": {"name": "Northshire Vineyards", "zone": "elwynn", "lv": (3, 5),
"princess": [["princess", "porcine_entourage", "porcine_entourage"]]}, "hunts": {"defias_thug": [["defias_thug"]],
"links": {"goldshire": 3, "forests_edge": 2}}, "garrick_padfoot": [["garrick_padfoot", "defias_thug"]]}},
"forests_edge": {"name": "Forest's Edge", "zone": "Elwynn Forest", "lv": (9, 11), "echo_ridge": {"name": "Echo Ridge Mine", "zone": "elwynn", "lv": (3, 5),
"hunts": {"riverpaw_gnoll": [["riverpaw_gnoll"], ["riverpaw_gnoll", "riverpaw_mystic"]], "hunts": {"kobold_worker": [["kobold_worker"]], "kobold_laborer": [["kobold_laborer"]]}},
"hogger": [["hogger"]]}, "goldshire": {"name": "Goldshire", "zone": "elwynn", "hub": True, "lv": (5, 10)},
"links": {"goldshire": 3, "brackwell": 2, "sentinel_hill": 5}}, "fargodeep": {"name": "Fargodeep Mine", "zone": "elwynn", "lv": (6, 8),
"sentinel_hill": {"name": "Sentinel Hill", "zone": "Westfall", "hub": True, "lv": (10, 20), "hunts": {"kobold_tunneler": [["kobold_tunneler"]],
"links": {"goldshire": 7, "forests_edge": 5, "saldeans_farm": 2, "molsen_farm": 2, "kobold_miner": [["kobold_miner"], ["kobold_miner", "kobold_tunneler"]]}},
"dust_plains": 3, "gold_coast": 3, "moonbrook": 3}}, "crystal_lake": {"name": "Crystal Lake", "zone": "elwynn", "lv": (7, 9),
"saldeans_farm": {"name": "Saldean's Farm", "zone": "Westfall", "lv": (10, 13), "hunts": {"murloc_streamrunner": [["murloc_streamrunner"]],
"hunts": {"harvest_watcher": [["harvest_watcher"]], "murloc_forager": [["murloc_forager"], ["murloc_forager", "murloc_streamrunner"]]}},
"young_fleshripper": [["young_fleshripper"]], "goretusk": [["goretusk"]]}, "brackwell": {"name": "Brackwell Pumpkin Patch", "zone": "elwynn", "lv": (8, 10),
"links": {"sentinel_hill": 2}}, "hunts": {"defias_bandit": [["defias_bandit"]],
"molsen_farm": {"name": "The Molsen Farm", "zone": "Westfall", "lv": (12, 14), "princess": [["princess", "porcine_entourage", "porcine_entourage"]]}},
"hunts": {"defias_trapper": [["defias_trapper"]], "forests_edge": {"name": "Forest's Edge", "zone": "elwynn", "lv": (9, 11),
"defias_smuggler": [["defias_smuggler"], ["defias_smuggler", "defias_trapper"]]}, "hunts": {"riverpaw_gnoll": [["riverpaw_gnoll"], ["riverpaw_gnoll", "riverpaw_mystic"]],
"links": {"sentinel_hill": 2}}, "hogger": [["hogger"]]}},
"dust_plains": {"name": "The Dust Plains", "zone": "Westfall", "lv": (14, 16), "sentinel_hill": {"name": "Sentinel Hill", "zone": "westfall", "hub": True, "lv": (10, 20)},
"hunts": {"riverpaw_brute": [["riverpaw_brute"], ["riverpaw_brute", "riverpaw_herbalist"]]}, "saldeans_farm": {"name": "Saldean's Farm", "zone": "westfall", "lv": (10, 13),
"links": {"sentinel_hill": 3, "dagger_hills": 2}}, "hunts": {"harvest_watcher": [["harvest_watcher"]],
"gold_coast": {"name": "Gold Coast Quarry", "zone": "Westfall", "lv": (15, 16), "young_fleshripper": [["young_fleshripper"]], "goretusk": [["goretusk"]]}},
"hunts": {"murloc_coastrunner": [["murloc_coastrunner"], ["murloc_coastrunner", "murloc_tidecaller"]]}, "molsen_farm": {"name": "The Molsen Farm", "zone": "westfall", "lv": (12, 14),
"links": {"sentinel_hill": 3}}, "hunts": {"defias_trapper": [["defias_trapper"]],
"moonbrook": {"name": "Moonbrook", "zone": "Westfall", "lv": (16, 18), "dungeon": "deadmines", "defias_smuggler": [["defias_smuggler"], ["defias_smuggler", "defias_trapper"]]}},
"hunts": {"defias_pillager": [["defias_pillager"], ["defias_looter", "defias_pillager"]], "dust_plains": {"name": "The Dust Plains", "zone": "westfall", "lv": (14, 16),
"defias_looter": [["defias_looter"]]}, "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),
"dagger_hills": {"name": "The Dagger Hills", "zone": "Westfall", "lv": (17, 19), "hunts": {"murloc_coastrunner": [["murloc_coastrunner"], ["murloc_coastrunner", "murloc_tidecaller"]]}},
"hunts": {"defias_pathstalker": [["defias_pathstalker"]], "moonbrook": {"name": "Moonbrook", "zone": "westfall", "lv": (16, 18), "dungeon": "deadmines",
"defias_highwayman": [["defias_highwayman"], ["defias_highwayman", "defias_pathstalker"]]}, "hunts": {"defias_pillager": [["defias_pillager"], ["defias_looter", "defias_pillager"]],
"links": {"moonbrook": 2, "dust_plains": 2}}, "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 = { DUNGEONS = {
"deadmines": { "deadmines": {
@ -458,7 +463,7 @@ QUESTS = {
"name": "Report to Goldshire", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 5, "name": "Report to Goldshire", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 5,
"req": ["investigate_echo_ridge"], "ender": "goldshire", "ender_npc": "Marshal Dughan", "req": ["investigate_echo_ridge"], "ender": "goldshire", "ender_npc": "Marshal Dughan",
"text": "Take word of the kobolds to Marshal Dughan in Goldshire.", "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": { "gold_dust_exchange": {
"name": "Gold Dust Exchange", "giver": "goldshire", "npc": "Remy \"Two Times\"", "lv": 7, "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.", "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, "name": "Report to Gryan Stoutmantle", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 10,
"req": ["riverpaw_gnoll_bounty"], "ender": "sentinel_hill", "ender_npc": "Gryan Stoutmantle", "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.", "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": { "the_killing_fields": {
"name": "The Killing Fields", "giver": "saldeans_farm", "npc": "Farmer Saldean", "lv": 12, "name": "The Killing Fields", "giver": "saldeans_farm", "npc": "Farmer Saldean", "lv": 12,
"text": "Harvest Watchers turned on us. Smash them.", "text": "Harvest Watchers turned on us. Smash them.",

196
game.py
View file

@ -7,15 +7,15 @@ adventuring for up to OFFLINE_CAP.
from __future__ import annotations from __future__ import annotations
import heapq
import random import random
import combat import combat
from content import (ABILITIES, ARMOUR_TYPES, BASE_NAMES, BOSS_LOOT, BOSS_LOOT_CHANCE, CLASSES, DUNGEONS, 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, FLIGHT_COST, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, ROADS, SLOT_ARMOUR,
SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, XP_TO_NEXT) SLOT_BUDGET, SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED,
XP_TO_NEXT, ZONES)
VERSION = 1 VERSION = 2
MINUTE = 60 MINUTE = 60
HOUR = 3600 HOUR = 3600
OFFLINE_CAP = 12 * HOUR OFFLINE_CAP = 12 * HOUR
@ -160,7 +160,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
armour = spec["armour"] armour = spec["armour"]
player = { player = {
"id": uid, "name": name, "cls": cls, "level": 1, "xp": 0, "copper": 0, "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, "rest": {"at": now, "hp": 1e9, "mana": 1.0}, "dead_until": 0, "next_pull": 0, "deaths": 0,
"quests": {}, "done": [], "bags": {}, "manual": [], "quests": {}, "done": [], "bags": {}, "manual": [],
"equip": {slot: None for slot in SLOTS}, "equip": {slot: None for slot in SLOTS},
@ -269,6 +269,26 @@ def new_world(now: float) -> dict:
"config": {"clock_offset": 0}, "created": now} "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: def _next_id(world: dict, prefix: str) -> str:
world["next"] += 1 world["next"] += 1
return f"{prefix}{world['next']}" 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)) combat.advance(fight, now, watch_map(world))
if fight["state"] == "on": if fight["state"] == "on":
return return
_end_hunt_fight(world, player, fight, rng) _end_field_fight(world, player, fight, rng)
continue 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 return
if activity["kind"] == "travel":
if now < activity["end"]:
return
_arrive(world, player, activity["to"], activity["end"])
continue
if activity["kind"] == "hunt": if activity["kind"] == "hunt":
start = max(player["next_pull"], player["dead_until"]) pull = rng.choice(PLACES[player["place"]]["hunts"][activity["hunt"]])
if start > now: _start_field_fight(world, player, pull, "hunt", start, rng)
return else:
if start > player["last_active"] + OFFLINE_CAP: road = ROADS[activity["road"]]
player["activity"] = None stop = _stops(activity)[activity["leg"]]
log(player, start, "You made camp. Come back to keep going.", "info") pull = rng.choice(stop["pulls"])
return if rng.random() < road["rare_odds"]:
_start_hunt_fight(world, player, activity["hunt"], start, rng) pull = [road["rare"]]
continue _start_field_fight(world, player, pull, "journey", start, rng, stop["name"])
return
def _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: random.Random) -> None: def _stops(journey: dict) -> list[dict]:
pulls = PLACES[player["place"]]["hunts"][hunt] road = ROADS[journey["road"]]
pull = rng.choice(pulls) 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 = [] enemies = []
for index, mob_id in enumerate(pull): for index, mob_id in enumerate(pull):
low, high = MOBS[mob_id]["lv"] 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) enemies.append(unit)
fid = _next_id(world, "f") fid = _next_id(world, "f")
name = MOBS[pull[0]]["name"] if len(pull) == 1 else f"{MOBS[pull[0]]['name']} and friends" 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), 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 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"] end = fight["ended"]
me = fight["units"][player["id"]] me = fight["units"][player["id"]]
player["fight"] = None 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) hp, mana = hp_now(player, end)
missing = max(1 - hp / st["max_hp"], 1 - mana) missing = max(1 - hp / st["max_hp"], 1 - mana)
player["next_pull"] = end + max(2.0, missing / REST * 0.85) 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: else:
player["deaths"] += 1 player["deaths"] += 1
player["dead_until"] = end + REVIVE player["dead_until"] = end + REVIVE
player["rest"] = {"at": end + REVIVE, "hp": st["max_hp"] * 0.5, "mana": 0.5} player["rest"] = {"at": end + REVIVE, "hp": st["max_hp"] * 0.5, "mana": 0.5}
player["next_pull"] = end + REVIVE + 0.5 / REST player["next_pull"] = end + REVIVE + 0.5 / REST
log(player, end, f"Killed by {fight['name']}", "death") 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["activity"] = None
player["deaths"] = 0 player["deaths"] = 0
log(player, end, "Died three times in a row. You stopped hunting here.", "death") 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) gain_xp(world, player, xp, now)
def _arrive(world: dict, player: dict, place: str, at: float) -> None: def _arrive(world: dict, player: dict, zone: str, at: float, flew: bool = False) -> None:
player["place"] = place player["place"] = ZONES[zone]["hub"]
player["activity"] = None 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 qid, progress in player["quests"].items():
for index, objective in enumerate(QUESTS[qid]["obj"]): for index, objective in enumerate(QUESTS[qid]["obj"]):
if objective.get("visit") == place: if objective.get("visit") == zone:
progress[index] = 1 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} "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: def start_hunt(world: dict, player: dict, hunt: str, now: float) -> str | None:
if _busy(player): if _busy(player):
return _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" return "Nothing to hunt like that here"
_leave_fight(world, player, now) _leave_fight(world, player, now)
player["place"] = place
player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now} player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now}
player["deaths"] = 0 player["deaths"] = 0
player["next_pull"] = max(player["next_pull"], now) player["next_pull"] = max(player["next_pull"], now)
@ -442,41 +493,46 @@ def _busy(player: dict) -> str | None:
activity = player["activity"] activity = player["activity"]
if activity and activity["kind"] == "dungeon": if activity and activity["kind"] == "dungeon":
return "You're in a 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 "You're on the road"
return None return None
def route(start: str, goal: str) -> tuple[float, list[str]] | None: def road_between(a: str, b: str) -> str | None:
queue = [(0.0, start, [start])] return next((rid for rid, road in ROADS.items() if set(road["zones"]) == {a, b}), None)
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 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): if _busy(player):
return _busy(player) return _busy(player)
if to not in PLACES or to == player["place"]: here = zone_of(player["place"])
return "Pick somewhere else" road = road_between(here, to) if to in ZONES else None
found = route(player["place"], to) if not road:
if not found:
return "No road there" return "No road there"
if player["dead_until"] > now: if player["dead_until"] > now:
return "You're dead" return "You're dead"
minutes, path = found
_leave_fight(world, player, now) _leave_fight(world, player, now)
player["activity"] = {"kind": "travel", "from": player["place"], "to": to, "path": path, player["activity"] = {"kind": "journey", "road": road, "from": here, "to": to, "leg": 0, "since": now}
"start": now, "end": now + minutes * MINUTE} 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 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: 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) have = player["bags"].get(item_id, 0)
if qty == "all": if qty == "all":
qty = have 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: 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/")] junk = [i for i in player["bags"] if i.startswith("j/")]
if not junk: if not junk:
return "No junk to sell" 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]: def available_quests(player: dict) -> list[str]:
return [qid for qid, q in QUESTS.items() 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", []))] 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: if len(player["quests"]) >= MAX_QUESTS:
return f"Your quest log is full ({MAX_QUESTS})" return f"Your quest log is full ({MAX_QUESTS})"
player["quests"][qid] = [0] * len(QUESTS[qid]["obj"]) player["quests"][qid] = [0] * len(QUESTS[qid]["obj"])
for index, objective in enumerate(QUESTS[qid]["obj"]): _visit(player, zone_of(player["place"]))
if objective.get("visit") == player["place"]:
player["quests"][qid][index] = 1
return None return None
@ -609,8 +659,9 @@ def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | No
quest = QUESTS.get(qid) quest = QUESTS.get(qid)
if not quest or qid not in player["quests"]: if not quest or qid not in player["quests"]:
return "Not on that quest" return "Not on that quest"
if player["place"] != quest.get("ender", quest["giver"]): ender = quest.get("ender", quest["giver"])
return f"Turn it in at {PLACES[quest.get('ender', quest['giver'])]['name']}" 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): if not quest_done(player, qid):
return "Not finished yet" return "Not finished yet"
options = quest.get("choice", []) 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" return "Only the leader can lead you in"
if group["dungeon"]: if group["dungeon"]:
return "You're already inside" 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: 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): if len(group["members"]) < min_party(world):
return f"Dungeons need at least {min_party(world)} players" return f"Dungeons need at least {min_party(world)} players"
members = [world["players"][m] for m in group["members"]] 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: 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] dead = [m["name"] for m in members if m["dead_until"] > now]
if dead: if dead:
return f"{', '.join(dead)} is dead" return f"{', '.join(dead)} is dead"
for member in members: for member in members:
# The meeting stone summons everyone, wherever they are.
_leave_fight(world, member, now) _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"]} member["activity"] = {"kind": "dungeon", "group": group["id"]}
log(member, now, f"Entered {DUNGEONS[dungeon_id]['name']}", "travel") 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, group["dungeon"] = {"id": dungeon_id, "pull": 0, "state": "rest", "next": now + DUNGEON_REST, "fight": None,

View file

@ -35,6 +35,7 @@ let failures = 0;
let selected = null; // bag item let selected = null; // bag item
let regCls = null; let regCls = null;
let pickReward = {}; // quest id -> reward index let pickReward = {}; // quest id -> reward index
let openQuest = null; // quest offer shown in full
let seenEv = { id: null, t: -1 }; let seenEv = { id: null, t: -1 };
let barKey = ""; let barKey = "";
let seenChat = Number(remember("seenChat") || 0); let seenChat = Number(remember("seenChat") || 0);
@ -289,12 +290,9 @@ function abilityRow(id, level, me) {
function adventurePage() { function adventurePage() {
const me = S.me; const me = S.me;
const here = place(me.place);
const act = me.activity; const act = me.activity;
const parts = [statusCard()]; const parts = [statusCard()];
if (!act || act.kind !== "dungeon") { if (!act || act.kind !== "dungeon") parts.push(zoneCard(zoneOf(me.place)));
parts.push(placeCard(here));
}
parts.push(logCard()); parts.push(logCard());
if (S.dev) parts.push(devCard()); if (S.dev) parts.push(devCard());
return parts.join(""); return parts.join("");
@ -308,19 +306,21 @@ function statusCard() {
return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · <span data-until="${me.dead_until}"></span></span></div>`; return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · <span data-until="${me.dead_until}"></span></span></div>`;
} }
if (!act) return ""; if (!act) return "";
if (act.kind === "travel") { const waiting = !S.fight && me.next_pull > t ? `resting · <span data-until="${me.next_pull}"></span>` : "";
if (act.kind === "journey") {
const road = C.roads[act.road];
const stops = act.to === road.zones[1] ? road.stops : road.stops.slice().reverse();
return `<div class="card"> return `<div class="card">
<div class="row"><b>🐎 On the road to ${esc(place(act.to).name)}</b><span class="right muted small" data-until="${act.end}"></span></div> <div class="row"><b>🛤️ On the road to ${esc(C.zones[act.to].name)}</b><span class="muted small">${waiting}</span>
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i></i><span></span></div> <button class="btn small right" data-act="stop">Turn back</button></div>
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div> <div style="margin-top:6px">${stops.map((stop, i) => `<div class="small ${i < act.leg ? "muted" : ""}">${i < act.leg ? "✅" : i === act.leg ? "➡️" : "▫️"} ${esc(stop.name)}</div>`).join("")}
<div class="small">${act.leg >= stops.length ? "✅" : "▫️"} ${esc(C.places[C.zones[act.to].hub].name)}</div></div>
</div>`; </div>`;
} }
if (act.kind === "hunt") { if (act.kind === "hunt") {
const mob = C.mobs[act.hunt];
const waiting = !S.fight && me.next_pull > t;
return `<div class="card"><div class="row"> return `<div class="card"><div class="row">
<b>🎯 Hunting ${esc(mob.name)}</b> <b>🎯 Hunting ${esc(C.mobs[act.hunt].name)}</b><span class="muted small">${esc(place(me.place).name)}</span>
<span class="muted small">${waiting ? `resting · <span data-until="${me.next_pull}"></span>` : ""}</span> <span class="muted small">${waiting}</span>
<button class="btn small right" data-act="stop">Stop</button></div> <button class="btn small right" data-act="stop">Stop</button></div>
</div>`; </div>`;
} }
@ -357,46 +357,59 @@ function dungeonCard() {
</div>`; </div>`;
} }
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 me = S.me;
const z = C.zones[zone];
const act = me.activity; 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 hunting = act && act.kind === "hunt" ? act.hunt : null;
const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => { const subzones = Object.entries(C.places).filter(([, p]) => p.zone === zone && p.hunts).map(([pid, p]) => {
const mob = C.mobs[id]; const rows = Object.entries(p.hunts).map(([id, pulls]) => {
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`; const mob = C.mobs[id];
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name); const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id))); const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
return `<div class="item-row" style="cursor:default"> const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span> return `<div class="item-row" style="cursor:default">
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div> <div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>` ${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`} ${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
</div>`; : `<button class="btn small" data-act="hunt" data-id="${id}" ${away ? "disabled" : ""}>Hunt</button>`}
</div>`;
}).join("");
return `<div class="small muted" style="margin:8px 0 4px">${esc(p.name)} · ${p.lv[0]}-${p.lv[1]}</div><div class="list">${rows}</div>`;
}).join(""); }).join("");
const offers = me.available.map((q) => questOffer(q)).join(""); // Nearest first; quests far above you wait until they're within reach.
const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q)) 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(""); .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 junk = me.bags.filter((b) => b.q === "poor");
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0); const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
const vendor = here.hub ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span> const roads = Object.entries(C.roads).filter(([, r]) => r.zones.includes(zone)).map(([, r]) => {
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : ""; const to = r.zones.find((x) => x !== zone);
const roads = Object.entries(here.links).map(([to, minutes]) => return `<div class="item-row" style="cursor:default">
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join(""); <div class="grow"><b>🛤️ ${esc(C.zones[to].name)}</b> <span class="small muted">lv ${C.zones[to].lv[0]}-${C.zones[to].lv[1]} · ${esc(r.name)}, ${r.stops.length} fights</span></div>
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id]) <button class="btn small" data-act="journey" data-id="${to}" ${away ? "disabled" : ""}>Set out</button>
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join(""); </div>`;
}).join("");
const flights = me.zones.filter((x) => x !== zone).map((to) =>
`<button class="btn small" data-act="fly" data-id="${to}" ${away ? "disabled" : ""}>🦅 ${esc(C.zones[to].name)} ${money(C.flight_cost)}</button>`).join("");
return `<div class="card"> return `<div class="card">
<div class="row"><h2 style="margin:0">${esc(here.name)}</h2><span class="muted small">${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}</span></div> <div class="row"><h2 style="margin:0">${esc(z.name)}</h2><span class="muted small">lv ${z.lv[0]}-${z.lv[1]}</span>
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""} <button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>🪙 Sell junk${junk.length ? " " + money(junkValue) : ""}</button></div>
${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""} ${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""}
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""} ${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
${dungeon} ${subzones ? `<h3>Hunt</h3>${subzones}` : ""}
${vendor} ${dungeon ? dungeonEntrance(dungeon) : ""}
<h3>Roads</h3> ${roads ? `<h3>Roads</h3><div class="list">${roads}</div>` : ""}
<div class="row wrap">${roads}</div> ${flights ? `<div class="row wrap" style="margin-top:8px"><span class="small muted">Flight master</span>${flights}</div>` : ""}
<div class="row" style="margin-top:8px"><select id="far" class="grow" ${travelling ? "disabled" : ""}><option value="">Travel further…</option>${far}</select>
<button class="btn small" data-act="travel-far" ${travelling ? "disabled" : ""}>Go</button></div>
</div>`; </div>`;
} }
@ -405,22 +418,25 @@ function dungeonEntrance(id) {
const g = S.group; const g = S.group;
let action; let action;
if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`; if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`;
else if (g.leader !== S.uid) action = `<span class="small muted">Waiting for the leader</span>`; else if (g.leader !== S.uid) action = `<span class="small muted">The leader summons you</span>`;
else action = `<button class="btn primary small" data-act="enter">Enter</button>`; else action = `<button class="btn primary small" data-act="enter">Summon &amp; enter</button>`;
return `<h3>Dungeon</h3><div class="item-row" style="cursor:default"> return `<h3>Dungeon</h3><div class="item-row" style="cursor:default">
<div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span></div> <div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">${esc(place(spec.entrance).name)} · lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span>
<div class="tip">The meeting stone summons your whole group, wherever they are.</div></div>
${action}</div>`; ${action}</div>`;
} }
function questOffer(qid) { function questOffer(qid) {
const q = C.quests[qid]; const q = C.quests[qid];
const tooHigh = q.lv > S.me.level + 3; const tooHigh = q.lv > S.me.level + 3;
return `<div class="quest"> const open = openQuest === qid;
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span></div> return `<div class="quest" data-act="open-quest" data-id="${qid}" style="cursor:pointer">
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div> <div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span>
${open ? "" : `<button class="btn small primary" data-act="accept" data-id="${qid}">Accept</button>`}</div>
${open ? `<div class="small muted" style="margin:4px 0">${esc(q.npc)}, ${esc(place(q.giver).name)}: “${esc(q.text)}”</div>
${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")} ${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span> <div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span>
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div> <button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>` : ""}
</div>`; </div>`;
} }
@ -447,7 +463,7 @@ function objText(o, have) {
const count = have === undefined ? "" : ` ${have} / ${need}`; const count = have === undefined ? "" : ` ${have} / ${need}`;
if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${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}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`; if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`;
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 ""; return "";
} }
@ -502,8 +518,7 @@ function questsPage() {
return `<div class="quest ${ready ? "ready" : ""}"> return `<div class="quest ${ready ? "ready" : ""}">
<div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div> <div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div>
${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")} ${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`}</span> <div class="row" style="margin-top:6px"><span class="small muted">${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)}` : ""}</span>
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div> <button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
</div>`; </div>`;
}).join("")} }).join("")}
@ -552,14 +567,13 @@ function characterPage() {
}).join(""); }).join("");
const bags = me.bags.map((it) => { const bags = me.bags.map((it) => {
const on = selected === it.id; const on = selected === it.id;
const atHub = place(me.place).hub;
return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}"> return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}">
<div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""} <div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""}
<div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div> <div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div>
${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""} ${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""}
${on ? `<div class="row" style="margin-top:6px"> ${on ? `<div class="row" style="margin-top:6px">
${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""} ${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""}
<button class="btn small" data-act="sell" data-id="${esc(it.id)}" ${atHub ? "" : "disabled"}>${atHub ? `Sell ${money(it.value)}` : "Sell in town"}</button></div>` : ""} <button class="btn small" data-act="sell" data-id="${esc(it.id)}">Sell ${money(it.value)}</button></div>` : ""}
</div></div>`; </div></div>`;
}).join(""); }).join("");
return `<div class="card"> return `<div class="card">
@ -586,7 +600,7 @@ function groupPage() {
const g = S.group; const g = S.group;
let mine; let mine;
if (g) { if (g) {
const here = place(S.me.place); const dungeon = zoneDungeon(zoneOf(S.me.place));
mine = `<div class="card"><h2>Your group</h2><div class="list"> mine = `<div class="card"><h2>Your group</h2><div class="list">
${g.members.map((m) => `<div class="item-row" style="cursor:default"> ${g.members.map((m) => `<div class="item-row" style="cursor:default">
<b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""} <b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""}
@ -594,21 +608,21 @@ function groupPage() {
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")} <div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
</div> </div>
<div class="row" style="margin-top:8px"> <div class="row" style="margin-top:8px">
${!g.dungeon && here.dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Enter ${esc(C.dungeons[here.dungeon].name)}</button>` : ""} ${!g.dungeon && dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Summon &amp; enter ${esc(C.dungeons[dungeon].name)}</button>` : ""}
${!g.dungeon && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""} ${!g.dungeon && !dungeon ? `<span class="small muted">The leader summons everyone from the meeting stone in the dungeon's zone</span>` : ""}
<button class="btn small danger right" data-act="leave-group">Leave group</button></div></div> <button class="btn small danger right" data-act="leave-group">Leave group</button></div></div>
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`; ${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
} else { } else {
const open = S.groups.filter((x) => !x.inside && x.size < 5); const open = S.groups.filter((x) => !x.inside && x.size < 5);
mine = `<div class="card"><h2>Group</h2> mine = `<div class="card"><h2>Group</h2>
<p class="tip">Dungeons take a group. Meet at the entrance; the leader leads you in.</p> <p class="tip">Dungeons take a group. The leader summons everyone from the dungeon's meeting stone.</p>
<div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span> <div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span>
<button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div> <button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div>
<div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`; <div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`;
} }
const people = S.players.map((p) => `<div class="item-row" style="cursor:default"> const people = S.players.map((p) => `<div class="item-row" style="cursor:default">
<span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b> <span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b>
<span class="small muted grow">${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" : ""}</span></div>`).join(""); <span class="small muted grow">${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" : ""}</span></div>`).join("");
return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`; return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`;
} }
@ -762,7 +776,7 @@ function renderBar() {
animate(); animate();
} }
// Moves cast bars, cooldowns and travel along between polls. // Moves cast bars, cooldowns and countdowns along between polls.
function animate() { function animate() {
if (!S) return; if (!S) return;
const t = fightTime(); const t = fightTime();
@ -770,10 +784,6 @@ function animate() {
const start = Number(el.dataset.start), end = Number(el.dataset.end); 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}%`; 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]")) { for (const el of document.querySelectorAll("[data-until]")) {
el.textContent = clock(Number(el.dataset.until) - now()); el.textContent = clock(Number(el.dataset.until) - now());
} }
@ -863,9 +873,10 @@ document.addEventListener("click", async (ev) => {
break; break;
case "hunt": api("hunt", { id }); break; case "hunt": api("hunt", { id }); break;
case "stop": api("stop"); break; case "stop": api("stop"); break;
case "travel": api("travel", { id }); break; case "journey": api("journey", { id }); break;
case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break; case "fly": api("fly", { id }); break;
case "accept": api("accept", { 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 "abandon": if (confirm("Abandon this quest?")) api("abandon", { id }); break;
case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break; case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break;
case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break; case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break;

View file

@ -85,7 +85,6 @@ main { max-width: 760px; margin: 0 auto; padding: 8px 12px; }
.bar.low > i { background: var(--hp-low); } .bar.low > i { background: var(--hp-low); }
.bar.cast > i { background: var(--cast); transition: none; } .bar.cast > i { background: var(--cast); transition: none; }
.bar.cast.safe > i { background: #8b8b8b; } .bar.cast.safe > i { background: #8b8b8b; }
.bar.road > i { background: #8a6a2a; transition: none; }
/* fight */ /* fight */
.fight-head { display: flex; align-items: baseline; gap: 8px; } .fight-head { display: flex; align-items: baseline; gap: 8px; }

View file

@ -35,16 +35,28 @@ class Content(unittest.TestCase):
for ab in spec["abilities"]: for ab in spec["abilities"]:
self.assertIn(ab, content.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 pid, place in content.PLACES.items():
for other in place["links"]: self.assertIn(place["zone"], content.ZONES)
self.assertIn(pid, content.PLACES[other]["links"], f"{pid} -> {other}")
for hunt, pulls in place.get("hunts", {}).items(): 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) self.assertIn(hunt, content.MOBS)
for pull in pulls: for pull in pulls:
for mob in pull: for mob in pull:
self.assertIn(mob, content.MOBS) 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): def test_quests_point_at_real_things(self):
for qid, quest in content.QUESTS.items(): for qid, quest in content.QUESTS.items():
self.assertIn(quest["giver"], content.PLACES) self.assertIn(quest["giver"], content.PLACES)
@ -181,8 +193,8 @@ class World(unittest.TestCase):
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
fred = world["players"]["fred"] fred = world["players"]["fred"]
self.assertIsNone(game.accept(fred, "kobold_camp_cleanup")) self.assertIsNone(game.accept(fred, "kobold_camp_cleanup"))
fred["place"] = "northshire_valley"
self.assertIsNone(game.start_hunt(world, fred, "kobold_vermin", 1000)) 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)) game.process(world, 1000 + 20 * 60, random.Random(1))
self.assertGreater(fred["level"], 1) self.assertGreater(fred["level"], 1)
self.assertEqual(fred["quests"]["kobold_camp_cleanup"], [10]) 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): def test_hunting_stops_after_the_offline_cap(self):
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
fred = world["players"]["fred"] fred = world["players"]["fred"]
fred["place"] = "northshire_valley"
game.start_hunt(world, fred, "kobold_vermin", 1000) game.start_hunt(world, fred, "kobold_vermin", 1000)
game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1)) game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1))
self.assertIsNone(fred["activity"]) self.assertIsNone(fred["activity"])
@ -201,33 +212,73 @@ class World(unittest.TestCase):
fred = world["players"]["fred"] fred = world["players"]["fred"]
game.accept(fred, "kobold_camp_cleanup") game.accept(fred, "kobold_camp_cleanup")
fred["quests"]["kobold_camp_cleanup"] = [10] 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)) 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.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.assertIsNone(game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
self.assertIn("w/feet/mail/4", fred["bags"]) self.assertIn("w/feet/mail/4", fred["bags"])
self.assertIn("investigate_echo_ridge", game.available_quests(fred)) self.assertIn("investigate_echo_ridge", game.available_quests(fred))
def test_travel_takes_the_shortest_road(self): def test_a_journey_is_a_fight_at_every_stop(self):
minutes, path = game.route("northshire_abbey", "moonbrook")
self.assertEqual(path, ["northshire_abbey", "goldshire", "sentinel_hill", "moonbrook"])
self.assertEqual(minutes, 14)
world = world_with(("fred", "warrior")) world = world_with(("fred", "warrior"))
fred = world["players"]["fred"] fred = world["players"]["fred"]
self.assertIsNone(game.travel(world, fred, "goldshire", 1000)) game.dev_level(world, fred, 14, 1000)
game.process(world, 1000 + 4 * 60 - 1, random.Random(1)) self.assertIsNone(game.accept(fred, "riverpaw_gnoll_bounty"))
self.assertEqual(fred["place"], "northshire_abbey") fred["done"].append("riverpaw_gnoll_bounty")
game.process(world, 1000 + 4 * 60, random.Random(1)) fred["quests"].pop("riverpaw_gnoll_bounty")
self.assertEqual(fred["place"], "goldshire") 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.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): def test_a_group_clears_the_deadmines(self):
world = world_with(*[(uid, cls) for uid, cls in world = world_with(*[(uid, cls) for uid, cls in
(("t", "warrior"), ("h", "priest"), ("m", "mage"), ("r", "rogue"), ("x", "hunter"))]) (("t", "warrior"), ("h", "priest"), ("m", "mage"), ("r", "rogue"), ("x", "hunter"))])
for uid, player in world["players"].items(): for uid, player in world["players"].items():
game.dev_level(world, player, 20, 1000) game.dev_level(world, player, 20, 1000)
player["place"] = "moonbrook" world["players"]["t"]["place"] = "sentinel_hill"
leader = world["players"]["t"] leader = world["players"]["t"]
game.create_group(world, leader) game.create_group(world, leader)
gid = leader["group"] gid = leader["group"]
@ -240,17 +291,20 @@ class World(unittest.TestCase):
loot = sum(len(p["bags"]) for p in world["players"].values()) loot = sum(len(p["bags"]) for p in world["players"].values())
self.assertGreater(loot, 0) 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) world = world_with(("a", "warrior"), ("b", "priest"), dev=False)
a, b = world["players"]["a"], world["players"]["b"] a, b = world["players"]["a"], world["players"]["b"]
a["place"] = "moonbrook"
game.create_group(world, a) 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)) self.assertIn("at least 2", game.enter_dungeon(world, a, 0))
game.join_group(world, b, a["group"]) game.join_group(world, b, a["group"])
game.journey(world, b, "westfall", 0)
self.assertIn("Waiting on B", game.enter_dungeon(world, a, 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.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) game.leave_group(world, b, 10)
self.assertIsNone(b["activity"]) self.assertIsNone(b["activity"])
self.assertEqual(b["place"], "moonbrook") self.assertEqual(b["place"], "moonbrook")