First slice: classes, combat, questing, Deadmines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-23 22:52:42 +01:00
commit 71dd99795a
17 changed files with 4522 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
*.sqlite3
result

25
CLAUDE.md Normal file
View file

@ -0,0 +1,25 @@
# World of Guildhall
WoW Classic–style co-op adventure for Fred's friend group. Stdlib Python server
(`app.py`), content tables (`content.py`), fight engine (`combat.py`), world
rules (`game.py`), vanilla JS client (`static/`). The world is one JSON doc in
SQLite.
## Design rules
- Every ability is auto (rule, AUTO_CD × cooldown) or manual (pressed, real
cooldown). Manual only holds while the player is watching (`WATCH`); never
let an unwatched player's abilities sit unused.
- Dungeons are group-only and are where active play matters: tune bosses so an
all-auto group struggles (`tools/balance.py dungeon`), trash so it doesn't.
- Use Classic's names for places, mobs, quests, NPCs and items.
- In-game text is terse: icons and numbers over prose, details in tips.
- Every requirement/progress ratio is **have / need** (`3 / 10`).
## Checks
```
python3 -m unittest discover -s tests
python3 tools/balance.py solo
python3 tools/balance.py dungeon
```

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# World of Guildhall
A co-op adventure for a handful of friends, set in WoW Classic's Elwynn Forest
and Westfall. Quest and hunt your way from Northshire to the Deadmines: your
character keeps adventuring while you're away, and dungeons are where you play
together, live.
Prototype. The first slice: levels 1–20, two zones, one dungeon.
## What's in it
- **Classes:** all nine of Classic's, one spec each for now. Tanks: Protection
Warrior, Protection Paladin, Enhancement Shaman. Healers: Restoration Druid,
Holy Priest. Damage: Frost Mage, Combat Rogue, Beast Mastery Hunter (with a
wolf), Affliction Warlock (a Voidwalker alone, an Imp in a group). Each spec
has four signature abilities, learned at levels 1, 3, 6 and 10.
- **Auto or you:** every ability is either on auto (a rule decides: "someone
under 50%", "a mob is casting", "a mob hits someone else") on a 1.5× cooldown,
or in your hands on the real one. Manual abilities only fire when you press
them, and only while you're watching: look away for 12 seconds and they go
back to auto, so nobody idles into a wipe.
- **Combat:** live, in quarter-second steps on the server. Mobs pick targets by
threat (tanks make 1.8× threat, healing makes half), cast telegraphed spells
you can interrupt, and break out of crowd control when hit. Bosses have
phases: Mr. Smite stomps and swaps weapons, VanCleef calls his Blackguards.
Tap a frame to target it; tap a party member to aim your heals.
- **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.
- **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
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
Deadmines.
## Running it
```
nix run . -- --dev --port 8100 --db ./wog.sqlite3
```
`--dev` trusts a `wog_user` cookie (a user switcher on the Adventure page) and
adds cheats: set level with matching greens, skip time, teleport.
`tools/balance.py solo|dungeon [runs] [perfect]` simulates solo hunts for every
class and all-auto Deadmines runs.
In production it sits behind Authelia, which passes the signed-in user in
`Remote-User`. The NixOS module is `nixosModules.default` (`services.wog`).

411
app.py Normal file
View file

@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""Small, dependency-free server for World of Guildhall."""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import mimetypes
import random
import re
import sqlite3
import threading
import time
from contextlib import closing
from datetime import date
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
import combat
import content
import game
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
MAX_BODY = 16 * 1024
NAME = re.compile(r"^[A-Za-z][A-Za-z'-]{1,15}$")
TICK_SECONDS = 0.25
SAVE_SECONDS = 5
def build_version() -> str:
"""Changes whenever the code or client does; open tabs reload when it does."""
digest = hashlib.sha256()
for path in sorted([*APP_DIR.glob("*.py"), *STATIC_DIR.rglob("*")]):
if path.is_file():
digest.update(path.name.encode())
digest.update(path.read_bytes())
return digest.hexdigest()[:12]
VERSION = build_version()
mimetypes.add_type("application/manifest+json", ".webmanifest")
class Store:
"""The world lives in memory and is written whole to SQLite every few seconds."""
def __init__(self, path: Path, dev: bool):
self.path = path
self.lock = threading.Lock()
self.rng = random.Random()
self.saved = 0.0
path.parent.mkdir(parents=True, exist_ok=True)
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["config"]["dev"] = dev
self.save()
def now(self) -> float:
return time.time() + self.world["config"]["clock_offset"]
def save(self) -> None:
data = json.dumps(self.world, separators=(",", ":"))
with closing(sqlite3.connect(self.path)) as db, db:
db.execute("INSERT INTO world (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data",
(data,))
self.saved = time.time()
def backup(self, destination: Path, keep: int) -> Path:
"""Write today's consistent snapshot and prune all but the newest `keep`."""
destination.mkdir(parents=True, exist_ok=True)
target = destination / f"wog-{date.today().isoformat()}.sqlite3"
partial = target.with_suffix(".partial")
with closing(sqlite3.connect(self.path)) as source, closing(sqlite3.connect(partial)) as copy:
source.backup(copy)
partial.replace(target)
snapshots = sorted(destination.glob("wog-*.sqlite3"))
for old in snapshots[:-keep] if keep > 0 else []:
old.unlink()
return target
def tick(self) -> None:
with self.lock:
game.process(self.world, self.now(), self.rng)
if time.time() - self.saved > SAVE_SECONDS:
self.save()
def static_content() -> dict:
referenced = set(content.ITEMS)
for quest in content.QUESTS.values():
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,
"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,
"xp": content.XP_TO_NEXT, "max_level": content.MAX_LEVEL,
"items": {i: game.item(i) for i in sorted(referenced)},
"auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
}
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",
"quests", "done", "manual", "group", "dead_until", "next_pull")}
view["hp"], view["mana"] = round(hp), round(mana, 3)
view["stats"] = st
view["xp_next"] = content.XP_TO_NEXT[player["level"]]
view["known"] = game.known(player)
view["equip"] = {slot: game.item(i) if i else None for slot, i in player["equip"].items()}
bags = []
for item_id, qty in sorted(player["bags"].items()):
spec = game.item(item_id)
if spec:
bags.append({**spec, "qty": qty, "fit": game.can_wear(player, spec) if "slot" in spec else None})
view["bags"] = bags
view["available"] = game.available_quests(player)
view["log"] = player["log"][-40:]
return view
def group_view(world: dict, group: dict | None, now: float) -> dict | None:
if not group:
return None
members = []
for uid in group["members"]:
member = world["players"][uid]
hp, mana = game.hp_now(member, now)
members.append({"id": uid, "name": member["name"], "cls": member["cls"], "level": member["level"],
"place": member["place"], "hp": round(hp), "max_hp": game.stats(member)["max_hp"],
"mana": round(mana, 2), "inside": bool(member["activity"]
and member["activity"]["kind"] == "dungeon"),
"busy": game._busy(member)})
dungeon = {k: v for k, v in group["dungeon"].items() if k != "last_fight"} if group["dungeon"] else None
return {"id": group["id"], "leader": group["leader"], "dungeon": dungeon, "members": members}
def roster(world: dict, now: float) -> list[dict]:
people = []
for player in world["players"].values():
activity = player["activity"]
people.append({"id": player["id"], "name": player["name"], "cls": player["cls"], "level": player["level"],
"place": player["place"], "group": player["group"],
"doing": activity["kind"] if activity else None,
"hunt": activity.get("hunt") if activity else None,
"online": now - player["last_active"] < 120})
return sorted(people, key=lambda p: (-p["level"], p["name"]))
class Handler(BaseHTTPRequestHandler):
server_version = "WoG"
store: Store
dev = False
user_header = "Remote-User"
def log_message(self, fmt: str, *args) -> None:
logging.debug("%s - %s", self.address_string(), fmt % args)
# --- plumbing --------------------------------------------------------
def identity(self) -> str | None:
if self.dev:
cookie = self.headers.get("Cookie", "")
match = re.search(r"(?:^|;\s*)wog_user=([^;]+)", cookie)
return unquote(match.group(1)) if match else "fred"
user = self.headers.get(self.user_header, "").strip()
return user or None
def send_bytes(self, body: bytes, kind: str = "application/json", cache: str = "no-store") -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", kind)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", cache)
self.end_headers()
self.wfile.write(body)
def send_json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def fail(self, message: str, status: HTTPStatus = HTTPStatus.BAD_REQUEST) -> None:
self.send_json({"error": message}, status)
def read_body(self) -> dict | None:
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY:
return None
try:
data = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def send_static(self, path: str) -> None:
name = "index.html" if path in ("", "/") else path.lstrip("/").removeprefix("static/")
target = (STATIC_DIR / name).resolve()
if STATIC_DIR.resolve() not in target.parents or not target.is_file():
target = STATIC_DIR / "index.html"
body = target.read_bytes()
if target.name == "index.html":
# Versioned URLs, so no cache (a home-screen app's included) can serve an old client.
for asset in (b"/static/app.js", b"/static/styles.css"):
body = body.replace(asset + b'"', asset + b"?v=" + VERSION.encode() + b'"')
self.send_bytes(body, mimetypes.guess_type(target.name)[0] or "application/octet-stream", "no-cache")
# --- routes ----------------------------------------------------------
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/api/content":
return self.send_bytes(CONTENT, cache="no-cache")
if path == "/api/state":
uid = self.identity()
if not uid:
return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED)
with self.store.lock:
world, now = self.store.world, self.store.now()
player = world["players"].get(uid)
if player:
player["last_active"] = player["watch"] = now
game.process(world, now, self.store.rng)
return self.send_json(self.state(uid))
if path.startswith("/api/"):
return self.fail("Not found", HTTPStatus.NOT_FOUND)
self.send_static(path)
def do_POST(self) -> None:
path = urlparse(self.path).path
uid = self.identity()
if not uid:
return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED)
body = self.read_body()
if body is None:
return self.fail("Bad request body")
with self.store.lock:
world, now = self.store.world, self.store.now()
game.process(world, now, self.store.rng)
player = world["players"].get(uid)
if path == "/api/register":
error = self.register(uid, body, now)
elif not player:
return self.fail("Create your character first", HTTPStatus.CONFLICT)
elif path.startswith("/api/dev/"):
error = self.dev_route(path, player, body, now) if self.dev else "Not found"
else:
player["last_active"] = player["watch"] = now
error = self.player_route(path, player, body, now)
if error:
return self.fail(error)
game.process(world, now, self.store.rng)
if path not in ("/api/press", "/api/target"):
self.store.save()
self.send_json(self.state(uid))
def player_route(self, path: str, player: dict, body: dict, now: float) -> str | None:
world = self.store.world
arg = body.get("id")
arg = str(arg) if arg is not None else ""
if path == "/api/press":
fight = game.current_fight(world, player)
if not fight:
return "You're not fighting"
target = body.get("target")
return combat.press(fight, player["id"], arg, now, str(target) if target else None)
if path == "/api/target":
fight = game.current_fight(world, player)
return combat.set_target(fight, player["id"], arg) if fight else "You're not fighting"
if path == "/api/manual":
return game.set_manual(world, player, arg, bool(body.get("on")))
if path == "/api/hunt":
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/equip":
return game.equip(player, arg)
if path == "/api/unequip":
return game.unequip(player, arg)
if path == "/api/sell":
qty = body.get("qty", 1)
return game.sell(player, arg, qty if qty == "all" else (qty if isinstance(qty, int) else -1))
if path == "/api/sell_junk":
return game.sell_junk(player)
if path == "/api/accept":
return game.accept(player, arg)
if path == "/api/abandon":
return game.abandon(player, arg)
if path == "/api/turn_in":
return game.turn_in(world, player, arg, body.get("choice"), now)
if path == "/api/group/create":
return game.create_group(world, player)
if path == "/api/group/join":
return game.join_group(world, player, arg)
if path == "/api/group/leave":
return game.leave_group(world, player, now)
if path == "/api/dungeon/enter":
return game.enter_dungeon(world, player, now)
if path == "/api/dungeon/pull":
return game.pull_now(world, player, now)
if path == "/api/dungeon/leave":
return game.leave_dungeon(world, player, now)
if path == "/api/say":
return game.say(world, player, str(body.get("text", "")), now)
return "Not found"
def register(self, uid: str, body: dict, now: float) -> str | None:
world = self.store.world
if uid in world["players"]:
return "You already have a character"
name = str(body.get("name", "")).strip().capitalize()
cls = body.get("cls")
if not NAME.match(name):
return "Names are 2 to 16 letters"
if any(p["name"].lower() == name.lower() for p in world["players"].values()):
return "That name is taken"
if cls not in content.CLASSES:
return "Pick a class"
world["players"][uid] = game.new_player(uid, name, cls, now)
game.world_log(world, now, f"{name} the {content.CLASSES[cls]['name']} arrived in Northshire", "join", uid)
return None
def dev_route(self, path: str, player: dict, body: dict, now: float) -> str | None:
world = self.store.world
if path == "/api/dev/level":
return game.dev_level(world, player, int(body.get("level", 1)), now)
if path == "/api/dev/advance":
world["config"]["clock_offset"] += float(body.get("minutes", 10)) * game.MINUTE
return None
if path == "/api/dev/teleport":
if str(body.get("id")) not in content.PLACES:
return "No such place"
game.stop(world, player, now)
player["place"] = str(body["id"])
return None
return "Not found"
def state(self, uid: str) -> dict:
world = self.store.world
now = self.store.now()
player = world["players"].get(uid)
fight = game.shown_fight(world, player, now) if player else None
group = world["groups"].get(player["group"] or "") if player else None
return {
"now": now, "dev": self.dev, "uid": uid, "version": VERSION,
"me": player_view(world, player, now) if player else None,
"fight": combat.view(fight) if fight else None,
"group": group_view(world, group, now),
"groups": [{"id": g["id"], "leader": world["players"][g["leader"]]["name"], "size": len(g["members"]),
"inside": bool(g["dungeon"])} for g in world["groups"].values()],
"players": roster(world, now),
"log": world["log"][-30:],
"chat": world["chat"][-60:],
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8100)
parser.add_argument("--db", type=Path, default=Path("wog.sqlite3"))
parser.add_argument("--dev", action="store_true", help="trust a wog_user cookie and enable cheats")
parser.add_argument("--user-header", default="Remote-User")
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
store = Store(args.db, args.dev)
if args.backup_to:
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
return
Handler.store = store
Handler.dev = args.dev
Handler.user_header = args.user_header
def ticker() -> None:
while True:
time.sleep(TICK_SECONDS)
try:
store.tick()
except Exception:
logging.exception("tick failed")
threading.Thread(target=ticker, daemon=True).start()
server = ThreadingHTTPServer((args.host, args.port), Handler)
logging.info("World of Guildhall listening on http://%s:%d%s", args.host, args.port, " (dev)" if args.dev else "")
server.serve_forever()
if __name__ == "__main__":
main()

798
combat.py Normal file
View file

@ -0,0 +1,798 @@
"""The fight engine: a party against a pack, stepped in fixed slices of time.
A fight is one JSON-able dict. It only ever moves forward through `advance`, which
replays the slices between its last step and now; each slice draws from a random
seeded by the fight and the slice number, so the same presses give the same fight.
Every ability is either on auto (a rule decides when, on a longer cooldown) or in
the player's hands. A player's hands only count while they're watching: once they
look away, everything they hold falls back to auto.
"""
from __future__ import annotations
import random
from content import ABILITIES, BOSS_DMG, BOSS_HP, ELITE_DMG, ELITE_HP, ENEMY_ABILITIES, MOBS
STEP = 0.25 # seconds per slice
GCD = 1.5
ENERGY_GCD = 1.0 # rogues
AUTO_DELAY = 0.5 # auto waits this long after it could act
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
AUTO_REACT = 1.0 # seconds into a cast before auto interrupts it
WATCH = 12 # seconds since a player last looked that their manual choices still hold
QUEUE = 1.5 # a press waits this long for the global cooldown before it's dropped
CRIT, CRIT_MULT = 0.05, 1.5
SPREAD = 0.1
HEAL_THREAT = 0.5
STICKY = 1.1 # a mob only turns when someone has 10% more threat than its target
TANK_THREAT = 1.8
PET_THREAT = 0.5
PET_TANK_THREAT = 3.0
MANA_REGEN = 0.02 # share of max mana per second
ENERGY_REGEN = 10
RAGE_PER_SWING = 7
RAGE_TAKEN = 40 # rage for taking a whole health bar of damage
MAX_TIME = 300
LOG = 40
EVENTS = 60
def mob_hp(level: int) -> float:
return 35 + 15 * level + 0.8 * level * level
def mob_swing(level: int) -> float:
"""Damage of an ordinary mob's 2s swing at this level."""
return 3 + 1.5 * level
# --- building a fight ------------------------------------------------------
def mob_unit(uid: str, mob_id: str, level: int, hp_scale: float = 1.0, dmg_scale: float = 1.0) -> dict:
mob = MOBS[mob_id]
hp = mob_hp(level) * mob.get("hp", 1)
swing = mob_swing(level) * mob.get("dmg", 1)
if mob.get("elite"):
hp, swing = hp * ELITE_HP, swing * ELITE_DMG
if mob.get("boss"):
hp, swing = hp * BOSS_HP, swing * BOSS_DMG
hp = max(1, round(hp * hp_scale))
return {
"id": uid, "mob": mob_id, "name": mob["name"], "side": "enemy", "kind": mob["kind"], "level": level,
"hp": hp, "max_hp": hp, "power": 0, "armor": 0,
"swing": {"dmg": swing * dmg_scale, "every": 2.0, "next": 0.5, "school": "phys"},
"auras": [], "cast": None, "threat": {}, "target": None, "taunt": None,
"stun": 0, "cc": 0, "root": 0, "dead": False,
"boss": bool(mob.get("boss")), "elite": bool(mob.get("elite")), "rare": bool(mob.get("rare")),
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"]} for a in mob.get("abilities", [])],
"phases": [{**phase, "done": False} for phase in mob.get("phases", [])],
}
def new_fight(fid: str, seed: int, at: float, party: list[dict], enemies: list[dict], kind: str,
name: str = "", scale: tuple = (1.0, 1.0)) -> dict:
fight = {"id": fid, "seed": seed, "at": at, "t": 0.0, "n": 0, "state": "on", "kind": kind, "name": name,
"units": {}, "log": [], "ev": [], "kills": [], "next": 0, "scale": list(scale), "ended": None}
for unit in party + enemies:
fight["units"][unit["id"]] = unit
for enemy in enemies:
_engage(fight, enemy)
return fight
def _engage(fight: dict, enemy: dict) -> None:
"""Who a fresh mob starts on: the tank, else the first one in."""
party = alive(fight, "party")
for unit in party:
enemy["threat"][unit["id"]] = 10.0 if _tanks(unit) else 0.0
if party and not any(enemy["threat"].values()):
enemy["threat"][party[0]["id"]] = 1.0
def _tanks(unit: dict) -> bool:
return unit.get("role") == "tank" or unit.get("pet_tank", False)
def alive(fight: dict, side: str) -> list[dict]:
return [u for u in fight["units"].values() if u["side"] == side and not u["dead"]]
def players(fight: dict) -> list[dict]:
return [u for u in fight["units"].values() if u.get("kind") == "player"]
# --- the outside world's handles ------------------------------------------
def advance(fight: dict, now: float, watch: dict | None = None) -> None:
"""Run every slice up to `now` (game time)."""
end = now - fight["at"]
watch = watch or {}
while fight["state"] == "on" and fight["t"] + STEP <= end + 1e-9:
_step(fight, watch)
def end_time(fight: dict) -> float:
return fight["at"] + fight["t"]
def press(fight: dict, uid: str, ability: str, now: float, target: str | None = None) -> str | None:
unit = fight["units"].get(uid)
if fight["state"] != "on" or not unit or unit["dead"]:
return "You're not fighting"
if ability not in unit["abilities"]:
return "You don't know that"
unit["queued"] = {"ab": ability, "target": target, "at": now - fight["at"]}
return None
def set_target(fight: dict, uid: str, target: str) -> str | None:
unit, other = fight["units"].get(uid), fight["units"].get(target)
if not unit or not other or other["dead"]:
return "No such target"
if other["side"] == "enemy":
unit["target"], unit["picked"] = target, True
else:
unit["ftarget"] = target
return None
# --- a slice ---------------------------------------------------------------
def _step(fight: dict, watch: dict) -> None:
fight["t"] = t = round(fight["t"] + STEP, 4)
rng = random.Random(f"{fight['seed']}:{fight['n']}")
fight["n"] += 1
now = fight["at"] + t
units = list(fight["units"].values())
for unit in units:
if not unit["dead"]:
_tick(fight, unit, t, rng)
for unit in units:
if not unit["dead"] and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
_finish_cast(fight, unit, t, rng)
for unit in units:
if not unit["dead"] and unit["side"] == "party":
seen = watch.get(unit.get("owner") or unit["id"])
_think(fight, unit, t, rng, seen is not None and now - seen < WATCH)
for unit in units:
if not unit["dead"] and unit["side"] == "enemy":
_enemy_think(fight, unit, t, rng)
for unit in units:
if not unit["dead"]:
_swing(fight, unit, t, rng)
_phases(fight, t, rng)
_check_end(fight, t)
def _tick(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
res = unit.get("res")
if res and res["type"] == "mana":
res["cur"] = min(res["max"], res["cur"] + res["max"] * MANA_REGEN * STEP)
elif res and res["type"] == "energy":
res["cur"] = min(res["max"], res["cur"] + ENERGY_REGEN * STEP)
for aura in list(unit["auras"]):
if aura["kind"] in ("dot", "hot", "ground") and t >= aura["next"] - 1e-9:
aura["next"] += aura["every"]
source = fight["units"].get(aura["by"])
if aura["kind"] == "dot":
_damage(fight, source, unit, aura["amount"], aura["school"], t, rng, crit=False)
elif aura["kind"] == "hot":
_heal(fight, source, unit, aura["amount"], t, rng, crit=False)
else:
for enemy in alive(fight, "enemy"):
if enemy["cc"] <= t:
_damage(fight, unit, enemy, aura["amount"], aura["school"], t, rng, crit=False,
threat=aura.get("threat", 1))
if unit["dead"]:
return
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
unit["auras"].remove(aura)
if aura["kind"] == "enrage":
unit["swing"]["dmg"] /= aura["mult"]
# --- player side -----------------------------------------------------------
def _think(fight: dict, unit: dict, t: float, rng: random.Random, watching: bool) -> None:
if unit["stun"] > t or unit["cast"]:
return
_pick_target(fight, unit, t)
if unit["kind"] == "pet":
return
queued = unit.get("queued")
if queued and watching:
if t - queued["at"] > QUEUE:
unit["queued"] = None
else:
why = _not_ready(unit, queued["ab"], t, manual=True)
if why is None:
target = _manual_target(fight, unit, queued["ab"], queued["target"], t)
unit["queued"] = None
if target is not False:
_use(fight, unit, queued["ab"], target, t, rng, manual=True)
if unit["cast"] or not ABILITIES[queued["ab"]].get("offgcd"):
return
elif why == "mana":
unit["queued"] = None
_event(fight, t, unit["id"], 0, "oom")
elif queued:
unit["queued"] = None
for ab_id in unit["priority"]:
if ab_id not in unit["abilities"] or (watching and ab_id in unit["manual"]):
continue
if _not_ready(unit, ab_id, t, manual=False):
continue
target = _rule(fight, unit, ab_id, t)
if target is False:
continue
_use(fight, unit, ab_id, target, t, rng, manual=False)
if unit["cast"] or not ABILITIES[ab_id].get("offgcd"):
return
def _not_ready(unit: dict, ab_id: str, t: float, manual: bool) -> str | None:
ab = ABILITIES[ab_id]
if unit["cds"].get(ab_id, 0) > t + 1e-9:
return "cooldown"
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
return "gcd"
cost = _cost(unit, ab)
if cost and unit["res"]["cur"] < cost:
return unit["res"]["type"]
return None
def _cost(unit: dict, ab: dict) -> float:
cost = ab.get("cost", 0)
if cost and cost < 1:
return cost * unit["res"]["max"]
return cost
def _use(fight: dict, unit: dict, ab_id: str, target, t: float, rng: random.Random, manual: bool) -> None:
ab = ABILITIES[ab_id]
cost = _cost(unit, ab)
if cost:
unit["res"]["cur"] -= cost
unit["cds"][ab_id] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
cast = ab.get("cast", 0)
if not ab.get("offgcd"):
gcd = ENERGY_GCD if unit["res"] and unit["res"]["type"] == "energy" else GCD
unit["busy"] = t + max(gcd, cast)
if cast:
unit["cast"] = {"ab": ab_id, "name": ab["name"], "icon": ab["icon"], "target": target,
"start": t, "end": t + cast}
else:
_apply(fight, unit, ab_id, target, t, rng)
def _finish_cast(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
cast, unit["cast"] = unit["cast"], None
if unit["side"] == "party":
_apply(fight, unit, cast["ab"], cast["target"], t, rng)
else:
_enemy_effect(fight, unit, cast["ab"], cast["target"], t, rng)
def _apply(fight: dict, unit: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
ab = ABILITIES[ab_id]
units = fight["units"]
target = units.get(target_id) if target_id else None
if ab["target"] in ("enemy", "ally", "pet") and (not target or target["dead"]):
return
power = unit["power"]
threat = ab.get("threat", 1)
on = f" on {target['name']}" if target and target is not unit else ""
if ab["target"] != "enemy" or any(e["t"] in ("cc", "interrupt", "stun", "taunt") for e in ab["effects"]):
_log(fight, t, f"{unit['name']} · {ab['name']}{on}", "cast", unit["id"])
for eff in ab["effects"]:
kind = eff["t"]
if kind == "dmg":
_damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, threat=threat, label=ab["name"])
elif kind == "aoe":
for enemy in alive(fight, "enemy"):
if enemy["cc"] <= t:
_damage(fight, unit, enemy, eff["c"] * power, eff["school"], t, rng, threat=threat,
label=ab["name"])
elif kind in ("dot", "hot"):
_add_aura(target, {"id": ab_id, "kind": kind, "by": unit["id"], "amount": eff["c"] * power,
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
"school": eff.get("school"), "name": ab["name"], "icon": ab["icon"]})
elif kind == "ground":
_add_aura(unit, {"id": ab_id, "kind": "ground", "by": unit["id"], "amount": eff["c"] * power,
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
"school": eff["school"], "threat": threat, "name": ab["name"], "icon": ab["icon"]})
elif kind == "heal":
_heal(fight, unit, target, eff["c"] * power, t, rng, label=ab["name"])
elif kind == "shield":
if any(a["kind"] == "weakened" for a in target["auras"]):
continue
_add_aura(target, {"id": ab_id, "kind": "shield", "by": unit["id"], "amount": eff["c"] * power,
"until": t + eff["dur"], "name": ab["name"], "icon": ab["icon"]})
_add_aura(target, {"id": "weakened_soul", "kind": "weakened", "by": unit["id"], "until": t + 15,
"name": "Weakened Soul", "icon": "🩹"})
_heal_threat(fight, unit, eff["c"] * power * 0.5)
elif kind == "aura":
holder = target or unit
spec = {k: v for k, v in eff.items() if k not in ("t", "dur")}
_add_aura(holder, {**spec, "id": ab_id, "by": unit["id"], "until": t + eff["dur"]})
elif kind == "taunt":
top = max(target["threat"].values(), default=0)
target["threat"][unit["id"]] = top * STICKY + 1
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
target["target"] = unit["id"]
elif kind == "stun":
_stop_cast(fight, target, unit, t, force=not target["boss"])
if not target["boss"]:
target["stun"] = t + eff["dur"]
elif kind == "interrupt":
_stop_cast(fight, target, unit, t)
elif kind == "cc":
if target["boss"]:
_log(fight, t, f"{target['name']} is immune", "miss")
continue
_stop_cast(fight, target, unit, t, force=True, quiet=True)
target["cc"] = t + eff["dur"]
target["cc_icon"] = ab["icon"]
for other in alive(fight, "party"):
if other["target"] == target["id"] and not other.get("picked"):
other["target"] = None
elif kind == "freeze":
for enemy in alive(fight, "enemy"):
if not enemy["boss"]:
enemy["root"] = t + eff["dur"]
elif kind == "combo":
unit["combo"] = min(5, unit["combo"] + eff["n"])
elif kind == "finisher":
points, unit["combo"] = unit["combo"], 0
if points:
_damage(fight, unit, target, eff["c"] * power * points, "phys", t, rng, threat=threat,
label=ab["name"])
elif kind == "drain":
dealt = _damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, label=ab["name"])
_heal(fight, unit, unit, dealt, t, rng, crit=False)
def _stop_cast(fight: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
cast = enemy.get("cast")
if not cast:
return
if not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]:
return
enemy["cast"] = None
if not quiet:
_log(fight, t, f"{by['name']} interrupts {enemy['name']}'s {cast['name']}", "interrupt", by["id"])
_event(fight, t, enemy["id"], 0, "interrupt")
def _add_aura(unit: dict, aura: dict) -> None:
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
unit["auras"].append(aura)
def _pet_of(fight: dict, unit: dict) -> dict | None:
return next((u for u in alive(fight, "party") if u.get("owner") == unit["id"]), None)
def _pick_target(fight: dict, unit: dict, t: float) -> None:
current = fight["units"].get(unit.get("target") or "")
if current and not current["dead"] and (unit.get("picked") or current["cc"] <= t):
return
unit["picked"] = False
enemies = alive(fight, "enemy")
free = [e for e in enemies if e["cc"] <= t] or enemies
if not free:
unit["target"] = None
return
owner = fight["units"].get(unit.get("owner") or "")
if unit["kind"] == "pet" and not unit.get("pet_tank") and owner and not owner["dead"] and owner.get("target"):
unit["target"] = owner["target"]
return
if _tanks(unit):
tanks = {u["id"] for u in alive(fight, "party") if _tanks(u)}
loose = [e for e in free if e["target"] not in tanks]
unit["target"] = (loose or sorted(free, key=lambda e: e["hp"] / e["max_hp"]))[0]["id"]
return
tank = next((u for u in alive(fight, "party") if _tanks(u) and u.get("target")), None)
focus = fight["units"].get(tank["target"]) if tank else None
if focus and not focus["dead"] and focus["cc"] <= t:
unit["target"] = focus["id"]
else:
unit["target"] = min(free, key=lambda e: e["hp"] / e["max_hp"])["id"]
def _hurt(unit: dict) -> float:
return unit["hp"] / unit["max_hp"]
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
def _rule(fight: dict, unit: dict, ab_id: str, t: float):
"""Auto's decision: the target to use this on, None for no target, False for not now."""
ab = ABILITIES[ab_id]
rule = ab["auto"]
r = rule["r"]
enemies = alive(fight, "enemy")
party = alive(fight, "party")
target = fight["units"].get(unit.get("target") or "")
if target and target["dead"]:
target = None
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
if rule.get("save") and unit.get("role") == "healer" and len(players(fight)) > 1 and \
unit["res"]["cur"] < rule["save"] * unit["res"]["max"]:
return False # a group's healer keeps their mana for healing
if r == "always":
if ab["target"] == "enemy":
return target["id"] if target else False
return None
if r == "dot":
return target["id"] if target and not _has(target, ab_id, unit["id"]) and _hurt(target) > 0.2 else False
if r == "heal":
low = min(party, key=_hurt, default=None)
return low["id"] if low and _hurt(low) < rule["pct"] else False
if r == "hot":
hurt = sorted((u for u in party if _hurt(u) < rule["pct"] and not _has(u, ab_id, unit["id"])), key=_hurt)
return hurt[0]["id"] if hurt else False
if r == "shield":
targeted = {e["target"] for e in enemies}
hurt = [u for u in party if not any(a["kind"] in ("shield", "weakened") for a in u["auras"])
and (u["id"] in targeted and _hurt(u) < 0.95 or _hurt(u) < rule["pct"])]
return min(hurt, key=_hurt)["id"] if hurt else False
if r == "self_below":
return target["id"] if target and _hurt(unit) < rule["pct"] else False
if r == "taunt":
if not _tanks(unit):
return False
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
and not _tanks(fight["units"][e["target"]]) and t - e.get("since", 0) >= AUTO_REACT]
return loose[0]["id"] if loose else False
if r == "aoe":
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
if r == "interrupt":
casting = [e for e in enemies if e["cast"] and ENEMY_ABILITIES[e["cast"]["ab"]]["interrupt"]
and t - e["cast"]["start"] >= AUTO_REACT]
return casting[0]["id"] if casting else False
if r == "combo":
return target["id"] if target and unit["combo"] >= rule["n"] else False
if r == "cc":
free = [e for e in enemies if e["cc"] <= t]
if len(free) < 2:
return False
focus = {u.get("target") for u in party if _tanks(u)} | {unit.get("target")}
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _hurt(e) > 0.5]
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
if r == "peel":
return None if on_me and not _tanks(unit) and any(not e["boss"] for e in on_me) else False
if r == "tanking":
return None if on_me and (any(e["boss"] or e["elite"] for e in on_me) or _hurt(unit) < rule["pct"]) else False
if r == "danger":
return None if on_me and _hurt(unit) < rule["pct"] else False
if r == "buff":
return None if not _has(unit, ab_id) else False
if r == "boss":
pet = _pet_of(fight, unit)
if not pet or not target:
return False
return pet["id"] if target["boss"] or target["elite"] or target["rare"] else False
return False
def _manual_target(fight: dict, unit: dict, ab_id: str, given: str | None, t: float):
ab = ABILITIES[ab_id]
units = fight["units"]
kind = ab["target"]
if kind in ("self", "aoe"):
return None
if kind == "pet":
pet = _pet_of(fight, unit)
return pet["id"] if pet else False
if kind == "ally":
for choice in (given, unit.get("ftarget")):
other = units.get(choice or "")
if other and not other["dead"] and other["side"] == "party":
return other["id"]
return min(alive(fight, "party"), key=_hurt)["id"]
other = units.get(given or "")
if other and not other["dead"] and other["side"] == "enemy":
return other["id"]
smart = _rule(fight, unit, ab_id, t) if ab["auto"]["r"] in ("interrupt", "cc", "taunt") else False
if smart:
return smart
target = units.get(unit.get("target") or "")
return target["id"] if target and not target["dead"] else False
# --- enemy side ------------------------------------------------------------
def _enemy_think(fight: dict, enemy: dict, t: float, rng: random.Random) -> None:
if enemy["stun"] > t or enemy["cc"] > t:
enemy["cast"] = None
return
_enemy_target(fight, enemy, t)
if enemy["cast"] or not enemy["target"]:
return
for slot in enemy["abil"]:
if t < slot["next"]:
continue
spec = ENEMY_ABILITIES[slot["id"]]
target = _enemy_ability_target(fight, enemy, spec, rng)
if target is False:
slot["next"] = t + 2
continue
slot["next"] = t + spec["every"]
if spec["cast"]:
enemy["cast"] = {"ab": slot["id"], "name": spec["name"], "icon": spec["icon"], "target": target,
"start": t, "end": t + spec["cast"], "interrupt": spec["interrupt"]}
else:
_enemy_effect(fight, enemy, slot["id"], target, t, rng)
return
def _enemy_target(fight: dict, enemy: dict, t: float) -> None:
units = fight["units"]
taunt = enemy.get("taunt")
if taunt and taunt["until"] > t and not units[taunt["by"]]["dead"]:
enemy["target"] = taunt["by"]
return
table = {uid: v for uid, v in enemy["threat"].items() if not units[uid]["dead"]}
if not table:
party = alive(fight, "party")
enemy["target"] = party[0]["id"] if party else None
if party:
enemy["threat"][party[0]["id"]] = 1.0
return
top = max(table, key=table.get)
current = enemy.get("target")
if current in table and table[top] <= table[current] * STICKY:
return
enemy["target"], enemy["since"] = top, t
def _enemy_ability_target(fight: dict, enemy: dict, spec: dict, rng: random.Random):
kind = spec["target"]
if kind == "target":
return enemy["target"]
if kind == "all":
return None
if kind == "hurt_ally":
hurt = [e for e in alive(fight, "enemy") if _hurt(e) < 0.7]
return min(hurt, key=_hurt)["id"] if hurt else False
if kind == "random_soft":
soft = [u for u in alive(fight, "party") if u["kind"] == "player" and not _tanks(u)]
return rng.choice(soft)["id"] if soft else enemy["target"]
return False
def _enemy_effect(fight: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
spec = ENEMY_ABILITIES[ab_id]
kind, amount, school = spec["effect"]
target = fight["units"].get(target_id or "")
swing = enemy["swing"]["dmg"]
if kind == "hit":
if target and not target["dead"]:
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
_damage(fight, enemy, target, swing * amount, school, t, rng, label=spec["name"])
elif kind == "aoe":
_log(fight, t, f"{enemy['name']} · {spec['name']}", "enemy", enemy["id"])
for unit in alive(fight, "party"):
_damage(fight, enemy, unit, swing * amount, school, t, rng, label=spec["name"])
elif kind == "heal":
if target and not target["dead"]:
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
gain = min(target["max_hp"] - target["hp"], round(target["max_hp"] * amount))
target["hp"] += gain
_event(fight, t, target["id"], gain, "heal")
elif kind == "disarm":
if target and not target["dead"]:
_log(fight, t, f"{enemy['name']} disarms {target['name']}", "enemy", enemy["id"])
_add_aura(target, {"id": "disarm", "kind": "disarm", "by": enemy["id"], "until": t + amount,
"name": "Disarmed", "icon": "🫳"})
def _phases(fight: dict, t: float, rng: random.Random) -> None:
for enemy in alive(fight, "enemy"):
for phase in enemy["phases"]:
if phase["done"] or _hurt(enemy) > phase["at"]:
continue
phase["done"] = True
_log(fight, t, f"{enemy['name']} yells: {phase['say']}", "yell", enemy["id"])
for what, arg in phase["do"]:
if what == "stun_all":
for unit in alive(fight, "party"):
unit["stun"] = t + arg
unit["cast"] = None
elif what == "enrage":
enemy["swing"]["dmg"] *= arg
enemy["auras"].append({"id": f"enrage{t}", "kind": "enrage", "by": enemy["id"], "mult": arg,
"until": t + MAX_TIME, "name": "Enraged", "icon": "😡"})
elif what == "summon":
_summon(fight, enemy, arg, t)
def _summon(fight: dict, by: dict, mobs: list, t: float) -> None:
hp_scale, dmg_scale = fight["scale"]
for mob_id in mobs:
fight["next"] += 1
unit = mob_unit(f"s{fight['next']}", mob_id, MOBS[mob_id]["lv"][1], hp_scale, dmg_scale)
unit["swing"]["next"] = t + 1.0
fight["units"][unit["id"]] = unit
_engage(fight, unit)
_log(fight, t, f"{unit['name']} joins the fight", "enemy", unit["id"])
# --- shared mechanics ------------------------------------------------------
def _swing(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
swing = unit.get("swing")
if not swing or t < swing["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit["cc"] > t:
return
if unit["side"] == "enemy" and unit["root"] > t:
return
target = fight["units"].get(unit.get("target") or "")
if not target or target["dead"]:
return
swing["next"] = t + swing["every"]
amount = swing["dmg"]
if any(a["kind"] == "disarm" for a in unit["auras"]):
amount *= 0.5
_damage(fight, unit, target, amount, swing["school"], t, rng, swing=True)
res = unit.get("res")
if res and res["type"] == "rage":
res["cur"] = min(res["max"], res["cur"] + RAGE_PER_SWING)
def _damage(fight: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
rng: random.Random, threat: float = 1.0, crit: bool = True, swing: bool = False,
label: str = "") -> int:
if target["dead"] or amount <= 0:
return 0
critted = crit and source is not None and source["side"] == "party" and rng.random() < CRIT
if critted:
amount *= CRIT_MULT
amount *= 1 + rng.uniform(-SPREAD, SPREAD)
if source:
for aura in source["auras"]:
if aura["kind"] == "done":
amount *= aura["mult"]
for aura in target["auras"]:
if aura["kind"] == "vuln" and aura["school"] == school:
amount *= aura["mult"]
elif aura["kind"] == "taken" and aura["school"] in (school, "all"):
amount *= aura["mult"]
if school == "phys" and target["side"] == "party" and source:
armor = target["armor"]
amount *= 1 - armor / (armor + 400 + 85 * source["level"])
amount = max(1, round(amount))
dealt = amount
for aura in list(target["auras"]):
if aura["kind"] == "shield" and amount:
soak = min(amount, round(aura["amount"]))
aura["amount"] -= soak
amount -= soak
if aura["amount"] < 1:
target["auras"].remove(aura)
target["hp"] -= amount
_event(fight, t, target["id"], dealt, "crit" if critted else ("absorb" if not amount else "dmg"),
source["id"] if source else None, label)
if target["side"] == "enemy" and source:
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + \
dealt * threat * source.get("threat_mult", 1)
if target["cc"] > t:
target["cc"] = 0
_log(fight, t, f"{target['name']} breaks free", "miss", target["id"])
res = target.get("res")
if res and res["type"] == "rage" and amount:
res["cur"] = min(res["max"], res["cur"] + amount / target["max_hp"] * RAGE_TAKEN)
if swing and source and source["side"] == "enemy":
for aura in list(target["auras"]):
if aura["kind"] == "reflect":
aura["charges"] -= 1
if aura["charges"] <= 0:
target["auras"].remove(aura)
_damage(fight, target, source, aura["c"] * target["power"], aura["school"], t, rng,
threat=2.0, label=aura["name"])
break
if target["hp"] <= 0:
_die(fight, target, t)
return dealt
def _heal(fight: dict, source: dict | None, target: dict, amount: float, t: float, rng: random.Random,
crit: bool = True, label: str = "") -> int:
if target["dead"] or amount <= 0:
return 0
critted = crit and rng.random() < CRIT
if critted:
amount *= CRIT_MULT
amount = round(amount * (1 + rng.uniform(-SPREAD, SPREAD)))
gain = max(0, min(amount, target["max_hp"] - target["hp"]))
target["hp"] += gain
_event(fight, t, target["id"], amount, "heal", source["id"] if source else None, label)
if source and gain:
_heal_threat(fight, source, gain)
return gain
def _heal_threat(fight: dict, source: dict, amount: float) -> None:
enemies = alive(fight, "enemy")
for enemy in enemies:
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
def _die(fight: dict, unit: dict, t: float) -> None:
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
_log(fight, t, f"{unit['name']} dies", "death", unit["id"])
if unit["side"] == "enemy":
fight["kills"].append(unit["mob"])
for other in alive(fight, "party"):
if other.get("target") == unit["id"]:
other["target"], other["picked"] = None, False
for mob_id in MOBS[unit["mob"]].get("on_death", []):
_summon(fight, unit, [mob_id], t)
else:
for enemy in alive(fight, "enemy"):
enemy["threat"].pop(unit["id"], None)
if enemy["target"] == unit["id"]:
enemy["target"] = None
def _check_end(fight: dict, t: float) -> None:
if not alive(fight, "enemy"):
fight["state"] = "won"
elif not any(not u["dead"] for u in players(fight)):
fight["state"] = "lost"
elif t >= MAX_TIME:
fight["state"] = "lost"
_log(fight, t, "The fight drags on too long. You fall back.", "death")
if fight["state"] != "on":
fight["ended"] = fight["at"] + t
def _log(fight: dict, t: float, text: str, kind: str, who: str | None = None) -> None:
fight["log"].append({"t": t, "text": text, "kind": kind, "who": who})
del fight["log"][:-LOG]
def _event(fight: dict, t: float, uid: str, amount: float, kind: str, source: str | None = None,
label: str = "") -> None:
fight["ev"].append({"t": t, "u": uid, "n": round(amount), "k": kind, "s": source, "l": label})
del fight["ev"][:-EVENTS]
# --- what the client sees --------------------------------------------------
def view(fight: dict) -> dict:
units = []
for unit in fight["units"].values():
item = {key: unit.get(key) for key in ("id", "name", "side", "kind", "cls", "role", "level", "hp",
"max_hp", "target", "boss", "elite", "rare", "dead", "owner",
"combo", "busy", "icon", "mob")}
item["hp"] = max(0, round(unit["hp"]))
if unit.get("res"):
item["res"] = {"type": unit["res"]["type"], "cur": round(unit["res"]["cur"]), "max": unit["res"]["max"]}
if unit["cast"]:
item["cast"] = {k: unit["cast"].get(k) for k in ("name", "icon", "start", "end", "target", "interrupt")}
item["auras"] = [{"icon": a.get("icon"), "name": a.get("name"), "until": round(a["until"], 2),
"kind": a["kind"], "mine": a["by"]} for a in unit["auras"] if a.get("icon")]
t = fight["t"]
item["cc"] = unit.get("cc_icon") if unit["cc"] > t else None
item["stun"] = unit["stun"] > t
item["root"] = unit.get("root", 0) > t
if unit["side"] == "enemy":
table = {k: v for k, v in unit["threat"].items() if not fight["units"][k]["dead"]}
top = max(table.values(), default=0) or 1
item["threat"] = sorted(([k, round(v / top, 2)] for k, v in table.items() if v > 0),
key=lambda kv: -kv[1])[:4]
if unit.get("kind") == "player":
item["cds"] = {k: round(v, 2) for k, v in unit["cds"].items() if v > t}
item["manual"] = unit["manual"]
item["abilities"] = unit["abilities"]
item["ftarget"] = unit.get("ftarget")
item["queued"] = (unit.get("queued") or {}).get("ab")
units.append(item)
return {"id": fight["id"], "t": fight["t"], "at": fight["at"], "state": fight["state"], "kind": fight["kind"],
"name": fight["name"], "units": units, "log": fight["log"][-14:], "ev": fight["ev"][-30:]}

656
content.py Normal file
View file

@ -0,0 +1,656 @@
"""Static game content: classes, abilities, mobs, places, quests, items.
Everything here is data. Numbers that scale with a character are coefficients of
their power (P); numbers that scale with a mob are multiples of its level's swing.
"""
from __future__ import annotations
# --- characters ------------------------------------------------------------
UNLOCK = [1, 3, 6, 10] # level each of a spec's four abilities is learned at
MAX_LEVEL = 20
# Half of Classic's curve: an evening and a night idle reaches the Deadmines.
XP_TO_NEXT = [0, 200, 450, 700, 1050, 1400, 1800, 2250, 2700, 3250, 3800, 4400, 5050, 5700,
6450, 7200, 8000, 8850, 9700, 10650, 0]
ARMOUR_TYPES = {"cloth": 1.2, "leather": 2.5, "mail": 5.0} # armour per item level, before the slot's share
SLOTS = ["weapon", "head", "chest", "hands", "legs", "feet", "back"]
SLOT_NAMES = {"weapon": "Weapon", "head": "Head", "chest": "Chest", "hands": "Hands", "legs": "Legs",
"feet": "Feet", "back": "Back"}
SLOT_BUDGET = {"weapon": 1.0, "head": 0.8, "chest": 1.0, "hands": 0.6, "legs": 0.9, "feet": 0.7, "back": 0.5}
SLOT_ARMOUR = {"weapon": 0, "head": 0.8, "chest": 1.0, "hands": 0.6, "legs": 0.9, "feet": 0.7, "back": 0.4}
WEAPON_SPEED = {"sword": 2.5, "mace": 2.7, "axe": 2.7, "dagger": 1.8, "staff": 3.0, "bow": 2.8, "gun": 2.8}
CLASSES = {
"warrior": {"name": "Warrior", "color": "#c69b6d", "icon": "⚔️", "armour": "mail", "res": "rage",
"weapons": ["sword", "axe", "mace", "dagger"], "hp": (60, 20), "shield": True,
"spec": "protection"},
"paladin": {"name": "Paladin", "color": "#f48cba", "icon": "🔨", "armour": "mail", "res": "mana",
"weapons": ["mace", "sword", "axe"], "hp": (55, 19), "shield": True, "spec": "protection_p"},
"shaman": {"name": "Shaman", "color": "#0070dd", "icon": "⚡", "armour": "leather", "res": "mana",
"weapons": ["mace", "axe", "staff", "dagger"], "hp": (55, 18), "shield": True, "spec": "enhancement"},
"druid": {"name": "Druid", "color": "#ff7c0a", "icon": "🌿", "armour": "leather", "res": "mana",
"weapons": ["staff", "mace", "dagger"], "hp": (45, 16), "spec": "restoration"},
"priest": {"name": "Priest", "color": "#f0f0f0", "icon": "✨", "armour": "cloth", "res": "mana",
"weapons": ["staff", "mace", "dagger"], "hp": (40, 15), "spec": "holy"},
"mage": {"name": "Mage", "color": "#3fc7eb", "icon": "❄️", "armour": "cloth", "res": "mana",
"weapons": ["staff", "dagger", "sword"], "hp": (40, 14), "spec": "frost"},
"rogue": {"name": "Rogue", "color": "#fff468", "icon": "🗡️", "armour": "leather", "res": "energy",
"weapons": ["dagger", "sword", "mace"], "hp": (50, 17), "spec": "combat"},
"hunter": {"name": "Hunter", "color": "#aad372", "icon": "🏹", "armour": "leather", "res": "mana",
"weapons": ["bow", "gun"], "hp": (50, 17), "spec": "beast_mastery", "pet": "wolf"},
"warlock": {"name": "Warlock", "color": "#8788ee", "icon": "💀", "armour": "cloth", "res": "mana",
"weapons": ["staff", "dagger", "sword"], "hp": (45, 15), "spec": "affliction", "pet": "demon"},
}
def dmg(c, school="phys"):
return {"t": "dmg", "c": c, "school": school}
def aoe(c, school="phys"):
return {"t": "aoe", "c": c, "school": school}
def dot(c, dur, school="magic", every=3):
return {"t": "dot", "c": c, "dur": dur, "every": every, "school": school}
def heal(c):
return {"t": "heal", "c": c}
def hot(c, dur, every=3):
return {"t": "hot", "c": c, "dur": dur, "every": every}
def aura(kind, dur, **extra):
return {"t": "aura", "kind": kind, "dur": dur, **extra}
# Each ability: who it targets, what it costs, its cooldown when pressed by hand
# (auto runs it AUTO_CD times slower), and the rule auto uses to decide to cast it.
# Costs are a share of max mana, or flat rage / energy.
ABILITIES = {
# Warrior: Protection
"sunder": {"name": "Sunder Armor", "icon": "🪓", "target": "enemy", "cost": 12, "threat": 2.5,
"effects": [dmg(0.35)], "auto": {"r": "always"},
"tip": "A hit that is mostly threat. The tank's filler."},
"taunt": {"name": "Taunt", "icon": "😤", "target": "enemy", "cd": 8, "offgcd": True,
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
"tip": "Forces a mob to attack you for 3s and puts you on top of its threat."},
"thunder_clap": {"name": "Thunder Clap", "icon": "⚡", "target": "aoe", "cost": 20, "cd": 6, "threat": 1.75,
"effects": [aoe(0.4)], "auto": {"r": "aoe", "n": 2},
"tip": "Hits every mob. Holds a pack."},
"shield_block": {"name": "Shield Block", "icon": "🛡️", "target": "self", "cost": 10, "cd": 10, "offgcd": True,
"effects": [aura("taken", 5, mult=0.5, school="phys", name="Shield Block", icon="🛡️")],
"auto": {"r": "tanking", "pct": 0.7},
"tip": "Halves physical damage taken for 5s. Time it for the big hit."},
# Paladin: Protection
"judgement": {"name": "Judgement", "icon": "⚖️", "target": "enemy", "cost": 0.05, "cd": 8, "threat": 1.5,
"effects": [dmg(0.8, "holy")], "auto": {"r": "always"},
"tip": "Holy strike. Your seal's power, released."},
"holy_light": {"name": "Holy Light", "icon": "💛", "target": "ally", "cost": 0.14, "cast": 2.5,
"effects": [heal(1.6)], "auto": {"r": "heal", "pct": 0.45},
"tip": "A slow, big heal on anyone."},
"consecration": {"name": "Consecration", "icon": "🔥", "target": "aoe", "cost": 0.12, "cd": 8, "threat": 1.5,
"effects": [{"t": "ground", "c": 0.15, "dur": 8, "every": 1, "school": "holy"}],
"auto": {"r": "aoe", "n": 1}, "tip": "Holy ground burns every mob for 8s."},
"hammer_of_justice": {"name": "Hammer of Justice", "icon": "🔨", "target": "enemy", "cost": 0.05, "cd": 30,
"offgcd": True, "effects": [{"t": "stun", "dur": 4}], "auto": {"r": "interrupt"},
"tip": "Stuns a mob for 4s, stopping its cast. Bosses only lose the cast."},
# Shaman: Enhancement
"stormstrike": {"name": "Stormstrike", "icon": "🌩️", "target": "enemy", "cost": 0.06, "cd": 8,
"effects": [dmg(1.1), aura("vuln", 8, mult=1.2, school="nature", name="Stormstrike", icon="🌩️")],
"auto": {"r": "always"}, "tip": "Two-weapon strike. Nature hits on the target are 20% stronger."},
"flame_shock": {"name": "Flame Shock", "icon": "🔥", "target": "enemy", "cost": 0.08, "cd": 6, "threat": 1.5,
"effects": [dmg(0.3, "fire"), dot(0.18, 12, "fire")], "auto": {"r": "dot"},
"tip": "Fire burst, then burns for 12s."},
"earth_shock": {"name": "Earth Shock", "icon": "🪨", "target": "enemy", "cost": 0.07, "cd": 6, "threat": 2.0,
"offgcd": True, "effects": [dmg(0.6, "nature"), {"t": "interrupt"}], "auto": {"r": "interrupt"},
"tip": "Nature hit that stops a cast. Big threat."},
"lightning_shield": {"name": "Lightning Shield", "icon": "🔵", "target": "self", "cost": 0.05,
"effects": [aura("reflect", 60, charges=3, c=0.3, school="nature", name="Lightning Shield",
icon="🔵")],
"auto": {"r": "buff"}, "tip": "3 charges: each mob that hits you takes nature damage."},
# Druid: Restoration
"wrath": {"name": "Wrath", "icon": "🌞", "target": "enemy", "cost": 0.06, "cast": 1.75,
"effects": [dmg(0.7, "nature")], "auto": {"r": "always", "save": 0.7}, "tip": "Nature bolt."},
"regrowth": {"name": "Regrowth", "icon": "🌱", "target": "ally", "cost": 0.15, "cast": 2,
"effects": [heal(0.9), hot(0.12, 12)], "auto": {"r": "heal", "pct": 0.5},
"tip": "Heals, then keeps healing for 12s."},
"moonfire": {"name": "Moonfire", "icon": "🌙", "target": "enemy", "cost": 0.07,
"effects": [dmg(0.3, "arcane"), dot(0.12, 9, "arcane")], "auto": {"r": "dot", "save": 0.7},
"tip": "Arcane hit, then burns for 9s."},
"rejuvenation": {"name": "Rejuvenation", "icon": "🍃", "target": "ally", "cost": 0.08,
"effects": [hot(0.25, 12)], "auto": {"r": "hot", "pct": 0.85},
"tip": "Heals over 12s. Cheap, instant, stacks with Regrowth."},
# Priest: Holy
"smite": {"name": "Smite", "icon": "☀️", "target": "enemy", "cost": 0.06, "cast": 1.75,
"effects": [dmg(0.7, "holy")], "auto": {"r": "always", "save": 0.7}, "tip": "Holy bolt."},
"heal": {"name": "Heal", "icon": "💖", "target": "ally", "cost": 0.13, "cast": 2.5,
"effects": [heal(1.5)], "auto": {"r": "heal", "pct": 0.5}, "tip": "The big, slow heal."},
"pw_shield": {"name": "Power Word: Shield", "icon": "🔰", "target": "ally", "cost": 0.12, "cd": 4,
"effects": [{"t": "shield", "c": 1.1, "dur": 30}], "auto": {"r": "shield", "pct": 0.7},
"tip": "Absorbs damage. 15s before the same target can be shielded again."},
"renew": {"name": "Renew", "icon": "💫", "target": "ally", "cost": 0.08,
"effects": [hot(0.22, 15)], "auto": {"r": "hot", "pct": 0.85}, "tip": "Heals over 15s."},
# Mage: Frost
"frostbolt": {"name": "Frostbolt", "icon": "❄️", "target": "enemy", "cost": 0.06, "cast": 2,
"effects": [dmg(1.0, "frost")], "auto": {"r": "always"}, "tip": "Frost bolt. Your filler."},
"frost_nova": {"name": "Frost Nova", "icon": "🧊", "target": "aoe", "cost": 0.08, "cd": 20,
"effects": [aoe(0.2, "frost"), {"t": "freeze", "dur": 6}], "auto": {"r": "peel"},
"tip": "Freezes every mob for 6s. Damage breaks it."},
"polymorph": {"name": "Polymorph", "icon": "🐑", "target": "enemy", "cost": 0.1, "cast": 1.5, "cd": 3,
"effects": [{"t": "cc", "dur": 20}], "auto": {"r": "cc"},
"tip": "Turns a mob into a sheep for 20s. Damage breaks it. Not bosses."},
"counterspell": {"name": "Counterspell", "icon": "🚫", "target": "enemy", "cost": 0.07, "cd": 15, "offgcd": True,
"effects": [{"t": "interrupt"}], "auto": {"r": "interrupt"}, "tip": "Stops a cast."},
# Rogue: Combat
"sinister_strike": {"name": "Sinister Strike", "icon": "🗡️", "target": "enemy", "cost": 40,
"effects": [dmg(0.75), {"t": "combo", "n": 1}], "auto": {"r": "always"},
"tip": "Hit, +1 combo point."},
"eviscerate": {"name": "Eviscerate", "icon": "🩸", "target": "enemy", "cost": 35,
"effects": [{"t": "finisher", "c": 0.42}], "auto": {"r": "combo", "n": 4},
"tip": "Spends combo points: more points, more damage."},
"kick": {"name": "Kick", "icon": "🦶", "target": "enemy", "cost": 25, "cd": 10, "offgcd": True,
"effects": [dmg(0.2), {"t": "interrupt"}], "auto": {"r": "interrupt"}, "tip": "Stops a cast."},
"evasion": {"name": "Evasion", "icon": "💨", "target": "self", "cd": 60, "offgcd": True,
"effects": [aura("taken", 10, mult=0.4, school="phys", name="Evasion", icon="💨")],
"auto": {"r": "danger", "pct": 0.4}, "tip": "Dodge most attacks for 10s."},
# Hunter: Beast Mastery
"serpent_sting": {"name": "Serpent Sting", "icon": "🐍", "target": "enemy", "cost": 0.07,
"effects": [dot(0.2, 15, "nature")], "auto": {"r": "dot"}, "tip": "Poison over 15s."},
"arcane_shot": {"name": "Arcane Shot", "icon": "🔮", "target": "enemy", "cost": 0.06, "cd": 6,
"effects": [dmg(0.8, "arcane")], "auto": {"r": "always"}, "tip": "Instant arcane shot."},
"freezing_trap": {"name": "Freezing Trap", "icon": "🪤", "target": "enemy", "cost": 0.1, "cd": 15,
"effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"},
"tip": "Freezes a mob solid for 15s. Damage breaks it. Not bosses."},
"bestial_wrath": {"name": "Bestial Wrath", "icon": "🐺", "target": "pet", "cost": 0.08, "cd": 60, "offgcd": True,
"effects": [aura("done", 15, mult=1.6, name="Bestial Wrath", icon="🐺")],
"auto": {"r": "boss"}, "tip": "Your pet hits 60% harder for 15s."},
# Warlock: Affliction
"shadow_bolt": {"name": "Shadow Bolt", "icon": "🌑", "target": "enemy", "cost": 0.07, "cast": 2.5,
"effects": [dmg(1.1, "shadow")], "auto": {"r": "always"}, "tip": "Shadow bolt."},
"corruption": {"name": "Corruption", "icon": "🦠", "target": "enemy", "cost": 0.07,
"effects": [dot(0.28, 18, "shadow")], "auto": {"r": "dot"}, "tip": "Shadow over 18s."},
"fear": {"name": "Fear", "icon": "😱", "target": "enemy", "cost": 0.08, "cast": 1.5, "cd": 3,
"effects": [{"t": "cc", "dur": 10}], "auto": {"r": "cc"},
"tip": "Sends a mob running for 10s. Damage breaks it. Not bosses."},
"drain_life": {"name": "Drain Life", "icon": "🩸", "target": "enemy", "cost": 0.1, "cast": 3, "cd": 8,
"effects": [{"t": "drain", "c": 0.9, "school": "shadow"}], "auto": {"r": "self_below", "pct": 0.6},
"tip": "Steals health over 3s."},
}
# Specs list their abilities in the order they're learned (UNLOCK), and auto works
# through `priority` top-down.
SPECS = {
"protection": {"cls": "warrior", "name": "Protection", "role": "tank",
"abilities": ["sunder", "taunt", "thunder_clap", "shield_block"],
"priority": ["taunt", "shield_block", "thunder_clap", "sunder"]},
"protection_p": {"cls": "paladin", "name": "Protection", "role": "tank",
"abilities": ["judgement", "holy_light", "consecration", "hammer_of_justice"],
"priority": ["hammer_of_justice", "holy_light", "consecration", "judgement"]},
"enhancement": {"cls": "shaman", "name": "Enhancement", "role": "tank",
"abilities": ["stormstrike", "flame_shock", "earth_shock", "lightning_shield"],
"priority": ["earth_shock", "lightning_shield", "stormstrike", "flame_shock"]},
"restoration": {"cls": "druid", "name": "Restoration", "role": "healer",
"abilities": ["wrath", "regrowth", "moonfire", "rejuvenation"],
"priority": ["regrowth", "rejuvenation", "moonfire", "wrath"]},
"holy": {"cls": "priest", "name": "Holy", "role": "healer",
"abilities": ["smite", "heal", "pw_shield", "renew"],
"priority": ["pw_shield", "heal", "renew", "smite"]},
"frost": {"cls": "mage", "name": "Frost", "role": "dps",
"abilities": ["frostbolt", "frost_nova", "polymorph", "counterspell"],
"priority": ["counterspell", "frost_nova", "polymorph", "frostbolt"]},
"combat": {"cls": "rogue", "name": "Combat", "role": "dps",
"abilities": ["sinister_strike", "eviscerate", "kick", "evasion"],
"priority": ["kick", "evasion", "eviscerate", "sinister_strike"]},
"beast_mastery": {"cls": "hunter", "name": "Beast Mastery", "role": "dps",
"abilities": ["serpent_sting", "arcane_shot", "freezing_trap", "bestial_wrath"],
"priority": ["bestial_wrath", "freezing_trap", "serpent_sting", "arcane_shot"]},
"affliction": {"cls": "warlock", "name": "Affliction", "role": "dps",
"abilities": ["shadow_bolt", "corruption", "fear", "drain_life"],
"priority": ["fear", "drain_life", "corruption", "shadow_bolt"]},
}
PETS = {
# dmg: each 2s swing as a share of the owner's power
"wolf": {"name": "Wolf", "icon": "🐺", "hp": 0.65, "dmg": 0.3, "swing": 2.0, "school": "phys"},
"voidwalker": {"name": "Voidwalker", "icon": "👤", "hp": 0.9, "dmg": 0.12, "swing": 2.0, "school": "shadow"},
"imp": {"name": "Imp", "icon": "😈", "hp": 0.35, "dmg": 0.28, "swing": 2.0, "school": "fire"},
}
# --- mobs ------------------------------------------------------------------
# Enemy abilities. `effect` is (kind, amount, school): hit/aoe amounts are swings,
# heal amounts are a share of max health.
ENEMY_ABILITIES = {
"fireball": {"name": "Fireball", "icon": "🔥", "cast": 2.5, "every": 10, "first": 3, "interrupt": True,
"target": "target", "effect": ("hit", 2.2, "fire")},
"frostbolt": {"name": "Frostbolt", "icon": "❄️", "cast": 2.5, "every": 10, "first": 3, "interrupt": True,
"target": "target", "effect": ("hit", 2.0, "frost")},
"healing_wave": {"name": "Healing Wave", "icon": "💧", "cast": 2.5, "every": 9, "first": 5, "interrupt": True,
"target": "hurt_ally", "effect": ("heal", 0.3, "nature")},
"slam": {"name": "Rhahk'Zor Slam", "icon": "💥", "cast": 2.0, "every": 12, "first": 7, "interrupt": False,
"target": "target", "effect": ("hit", 3.2, "phys")},
"bomb": {"name": "Throw Bomb", "icon": "💣", "cast": 2.0, "every": 11, "first": 4, "interrupt": True,
"target": "all", "effect": ("aoe", 0.9, "fire")},
"saw_blades": {"name": "Saw Blades", "icon": "⚙️", "cast": 1.5, "every": 14, "first": 6, "interrupt": False,
"target": "all", "effect": ("aoe", 1.0, "phys")},
"molten_metal": {"name": "Molten Metal", "icon": "🌋", "cast": 3.0, "every": 13, "first": 5, "interrupt": True,
"target": "random_soft", "effect": ("hit", 3.5, "fire")},
"thrash": {"name": "Thrash", "icon": "🌀", "cast": 0, "every": 9, "first": 6, "interrupt": False,
"target": "target", "effect": ("hit", 2.0, "phys")},
"disarm": {"name": "Disarm", "icon": "🫳", "cast": 0, "every": 15, "first": 8, "interrupt": False,
"target": "target", "effect": ("disarm", 6, "phys")},
"hogger_bite": {"name": "Hogger's Bite", "icon": "🦷", "cast": 1.5, "every": 12, "first": 6, "interrupt": False,
"target": "target", "effect": ("hit", 2.5, "phys")},
}
# Mob stats: level range, kind, multipliers on the level's health and swing.
MOBS = {
"young_wolf": {"name": "Young Wolf", "lv": (1, 2), "kind": "beast", "junk": "Ruined Pelt"},
"kobold_vermin": {"name": "Kobold Vermin", "lv": (1, 2), "kind": "humanoid", "junk": "Ragged Kobold Vest"},
"kobold_worker": {"name": "Kobold Worker", "lv": (3, 4), "kind": "humanoid", "junk": "Dull Kobold Pick"},
"defias_thug": {"name": "Defias Thug", "lv": (3, 5), "kind": "humanoid", "junk": "Frayed Burlap"},
"garrick_padfoot": {"name": "Garrick Padfoot", "lv": (5, 5), "kind": "humanoid", "hp": 2.0, "dmg": 1.2,
"rare": True, "junk": "Garrick's Head"},
"kobold_laborer": {"name": "Kobold Laborer", "lv": (4, 5), "kind": "humanoid", "junk": "Cracked Candle"},
"kobold_tunneler": {"name": "Kobold Tunneler", "lv": (6, 7), "kind": "humanoid", "junk": "Kobold Mining Helmet"},
"kobold_miner": {"name": "Kobold Miner", "lv": (7, 8), "kind": "humanoid", "junk": "Worn Mining Shovel"},
"murloc_streamrunner": {"name": "Murloc Streamrunner", "lv": (7, 8), "kind": "humanoid", "junk": "Slimy Scale"},
"murloc_forager": {"name": "Murloc Forager", "lv": (8, 9), "kind": "humanoid", "junk": "Murloc Fin"},
"defias_bandit": {"name": "Defias Bandit", "lv": (8, 9), "kind": "humanoid", "junk": "Bandit Cinch"},
"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"},
"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"},
"hogger": {"name": "Hogger", "lv": (11, 11), "kind": "humanoid", "hp": 3.0, "dmg": 1.5, "rare": True,
"abilities": ["hogger_bite"], "junk": "Huge Gnoll Claw"},
"harvest_watcher": {"name": "Harvest Watcher", "lv": (10, 12), "kind": "mechanical", "junk": "Scrap Metal"},
"young_fleshripper": {"name": "Young Fleshripper", "lv": (10, 11), "kind": "beast", "junk": "Tattered Feather"},
"goretusk": {"name": "Goretusk", "lv": (12, 13), "kind": "beast", "junk": "Cracked Tusk"},
"defias_trapper": {"name": "Defias Trapper", "lv": (12, 13), "kind": "humanoid", "junk": "Rusty Trap"},
"defias_smuggler": {"name": "Defias Smuggler", "lv": (13, 14), "kind": "humanoid", "junk": "Contraband Crate"},
"riverpaw_brute": {"name": "Riverpaw Brute", "lv": (14, 16), "kind": "humanoid", "dmg": 1.1, "junk": "Gnoll Paw"},
"riverpaw_herbalist": {"name": "Riverpaw Herbalist", "lv": (15, 15), "kind": "humanoid", "hp": 0.8,
"abilities": ["healing_wave"], "junk": "Bundle of Herbs"},
"murloc_coastrunner": {"name": "Murloc Coastrunner", "lv": (15, 16), "kind": "humanoid", "junk": "Shiny Shell"},
"murloc_tidecaller": {"name": "Murloc Tidecaller", "lv": (16, 16), "kind": "humanoid", "hp": 0.8,
"abilities": ["frostbolt"], "junk": "Tidecaller's Pearl"},
"defias_pillager": {"name": "Defias Pillager", "lv": (16, 17), "kind": "humanoid", "hp": 0.8,
"abilities": ["fireball"], "junk": "Scorched Rag"},
"defias_looter": {"name": "Defias Looter", "lv": (16, 18), "kind": "humanoid", "junk": "Stolen Silverware"},
"defias_pathstalker": {"name": "Defias Pathstalker", "lv": (17, 18), "kind": "humanoid", "junk": "Muddy Boots"},
"defias_highwayman": {"name": "Defias Highwayman", "lv": (18, 19), "kind": "humanoid", "dmg": 1.1,
"junk": "Highwayman's Mask"},
# The Deadmines. Everything in here is elite.
"defias_miner": {"name": "Defias Miner", "lv": (17, 18), "kind": "humanoid", "elite": True, "junk": "Miner's Lamp"},
"defias_overseer": {"name": "Defias Overseer", "lv": (18, 18), "kind": "humanoid", "elite": True,
"dmg": 1.15, "junk": "Overseer's Whistle"},
"rhahkzor": {"name": "Rhahk'Zor", "lv": (19, 19), "kind": "giant", "boss": True, "hp": 3.0, "dmg": 1.4,
"abilities": ["slam"], "junk": "Ogre Tooth"},
"goblin_woodcarver": {"name": "Goblin Woodcarver", "lv": (18, 18), "kind": "humanoid", "elite": True,
"junk": "Wood Shavings"},
"goblin_engineer": {"name": "Goblin Engineer", "lv": (18, 18), "kind": "humanoid", "elite": True, "hp": 0.8,
"abilities": ["bomb"], "junk": "Gear Assembly"},
"sneeds_shredder": {"name": "Sneed's Shredder", "lv": (20, 20), "kind": "mechanical", "boss": True, "hp": 2.6,
"dmg": 1.3, "abilities": ["saw_blades"], "on_death": ["sneed"], "junk": "Shredder Saw"},
"sneed": {"name": "Sneed", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 1.2, "dmg": 1.2,
"abilities": ["disarm"], "junk": "Sneed's Goggles"},
"defias_evoker": {"name": "Defias Evoker", "lv": (18, 18), "kind": "humanoid", "elite": True, "hp": 0.75,
"abilities": ["fireball"], "junk": "Evoker's Robe Scrap"},
"defias_taskmaster": {"name": "Defias Taskmaster", "lv": (18, 18), "kind": "humanoid", "elite": True,
"junk": "Taskmaster's Whip"},
"defias_strip_miner": {"name": "Defias Strip Miner", "lv": (17, 17), "kind": "humanoid", "elite": True,
"junk": "Ore Sack"},
"gilnid": {"name": "Gilnid", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 3.2, "dmg": 1.2,
"abilities": ["molten_metal"], "junk": "Smelting Tongs"},
"defias_pirate": {"name": "Defias Pirate", "lv": (18, 19), "kind": "humanoid", "elite": True, "junk": "Pirate Hat"},
"defias_squallshaper": {"name": "Defias Squallshaper", "lv": (19, 19), "kind": "humanoid", "elite": True,
"hp": 0.75, "abilities": ["healing_wave"], "junk": "Water-logged Rune"},
"mr_smite": {"name": "Mr. Smite", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 3.8, "dmg": 1.25,
"phases": [{"at": 0.66, "say": "You landlubbers are tougher than I thought! I'll have to improvise!",
"do": [("stun_all", 2.5), ("enrage", 1.25)]},
{"at": 0.33, "say": "D'ah! Now you're making me angry!",
"do": [("stun_all", 2.5), ("enrage", 1.25)]}],
"junk": "Smite's Anchor Chain"},
"defias_blackguard": {"name": "Defias Blackguard", "lv": (19, 19), "kind": "humanoid", "elite": True,
"hp": 0.7, "junk": "Blackguard Cowl"},
"edwin_vancleef": {"name": "Edwin VanCleef", "lv": (21, 21), "kind": "humanoid", "boss": True, "hp": 4.6,
"dmg": 1.35, "abilities": ["thrash"],
"phases": [{"at": 0.5, "say": "Lapdogs, all of you!",
"do": [("summon", ["defias_blackguard", "defias_blackguard"])]}],
"junk": "VanCleef's Bandana"},
}
ELITE_HP, ELITE_DMG = 3.2, 2.0
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}},
}
GRAVEYARD = {"Elwynn Forest": "goldshire", "Westfall": "sentinel_hill"}
DUNGEONS = {
"deadmines": {
"name": "The Deadmines", "entrance": "moonbrook", "lv": (17, 21), "size": 5,
"pulls": [
{"name": "Mine entrance", "mobs": ["defias_miner", "defias_miner", "defias_overseer"]},
{"name": "Rhahk'Zor", "mobs": ["rhahkzor"], "boss": True},
{"name": "Goblin workshop", "mobs": ["goblin_woodcarver", "goblin_engineer", "goblin_woodcarver"]},
{"name": "Sneed's Shredder", "mobs": ["sneeds_shredder"], "boss": True},
{"name": "The foundry", "mobs": ["defias_evoker", "defias_taskmaster", "defias_strip_miner"]},
{"name": "Gilnid", "mobs": ["gilnid", "defias_strip_miner"], "boss": True},
{"name": "The ship's deck", "mobs": ["defias_pirate", "defias_squallshaper", "defias_pirate"]},
{"name": "Mr. Smite", "mobs": ["mr_smite"], "boss": True},
{"name": "Edwin VanCleef", "mobs": ["edwin_vancleef"], "boss": True},
],
},
}
# --- quests ----------------------------------------------------------------
# Objectives: kill a mob, loot an item that only drops while you're on the quest,
# or visit a place. `ender` defaults to the giver's place.
QUESTS = {
"wolves_across_the_border": {
"name": "Wolves Across the Border", "giver": "northshire_abbey", "npc": "Eagan Peltskinner", "lv": 2,
"text": "The wolves are thinning the herds. Bring me their meat.",
"obj": [{"loot": "Tough Wolf Meat", "n": 8, "from": ["young_wolf"], "chance": 0.6}],
"xp": 250, "copper": 40},
"kobold_camp_cleanup": {
"name": "Kobold Camp Cleanup", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 2,
"text": "Kobold vermin infest the valley. Thin them out.",
"obj": [{"kill": "kobold_vermin", "n": 10}], "xp": 280, "copper": 50,
"choice": ["w/feet/cloth/4", "w/feet/leather/4", "w/feet/mail/4"]},
"investigate_echo_ridge": {
"name": "Investigate Echo Ridge", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 4,
"req": ["kobold_camp_cleanup"], "text": "More kobolds dig at Echo Ridge. Find out why.",
"obj": [{"kill": "kobold_worker", "n": 10}], "xp": 450, "copper": 80,
"choice": ["g/hands/cloth/eagle/6", "g/hands/leather/monkey/6", "g/hands/mail/bear/6"]},
"brotherhood_of_thieves": {
"name": "Brotherhood of Thieves", "giver": "northshire_abbey", "npc": "Deputy Willem", "lv": 4,
"text": "Defias thugs are stealing from the vineyards. Their red bandanas will prove the work.",
"obj": [{"loot": "Red Burlap Bandana", "n": 12, "from": ["defias_thug"], "chance": 0.5}],
"xp": 450, "copper": 90},
"bounty_on_garrick_padfoot": {
"name": "Bounty on Garrick Padfoot", "giver": "northshire_abbey", "npc": "Deputy Willem", "lv": 5,
"req": ["brotherhood_of_thieves"], "text": "Their leader hides in the vineyards. Bring him down.",
"obj": [{"kill": "garrick_padfoot", "n": 1}], "xp": 550, "copper": 150,
"choice": ["g/weapon/sword/tiger/7", "g/weapon/staff/eagle/7", "g/weapon/dagger/monkey/7",
"g/weapon/bow/monkey/7"]},
"report_to_goldshire": {
"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},
"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.",
"obj": [{"loot": "Gold Dust", "n": 10, "from": ["kobold_tunneler", "kobold_miner"], "chance": 0.4}],
"xp": 700, "copper": 250},
"kobold_candles": {
"name": "Kobold Candles", "giver": "goldshire", "npc": "William Pestle", "lv": 8,
"text": "Kobold candles make fine wax for my potions.",
"obj": [{"loot": "Large Candle", "n": 8, "from": ["kobold_miner"], "chance": 0.5}], "xp": 800, "copper": 220,
"choice": ["g/legs/cloth/eagle/9", "g/legs/leather/monkey/9", "g/legs/mail/bear/9"]},
"bounty_on_murlocs": {
"name": "Bounty on Murlocs", "giver": "crystal_lake", "npc": "Guard Thomas", "lv": 9,
"text": "Murlocs raid the lake shore. Bring me their fins.",
"obj": [{"loot": "Torn Murloc Fin", "n": 8, "from": ["murloc_streamrunner", "murloc_forager"], "chance": 0.5}],
"xp": 900, "copper": 300,
"choice": ["g/head/cloth/eagle/10", "g/head/leather/tiger/10", "g/head/mail/bear/10"]},
"princess_must_die": {
"name": "Princess Must Die!", "giver": "brackwell", "npc": "Ma Stonefield", "lv": 9,
"text": "That prize sow Princess has been eating my pumpkins. End her.",
"obj": [{"kill": "princess", "n": 1}], "xp": 1000, "copper": 350},
"the_defias_bandits": {
"name": "Bandits in the Patch", "giver": "brackwell", "npc": "Ma Stonefield", "lv": 9,
"text": "Defias bandits camp in the pumpkin patch. Run them off.",
"obj": [{"kill": "defias_bandit", "n": 12}], "xp": 950, "copper": 320},
"riverpaw_gnoll_bounty": {
"name": "Riverpaw Gnoll Bounty", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 10,
"text": "Gnolls from Westfall raid the forest's edge. Bring their armbands.",
"obj": [{"loot": "Painted Gnoll Armband", "n": 8, "from": ["riverpaw_gnoll", "riverpaw_mystic"],
"chance": 0.4}], "xp": 1100, "copper": 400},
"wanted_hogger": {
"name": "Wanted: Hogger", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 11,
"req": ["riverpaw_gnoll_bounty"], "text": "A huge gnoll leads them. Hogger. Bring him down.",
"obj": [{"kill": "hogger", "n": 1}], "xp": 1600, "copper": 900,
"choice": ["hoggers_trousers", "hoggers_gauntlets", "hoggers_cleaver"]},
"report_to_gryan": {
"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},
"the_killing_fields": {
"name": "The Killing Fields", "giver": "saldeans_farm", "npc": "Farmer Saldean", "lv": 12,
"text": "Harvest Watchers turned on us. Smash them.",
"obj": [{"kill": "harvest_watcher", "n": 12}], "xp": 1300, "copper": 400,
"choice": ["g/feet/cloth/eagle/12", "g/feet/leather/monkey/12", "g/feet/mail/bear/12"]},
"goretusk_liver_pie": {
"name": "Goretusk Liver Pie", "giver": "saldeans_farm", "npc": "Salma Saldean", "lv": 12,
"text": "Bring me goretusk livers and I'll bake you a pie that sticks to the ribs.",
"obj": [{"loot": "Goretusk Liver", "n": 8, "from": ["goretusk"], "chance": 0.5}], "xp": 1250, "copper": 380},
"the_peoples_militia": {
"name": "The People's Militia", "giver": "sentinel_hill", "npc": "Lieutenant Horatio Laine", "lv": 13,
"text": "The Defias hold the Molsen farm. Strike back.",
"obj": [{"kill": "defias_trapper", "n": 10}, {"kill": "defias_smuggler", "n": 10}], "xp": 1700,
"copper": 600, "choice": ["g/chest/cloth/eagle/14", "g/chest/leather/monkey/14", "g/chest/mail/bear/14"]},
"patrolling_westfall": {
"name": "Patrolling Westfall", "giver": "sentinel_hill", "npc": "Captain Danuvin", "lv": 15,
"text": "Riverpaw brutes roam the plains. Bring back their paws.",
"obj": [{"loot": "Gnoll Paw", "n": 8, "from": ["riverpaw_brute", "riverpaw_herbalist"], "chance": 0.5}],
"xp": 1900, "copper": 700},
"the_coast_isnt_clear": {
"name": "The Coast Isn't Clear", "giver": "sentinel_hill", "npc": "Captain Grayson", "lv": 16,
"text": "Murlocs overran the quarry on the Gold Coast.",
"obj": [{"kill": "murloc_coastrunner", "n": 10}, {"kill": "murloc_tidecaller", "n": 5}], "xp": 2100,
"copper": 750, "choice": ["g/back/cloth/tiger/16", "g/back/cloth/bear/16", "g/back/cloth/eagle/16"]},
"red_silk_bandanas": {
"name": "Red Silk Bandanas", "giver": "sentinel_hill", "npc": "Scout Riell", "lv": 17,
"req": ["the_peoples_militia"], "text": "The Defias in Moonbrook wear red silk. Bring me ten.",
"obj": [{"loot": "Red Silk Bandana", "n": 10, "from": ["defias_pillager", "defias_looter"], "chance": 0.5}],
"xp": 2300, "copper": 900},
"the_peoples_militia_2": {
"name": "The People's Militia, Part 2", "giver": "sentinel_hill", "npc": "Lieutenant Horatio Laine", "lv": 18,
"req": ["the_peoples_militia"], "text": "Their scouts watch from the Dagger Hills. Clear them out.",
"obj": [{"kill": "defias_pathstalker", "n": 10}, {"kill": "defias_highwayman", "n": 5}], "xp": 2500,
"copper": 1000, "choice": ["g/weapon/staff/eagle/19", "g/weapon/sword/tiger/19", "g/weapon/mace/bear/19",
"g/weapon/bow/monkey/19", "g/weapon/dagger/monkey/19"]},
"underground_assault": {
"name": "Underground Assault", "giver": "sentinel_hill", "npc": "Shoni the Shilent", "lv": 18,
"text": "Sneed's Shredder runs on a gnome's stolen gear. Bring me Gnoam Sprecklesprocket.",
"obj": [{"loot": "Gnoam Sprecklesprocket", "n": 1, "from": ["sneeds_shredder"], "chance": 1.0}],
"xp": 2400, "copper": 1100},
"collecting_memories": {
"name": "Collecting Memories", "giver": "sentinel_hill", "npc": "Wilder Thistlenettle", "lv": 18,
"text": "My brother's miners' union cards are down in that mine.",
"obj": [{"loot": "Miner's Union Card", "n": 4, "from": ["defias_miner", "defias_strip_miner"],
"chance": 0.5}], "xp": 2400, "copper": 900},
"the_defias_brotherhood": {
"name": "The Defias Brotherhood", "giver": "sentinel_hill", "npc": "Gryan Stoutmantle", "lv": 20,
"req": ["red_silk_bandanas"], "text": "Edwin VanCleef leads them from the Deadmines. End this.",
"obj": [{"kill": "edwin_vancleef", "n": 1}], "xp": 4000, "copper": 2500,
"choice": ["chausses_of_westfall", "tunic_of_westfall", "staff_of_westfall"]},
}
# --- items -----------------------------------------------------------------
QUALITY = {"poor": 0, "common": 0.55, "uncommon": 1.0, "rare": 1.3, "epic": 1.6}
# Suffix: (name, share of the stat budget that goes to stamina; the rest is power)
SUFFIXES = {
"stamina": ("of Stamina", 1.0), "bear": ("of the Bear", 0.6), "eagle": ("of the Eagle", 0.5),
"monkey": ("of the Monkey", 0.4), "tiger": ("of the Tiger", 0.3), "power": ("of Power", 0.0),
}
# Base names for generated gear, by item-level band (1-7, 8-14, 15+).
BASE_NAMES = {
("head", "cloth"): ["Linen Cap", "Woolen Hood", "Silk Turban"],
("chest", "cloth"): ["Linen Robe", "Woolen Robe", "Silk-threaded Robe"],
("hands", "cloth"): ["Linen Gloves", "Woolen Gloves", "Silk Gloves"],
("legs", "cloth"): ["Linen Pants", "Woolen Leggings", "Spellbinder Pants"],
("feet", "cloth"): ["Linen Shoes", "Woolen Boots", "Silk Slippers"],
("back", "cloth"): ["Frayed Cloak", "Woolen Cape", "Silk Cloak"],
("head", "leather"): ["Rough Leather Cap", "Embossed Leather Cap", "Dark Leather Hood"],
("chest", "leather"): ["Rough Leather Vest", "Embossed Leather Vest", "Dark Leather Tunic"],
("hands", "leather"): ["Rough Leather Gloves", "Embossed Leather Gloves", "Dark Leather Gloves"],
("legs", "leather"): ["Rough Leather Pants", "Embossed Leather Pants", "Dark Leather Pants"],
("feet", "leather"): ["Rough Leather Boots", "Embossed Leather Boots", "Dark Leather Boots"],
("head", "mail"): ["Rusted Chain Coif", "Chainmail Coif", "Banded Helm"],
("chest", "mail"): ["Rusted Chain Vest", "Chainmail Armor", "Banded Hauberk"],
("hands", "mail"): ["Rusted Chain Gloves", "Chainmail Gloves", "Banded Gauntlets"],
("legs", "mail"): ["Rusted Chain Leggings", "Chainmail Pants", "Banded Leggings"],
("feet", "mail"): ["Rusted Chain Boots", "Chainmail Boots", "Banded Boots"],
("weapon", "sword"): ["Short Sword", "Broadsword", "Cutlass"],
("weapon", "mace"): ["Club", "Morning Star", "Warhammer"],
("weapon", "axe"): ["Hatchet", "Battle Axe", "Cleaver"],
("weapon", "dagger"): ["Dirk", "Stiletto", "Kris"],
("weapon", "staff"): ["Walking Stick", "Quarter Staff", "Oaken Staff"],
("weapon", "bow"): ["Short Bow", "Hunting Bow", "Recurve Bow"],
("weapon", "gun"): ["Blunderbuss", "Flintlock", "Hunting Rifle"],
}
# Named items. `kind` is the armour type for armour, the weapon type for weapons.
ITEMS = {
"hoggers_trousers": {"name": "Hogger's Trousers", "slot": "legs", "kind": "leather", "ilvl": 14, "q": "rare",
"sta": 0.5},
"hoggers_gauntlets": {"name": "Hogger's Gauntlets", "slot": "hands", "kind": "mail", "ilvl": 14, "q": "rare",
"sta": 0.6},
"hoggers_cleaver": {"name": "Hogger's Cleaver", "slot": "weapon", "kind": "axe", "ilvl": 14, "q": "rare",
"sta": 0.3},
"chausses_of_westfall": {"name": "Chausses of Westfall", "slot": "legs", "kind": "mail", "ilvl": 22, "q": "rare",
"sta": 0.6},
"tunic_of_westfall": {"name": "Tunic of Westfall", "slot": "chest", "kind": "leather", "ilvl": 22, "q": "rare",
"sta": 0.4},
"staff_of_westfall": {"name": "Staff of Westfall", "slot": "weapon", "kind": "staff", "ilvl": 22, "q": "rare",
"sta": 0.3},
# The Deadmines
"rhahkzors_hammer": {"name": "Rhahk'Zor's Hammer", "slot": "weapon", "kind": "mace", "ilvl": 20, "q": "rare",
"sta": 0.4},
"ironjaw_bracers": {"name": "Ironjaw's Bracers", "slot": "hands", "kind": "mail", "ilvl": 20, "q": "rare",
"sta": 0.6},
"buzzer_blade": {"name": "Buzzer Blade", "slot": "weapon", "kind": "dagger", "ilvl": 21, "q": "rare", "sta": 0.2},
"gold_flecked_gloves": {"name": "Gold-flecked Gloves", "slot": "hands", "kind": "cloth", "ilvl": 21, "q": "rare",
"sta": 0.3},
"buzzsaw": {"name": "Buzzsaw", "slot": "weapon", "kind": "sword", "ilvl": 21, "q": "rare", "sta": 0.3},
"smelting_pants": {"name": "Smelting Pants", "slot": "legs", "kind": "leather", "ilvl": 21, "q": "rare",
"sta": 0.4},
"gilnids_robe": {"name": "Gilnid's Smelting Robe", "slot": "chest", "kind": "cloth", "ilvl": 21, "q": "rare",
"sta": 0.3},
"smites_reaver": {"name": "Smite's Reaver", "slot": "weapon", "kind": "axe", "ilvl": 22, "q": "rare", "sta": 0.4},
"thiefs_blade": {"name": "Thief's Blade", "slot": "weapon", "kind": "sword", "ilvl": 22, "q": "rare", "sta": 0.2},
"smites_mighty_hammer": {"name": "Smite's Mighty Hammer", "slot": "weapon", "kind": "mace", "ilvl": 22, "q": "rare",
"sta": 0.3},
"cruel_barb": {"name": "Cruel Barb", "slot": "weapon", "kind": "sword", "ilvl": 24, "q": "rare", "sta": 0.2},
"cape_of_the_brotherhood": {"name": "Cape of the Brotherhood", "slot": "back", "kind": "cloth", "ilvl": 24,
"q": "rare", "sta": 0.4},
"blackened_defias_armor": {"name": "Blackened Defias Armor", "slot": "chest", "kind": "leather", "ilvl": 24,
"q": "rare", "sta": 0.3},
"corsairs_overshirt": {"name": "Corsair's Overshirt", "slot": "chest", "kind": "cloth", "ilvl": 23, "q": "rare",
"sta": 0.3},
"vancleefs_helm": {"name": "VanCleef's Battlehelm", "slot": "head", "kind": "mail", "ilvl": 24, "q": "rare",
"sta": 0.6},
"emberstone_staff": {"name": "Emberstone Staff", "slot": "weapon", "kind": "staff", "ilvl": 23, "q": "rare",
"sta": 0.3},
"defias_hunting_bow": {"name": "Defias Hunting Bow", "slot": "weapon", "kind": "bow", "ilvl": 22, "q": "rare",
"sta": 0.3},
"blackened_defias_gloves": {"name": "Blackened Defias Gloves", "slot": "hands", "kind": "leather", "ilvl": 19,
"q": "uncommon", "sta": 0.4},
"blackened_defias_boots": {"name": "Blackened Defias Boots", "slot": "feet", "kind": "leather", "ilvl": 19,
"q": "uncommon", "sta": 0.4},
"miners_cape": {"name": "Miner's Cape", "slot": "back", "kind": "cloth", "ilvl": 19, "q": "uncommon", "sta": 0.5},
"foremans_boots": {"name": "Foreman's Boots", "slot": "feet", "kind": "mail", "ilvl": 19, "q": "uncommon",
"sta": 0.6},
"silk_mask": {"name": "Squallshaper's Hood", "slot": "head", "kind": "cloth", "ilvl": 20, "q": "uncommon",
"sta": 0.4},
}
# Each boss gives every player one roll at its table, so nobody fights over loot.
BOSS_LOOT = {
"rhahkzor": ["rhahkzors_hammer", "ironjaw_bracers", "foremans_boots"],
"sneed": ["buzzer_blade", "gold_flecked_gloves", "buzzsaw", "blackened_defias_gloves"],
"gilnid": ["smelting_pants", "gilnids_robe", "blackened_defias_boots", "defias_hunting_bow"],
"mr_smite": ["smites_reaver", "thiefs_blade", "smites_mighty_hammer", "silk_mask"],
"edwin_vancleef": ["cruel_barb", "cape_of_the_brotherhood", "blackened_defias_armor", "corsairs_overshirt",
"vancleefs_helm", "emberstone_staff"],
}
BOSS_LOOT_CHANCE = 0.6 # each player's chance at something they can use, per boss
TRASH_LOOT = ["blackened_defias_gloves", "blackened_defias_boots", "miners_cape", "foremans_boots", "silk_mask"]
TRASH_LOOT_CHANCE = 0.08

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792615934,
"narHash": "sha256-MlvRfa1O5Cqc2/a+gG+l9TSuowEoCKCmrJ1d9oAVm0I=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

36
flake.nix Normal file
View file

@ -0,0 +1,36 @@
{
description = "World of Guildhall: a co-op adventure for a small group of friends";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
in
{
packages = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; };
in {
default = pkgs.callPackage ./package.nix { };
wog = self.packages.${system}.default;
});
nixosModules.default = import ./module.nix self;
nixosModules.wog = self.nixosModules.default;
checks = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; };
in {
inherit (self.packages.${system}) default;
tests = pkgs.runCommand "wog-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } ''
cp -r ${self} source
chmod -R u+w source
cd source
python3 -m unittest discover -s tests -v
python3 -m py_compile app.py game.py combat.py content.py
node --check static/app.js
touch $out
'';
});
};
}

885
game.py Normal file
View file

@ -0,0 +1,885 @@
"""World of Guildhall: characters, gear, places, quests, groups and dungeons.
The world is one JSON-able dict. Time moves it forward: `process` runs everyone's
hunts and dungeon runs up to now, fight by fight, so a player who looks away keeps
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)
VERSION = 1
MINUTE = 60
HOUR = 3600
OFFLINE_CAP = 12 * HOUR
REST = 0.06 # share of health and mana back per second out of combat
REVIVE = 30 # seconds dead before running back
DEATHS_TO_STOP = 3 # in a row on one hunt
DUNGEON_REST = 25 # seconds between pulls before the next one starts by itself
WIPE_DELAY = 15
PARTY_MAX = 5
MAX_QUESTS = 12
MAX_LOG = 80
MAX_CHAT = 200
SHOW_ENDED = 4 # seconds a finished fight stays on screen
GREEN_ODDS = 0.025
JUNK_ODDS = 0.5
ARMOUR_RANK = {"cloth": 0, "leather": 1, "mail": 2}
# --- items -----------------------------------------------------------------
def _band(ilvl: int) -> int:
return 0 if ilvl < 8 else 1 if ilvl < 15 else 2
def _gear(item_id: str, name: str, slot: str, kind: str, ilvl: int, quality: str, share: float) -> dict:
budget = ilvl * SLOT_BUDGET[slot] * QUALITY[quality] * 0.9
item = {"id": item_id, "name": name, "slot": slot, "kind": kind, "ilvl": ilvl, "q": quality,
"req": max(1, ilvl - 3 if quality != "common" else ilvl - 1),
"sta": round(budget * share), "power": round(budget * (1 - share)), "armor": 0,
"value": round(ilvl ** 1.5 * 4 * (QUALITY[quality] + 0.3))}
if slot == "weapon":
item["dps"] = round((1 + 0.45 * ilvl) * (0.8 + 0.2 * QUALITY[quality]), 1)
item["speed"] = WEAPON_SPEED[kind]
else:
item["armor"] = round(ilvl * ARMOUR_TYPES[kind] * SLOT_ARMOUR[slot] * (0.85 + 0.15 * QUALITY[quality]))
return item
def item(item_id: str) -> dict | None:
"""Every item is described by its id: named, generated (w/ white, g/ green), junk (j/) or quest (q/)."""
if item_id in ITEMS:
spec = ITEMS[item_id]
return _gear(item_id, spec["name"], spec["slot"], spec["kind"], spec["ilvl"], spec["q"], spec["sta"])
parts = item_id.split("/")
try:
if parts[0] == "w":
slot, kind, ilvl = parts[1], parts[2], int(parts[3])
return _gear(item_id, BASE_NAMES[(slot, kind)][_band(ilvl)], slot, kind, ilvl, "common", 0.5)
if parts[0] == "g":
slot, kind, suffix, ilvl = parts[1], parts[2], parts[3], int(parts[4])
name = f"{BASE_NAMES[(slot, kind)][_band(ilvl)]} {SUFFIXES[suffix][0]}"
return _gear(item_id, name, slot, kind, ilvl, "uncommon", SUFFIXES[suffix][1])
if parts[0] == "j":
mob = MOBS[parts[1]]
return {"id": item_id, "name": mob["junk"], "q": "poor", "value": 2 + mob["lv"][1] * 4}
except (KeyError, IndexError, ValueError):
return None
return None
def random_green(level: int, rng: random.Random) -> str:
slot = rng.choice(SLOTS)
suffix = rng.choice(list(SUFFIXES))
ilvl = max(2, level + rng.randint(-1, 1))
if slot == "weapon":
kind = rng.choice(list(WEAPON_SPEED))
elif slot == "back":
kind = "cloth"
else:
kind = rng.choice(list(ARMOUR_TYPES))
return f"g/{slot}/{kind}/{suffix}/{ilvl}"
def fits(player: dict, spec: dict) -> bool:
"""The class can use it at some level (best armour type only, like loot rules in Classic)."""
cls = CLASSES[player["cls"]]
if spec["slot"] == "weapon":
return spec["kind"] in cls["weapons"]
return spec["slot"] == "back" or spec["kind"] == cls["armour"]
def can_wear(player: dict, spec: dict) -> str | None:
cls = CLASSES[player["cls"]]
if "slot" not in spec:
return "That can't be worn"
if spec["slot"] == "weapon":
if spec["kind"] not in cls["weapons"]:
return f"{cls['name']}s can't use a {spec['kind']}"
elif ARMOUR_RANK[spec["kind"]] > ARMOUR_RANK[cls["armour"]]:
return f"{cls['name']}s can't wear {spec['kind']}"
if spec["req"] > player["level"]:
return f"Requires level {spec['req']}"
return None
# --- characters ------------------------------------------------------------
def spec_of(player: dict) -> dict:
return SPECS[CLASSES[player["cls"]]["spec"]]
def known(player: dict) -> list[str]:
return [ab for ab, level in zip(spec_of(player)["abilities"], UNLOCK) if player["level"] >= level]
def stats(player: dict) -> dict:
cls = CLASSES[player["cls"]]
level = player["level"]
worn = [item(i) for i in player["equip"].values() if i]
worn = [w for w in worn if w]
sta = sum(w["sta"] for w in worn)
gear_power = sum(w["power"] for w in worn)
armor = sum(w["armor"] for w in worn)
if cls.get("shield"):
armor += 8 * level
if spec_of(player)["role"] == "tank":
armor = round(armor * 1.6)
weapon = item(player["equip"].get("weapon") or "") or {"dps": 1 + 0.3 * level, "speed": 2.0}
power = 10 + 5 * level + gear_power
max_hp = cls["hp"][0] + cls["hp"][1] * level + sta * 8
res = cls["res"]
res_max = 100 if res in ("rage", "energy") else 40 + 15 * level + 3 * gear_power
swing = weapon["speed"] * (weapon["dps"] + power * 0.05)
return {"max_hp": max_hp, "power": power, "armor": armor, "sta": sta, "res": res, "res_max": res_max,
"swing": round(swing, 1), "speed": weapon["speed"], "dps": weapon["dps"]}
def hp_now(player: dict, now: float) -> tuple[float, float]:
"""Health and mana share, resting since the last fight ended."""
st = stats(player)
if player["dead_until"] > now:
return 0.0, 0.0
rest = player["rest"]
gone = max(0.0, now - rest["at"])
hp = min(st["max_hp"], rest["hp"] + st["max_hp"] * REST * gone)
mana = min(1.0, rest["mana"] + REST * gone)
return hp, mana
def new_player(uid: str, name: str, cls: str, now: float) -> dict:
spec = CLASSES[cls]
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,
"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},
"last_active": now, "watch": 0, "log": [], "joined": now,
}
player["equip"]["weapon"] = f"w/weapon/{spec['weapons'][0]}/2"
player["equip"]["chest"] = f"w/chest/{armour}/2"
player["equip"]["legs"] = f"w/legs/{armour}/2"
player["rest"]["hp"] = stats(player)["max_hp"]
return player
def player_unit(player: dict, now: float, group: bool) -> list[dict]:
"""The player (and their pet) as fight units."""
st = stats(player)
spec = spec_of(player)
hp, mana = hp_now(player, now)
res = {"type": st["res"], "max": st["res_max"]}
res["cur"] = {"rage": 20, "energy": 100}.get(st["res"], round(mana * st["res_max"]))
ranged = player["cls"] == "hunter"
unit = {
"id": player["id"], "name": player["name"], "side": "party", "kind": "player", "cls": player["cls"],
"role": spec["role"], "level": player["level"], "hp": max(1, round(hp)), "max_hp": st["max_hp"],
"power": st["power"], "armor": st["armor"], "res": res,
"swing": {"dmg": st["swing"] * (1.1 if ranged else 1), "every": st["speed"], "next": 0.25,
"school": "phys"},
"abilities": known(player), "priority": spec["priority"], "manual": list(player["manual"]),
"auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False,
"ftarget": None, "queued": None, "stun": 0, "cc": 0, "dead": False,
"threat_mult": combat.TANK_THREAT if spec["role"] == "tank" else 1.0,
}
units = [unit]
pet_kind = CLASSES[player["cls"]].get("pet")
if pet_kind:
pet_id = "wolf" if pet_kind == "wolf" else ("imp" if group else "voidwalker")
pet = PETS[pet_id]
tank = not group
pet_hp = round(st["max_hp"] * pet["hp"])
units.append({
"id": f"{player['id']}:pet", "name": pet["name"], "icon": pet["icon"], "side": "party", "kind": "pet",
"owner": player["id"], "level": player["level"], "hp": pet_hp, "max_hp": pet_hp, "power": st["power"],
"armor": st["armor"] * (2 if tank else 1) + 10 * player["level"], "res": None,
"swing": {"dmg": pet["dmg"] * st["power"], "every": pet["swing"], "next": 0.5, "school": pet["school"]},
"auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False,
"stun": 0, "cc": 0, "dead": False, "pet_tank": tank,
"threat_mult": combat.PET_TANK_THREAT if tank else combat.PET_THREAT,
})
return units
def kill_xp(player_level: int, mob_level: int, mob: dict) -> int:
diff = mob_level - player_level
gray = 5 + player_level // 10
if diff <= -gray:
return 0
mult = 1 + 0.05 * min(diff, 4) if diff >= 0 else 1 + diff / (gray + 1)
if mob.get("elite"):
mult *= 2
if mob.get("boss"):
mult *= 3
if mob.get("rare"):
mult *= 2
return round((45 + 5 * mob_level) * mult)
def log(player: dict, now: float, text: str, kind: str = "info") -> None:
player["log"].append({"t": now, "text": text, "kind": kind})
del player["log"][:-MAX_LOG]
def world_log(world: dict, now: float, text: str, kind: str = "info", who: str | None = None) -> None:
world["log"].append({"t": now, "text": text, "kind": kind, "who": who})
del world["log"][:-MAX_LOG]
def gain_xp(world: dict, player: dict, xp: int, now: float) -> None:
if player["level"] >= MAX_LEVEL or xp <= 0:
return
player["xp"] += xp
while player["level"] < MAX_LEVEL and player["xp"] >= XP_TO_NEXT[player["level"]]:
player["xp"] -= XP_TO_NEXT[player["level"]]
player["level"] += 1
learned = [ABILITIES[ab]["name"] for ab, lv in zip(spec_of(player)["abilities"], UNLOCK)
if lv == player["level"]]
extra = f" · learned {', '.join(learned)}" if learned else ""
log(player, now, f"Level {player['level']}!{extra}", "level")
world_log(world, now, f"{player['name']} reached level {player['level']}", "level", player["id"])
if player["level"] >= MAX_LEVEL:
player["xp"] = 0
def bag_add(player: dict, item_id: str, qty: int = 1) -> None:
player["bags"][item_id] = player["bags"].get(item_id, 0) + qty
def bag_take(player: dict, item_id: str, qty: int = 1) -> None:
player["bags"][item_id] -= qty
if player["bags"][item_id] <= 0:
del player["bags"][item_id]
# --- the world -------------------------------------------------------------
def new_world(now: float) -> dict:
return {"version": VERSION, "players": {}, "fights": {}, "groups": {}, "next": 1, "log": [], "chat": [],
"config": {"clock_offset": 0}, "created": now}
def _next_id(world: dict, prefix: str) -> str:
world["next"] += 1
return f"{prefix}{world['next']}"
def watch_map(world: dict) -> dict:
return {uid: p["watch"] for uid, p in world["players"].items()}
def process(world: dict, now: float, rng: random.Random) -> None:
for group in list(world["groups"].values()):
if group.get("dungeon"):
_process_dungeon(world, group, now, rng)
for player in world["players"].values():
_process_player(world, player, now, rng)
def _process_player(world: dict, player: dict, now: float, rng: random.Random) -> None:
for _ in range(100000):
activity = player["activity"]
if activity and activity["kind"] == "dungeon":
return
if player["fight"]:
fight = world["fights"][player["fight"]]
combat.advance(fight, now, watch_map(world))
if fight["state"] == "on":
return
_end_hunt_fight(world, player, fight, rng)
continue
if not activity:
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
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)
enemies = []
for index, mob_id in enumerate(pull):
low, high = MOBS[mob_id]["lv"]
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high))
unit["swing"]["next"] = 0.6 + 0.4 * index
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"
world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, player_unit(player, at, False),
enemies, "hunt", name)
player["fight"] = fid
def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None:
end = fight["ended"]
me = fight["units"][player["id"]]
player["fight"] = None
player["last_fight"] = world["fights"].pop(fight["id"])
st = stats(player)
if fight["state"] == "won":
player["deaths"] = 0
_reward_kills(world, player, fight, 1, end, rng)
player["rest"] = {"at": end, "hp": me["hp"],
"mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0}
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)
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:
player["activity"] = None
player["deaths"] = 0
log(player, end, "Died three times in a row. You stopped hunting here.", "death")
def _reward_kills(world: dict, player: dict, fight: dict, share: float, now: float, rng: random.Random,
dungeon: bool = False) -> None:
xp = 0
for unit in fight["units"].values():
if unit["side"] != "enemy" or not unit["dead"]:
continue
mob = MOBS[unit["mob"]]
xp += round(kill_xp(player["level"], unit["level"], mob) * share)
_quest_kill(player, unit["mob"], now, rng)
if dungeon:
loot = None
if mob.get("boss") and unit["mob"] in BOSS_LOOT and rng.random() < BOSS_LOOT_CHANCE:
usable = [i for i in BOSS_LOOT[unit["mob"]] if fits(player, item(i))]
loot = rng.choice(usable or BOSS_LOOT[unit["mob"]])
elif not mob.get("boss") and rng.random() < TRASH_LOOT_CHANCE:
loot = rng.choice(TRASH_LOOT)
if loot:
bag_add(player, loot)
log(player, now, f"Loot: {item(loot)['name']}", "loot")
world_log(world, now, f"{player['name']} looted {item(loot)['name']}", "loot", player["id"])
if not mob.get("boss") and rng.random() < JUNK_ODDS:
bag_add(player, f"j/{unit['mob']}")
if unit["kind"] == "humanoid":
player["copper"] += round(rng.randint(unit["level"], unit["level"] * 4) * (2 if dungeon else 1))
if rng.random() < (0.5 if mob.get("rare") else GREEN_ODDS):
green = random_green(unit["level"], rng)
bag_add(player, green)
log(player, now, f"Loot: {item(green)['name']}", "loot")
if mob.get("rare") and not dungeon:
world_log(world, now, f"{player['name']} slew {mob['name']}", "rare", player["id"])
gain_xp(world, player, xp, now)
def _arrive(world: dict, player: dict, place: str, at: float) -> None:
player["place"] = place
player["activity"] = None
log(player, at, f"Arrived at {PLACES[place]['name']}", "travel")
for qid, progress in player["quests"].items():
for index, objective in enumerate(QUESTS[qid]["obj"]):
if objective.get("visit") == place:
progress[index] = 1
# --- actions ---------------------------------------------------------------
def _leave_fight(world: dict, player: dict, now: float) -> None:
"""Flee a solo fight: no rewards, keep what health you have."""
fid = player["fight"]
if not fid:
return
fight = world["fights"].pop(fid, None)
player["fight"] = None
if fight:
me = fight["units"][player["id"]]
player["rest"] = {"at": now, "hp": max(1, me["hp"]),
"mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0}
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", {}):
return "Nothing to hunt like that here"
_leave_fight(world, player, now)
player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now}
player["deaths"] = 0
player["next_pull"] = max(player["next_pull"], now)
return None
def stop(world: dict, player: dict, now: float) -> str | None:
if player["activity"] and player["activity"]["kind"] == "dungeon":
return "Leave the dungeon first"
_leave_fight(world, player, now)
player["activity"] = None
return None
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":
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 travel(world: dict, player: dict, to: str, now: float) -> str | None:
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:
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}
return None
def equip(player: dict, item_id: str) -> str | None:
if player["bags"].get(item_id, 0) < 1:
return "You don't have that"
spec = item(item_id)
if not spec:
return "No such item"
problem = can_wear(player, spec)
if problem:
return problem
if player["fight"]:
return "Not in the middle of a fight"
old = player["equip"][spec["slot"]]
bag_take(player, item_id)
if old:
bag_add(player, old)
player["equip"][spec["slot"]] = item_id
return None
def unequip(player: dict, slot: str) -> str | None:
if slot not in player["equip"] or not player["equip"][slot]:
return "Nothing there"
if player["fight"]:
return "Not in the middle of a fight"
bag_add(player, player["equip"][slot])
player["equip"][slot] = None
return 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
if not isinstance(qty, int) or qty < 1 or qty > have:
return "Bad quantity"
spec = item(item_id)
if not spec:
return "No such item"
bag_take(player, item_id, qty)
player["copper"] += spec["value"] * qty
return 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"
for item_id in junk:
player["copper"] += item(item_id)["value"] * player["bags"].pop(item_id)
return None
def set_manual(world: dict, player: dict, ability: str, manual: bool) -> str | None:
if ability not in spec_of(player)["abilities"]:
return "Not one of yours"
if manual and ability not in player["manual"]:
player["manual"].append(ability)
elif not manual and ability in player["manual"]:
player["manual"].remove(ability)
fight = current_fight(world, player)
if fight and player["id"] in fight["units"]:
fight["units"][player["id"]]["manual"] = list(player["manual"])
return None
def current_fight(world: dict, player: dict) -> dict | None:
if player["fight"]:
return world["fights"].get(player["fight"])
group = world["groups"].get(player["group"] or "")
if group and group.get("dungeon") and group["dungeon"].get("fight") and \
player["activity"] and player["activity"]["kind"] == "dungeon":
return world["fights"].get(group["dungeon"]["fight"])
return None
def shown_fight(world: dict, player: dict, now: float) -> dict | None:
"""The live fight, or the one that just ended so its last blows can be seen."""
fight = current_fight(world, player)
if fight:
return fight
group = world["groups"].get(player["group"] or "")
inside = player["activity"] and player["activity"]["kind"] == "dungeon"
last = group["dungeon"].get("last_fight") if inside and group and group.get("dungeon") else \
player.get("last_fight")
if last and now - last["ended"] < SHOW_ENDED:
return last
return None
# --- quests ----------------------------------------------------------------
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"]
and all(req in player["done"] for req in q.get("req", []))]
def quest_done(player: dict, qid: str) -> bool:
progress = player["quests"][qid]
return all(progress[i] >= o.get("n", 1) for i, o in enumerate(QUESTS[qid]["obj"]))
def accept(player: dict, qid: str) -> str | None:
if qid not in available_quests(player):
return "Nobody here offers that"
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
return None
def abandon(player: dict, qid: str) -> str | None:
if qid not in player["quests"]:
return "Not on that quest"
del player["quests"][qid]
return None
def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | None:
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']}"
if not quest_done(player, qid):
return "Not finished yet"
options = quest.get("choice", [])
if options:
if not isinstance(choice, int) or not 0 <= choice < len(options):
return "Pick a reward"
bag_add(player, options[choice])
log(player, now, f"Reward: {item(options[choice])['name']}", "loot")
del player["quests"][qid]
player["done"].append(qid)
player["copper"] += quest.get("copper", 0)
log(player, now, f"Quest complete: {quest['name']}", "quest")
world_log(world, now, f"{player['name']} completed {quest['name']}", "quest", player["id"])
gain_xp(world, player, quest["xp"], now)
return None
def _quest_kill(player: dict, mob_id: str, now: float, rng: random.Random) -> None:
for qid, progress in player["quests"].items():
for index, objective in enumerate(QUESTS[qid]["obj"]):
if progress[index] >= objective.get("n", 1):
continue
if objective.get("kill") == mob_id:
progress[index] += 1
elif mob_id in objective.get("from", []) and rng.random() < objective["chance"]:
progress[index] += 1
else:
continue
if quest_done(player, qid):
log(player, now, f"{QUESTS[qid]['name']}: done, turn it in", "quest")
# --- groups and dungeons ---------------------------------------------------
def create_group(world: dict, player: dict) -> str | None:
if player["group"]:
return "You're already in a group"
gid = _next_id(world, "g")
world["groups"][gid] = {"id": gid, "leader": player["id"], "members": [player["id"]], "dungeon": None}
player["group"] = gid
return None
def join_group(world: dict, player: dict, gid: str) -> str | None:
group = world["groups"].get(gid)
if not group:
return "That group is gone"
if player["group"]:
return "Leave your group first"
if len(group["members"]) >= PARTY_MAX:
return "That group is full"
if group["dungeon"]:
return "They're already inside"
group["members"].append(player["id"])
player["group"] = gid
return None
def leave_group(world: dict, player: dict, now: float) -> str | None:
group = world["groups"].get(player["group"] or "")
if not group:
return "You're not in a group"
_exit_dungeon(world, group, player, now)
group["members"].remove(player["id"])
player["group"] = None
if not group["members"]:
_close_dungeon(world, group)
del world["groups"][group["id"]]
elif group["leader"] == player["id"]:
group["leader"] = group["members"][0]
return None
def _exit_dungeon(world: dict, group: dict, player: dict, now: float) -> None:
if not (player["activity"] and player["activity"]["kind"] == "dungeon"):
return
dungeon = group["dungeon"]
fight = world["fights"].get(dungeon["fight"] or "") if dungeon else None
if fight and player["id"] in fight["units"]:
unit = fight["units"][player["id"]]
unit["dead"], unit["hp"] = True, 0
for pet in fight["units"].values():
if pet.get("owner") == player["id"]:
pet["dead"] = True
player["activity"] = None
player["place"] = DUNGEONS[dungeon["id"]]["entrance"] if dungeon else player["place"]
player["rest"] = {"at": now, "hp": stats(player)["max_hp"] * 0.5, "mana": 0.5}
player["dead_until"] = 0
log(player, now, "Left the dungeon", "travel")
if dungeon and not any(world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon"
for m in group["members"] if m != player["id"]):
_close_dungeon(world, group)
def _close_dungeon(world: dict, group: dict) -> None:
dungeon = group.get("dungeon")
if dungeon and dungeon.get("fight"):
world["fights"].pop(dungeon["fight"], None)
group["dungeon"] = None
def min_party(world: dict) -> int:
return 1 if world["config"].get("dev") else 2
def enter_dungeon(world: dict, player: dict, now: float) -> str | None:
group = world["groups"].get(player["group"] or "")
if not group:
return "Form a group first"
if group["leader"] != player["id"]:
return "Only the leader can lead you in"
if group["dungeon"]:
return "You're already inside"
dungeon_id = PLACES[player["place"]].get("dungeon")
if not dungeon_id:
return "There's no dungeon here"
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)]
if away:
return f"Waiting on {', '.join(away)} to reach {PLACES[player['place']]['name']}"
dead = [m["name"] for m in members if m["dead_until"] > now]
if dead:
return f"{', '.join(dead)} is dead"
for member in members:
_leave_fight(world, member, now)
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,
"started": now, "wipes": 0, "last": None}
world_log(world, now, f"{', '.join(m['name'] for m in members)} entered {DUNGEONS[dungeon_id]['name']}",
"dungeon", player["id"])
return None
def pull_now(world: dict, player: dict, now: float) -> str | None:
group = world["groups"].get(player["group"] or "")
if not group or not group["dungeon"]:
return "You're not in a dungeon"
if group["leader"] != player["id"]:
return "Only the leader pulls"
dungeon = group["dungeon"]
if dungeon["state"] != "rest":
return "Not now"
dungeon["next"] = now
return None
def leave_dungeon(world: dict, player: dict, now: float) -> str | None:
group = world["groups"].get(player["group"] or "")
if not group or not group["dungeon"]:
return "You're not in a dungeon"
_exit_dungeon(world, group, player, now)
return None
def _dungeon_members(world: dict, group: dict) -> list[dict]:
return [world["players"][m] for m in group["members"]
if world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon"]
def dungeon_scale(size: int) -> tuple[float, float]:
"""Packs are tuned for five; smaller groups face lighter ones."""
return 0.1 + 0.18 * size, 0.5 + 0.1 * size
def _process_dungeon(world: dict, group: dict, now: float, rng: random.Random) -> None:
for _ in range(1000):
dungeon = group["dungeon"]
if not dungeon:
return
members = _dungeon_members(world, group)
if not members:
_close_dungeon(world, group)
return
if dungeon["fight"]:
fight = world["fights"][dungeon["fight"]]
combat.advance(fight, now, watch_map(world))
if fight["state"] == "on":
return
_end_dungeon_fight(world, group, fight, rng)
continue
if dungeon["state"] == "rest" and now >= dungeon["next"]:
_start_pull(world, group, dungeon["next"], rng)
continue
return
def _start_pull(world: dict, group: dict, at: float, rng: random.Random) -> None:
dungeon = group["dungeon"]
spec = DUNGEONS[dungeon["id"]]
pull = spec["pulls"][dungeon["pull"]]
members = _dungeon_members(world, group)
hp_scale, dmg_scale = dungeon_scale(len(members))
party = []
for member in members:
party += player_unit(member, at, True)
enemies = []
for index, mob_id in enumerate(pull["mobs"]):
low, high = MOBS[mob_id]["lv"]
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale)
unit["swing"]["next"] = 0.8 + 0.4 * index
enemies.append(unit)
fid = _next_id(world, "f")
world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, party, enemies, "dungeon",
pull["name"], (hp_scale, dmg_scale))
dungeon["fight"] = fid
dungeon["state"] = "fight"
def _end_dungeon_fight(world: dict, group: dict, fight: dict, rng: random.Random) -> None:
dungeon = group["dungeon"]
spec = DUNGEONS[dungeon["id"]]
end = fight["ended"]
members = _dungeon_members(world, group)
dungeon["last_fight"] = world["fights"].pop(fight["id"])
dungeon["fight"] = None
present = [m for m in members if m["id"] in fight["units"]]
if fight["state"] == "won":
share = (1 + 0.1 * (len(present) - 1)) / max(1, len(present))
for member in present:
unit = fight["units"][member["id"]]
hp = unit["hp"] if not unit["dead"] else stats(member)["max_hp"] * 0.3
mana = unit["res"]["cur"] / unit["res"]["max"] if unit["res"]["type"] == "mana" else 1.0
member["rest"] = {"at": end, "hp": hp, "mana": mana if not unit["dead"] else 0.3}
_reward_kills(world, member, fight, share, end, rng, dungeon=True)
dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": True, "t": round(fight["t"])}
dungeon["pull"] += 1
if dungeon["pull"] >= len(spec["pulls"]):
dungeon["state"] = "done"
world_log(world, end, f"{', '.join(m['name'] for m in present)} cleared {spec['name']}!", "dungeon")
for member in present:
log(member, end, f"Cleared {spec['name']}", "dungeon")
else:
dungeon["state"] = "rest"
dungeon["next"] = end + DUNGEON_REST
else:
dungeon["wipes"] += 1
dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": False, "t": round(fight["t"])}
for member in present:
member["rest"] = {"at": end + WIPE_DELAY, "hp": stats(member)["max_hp"] * 0.5, "mana": 0.5}
log(member, end, f"Wiped on {spec['pulls'][dungeon['pull']]['name']}", "death")
dungeon["state"] = "rest"
dungeon["next"] = end + WIPE_DELAY + DUNGEON_REST
# --- chat ------------------------------------------------------------------
def say(world: dict, player: dict, text: str, now: float) -> str | None:
text = text.strip()[:300]
if not text:
return "Say something"
world["chat"].append({"t": now, "who": player["id"], "text": text})
del world["chat"][:-MAX_CHAT]
return None
# --- dev helpers -----------------------------------------------------------
def dev_level(world: dict, player: dict, level: int, now: float) -> str | None:
if not 1 <= level <= MAX_LEVEL:
return "Bad level"
player["level"], player["xp"] = level, 0
cls = CLASSES[player["cls"]]
armour = cls["armour"]
for slot in SLOTS:
kind = cls["weapons"][0] if slot == "weapon" else ("cloth" if slot == "back" else armour)
suffix = "bear" if spec_of(player)["role"] == "tank" else "eagle"
player["equip"][slot] = f"g/{slot}/{kind}/{suffix}/{level}"
player["rest"] = {"at": now, "hp": stats(player)["max_hp"], "mana": 1.0}
return None

97
module.nix Normal file
View file

@ -0,0 +1,97 @@
self: { config, lib, pkgs, ... }:
let
cfg = config.services.wog;
database = "/var/lib/wog/wog.sqlite3";
hardening = {
User = "wog";
Group = "wog";
StateDirectory = "wog";
StateDirectoryMode = "0700";
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
};
in
{
options.services.wog = {
enable = lib.mkEnableOption "World of Guildhall";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "wog.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "The wog package to run.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8100;
description = "Loopback TCP port used by the game.";
};
userHeader = lib.mkOption {
type = lib.types.str;
default = "Remote-User";
description = "Request header carrying the signed-in user, set by the authenticating proxy.";
};
backup = {
directory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Directory for nightly world snapshots; null disables backups.";
};
keep = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "Number of nightly snapshots to keep.";
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
users.users.wog = {
isSystemUser = true;
group = "wog";
description = "World of Guildhall service user";
};
users.groups.wog = { };
systemd.services.wog = {
description = "World of Guildhall";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = hardening // {
ExecStart = "${cfg.package}/bin/wog --host 127.0.0.1 --port ${toString cfg.port} --db ${database} --user-header ${lib.escapeShellArg cfg.userHeader}";
Restart = "on-failure";
RestartSec = 3;
};
};
}
(lib.mkIf (cfg.backup.directory != null) {
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 wog wog -" ];
systemd.services.wog-backup = {
description = "Snapshot World of Guildhall";
unitConfig.RequiresMountsFor = [ cfg.backup.directory ];
serviceConfig = hardening // {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/wog --db ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}";
ReadWritePaths = [ cfg.backup.directory ];
};
};
systemd.timers.wog-backup = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 03:25:00";
Persistent = true;
};
};
})
]);
}

26
package.nix Normal file
View file

@ -0,0 +1,26 @@
{ lib, stdenvNoCC, makeWrapper, python3 }:
stdenvNoCC.mkDerivation {
pname = "wog";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/wog $out/bin
cp app.py game.py combat.py content.py $out/share/wog/
cp -r static $out/share/wog/static
makeWrapper ${python3}/bin/python3 $out/bin/wog \
--add-flags "$out/share/wog/app.py"
runHook postInstall
'';
meta = {
description = "Co-op adventure for a small group of friends";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
mainProgram = "wog";
};
}

910
static/app.js Normal file
View file

@ -0,0 +1,910 @@
"use strict";
// World of Guildhall client. The server is the source of truth; between polls the
// client only animates bars and cooldowns forward from the last state it saw.
const POLL_MS = 3000;
const FIGHT_POLL_MS = 300; // fights are live: keep everyone's view close
const ROLE = { tank: "Tank", healer: "Healer", dps: "Damage" };
const RULES = {
always: (r) => (r.save ? `whenever it's ready (in a group: only above ${pct(r.save)} mana)` : "whenever it's ready"),
dot: (r) => (r.save ? `keeps it on the target (in a group: only above ${pct(r.save)} mana)` : "keeps it on the target"),
heal: (r) => `someone under ${pct(r.pct)}`,
hot: (r) => `someone under ${pct(r.pct)} without it`,
shield: (r) => `whoever's being hit, or under ${pct(r.pct)}`,
self_below: (r) => `you're under ${pct(r.pct)}`,
taunt: () => "a mob hits someone else",
aoe: (r) => (r.n > 1 ? `${r.n}+ mobs` : "every fight"),
interrupt: () => "a mob is casting",
combo: (r) => `${r.n}+ combo points`,
cc: () => "an extra mob joins",
peel: () => "mobs are on you",
tanking: (r) => `an elite hits you, or you're under ${pct(r.pct)}`,
danger: (r) => `hit while under ${pct(r.pct)}`,
buff: () => "keeps it up",
boss: () => "on elites, rares and bosses",
};
let C = null; // static content
let S = null; // latest state
let skew = 0; // server clock minus client clock
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "adventure";
let polledAt = 0;
let inflight = false;
let failures = 0;
let selected = null; // bag item
let regCls = null;
let pickReward = {}; // quest id -> reward index
let seenEv = { id: null, t: -1 };
let barKey = "";
let seenChat = Number(remember("seenChat") || 0);
const $ = (sel) => document.querySelector(sel);
function remember(key, value) {
try {
if (value === undefined) return localStorage.getItem("wog." + key);
localStorage.setItem("wog." + key, value);
} catch (e) { /* private mode */ }
return null;
}
function esc(text) {
return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function pct(x) { return `${Math.round(x * 100)}%`; }
function now() { return Date.now() / 1000 + skew; }
function money(copper) {
copper = Math.floor(copper || 0);
const g = Math.floor(copper / 10000), s = Math.floor(copper / 100) % 100, c = copper % 100;
const parts = [];
if (g) parts.push(`<span class="g">${g}g</span>`);
if (s) parts.push(`<span class="s">${s}s</span>`);
if (c || !parts.length) parts.push(`<span class="c">${c}c</span>`);
return `<span class="money">${parts.join(" ")}</span>`;
}
function clock(seconds) {
seconds = Math.max(0, Math.round(seconds));
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60), s = seconds % 60;
if (m < 60) return `${m}:${String(s).padStart(2, "0")}`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
function toast(text, ok = false) {
const el = $("#toast");
el.textContent = text;
el.className = "show" + (ok ? " ok" : "");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => (el.className = ""), 2600);
}
function clsColor(cls) { return C.classes[cls]?.color || "#ddd"; }
function place(id) { return C.places[id]; }
function specOf(cls) { return C.specs[C.classes[cls].spec]; }
// --- server -------------------------------------------------------------
async function refresh() {
if (inflight) return;
inflight = true;
try {
const res = await fetch("/api/state", { cache: "no-store" });
if (!res.ok) throw new Error(res.status);
accept(await res.json());
failures = 0;
} catch (e) {
failures++;
} finally {
inflight = false;
polledAt = Date.now();
}
}
async function api(path, body = {}) {
try {
const res = await fetch("/api/" + path, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
toast(data.error || "Something went wrong");
return false;
}
accept(data);
return true;
} catch (e) {
toast("Can't reach the server");
return false;
}
}
function accept(state) {
if (S && S.version !== state.version) {
location.reload();
return;
}
skew = state.now - Date.now() / 1000;
S = state;
render();
}
// --- rendering ----------------------------------------------------------
function render() {
if (!C || !S) return;
renderTop();
renderNav();
renderMain();
renderFight();
renderBar();
}
function myUnit() {
return S.fight ? S.fight.units.find((u) => u.id === S.uid) : null;
}
function renderTop() {
const me = S.me;
if (!me) {
$("#top").innerHTML = `<div class="inner"><b>World of Guildhall</b></div>`;
return;
}
const cls = C.classes[me.cls];
const unit = myUnit();
const hp = unit ? unit.hp : me.hp, max = unit ? unit.max_hp : me.stats.max_hp;
const res = unit ? unit.res : null;
const resType = me.stats.res;
const resPct = res ? res.cur / res.max : (resType === "mana" ? me.mana : resType === "energy" ? 1 : 0);
const xp = me.xp_next ? me.xp / me.xp_next : 1;
$("#top").innerHTML = `<div class="inner">
<div class="row">
<span class="who" style="color:${cls.color}">${esc(me.name)}</span>
<span class="muted small">${me.level} ${esc(specOf(me.cls).name)} ${esc(cls.name)}</span>
<span class="right small">${money(me.copper)}</span>
</div>
<div class="minibars">
${bar(hp, max, hp / max < 0.3 ? "low" : "", `${Math.round(hp)} / ${max}`)}
${bar(resPct, 1, resType, resType === "mana" ? pct(resPct) : `${Math.round(resPct * 100)} ${resType}`)}
</div>
<div class="xpbar" title="${me.xp} / ${me.xp_next} xp"><i style="width:${xp * 100}%"></i></div>
</div>`;
}
function bar(value, max, cls = "", text = "") {
const w = max ? Math.max(0, Math.min(1, value / max)) * 100 : 0;
return `<div class="bar ${cls}"><i style="width:${w}%"></i><span>${text}</span></div>`;
}
function renderNav() {
const me = S.me;
if (!me) {
$("#nav").innerHTML = "";
return;
}
const ready = Object.keys(me.quests).some((q) => questReady(q));
const newChat = S.chat.some((m) => m.t > seenChat && m.who !== S.uid);
const tabs = [
["adventure", "⚔️", "Adventure", false],
["quests", "📜", "Quests", ready],
["character", "🧍", "Character", false],
["group", "👥", "Group", !!S.group?.dungeon],
["chat", "💬", "Guild", newChat && page !== "chat"],
];
$("#nav").innerHTML = tabs.map(([id, icon, label, dot]) =>
`<button data-page="${id}" class="${page === id ? "on" : ""}"><span class="i">${icon}</span>${label}${dot ? '<span class="dot"></span>' : ""}</button>`).join("");
}
function renderMain() {
const main = $("#main");
if (!main.querySelector("#fightbox")) {
main.innerHTML = `<div id="fightbox"></div><div id="page"></div>`;
}
const el = $("#page");
if (!S.me) {
el.innerHTML = registerPage();
return;
}
if (page === "chat") {
seenChat = Math.max(seenChat, ...S.chat.map((m) => m.t), 0);
remember("seenChat", seenChat);
}
const html = { adventure: adventurePage, quests: questsPage, character: characterPage, group: groupPage, chat: chatPage }[page]();
// Keep a half-typed chat message across re-renders.
const typing = document.activeElement?.id === "say" ? document.activeElement.value : null;
if (typing !== null && page === "chat") return;
if (el.dataset.html !== html) {
el.innerHTML = html;
el.dataset.html = html;
}
}
// --- register -----------------------------------------------------------
function registerPage() {
const pick = regCls ? C.classes[regCls] : null;
const spec = pick ? specOf(regCls) : null;
return `<div class="card">
<h2>Create your character</h2>
<p class="muted small">You wake in Northshire Abbey. The Defias Brotherhood stirs in Westfall.</p>
<div class="row"><input id="name" placeholder="Name" maxlength="16" class="grow" value="${esc(remember("draftName") || "")}"></div>
<h3>Class</h3>
<div class="classes">${Object.entries(C.classes).map(([id, c]) => `
<div class="cls ${regCls === id ? "on" : ""}" data-cls="${id}">
<span class="ic">${c.icon}</span>
<b style="color:${c.color}">${esc(c.name)}</b>
<span class="small muted">${esc(specOf(id).name)} · ${ROLE[specOf(id).role]}</span>
</div>`).join("")}
</div>
${spec ? `<h3>${esc(spec.name)} ${esc(pick.name)} · ${ROLE[spec.role]}</h3>
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i])).join("")}</div>` : ""}
<div class="row" style="margin-top:12px"><button class="btn primary" data-act="register" ${regCls ? "" : "disabled"}>Enter the world</button></div>
</div>`;
}
function abilityRow(id, level, me) {
const a = C.abilities[id];
const known = !me || me.known.includes(id);
const manual = me && me.manual.includes(id);
const owner = Object.values(C.specs).find((sp) => sp.abilities.includes(id));
const cost = a.cost ? (a.cost < 1 ? `${Math.round(a.cost * 100)}% mana` : `${a.cost} ${C.classes[owner.cls].res}`) : "";
const facts = [cost, a.cast ? `${a.cast}s cast` : "instant", a.cd ? `${a.cd}s cooldown (auto ${Math.round(a.cd * C.auto_cd)}s)` : ""].filter(Boolean);
return `<div class="item-row" style="cursor:default; ${known ? "" : "opacity:.5"}">
<span style="font-size:22px">${a.icon}</span>
<div class="grow">
<b>${esc(a.name)}</b> <span class="small muted">${known ? "" : `level ${level}`}</span>
<div class="tip">${esc(a.tip)}</div>
<div class="tip">${facts.join(" · ")}</div>
<div class="tip">Auto: ${RULES[a.auto.r](a.auto)}</div>
</div>
${me ? `<button class="btn small ${manual ? "on" : ""}" data-act="manual" data-id="${id}" data-on="${manual ? 0 : 1}">${manual ? "You" : "Auto"}</button>` : ""}
</div>`;
}
// --- adventure ----------------------------------------------------------
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));
}
parts.push(logCard());
if (S.dev) parts.push(devCard());
return parts.join("");
}
function statusCard() {
const me = S.me;
const act = me.activity;
const t = now();
if (me.dead_until > t) {
return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · ${clock(me.dead_until - t)}</span></div>`;
}
if (!act) return "";
if (act.kind === "travel") {
const done = (t - act.start) / (act.end - act.start);
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}">${clock(act.end - t)}</span></div>
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i style="width:${done * 100}%"></i><span></span></div>
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div>
</div>`;
}
if (act.kind === "hunt") {
const mob = C.mobs[act.hunt];
const waiting = !S.fight && me.next_pull > t;
return `<div class="card"><div class="row">
<b>🎯 Hunting ${esc(mob.name)}</b>
<span class="muted small">${waiting ? `resting · ${clock(me.next_pull - t)}` : ""}</span>
<button class="btn small right" data-act="stop">Stop</button></div>
</div>`;
}
if (act.kind === "dungeon") return dungeonCard();
return "";
}
function dungeonCard() {
const g = S.group;
if (!g || !g.dungeon) return "";
const d = g.dungeon;
const spec = C.dungeons[d.id];
const t = now();
const leader = g.leader === S.uid;
const pulls = spec.pulls.map((p, i) => {
const mark = i < d.pull ? "✅" : i === d.pull && d.state !== "done" ? "➡️" : "▫️";
return `<div class="small ${i < d.pull ? "muted" : ""}">${mark} ${p.boss ? "<b>" : ""}${esc(p.name)}${p.boss ? "</b>" : ""}
<span class="muted">${i === d.pull && d.state !== "done" ? "· " + p.mobs.map((m) => esc(C.mobs[m].name)).join(", ") : ""}</span></div>`;
}).join("");
let status = "";
if (d.state === "rest") {
status = `<div class="row"><span>${d.last && !d.last.won ? "💀 Wiped. " : ""}Next pull in <b data-until="${d.next}">${clock(d.next - t)}</b></span>
${leader ? `<button class="btn primary small right" data-act="pull">Pull now</button>` : `<span class="right small muted">the leader can pull early</span>`}</div>`;
} else if (d.state === "done") {
status = `<div><b>🏆 ${esc(spec.name)} cleared!</b></div>`;
} else {
status = `<div class="muted small">Fighting: ${esc(spec.pulls[d.pull].name)}</div>`;
}
return `<div class="card">
<div class="row"><h2 style="margin:0">${esc(spec.name)}</h2><span class="muted small">wipes ${d.wipes}</span>
<button class="btn small danger right" data-act="leave-dungeon">Leave</button></div>
<div style="margin:8px 0">${status}</div>
${pulls}
</div>`;
}
function placeCard(here) {
const me = S.me;
const act = me.activity;
const travelling = act && act.kind === "travel";
const hunting = act && act.kind === "hunt" ? act.hunt : null;
const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => {
const mob = C.mobs[id];
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
return `<div class="item-row" style="cursor:default">
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`}
</div>`;
}).join("");
const offers = me.available.map((q) => questOffer(q)).join("");
const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q))
.map((q) => questTurnIn(q)).join("");
const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : "";
const junk = me.bags.filter((b) => b.q === "poor");
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
const vendor = here.hub ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span>
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : "";
const roads = Object.entries(here.links).map(([to, minutes]) =>
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join("");
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id])
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join("");
return `<div class="card">
<div class="row"><h2 style="margin:0">${esc(here.name)}</h2><span class="muted small">${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}</span></div>
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""}
${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""}
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
${dungeon}
${vendor}
<h3>Roads</h3>
<div class="row wrap">${roads}</div>
<div class="row" style="margin-top:8px"><select id="far" class="grow" ${travelling ? "disabled" : ""}><option value="">Travel further…</option>${far}</select>
<button class="btn small" data-act="travel-far" ${travelling ? "disabled" : ""}>Go</button></div>
</div>`;
}
function dungeonEntrance(id) {
const spec = C.dungeons[id];
const g = S.group;
let action;
if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`;
else if (g.leader !== S.uid) action = `<span class="small muted">Waiting for the leader</span>`;
else action = `<button class="btn primary small" data-act="enter">Enter</button>`;
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>
${action}</div>`;
}
function questOffer(qid) {
const q = C.quests[qid];
const tooHigh = q.lv > S.me.level + 3;
return `<div class="quest">
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span></div>
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div>
${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span>
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>
</div>`;
}
function questTurnIn(qid) {
const q = C.quests[qid];
const choices = (q.choice || []).map((id, i) => {
const it = C.items[id];
const on = pickReward[qid] === i;
const fit = fitsMe(it);
return `<div class="item-row ${on ? "sel" : ""}" data-act="pick-reward" data-id="${qid}" data-i="${i}" style="${on ? "border-color:var(--gold)" : ""}">
<span class="q-${it.q}">${esc(it.name)}</span><span class="small muted">${itemLine(it)}</span>
${fit ? "" : '<span class="small stat-down right">can\'t use</span>'}</div>`;
}).join("");
return `<div class="quest ready">
<div class="row"><span class="qmark">?</span><b class="grow">${esc(q.name)}</b></div>
<div class="small muted">${q.xp} xp · ${money(q.copper)}</div>
${choices ? `<div class="list" style="margin-top:6px">${choices}</div>` : ""}
<div class="row" style="margin-top:6px"><button class="btn small primary right" data-act="turn-in" data-id="${qid}">Complete</button></div>
</div>`;
}
function objText(o, have) {
const need = o.n || 1;
const count = have === undefined ? "" : ` ${have} / ${need}`;
if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`;
if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`;
if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`;
return "";
}
function questReady(qid) {
const progress = S.me.quests[qid];
return C.quests[qid].obj.every((o, i) => progress[i] >= (o.n || 1));
}
function logCard() {
const lines = S.me.log.slice(-12).reverse();
if (!lines.length) return "";
return `<div class="card"><h3 style="margin-top:0">Lately</h3><div class="log" style="max-height:none; flex-direction:column">
${lines.map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("")}</div></div>`;
}
function ago(t) {
const s = now() - t;
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function devCard() {
const users = ["fred", "kayla", "apollon", "tester"];
const current = (document.cookie.match(/wog_user=([^;]+)/) || [])[1] || "fred";
return `<div class="card devbar">
<b>Dev</b>
<select id="dev-user">${users.map((u) => `<option ${u === current ? "selected" : ""}>${u}</option>`).join("")}</select>
<input id="dev-level" type="number" min="1" max="20" value="${S.me.level}" style="width:56px"><button class="btn small" data-act="dev-level">Set level + gear</button>
<button class="btn small" data-act="dev-advance" data-m="10">+10m</button>
<button class="btn small" data-act="dev-advance" data-m="60">+1h</button>
<select id="dev-place">${Object.entries(C.places).map(([id, p]) => `<option value="${id}" ${id === S.me.place ? "selected" : ""}>${esc(p.name)}</option>`).join("")}</select>
<button class="btn small" data-act="dev-teleport">Teleport</button>
</div>`;
}
// --- quests -------------------------------------------------------------
function questsPage() {
const me = S.me;
const ids = Object.keys(me.quests);
if (!ids.length) {
return `<div class="card"><h2>Quest log</h2><p class="muted">No quests. Look for <span class="qmark">!</span> at the places you visit.</p>
<p class="small muted">${me.done.length} completed.</p></div>`;
}
return `<div class="card"><h2>Quest log <span class="muted small">${ids.length} / 12</span></h2><div class="list">
${ids.map((qid) => {
const q = C.quests[qid];
const ready = questReady(qid);
const ender = q.ender || q.giver;
return `<div class="quest ${ready ? "ready" : ""}">
<div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div>
${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")}
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`}</span>
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
</div>`;
}).join("")}
</div><p class="small muted">${me.done.length} completed.</p></div>`;
}
// --- character ----------------------------------------------------------
function itemLine(it) {
if (!it.slot) return it.value ? `sells ${money(it.value)}` : "";
const bits = [];
if (it.dps) bits.push(`${it.dps} dps`);
if (it.armor) bits.push(`${it.armor} armor`);
if (it.sta) bits.push(`+${it.sta} sta`);
if (it.power) bits.push(`+${it.power} power`);
return `${esc(C.slot_names[it.slot])} · ${esc(it.kind)} · ${bits.join(", ")}`;
}
function fitsMe(it) {
if (!it.slot) return false;
const cls = C.classes[S.me.cls];
if (it.slot === "weapon") return cls.weapons.includes(it.kind);
const rank = { cloth: 0, leather: 1, mail: 2 };
return rank[it.kind] <= rank[cls.armour];
}
function compare(it) {
const worn = S.me.equip[it.slot];
const diff = (key) => (it[key] || 0) - ((worn && worn[key]) || 0);
const parts = ["sta", "power", "armor", "dps"].map((k) => {
const d = Math.round(diff(k) * 10) / 10;
if (!d) return "";
return `<span class="${d > 0 ? "stat-up" : "stat-down"}">${d > 0 ? "+" : ""}${d} ${k}</span>`;
}).filter(Boolean);
return parts.length ? `vs worn: ${parts.join(" ")}` : "same as worn";
}
function characterPage() {
const me = S.me;
const st = me.stats;
const spec = specOf(me.cls);
const slots = C.slots.map((slot) => {
const it = me.equip[slot];
return `<div class="slot" ${it ? `data-act="unequip" data-id="${slot}"` : ""}><span class="k">${esc(C.slot_names[slot])}</span>
<span>${it ? `<span class="q-${it.q}">${esc(it.name)}</span> <span class="small muted">${itemLine(it).split(" · ").slice(2).join(" · ")}</span>` : '<span class="muted">—</span>'}</span></div>`;
}).join("");
const bags = me.bags.map((it) => {
const on = selected === it.id;
const atHub = place(me.place).hub;
return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}">
<div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""}
<div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div>
${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""}
${on ? `<div class="row" style="margin-top:6px">
${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""}
<button class="btn small" data-act="sell" data-id="${esc(it.id)}" ${atHub ? "" : "disabled"}>${atHub ? `Sell ${money(it.value)}` : "Sell in town"}</button></div>` : ""}
</div></div>`;
}).join("");
return `<div class="card">
<h2 style="color:${clsColor(me.cls)}">${esc(me.name)} <span class="muted small">level ${me.level} ${esc(spec.name)} ${esc(C.classes[me.cls].name)} · ${ROLE[spec.role]}</span></h2>
<div class="statgrid">
<div><b>${st.max_hp}</b><span>health</span></div>
<div><b>${st.power}</b><span>power</span></div>
<div><b>${st.armor}</b><span>armor</span></div>
<div><b>${st.res_max}</b><span>${st.res}</span></div>
<div><b>${st.dps}</b><span>weapon dps</span></div>
<div><b>${me.xp_next ? `${me.xp} / ${me.xp_next}` : "max"}</b><span>xp</span></div>
</div>
</div>
<div class="card"><h2>Abilities</h2>
<p class="tip">Abilities on <b>Auto</b> cast themselves when their rule says so, on a ${C.auto_cd}× cooldown. Set to <b>You</b> to press them yourself on the shorter cooldown. When you look away for ${C.watch}s, yours go back to auto.</p>
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i], me)).join("")}</div></div>
<div class="card"><h2>Equipment</h2><div class="list">${slots}</div></div>
<div class="card"><h2>Bags</h2>${bags ? `<div class="list">${bags}</div>` : '<p class="muted">Empty.</p>'}</div>`;
}
// --- group --------------------------------------------------------------
function groupPage() {
const g = S.group;
let mine;
if (g) {
const here = place(S.me.place);
mine = `<div class="card"><h2>Your group</h2><div class="list">
${g.members.map((m) => `<div class="item-row" style="cursor:default">
<b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""}
<span class="small muted grow">${m.level} ${esc(C.classes[m.cls].name)} · ${m.inside ? "inside" : esc(place(m.place).name)}${m.busy ? ` · ${esc(m.busy.replace("You're ", ""))}` : ""}</span>
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
</div>
<div class="row" style="margin-top:8px">
${!g.dungeon && here.dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Enter ${esc(C.dungeons[here.dungeon].name)}</button>` : ""}
${!g.dungeon && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""}
<button class="btn small danger right" data-act="leave-group">Leave group</button></div></div>
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
} else {
const open = S.groups.filter((x) => !x.inside && x.size < 5);
mine = `<div class="card"><h2>Group</h2>
<p class="tip">Dungeons take a group. Meet at the entrance; the leader leads you in.</p>
<div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span>
<button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div>
<div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`;
}
const people = S.players.map((p) => `<div class="item-row" style="cursor:default">
<span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b>
<span class="small muted grow">${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}</span></div>`).join("");
return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`;
}
// --- chat ---------------------------------------------------------------
function chatPage() {
const names = Object.fromEntries(S.players.map((p) => [p.id, p]));
const msgs = S.chat.slice().reverse().map((m) => {
const p = names[m.who];
return `<div class="msg"><b style="color:${p ? clsColor(p.cls) : "#ddd"}">${esc(p ? p.name : m.who)}</b>${esc(m.text)} <span class="small muted">${ago(m.t)}</span></div>`;
}).join("");
const events = S.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("");
return `<div class="card"><h2>Guild chat</h2>
<div class="row"><input id="say" class="grow" placeholder="Say something" maxlength="300"><button class="btn primary small" data-act="say">Send</button></div>
<div class="chat" style="margin-top:8px">${msgs || '<span class="muted">Quiet in here.</span>'}</div></div>
<div class="card"><h2>World</h2><div class="log" style="max-height:none; flex-direction:column">${events}</div></div>`;
}
// --- the fight ----------------------------------------------------------
function fightTime() {
return S.fight ? now() - S.fight.at : 0;
}
function renderFight() {
const box = $("#fightbox");
if (!box) return;
const f = S.fight;
box.style.display = page === "adventure" ? "" : "none";
if (!f) {
if (box.dataset.id) {
box.innerHTML = "";
box.dataset.id = "";
}
return;
}
if (box.dataset.id !== f.id) {
box.dataset.id = f.id;
box.innerHTML = `<div class="card"><div class="fight-head"><h2 style="margin:0" id="f-name"></h2><span class="muted small" id="f-state"></span></div>
<div class="side" id="f-enemy" style="margin-top:8px"></div><div class="vs">— vs —</div><div class="side" id="f-party"></div>
<div class="log" id="f-log"></div></div>`;
seenEv = { id: f.id, t: Math.max(-1, ...f.ev.map((e) => e.t)) - 0.5 };
}
$("#f-name").textContent = f.name;
$("#f-state").textContent = f.state === "on" ? clock(f.t) : f.state === "won" ? "Victory" : "Defeat";
const me = myUnit();
const byId = Object.fromEntries(f.units.map((u) => [u.id, u]));
for (const side of ["enemy", "party"]) {
const holder = $(`#f-${side}`);
const units = f.units.filter((u) => u.side === side);
const keep = new Set(units.map((u) => u.id));
for (const el of [...holder.children]) if (!keep.has(el.dataset.u)) el.remove();
for (const u of units) {
let el = holder.querySelector(`[data-u="${CSS.escape(u.id)}"]`);
if (!el) {
el = document.createElement("div");
el.className = "frame";
el.dataset.u = u.id;
el.dataset.act = "target";
el.innerHTML = `<div class="body"></div><div class="floats"></div>`;
holder.appendChild(el);
}
el.classList.toggle("dead", !!u.dead);
el.classList.toggle("target", !!me && me.target === u.id && side === "enemy");
el.classList.toggle("ftarget", !!me && me.ftarget === u.id && side === "party");
const html = frameHtml(u, byId, me);
const body = el.querySelector(".body");
if (body.dataset.html !== html) {
body.innerHTML = html;
body.dataset.html = html;
}
}
}
$("#f-log").innerHTML = f.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)}</div>`).join("");
floats(f);
}
function frameHtml(u, byId, me) {
const isPlayer = u.kind === "player";
const color = isPlayer ? clsColor(u.cls) : u.side === "enemy" ? "#f0b8a8" : "#cfe3c0";
const tags = [u.boss ? '<span class="tag boss">Boss</span>' : u.elite ? '<span class="tag">Elite</span>' : "", u.rare ? '<span class="tag">Rare</span>' : ""].join("");
const low = u.hp / u.max_hp < 0.3;
let html = `<div class="row"><span class="name" style="color:${color}">${u.icon ? u.icon + " " : ""}${esc(u.name)}</span><span class="lv">${u.level}</span>${tags}</div>`;
html += bar(u.hp, u.max_hp, low ? "low" : "", u.dead ? "dead" : `${u.hp} / ${u.max_hp}`);
if (u.res && u.res.type) html += `<div class="bar thin ${u.res.type}" style="margin-top:2px"><i style="width:${(u.res.cur / u.res.max) * 100}%"></i></div>`;
if (u.cast) {
const target = byId[u.cast.target];
html += `<div class="bar cast ${u.side === "enemy" && !u.cast.interrupt ? "safe" : ""}" data-start="${u.cast.start}" data-end="${u.cast.end}">
<i style="width:0"></i><span>${u.cast.icon} ${esc(u.cast.name)}${target && target.id !== u.id ? " → " + esc(target.name) : ""}</span></div>`;
}
const auras = u.auras.map((a) => `<span class="${a.mine === S.uid ? "mine" : ""}" title="${esc(a.name)}">${a.icon}</span>`).join("");
html += `<div class="auras">${auras}${u.combo ? `<span title="Combo points">${"🔸".repeat(u.combo)}</span>` : ""}${u.stun ? "<span title='Stunned'>💫</span>" : ""}${u.root ? "<span title='Frozen'>🧊</span>" : ""}</div>`;
if (u.side === "enemy" && !u.dead) {
const on = byId[u.target];
const safe = on && (on.role === "tank" || on.kind === "pet");
const mine = me && (u.threat || []).find(([id]) => id === S.uid);
html += `<div class="on-whom">${on ? `→ <b class="${safe ? "ok" : ""}">${esc(on.name)}</b>` : ""}${mine ? ` · your threat ${pct(mine[1])}` : ""}</div>`;
}
if (u.cc) html += `<span class="cc">${u.cc}</span>`;
return html;
}
function floats(f) {
for (const e of f.ev) {
if (e.t <= seenEv.t) continue;
seenEv.t = Math.max(seenEv.t, e.t);
const frame = document.querySelector(`[data-u="${CSS.escape(e.u)}"] .floats`);
if (!frame) continue;
const unit = f.units.find((u) => u.id === e.u);
let kind = e.k, text = String(e.n);
if (kind === "heal") text = "+" + e.n;
else if (kind === "interrupt") text = "Interrupted!";
else if (kind === "oom") text = "Not enough mana";
else if (kind === "absorb") text = "Absorbed";
else if (unit && unit.side === "party") { kind = "taken"; text = "-" + e.n; }
if (kind === "crit") text += "!";
const el = document.createElement("div");
el.className = `float ${kind}`;
el.textContent = text;
el.style.left = `${35 + Math.random() * 30}%`;
frame.appendChild(el);
setTimeout(() => el.remove(), 1200);
}
}
function renderBar() {
const el = $("#bar");
const me = myUnit();
const show = !!(S.me && me && !me.dead && S.fight.state === "on");
el.classList.toggle("show", show);
document.documentElement.style.setProperty("--bar-h", show ? "112px" : "0px");
if (!show) {
barKey = "";
return;
}
const spec = specOf(S.me.cls);
const key = spec.abilities.map((a) => `${a}:${me.abilities.includes(a)}:${S.me.manual.includes(a)}`).join("|");
if (key !== barKey) {
barKey = key;
el.innerHTML = `<div class="inner"><div id="mycast"></div><div class="abilities">${spec.abilities.map((id, i) => {
const a = C.abilities[id];
const known = me.abilities.includes(id);
const manual = S.me.manual.includes(id);
return `<div class="ab ${known ? (manual ? "manual" : "auto") : "locked"}" data-ab="${id}">
<span class="icon">${a.icon}</span><span class="label">${known ? esc(a.name) : `level ${C.unlock[i]}`}</span>
${known ? `<span class="mode" data-mode="${id}">${manual ? "YOU" : "AUTO"}</span>` : ""}
<span class="gcd"></span></div>`;
}).join("")}</div>
<div class="bar-hint">Tap a frame to target it · tap AUTO / YOU to switch</div></div>`;
}
animate();
}
// Moves cast bars, cooldowns and travel along between polls.
function animate() {
if (!S) return;
const t = fightTime();
for (const el of document.querySelectorAll(".bar.cast[data-start]")) {
const start = Number(el.dataset.start), end = Number(el.dataset.end);
el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`;
}
for (const el of document.querySelectorAll(".bar.road[data-from]")) {
const from = Number(el.dataset.from), to = Number(el.dataset.to);
el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`;
}
for (const el of document.querySelectorAll("[data-until]")) {
el.textContent = clock(Number(el.dataset.until) - now());
}
const me = myUnit();
if (!me || !$("#bar").classList.contains("show")) return;
const cast = $("#mycast");
if (cast) {
const html = me.cast ? `<div class="bar cast casting" data-start="${me.cast.start}" data-end="${me.cast.end}"><i style="width:${Math.min(1, (t - me.cast.start) / (me.cast.end - me.cast.start)) * 100}%"></i><span>${me.cast.icon} ${esc(me.cast.name)}</span></div>` : "";
if (cast.dataset.html !== html) { cast.innerHTML = html; cast.dataset.html = html; }
}
const gcdLeft = Math.max(0, (me.busy || 0) - t);
for (const el of document.querySelectorAll(".ab[data-ab]")) {
const id = el.dataset.ab;
const a = C.abilities[id];
const ready = (me.cds || {})[id] || 0;
const left = ready - t;
let cd = el.querySelector(".cdm");
if (left > 0.05) {
if (!cd) {
cd = document.createElement("span");
cd.className = "cdm";
el.appendChild(cd);
}
cd.textContent = left >= 10 ? Math.ceil(left) : left.toFixed(1);
} else if (cd) cd.remove();
const cost = a.cost ? (a.cost < 1 ? a.cost * me.res.max : a.cost) : 0;
el.classList.toggle("nores", !!(cost && me.res.cur < cost));
el.classList.toggle("queued", me.queued === id);
const gcd = el.querySelector(".gcd");
if (gcd) gcd.style.width = a.offgcd ? "0" : `${Math.min(1, gcdLeft / 1.5) * 100}%`;
}
}
// --- input --------------------------------------------------------------
document.addEventListener("pointerdown", (ev) => {
const mode = ev.target.closest("[data-mode]");
if (mode) {
ev.preventDefault();
const id = mode.dataset.mode;
api("manual", { id, on: !S.me.manual.includes(id) });
return;
}
const ab = ev.target.closest(".ab[data-ab]");
if (ab) {
ev.preventDefault();
const id = ab.dataset.ab;
if (ab.classList.contains("locked")) return;
if (!S.me.manual.includes(id)) {
toast("On auto. Tap AUTO to take it yourself.");
return;
}
ab.classList.add("queued");
api("press", { id });
return;
}
const frame = ev.target.closest("[data-act=target]");
if (frame) {
ev.preventDefault();
api("target", { id: frame.dataset.u });
}
});
document.addEventListener("click", async (ev) => {
const nav = ev.target.closest("[data-page]");
if (nav) {
page = nav.dataset.page;
remember("page", page);
render();
window.scrollTo(0, 0);
return;
}
const cls = ev.target.closest("[data-cls]");
if (cls) {
remember("draftName", $("#name")?.value || "");
regCls = cls.dataset.cls;
render();
return;
}
const el = ev.target.closest("[data-act]");
if (!el || el.dataset.act === "target") return;
const id = el.dataset.id;
switch (el.dataset.act) {
case "register":
if (await api("register", { name: $("#name").value, cls: regCls })) toast("Welcome to Azeroth", true);
break;
case "hunt": api("hunt", { id }); break;
case "stop": api("stop"); break;
case "travel": api("travel", { id }); break;
case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break;
case "accept": api("accept", { 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 "turn-in": api("turn_in", { id, choice: pickReward[id] }); break;
case "sell-junk": api("sell_junk"); break;
case "select": if (!ev.target.closest("button")) { selected = selected === id ? null : id; render(); } break;
case "equip": api("equip", { id }); break;
case "unequip": api("unequip", { id }); break;
case "sell": api("sell", { id, qty: "all" }); break;
case "manual": api("manual", { id, on: el.dataset.on === "1" }); break;
case "create-group": api("group/create"); break;
case "join": api("group/join", { id }); break;
case "leave-group": if (confirm("Leave the group?")) api("group/leave"); break;
case "enter": api("dungeon/enter"); break;
case "pull": api("dungeon/pull"); break;
case "leave-dungeon": if (confirm("Leave the dungeon?")) api("dungeon/leave"); break;
case "say": {
const input = $("#say");
if (input.value.trim() && await api("say", { text: input.value })) input.value = "";
break;
}
case "dev-level": api("dev/level", { level: Number($("#dev-level").value) }); break;
case "dev-advance": api("dev/advance", { minutes: Number(el.dataset.m) }); break;
case "dev-teleport": api("dev/teleport", { id: $("#dev-place").value }); break;
}
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" && ev.target.id === "say") $("[data-act=say]").click();
if (ev.target.matches("input, textarea, select")) return;
const n = Number(ev.key);
if (n >= 1 && n <= 4) {
const ab = document.querySelectorAll(".ab[data-ab]")[n - 1];
if (ab) ab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
}
});
document.addEventListener("change", (ev) => {
if (ev.target.id === "dev-user") {
document.cookie = `wog_user=${encodeURIComponent(ev.target.value)}; path=/; max-age=31536000`;
location.reload();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) refresh();
});
// --- boot ---------------------------------------------------------------
async function boot() {
const res = await fetch("/api/content");
C = await res.json();
await refresh();
setInterval(() => {
if (document.hidden) return;
const fast = S && S.fight && S.fight.state === "on";
if (Date.now() - polledAt >= (fast ? FIGHT_POLL_MS : POLL_MS)) refresh();
}, 100);
setInterval(animate, 100);
setInterval(() => { if (S && page === "adventure" && !S.fight) renderMain(); }, 1000);
}
boot();

20
static/index.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14110d">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>World of Guildhall</title>
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<header id="top"></header>
<main id="main"></main>
<div id="bar"></div>
<nav id="nav"></nav>
<div id="toast"></div>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,8 @@
{
"name": "World of Guildhall",
"short_name": "WoG",
"start_url": "/",
"display": "standalone",
"background_color": "#14110d",
"theme_color": "#14110d"
}

204
static/styles.css Normal file
View file

@ -0,0 +1,204 @@
:root {
--bg: #14110d;
--panel: #1e1913;
--panel-2: #272017;
--line: #3b3125;
--text: #ebe0c8;
--muted: #9a8c74;
--gold: #f6cf6a;
--hp: #2fae4e;
--hp-low: #c8372d;
--mana: #2f6fdb;
--rage: #c23b30;
--energy: #e3c33b;
--cast: #e8a93a;
--bad: #e2574c;
--good: #59c96b;
--q-poor: #9d9d9d;
--q-common: #f2f2f2;
--q-uncommon: #1eff00;
--q-rare: #3d9bff;
--q-epic: #b462f5;
--nav-h: 58px;
--bar-h: 0px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text); }
body {
font: 15px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding-bottom: calc(var(--nav-h) + var(--bar-h) + env(safe-area-inset-bottom) + 12px);
-webkit-tap-highlight-color: transparent;
}
button { font: inherit; color: inherit; }
h2 { font-size: 15px; margin: 0 0 8px; color: var(--gold); letter-spacing: .02em; }
h3 { font-size: 13px; margin: 10px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.row { display: flex; align-items: center; gap: 8px; }
.grow { flex: 1; min-width: 0; }
.right { margin-left: auto; }
.wrap { flex-wrap: wrap; }
main { max-width: 760px; margin: 0 auto; padding: 8px 12px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 10px 0; }
.btn {
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px;
cursor: pointer; touch-action: manipulation;
}
.btn:hover { border-color: #5a4a36; }
.btn.primary { background: #4a3714; border-color: #8a6a2a; color: var(--gold); }
.btn.danger { border-color: #6b2a24; color: #f19a90; }
.btn.small { padding: 4px 9px; font-size: 13px; }
.btn[disabled] { opacity: .45; cursor: default; }
.btn.on { border-color: var(--gold); box-shadow: inset 0 0 0 1px var(--gold); }
.list { display: flex; flex-direction: column; gap: 6px; }
.item-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 8px; background: var(--panel-2);
border: 1px solid transparent; cursor: pointer; }
.item-row.sel { border-color: var(--line); }
/* top bar */
#top { position: sticky; top: 0; z-index: 5; background: linear-gradient(#1b1610, #14110dee); border-bottom: 1px solid var(--line);
padding: 8px 12px calc(6px); backdrop-filter: blur(4px); }
#top .inner { max-width: 760px; margin: 0 auto; }
.who { font-weight: 700; }
.money { font-variant-numeric: tabular-nums; white-space: nowrap; }
.g { color: #ffd34d; } .s { color: #d6d6d6; } .c { color: #d0874a; }
.xpbar { height: 5px; border-radius: 3px; background: #2a2318; overflow: hidden; margin-top: 6px; }
.xpbar > i { display: block; height: 100%; background: linear-gradient(90deg, #8e4ec6, #b07ae8); }
.minibars { display: flex; gap: 6px; margin-top: 5px; }
.minibars .bar { flex: 1; }
/* bars */
.bar { position: relative; height: 16px; border-radius: 4px; background: #0d0b08; overflow: hidden; border: 1px solid #00000080; }
.bar > i { position: absolute; inset: 0 auto 0 0; background: var(--hp); transition: width .25s linear; }
.bar > span { position: relative; display: block; text-align: center; font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px #000; font-variant-numeric: tabular-nums; }
.bar.thin { height: 7px; }
.bar.thin > span { display: none; }
.bar.mana > i { background: var(--mana); }
.bar.rage > i { background: var(--rage); }
.bar.energy > i { background: var(--energy); }
.bar.low > i { background: var(--hp-low); }
.bar.cast > i { background: var(--cast); transition: none; }
.bar.cast.safe > i { background: #8b8b8b; }
.bar.road > i { background: #8a6a2a; transition: none; }
/* fight */
.fight-head { display: flex; align-items: baseline; gap: 8px; }
.side { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 6px; }
.frame { position: relative; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 6px 7px;
cursor: pointer; user-select: none; }
.frame.target { border-color: #e2c25a; box-shadow: 0 0 0 1px #e2c25a inset; }
.frame.ftarget { border-color: #59c96b; box-shadow: 0 0 0 1px #59c96b inset; }
.frame.dead { opacity: .4; }
.frame .name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.frame .lv { font-size: 11px; color: var(--muted); }
.frame .bar { margin-top: 4px; }
.frame .auras { display: flex; gap: 2px; flex-wrap: wrap; margin-top: 3px; min-height: 16px; font-size: 13px; }
.frame .auras .mine { filter: drop-shadow(0 0 2px #fff6); }
.frame .tag { font-size: 10px; padding: 0 5px; border-radius: 6px; background: #3a2c1a; color: var(--gold); margin-left: 4px; }
.frame .tag.boss { background: #4d1d1a; color: #ff9a8a; }
.frame .on-whom { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.frame .on-whom b { color: #ff9a8a; font-weight: 600; }
.frame .on-whom b.ok { color: var(--muted); }
.frame .cc { position: absolute; right: 6px; top: 4px; font-size: 18px; }
.floats { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
.float { position: absolute; left: 50%; top: 18px; transform: translateX(-50%); font-weight: 800; font-size: 14px;
text-shadow: 0 1px 2px #000, 0 0 4px #000; animation: rise 1.1s ease-out forwards; white-space: nowrap; }
.float.dmg { color: #ffffff; } .float.crit { color: #ffd34d; font-size: 18px; } .float.heal { color: #5cff7a; }
.float.taken { color: #ff6b5e; } .float.absorb { color: #9fd2ff; } .float.interrupt { color: #7fe3ff; }
.float.oom { color: #79a5ff; }
@keyframes rise { 0% { opacity: 0; transform: translate(-50%, 6px) scale(.9); } 15% { opacity: 1; transform: translate(-50%, 0) scale(1.08); }
100% { opacity: 0; transform: translate(-50%, -26px) scale(1); } }
.vs { text-align: center; color: var(--muted); font-size: 11px; margin: 6px 0; letter-spacing: .2em; }
.log { font-size: 12px; max-height: 140px; overflow-y: auto; display: flex; flex-direction: column-reverse; margin-top: 8px; }
.log div { padding: 1px 0; color: var(--muted); }
.log .yell { color: #ff6b5e; font-weight: 600; } .log .interrupt { color: #7fe3ff; } .log .death { color: #e2574c; }
.log .enemy { color: #e8a93a; } .log .cast { color: #c8bca4; } .log .loot { color: var(--q-uncommon); }
.log .level { color: var(--gold); font-weight: 600; } .log .quest { color: #f0d080; }
/* action bar */
#bar { position: fixed; left: 0; right: 0; bottom: calc(var(--nav-h) + env(safe-area-inset-bottom)); z-index: 6;
background: linear-gradient(#14110d00, #14110d 18%); padding: 14px 10px 6px; display: none; }
#bar.show { display: block; }
#bar .inner { max-width: 760px; margin: 0 auto; }
.abilities { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.ab { position: relative; height: 74px; border-radius: 10px; background: #231c13; border: 1px solid #4a3c2a; display: flex;
flex-direction: column; align-items: center; justify-content: center; cursor: pointer; overflow: hidden; touch-action: manipulation;
user-select: none; -webkit-user-select: none; }
.ab .icon { font-size: 26px; line-height: 1; }
.ab .label { font-size: 10.5px; margin-top: 3px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 94%; }
.ab .cdm { position: absolute; inset: 0; background: #000a; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 20px; text-shadow: 0 1px 2px #000; }
.ab .gcd { position: absolute; left: 0; bottom: 0; height: 3px; background: var(--gold); }
.ab .mode { position: absolute; top: 3px; right: 3px; font-size: 9px; font-weight: 800; padding: 1px 5px; border-radius: 6px;
background: #3b3125; color: var(--muted); letter-spacing: .04em; }
.ab.manual { border-color: #b8913a; background: #2e2414; }
.ab.manual .mode { background: var(--gold); color: #1a1408; }
.ab.auto .icon { opacity: .75; }
.ab.nores .icon { filter: grayscale(1) brightness(.6); }
.ab.queued { box-shadow: 0 0 0 2px #fff inset; }
.ab.locked { opacity: .35; }
.ab:active { transform: scale(.97); }
.bar-hint { font-size: 11px; color: var(--muted); text-align: center; margin-top: 5px; }
.casting { margin-bottom: 6px; }
/* nav */
#nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 7; height: calc(var(--nav-h) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom); background: #100d0a; border-top: 1px solid var(--line); display: flex; }
#nav button { flex: 1; background: none; border: 0; color: var(--muted); font-size: 11px; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px; cursor: pointer; position: relative; }
#nav button .i { font-size: 20px; }
#nav button.on { color: var(--gold); }
#nav .dot { position: absolute; top: 8px; right: calc(50% - 18px); width: 8px; height: 8px; border-radius: 50%; background: var(--gold); }
/* items */
.q-poor { color: var(--q-poor); } .q-common { color: var(--q-common); } .q-uncommon { color: var(--q-uncommon); }
.q-rare { color: var(--q-rare); } .q-epic { color: var(--q-epic); }
.tip { font-size: 12px; color: var(--muted); }
.stat-up { color: var(--good); } .stat-down { color: var(--bad); }
.slot { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; padding: 6px 8px; background: var(--panel-2);
border-radius: 8px; cursor: pointer; }
.slot .k { color: var(--muted); font-size: 12px; }
.statgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.statgrid div { background: var(--panel-2); border-radius: 8px; padding: 6px 8px; }
.statgrid b { display: block; font-size: 16px; }
.statgrid span { font-size: 11px; color: var(--muted); }
/* quests */
.quest { padding: 8px; background: var(--panel-2); border-radius: 8px; }
.quest .obj { font-size: 13px; color: var(--muted); }
.quest .obj.done { color: var(--good); }
.quest.ready { border: 1px solid #8a6a2a; }
.qmark { color: #ffd34d; font-weight: 900; }
/* register */
.classes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
.cls { padding: 10px; border-radius: 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
.cls.on { border-color: var(--gold); box-shadow: inset 0 0 0 1px var(--gold); }
.cls .ic { font-size: 22px; }
.cls b { display: block; }
input, select, textarea { font: inherit; color: var(--text); background: #0f0c09; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
input:focus, textarea:focus { outline: 1px solid #8a6a2a; }
/* chat */
.chat { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; }
.chat .msg b { margin-right: 6px; }
#toast { position: fixed; left: 50%; top: 70px; transform: translateX(-50%); z-index: 20; background: #3a1d19; border: 1px solid #7a3328;
color: #ffd2cb; padding: 8px 14px; border-radius: 8px; display: none; max-width: 90vw; text-align: center; }
#toast.show { display: block; }
#toast.ok { background: #1d3a22; border-color: #2e7a3c; color: #d2ffd8; }
.devbar { font-size: 12px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.devbar input, .devbar select { padding: 3px 6px; font-size: 12px; }
@media (max-width: 420px) {
.side { grid-template-columns: 1fr 1fr; }
.frame { padding: 5px 6px; }
.ab { height: 68px; }
}

260
tests/test_game.py Normal file
View file

@ -0,0 +1,260 @@
import random
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import combat # noqa: E402
import content # noqa: E402
import game # noqa: E402
def world_with(*players, now=1000.0, dev=True):
world = game.new_world(now)
world["config"]["dev"] = dev
for uid, cls in players:
world["players"][uid] = game.new_player(uid, uid.title(), cls, now)
return world
def solo_fight(cls, level, mob, seed=1, watch=None, manual=()):
player = game.new_player("p", "P", cls, 0)
game.dev_level({"config": {}}, player, level, 0)
player["manual"] = list(manual)
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False),
[combat.mob_unit("e0", mob, level)], "hunt")
return player, fight
class Content(unittest.TestCase):
def test_every_spec_has_four_known_abilities(self):
for spec in content.SPECS.values():
self.assertEqual(len(spec["abilities"]), 4)
self.assertEqual(sorted(spec["abilities"]), sorted(spec["priority"]))
for ab in spec["abilities"]:
self.assertIn(ab, content.ABILITIES)
def test_places_link_both_ways_and_hunts_exist(self):
for pid, place in content.PLACES.items():
for other in place["links"]:
self.assertIn(pid, content.PLACES[other]["links"], f"{pid} -> {other}")
for hunt, pulls in place.get("hunts", {}).items():
self.assertIn(hunt, content.MOBS)
for pull in pulls:
for mob in pull:
self.assertIn(mob, content.MOBS)
def test_quests_point_at_real_things(self):
for qid, quest in content.QUESTS.items():
self.assertIn(quest["giver"], content.PLACES)
for req in quest.get("req", []):
self.assertIn(req, content.QUESTS)
for objective in quest["obj"]:
if "kill" in objective:
self.assertIn(objective["kill"], content.MOBS)
for mob in objective.get("from", []):
self.assertIn(mob, content.MOBS)
for reward in quest.get("choice", []):
self.assertIsNotNone(game.item(reward), reward)
def test_loot_tables_resolve(self):
for items in list(content.BOSS_LOOT.values()) + [content.TRASH_LOOT]:
for item_id in items:
self.assertIsNotNone(game.item(item_id), item_id)
class Items(unittest.TestCase):
def test_generated_green_has_name_and_stats(self):
green = game.item("g/chest/mail/bear/14")
self.assertEqual(green["q"], "uncommon")
self.assertIn("of the Bear", green["name"])
self.assertGreater(green["sta"], green["power"])
self.assertGreater(green["armor"], 0)
def test_armour_rules(self):
mage = game.new_player("m", "M", "mage", 0)
self.assertIsNotNone(game.can_wear(mage, game.item("g/chest/mail/bear/2")))
self.assertIsNone(game.can_wear(mage, game.item("g/chest/cloth/eagle/2")))
warrior = game.new_player("w", "W", "warrior", 0)
self.assertIsNone(game.can_wear(warrior, game.item("g/chest/cloth/eagle/2")))
self.assertIn("level", game.can_wear(warrior, game.item("g/chest/mail/bear/14")))
class Combat(unittest.TestCase):
def test_every_class_beats_an_even_mob_alone(self):
for cls in content.CLASSES:
_, fight = solo_fight(cls, 10, "riverpaw_gnoll")
combat.advance(fight, 300)
self.assertEqual(fight["state"], "won", cls)
def test_same_seed_same_fight(self):
a = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
b = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
combat.advance(a, 300)
combat.advance(b, 300)
self.assertEqual(a["t"], b["t"])
self.assertEqual(a["units"]["p"]["hp"], b["units"]["p"]["hp"])
def test_manual_ability_waits_for_the_player_while_watched(self):
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
combat.advance(fight, 6, watch={"p": 5})
self.assertNotIn("sunder", fight["units"]["p"]["cds"])
combat.press(fight, "p", "sunder", 6)
combat.advance(fight, 7, watch={"p": 6})
self.assertIn("sunder", fight["units"]["p"]["cds"])
def test_manual_ability_falls_back_to_auto_when_nobody_watches(self):
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
combat.advance(fight, 6)
self.assertIn("sunder", fight["units"]["p"]["cds"])
def test_pressed_cooldown_is_shorter_than_auto(self):
_, fight = solo_fight("shaman", 10, "riverpaw_gnoll", manual=["stormstrike"])
combat.press(fight, "p", "stormstrike", 0)
combat.advance(fight, 0.25, watch={"p": 0})
pressed = fight["units"]["p"]["cds"]["stormstrike"]
_, auto = solo_fight("shaman", 10, "riverpaw_gnoll")
combat.advance(auto, 3)
used = auto["units"]["p"]["cds"]["stormstrike"]
self.assertAlmostEqual(pressed - 0.25, content.ABILITIES["stormstrike"]["cd"], places=2)
self.assertGreater(used - pressed, content.ABILITIES["stormstrike"]["cd"] * 0.4)
def test_interrupt_stops_a_cast(self):
player = game.new_player("p", "P", "rogue", 0)
game.dev_level({"config": {}}, player, 16, 0)
player["manual"] = ["kick"]
fight = combat.new_fight("f", 3, 0, game.player_unit(player, 0, False),
[combat.mob_unit("e0", "defias_pillager", 16)], "hunt")
for step in range(40):
combat.advance(fight, (step + 1) * combat.STEP, watch={"p": step * combat.STEP})
if fight["units"]["e0"]["cast"]:
break
self.assertTrue(fight["units"]["e0"]["cast"], "the pillager should start a Fireball")
combat.press(fight, "p", "kick", fight["t"], "e0")
combat.advance(fight, fight["t"] + 0.5, watch={"p": fight["t"]})
self.assertIsNone(fight["units"]["e0"]["cast"])
self.assertTrue(any("interrupts" in line["text"] for line in fight["log"]))
def test_mob_turns_on_the_healer_and_taunt_takes_it_back(self):
tank = game.new_player("tank", "Tank", "warrior", 0)
healer = game.new_player("heal", "Heal", "priest", 0)
for p in (tank, healer):
game.dev_level({"config": {}}, p, 18, 0)
party = game.player_unit(tank, 0, True) + game.player_unit(healer, 0, True)
fight = combat.new_fight("f", 5, 0, party, [combat.mob_unit("e0", "defias_overseer", 18)], "dungeon")
mob = fight["units"]["e0"]
mob["threat"]["heal"] = 10000
combat.advance(fight, 0.5)
self.assertEqual(mob["target"], "heal")
combat.advance(fight, 4)
self.assertEqual(mob["target"], "tank")
self.assertTrue(any("Taunt" in line["text"] for line in fight["log"]))
def test_cc_breaks_on_damage_and_bosses_are_immune(self):
player = game.new_player("p", "P", "mage", 0)
game.dev_level({"config": {}}, player, 18, 0)
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
[combat.mob_unit("e0", "defias_miner", 18), combat.mob_unit("e1", "rhahkzor", 19)],
"dungeon")
rng = random.Random(1)
combat._apply(fight, fight["units"]["p"], "polymorph", "e1", 0, rng)
self.assertEqual(fight["units"]["e1"]["cc"], 0)
combat._apply(fight, fight["units"]["p"], "polymorph", "e0", 0, rng)
self.assertGreater(fight["units"]["e0"]["cc"], 0)
combat._damage(fight, fight["units"]["p"], fight["units"]["e0"], 10, "frost", 0.5, rng)
self.assertEqual(fight["units"]["e0"]["cc"], 0)
def test_boss_phase_summons_adds(self):
player = game.new_player("p", "P", "warrior", 0)
game.dev_level({"config": {}}, player, 18, 0)
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
[combat.mob_unit("e0", "edwin_vancleef", 21)], "dungeon")
boss = fight["units"]["e0"]
boss["hp"] = boss["max_hp"] * 0.49
combat.advance(fight, 0.25)
self.assertEqual(sum(1 for u in fight["units"].values() if u.get("mob") == "defias_blackguard"), 2)
class World(unittest.TestCase):
def test_hunting_earns_xp_and_quest_progress_while_away(self):
world = world_with(("fred", "warrior"))
fred = world["players"]["fred"]
self.assertIsNone(game.accept(fred, "kobold_camp_cleanup"))
fred["place"] = "northshire_valley"
self.assertIsNone(game.start_hunt(world, fred, "kobold_vermin", 1000))
game.process(world, 1000 + 20 * 60, random.Random(1))
self.assertGreater(fred["level"], 1)
self.assertEqual(fred["quests"]["kobold_camp_cleanup"], [10])
def test_hunting_stops_after_the_offline_cap(self):
world = world_with(("fred", "warrior"))
fred = world["players"]["fred"]
fred["place"] = "northshire_valley"
game.start_hunt(world, fred, "kobold_vermin", 1000)
game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1))
self.assertIsNone(fred["activity"])
self.assertIsNone(fred["fight"])
def test_turn_in_needs_the_right_place_and_a_reward_pick(self):
world = world_with(("fred", "warrior"))
fred = world["players"]["fred"]
game.accept(fred, "kobold_camp_cleanup")
fred["quests"]["kobold_camp_cleanup"] = [10]
fred["place"] = "goldshire"
self.assertIn("Turn it in", game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
fred["place"] = "northshire_abbey"
self.assertEqual(game.turn_in(world, fred, "kobold_camp_cleanup", None, 0), "Pick a reward")
self.assertIsNone(game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
self.assertIn("w/feet/mail/4", fred["bags"])
self.assertIn("investigate_echo_ridge", game.available_quests(fred))
def test_travel_takes_the_shortest_road(self):
minutes, path = game.route("northshire_abbey", "moonbrook")
self.assertEqual(path, ["northshire_abbey", "goldshire", "sentinel_hill", "moonbrook"])
self.assertEqual(minutes, 14)
world = world_with(("fred", "warrior"))
fred = world["players"]["fred"]
self.assertIsNone(game.travel(world, fred, "goldshire", 1000))
game.process(world, 1000 + 4 * 60 - 1, random.Random(1))
self.assertEqual(fred["place"], "northshire_abbey")
game.process(world, 1000 + 4 * 60, random.Random(1))
self.assertEqual(fred["place"], "goldshire")
self.assertIsNone(fred["activity"])
def test_a_group_clears_the_deadmines(self):
world = world_with(*[(uid, cls) for uid, cls in
(("t", "warrior"), ("h", "priest"), ("m", "mage"), ("r", "rogue"), ("x", "hunter"))])
for uid, player in world["players"].items():
game.dev_level(world, player, 20, 1000)
player["place"] = "moonbrook"
leader = world["players"]["t"]
game.create_group(world, leader)
gid = leader["group"]
for uid in "hmrx":
self.assertIsNone(game.join_group(world, world["players"][uid], gid))
self.assertIsNone(game.enter_dungeon(world, leader, 1000))
game.process(world, 1000 + 3 * 3600, random.Random(4))
dungeon = world["groups"][gid]["dungeon"]
self.assertEqual(dungeon["state"], "done", dungeon)
loot = sum(len(p["bags"]) for p in world["players"].values())
self.assertGreater(loot, 0)
def test_dungeon_needs_everyone_at_the_door(self):
world = world_with(("a", "warrior"), ("b", "priest"), dev=False)
a, b = world["players"]["a"], world["players"]["b"]
a["place"] = "moonbrook"
game.create_group(world, a)
self.assertIn("at least 2", game.enter_dungeon(world, a, 0))
game.join_group(world, b, a["group"])
self.assertIn("Waiting on B", game.enter_dungeon(world, a, 0))
b["place"] = "moonbrook"
self.assertIsNone(game.enter_dungeon(world, a, 0))
self.assertEqual(b["activity"]["kind"], "dungeon")
game.leave_group(world, b, 10)
self.assertIsNone(b["activity"])
self.assertEqual(b["place"], "moonbrook")
if __name__ == "__main__":
unittest.main()

99
tools/balance.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Balance report: solo hunts per class and level, and all-auto Deadmines runs.
python3 tools/balance.py [solo|dungeon] [runs]
"""
from __future__ import annotations
import random
import statistics
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import combat # noqa: E402
import game # noqa: E402
from content import CLASSES, DUNGEONS, MOBS # noqa: E402
SOLO_TARGETS = {1: "young_wolf", 5: "defias_thug", 10: "riverpaw_gnoll", 15: "riverpaw_brute",
19: "defias_highwayman"}
def hero(cls: str, level: int, uid: str = "p") -> dict:
player = game.new_player(uid, cls.title(), cls, 0)
game.dev_level({"config": {}}, player, level, 0)
return player
def solo(runs: int) -> None:
print(f"{'class':9} {'lvl':>3} {'mob':20} {'win':>5} {'secs':>5} {'hp lost':>7}")
for cls in CLASSES:
for level, mob_id in SOLO_TARGETS.items():
wins, times, lost = 0, [], []
for seed in range(runs):
player = hero(cls, level)
rng = random.Random(seed)
mob_level = level
enemies = [combat.mob_unit("e0", mob_id, mob_level)]
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False), enemies, "hunt")
combat.advance(fight, 400)
me = fight["units"]["p"]
if fight["state"] == "won":
wins += 1
times.append(fight["t"])
lost.append(1 - me["hp"] / me["max_hp"])
print(f"{cls:9} {level:>3} {MOBS[mob_id]['name']:20} {wins / runs:>5.0%} "
f"{statistics.mean(times) if times else 0:>5.1f} {statistics.mean(lost) if lost else 0:>7.0%}")
def dungeon(runs: int, party: list[str], level: int) -> None:
pulls = DUNGEONS["deadmines"]["pulls"]
results = {p["name"]: {"won": 0, "t": [], "hp": [], "low": [], "mana": []} for p in pulls}
for seed in range(runs):
rng = random.Random(seed)
heroes = [hero(cls, level, f"p{i}") for i, cls in enumerate(party)]
for pull in pulls:
units = []
for h in heroes:
units += game.player_unit(h, 0, True)
hp_scale, dmg_scale = game.dungeon_scale(len(heroes))
enemies = []
for index, mob_id in enumerate(pull["mobs"]):
low, high = MOBS[mob_id]["lv"]
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale)
unit["swing"]["next"] = 0.8 + 0.4 * index
enemies.append(unit)
fight = combat.new_fight("f", rng.randrange(2 ** 31), 0, units, enemies, "dungeon", pull["name"],
(hp_scale, dmg_scale))
while fight["state"] == "on":
combat.advance(fight, fight["t"] + combat.STEP)
tank = fight["units"]["p0"]
fight["low"] = min(fight.get("low", 1), tank["hp"] / tank["max_hp"])
row = results[pull["name"]]
row["low"].append(fight.get("low", 1))
row["mana"].append(fight["units"]["p1"]["res"]["cur"] / fight["units"]["p1"]["res"]["max"])
if fight["state"] == "won":
row["won"] += 1
row["t"].append(fight["t"])
row["hp"].append(sum(1 for u in combat.players(fight) if u["dead"]))
print(f"party: {', '.join(party)} at {level}")
print(f"{'pull':20} {'win':>5} {'secs':>5} {'dead':>5} {'tank low':>8} {'heal mana':>9}")
for name, row in results.items():
print(f"{name:20} {row['won'] / runs:>5.0%} {statistics.mean(row['t']) if row['t'] else 0:>5.0f} "
f"{statistics.mean(row['hp']) if row['hp'] else 0:>5.1f} {statistics.mean(row['low']):>8.0%} "
f"{statistics.mean(row['mana']):>9.0%}")
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "solo"
runs = int(sys.argv[2]) if len(sys.argv) > 2 else 20
if mode == "solo":
solo(runs)
else:
if "perfect" in sys.argv:
combat.AUTO_CD, combat.AUTO_DELAY, combat.AUTO_REACT = 1.0, 0.0, 0.25
print("-- perfect hands --")
dungeon(runs, ["warrior", "priest", "mage", "rogue", "hunter"], 18)
dungeon(runs, ["paladin", "druid", "warlock", "shaman", "rogue"], 18)