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