Zones instead of road timers: journeys, flights, meeting stone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7150b2f89c
commit
0e396ee5d4
7 changed files with 363 additions and 230 deletions
13
README.md
13
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
|
- **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
15
app.py
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
129
content.py
129
content.py
|
|
@ -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
196
game.py
|
|
@ -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,
|
||||||
|
|
|
||||||
143
static/app.js
143
static/app.js
|
|
@ -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 & 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 & 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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue