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
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

15
app.py
View file

@ -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"

View file

@ -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.",

196
game.py
View file

@ -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,

View file

@ -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 `<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.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">
<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="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i></i><span></span></div>
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div>
<div class="row"><b>🛤️ On the road to ${esc(C.zones[act.to].name)}</b><span class="muted small">${waiting}</span>
<button class="btn small right" data-act="stop">Turn back</button></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>`;
}
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">
<b>🎯 Hunting ${esc(mob.name)}</b>
<span class="muted small">${waiting ? `resting · <span data-until="${me.next_pull}"></span>` : ""}</span>
<b>🎯 Hunting ${esc(C.mobs[act.hunt].name)}</b><span class="muted small">${esc(place(me.place).name)}</span>
<span class="muted small">${waiting}</span>
<button class="btn small right" data-act="stop">Stop</button></div>
</div>`;
}
@ -357,46 +357,59 @@ function dungeonCard() {
</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 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 `<div class="item-row" style="cursor:default">
<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>
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`}
</div>`;
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 `<div class="item-row" style="cursor:default">
<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>
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
: `<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("");
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 ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span>
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : "";
const roads = Object.entries(here.links).map(([to, minutes]) =>
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join("");
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id])
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join("");
const roads = Object.entries(C.roads).filter(([, r]) => r.zones.includes(zone)).map(([, r]) => {
const to = r.zones.find((x) => x !== zone);
return `<div class="item-row" style="cursor:default">
<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>
<button class="btn small" data-act="journey" data-id="${to}" ${away ? "disabled" : ""}>Set out</button>
</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">
<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>
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""}
<div class="row"><h2 style="margin:0">${esc(z.name)}</h2><span class="muted small">lv ${z.lv[0]}-${z.lv[1]}</span>
<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>` : ""}
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
${dungeon}
${vendor}
<h3>Roads</h3>
<div class="row wrap">${roads}</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>
${subzones ? `<h3>Hunt</h3>${subzones}` : ""}
${dungeon ? dungeonEntrance(dungeon) : ""}
${roads ? `<h3>Roads</h3><div class="list">${roads}</div>` : ""}
${flights ? `<div class="row wrap" style="margin-top:8px"><span class="small muted">Flight master</span>${flights}</div>` : ""}
</div>`;
}
@ -405,22 +418,25 @@ function dungeonEntrance(id) {
const g = S.group;
let action;
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 action = `<button class="btn primary small" data-act="enter">Enter</button>`;
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">Summon &amp; enter</button>`;
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>`;
}
function questOffer(qid) {
const q = C.quests[qid];
const tooHigh = q.lv > S.me.level + 3;
return `<div class="quest">
<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>
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div>
const open = openQuest === qid;
return `<div class="quest" data-act="open-quest" data-id="${qid}" style="cursor:pointer">
<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("")}
<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>`;
}
@ -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}`} <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 "";
}
@ -502,8 +518,7 @@ function questsPage() {
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>
${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>
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
<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>
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
</div>`;
}).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 `<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="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 ? `<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>` : ""}
<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>`;
}).join("");
return `<div class="card">
@ -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 = `<div class="card"><h2>Your group</h2><div class="list">
${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 ? " 👑" : ""}
@ -594,21 +608,21 @@ function groupPage() {
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
</div>
<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 && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""}
${!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 && !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>
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
} else {
const open = S.groups.filter((x) => !x.inside && x.size < 5);
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>
<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>`;
}
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 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>`;
}
@ -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;

View file

@ -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; }

View file

@ -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")