From 71dd99795a28f681f8ac3c48cff3f2cf8606c75e Mon Sep 17 00:00:00 2001 From: rope Date: Fri, 23 Oct 2026 22:52:42 +0100 Subject: [PATCH] First slice: classes, combat, questing, Deadmines Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + CLAUDE.md | 25 + README.md | 57 +++ app.py | 411 ++++++++++++++++ combat.py | 798 +++++++++++++++++++++++++++++++ content.py | 656 ++++++++++++++++++++++++++ flake.lock | 27 ++ flake.nix | 36 ++ game.py | 885 +++++++++++++++++++++++++++++++++++ module.nix | 97 ++++ package.nix | 26 ++ static/app.js | 910 ++++++++++++++++++++++++++++++++++++ static/index.html | 20 + static/manifest.webmanifest | 8 + static/styles.css | 204 ++++++++ tests/test_game.py | 260 +++++++++++ tools/balance.py | 99 ++++ 17 files changed, 4522 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app.py create mode 100644 combat.py create mode 100644 content.py create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 game.py create mode 100644 module.nix create mode 100644 package.nix create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/manifest.webmanifest create mode 100644 static/styles.css create mode 100644 tests/test_game.py create mode 100644 tools/balance.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc7d676 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.sqlite3 +result diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4577d41 --- /dev/null +++ b/CLAUDE.md @@ -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 +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..1893834 --- /dev/null +++ b/README.md @@ -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`). diff --git a/app.py b/app.py new file mode 100644 index 0000000..2233276 --- /dev/null +++ b/app.py @@ -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() diff --git a/combat.py b/combat.py new file mode 100644 index 0000000..8510cbf --- /dev/null +++ b/combat.py @@ -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:]} diff --git a/content.py b/content.py new file mode 100644 index 0000000..25c9005 --- /dev/null +++ b/content.py @@ -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 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..85dab75 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d84fe37 --- /dev/null +++ b/flake.nix @@ -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 + ''; + }); + }; +} diff --git a/game.py b/game.py new file mode 100644 index 0000000..51d0463 --- /dev/null +++ b/game.py @@ -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 diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..99c91fd --- /dev/null +++ b/module.nix @@ -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; + }; + }; + }) + ]); +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..7208b9e --- /dev/null +++ b/package.nix @@ -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"; + }; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..934f093 --- /dev/null +++ b/static/app.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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(`${g}g`); + if (s) parts.push(`${s}s`); + if (c || !parts.length) parts.push(`${c}c`); + return `${parts.join(" ")}`; +} + +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 = `
World of Guildhall
`; + 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 = `
+
+ ${esc(me.name)} + ${me.level} ${esc(specOf(me.cls).name)} ${esc(cls.name)} + ${money(me.copper)} +
+
+ ${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}`)} +
+
+
`; +} + +function bar(value, max, cls = "", text = "") { + const w = max ? Math.max(0, Math.min(1, value / max)) * 100 : 0; + return `
${text}
`; +} + +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]) => + ``).join(""); +} + +function renderMain() { + const main = $("#main"); + if (!main.querySelector("#fightbox")) { + main.innerHTML = `
`; + } + 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 `
+

Create your character

+

You wake in Northshire Abbey. The Defias Brotherhood stirs in Westfall.

+
+

Class

+
${Object.entries(C.classes).map(([id, c]) => ` +
+ ${c.icon} + ${esc(c.name)} + ${esc(specOf(id).name)} · ${ROLE[specOf(id).role]} +
`).join("")} +
+ ${spec ? `

${esc(spec.name)} ${esc(pick.name)} · ${ROLE[spec.role]}

+
${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i])).join("")}
` : ""} +
+
`; +} + +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 `
+ ${a.icon} +
+ ${esc(a.name)} ${known ? "" : `level ${level}`} +
${esc(a.tip)}
+
${facts.join(" · ")}
+
Auto: ${RULES[a.auto.r](a.auto)}
+
+ ${me ? `` : ""} +
`; +} + +// --- 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 `
💀 You died. Running back · ${clock(me.dead_until - t)}
`; + } + if (!act) return ""; + if (act.kind === "travel") { + const done = (t - act.start) / (act.end - act.start); + return `
+
🐎 On the road to ${esc(place(act.to).name)}${clock(act.end - t)}
+
+
${act.path.map((p) => esc(place(p).name)).join(" → ")}
+
`; + } + if (act.kind === "hunt") { + const mob = C.mobs[act.hunt]; + const waiting = !S.fight && me.next_pull > t; + return `
+ 🎯 Hunting ${esc(mob.name)} + ${waiting ? `resting · ${clock(me.next_pull - t)}` : ""} +
+
`; + } + 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 `
${mark} ${p.boss ? "" : ""}${esc(p.name)}${p.boss ? "" : ""} + ${i === d.pull && d.state !== "done" ? "· " + p.mobs.map((m) => esc(C.mobs[m].name)).join(", ") : ""}
`; + }).join(""); + let status = ""; + if (d.state === "rest") { + status = `
${d.last && !d.last.won ? "💀 Wiped. " : ""}Next pull in ${clock(d.next - t)} + ${leader ? `` : `the leader can pull early`}
`; + } else if (d.state === "done") { + status = `
🏆 ${esc(spec.name)} cleared!
`; + } else { + status = `
Fighting: ${esc(spec.pulls[d.pull].name)}
`; + } + return `
+

${esc(spec.name)}

wipes ${d.wipes} +
+
${status}
+ ${pulls} +
`; +} + +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 `
+
${esc(mob.name)} lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""} + ${hot ? '!' : ""}
+ ${hunting === id ? `` + : ``} +
`; + }).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 ? `
🪙 Vendor +
` : ""; + const roads = Object.entries(here.links).map(([to, minutes]) => + ``).join(""); + const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id]) + .map(([id, p]) => ``).join(""); + return `
+

${esc(here.name)}

${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}
+ ${hunts ? `

Hunt

${hunts}
` : ""} + ${turnIns ? `

Turn in

${turnIns}
` : ""} + ${offers ? `

Quests

${offers}
` : ""} + ${dungeon} + ${vendor} +

Roads

+
${roads}
+
+
+
`; +} + +function dungeonEntrance(id) { + const spec = C.dungeons[id]; + const g = S.group; + let action; + if (!g) action = `Form a group (👥) to go in`; + else if (g.leader !== S.uid) action = `Waiting for the leader`; + else action = ``; + return `

Dungeon

+
🕳️ ${esc(spec.name)} lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses
+ ${action}
`; +} + +function questOffer(qid) { + const q = C.quests[qid]; + const tooHigh = q.lv > S.me.level + 3; + return `
+
!${esc(q.name)}lv ${q.lv}
+
${esc(q.npc)}: “${esc(q.text)}”
+ ${q.obj.map((o) => `
· ${objText(o)}
`).join("")} +
${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""} +
+
`; +} + +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 `
+ ${esc(it.name)}${itemLine(it)} + ${fit ? "" : 'can\'t use'}
`; + }).join(""); + return `
+
?${esc(q.name)}
+
${q.xp} xp · ${money(q.copper)}
+ ${choices ? `
${choices}
` : ""} +
+
`; +} + +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}`} from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}`; + 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 `

Lately

+ ${lines.map((l) => `
${esc(l.text)} · ${ago(l.t)}
`).join("")}
`; +} + +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 `
+ Dev + + + + + + +
`; +} + +// --- quests ------------------------------------------------------------- + +function questsPage() { + const me = S.me; + const ids = Object.keys(me.quests); + if (!ids.length) { + return `

Quest log

No quests. Look for ! at the places you visit.

+

${me.done.length} completed.

`; + } + return `

Quest log ${ids.length} / 12

+ ${ids.map((qid) => { + const q = C.quests[qid]; + const ready = questReady(qid); + const ender = q.ender || q.giver; + return `
+
${ready ? '? ' : ""}${esc(q.name)}lv ${q.lv}
+ ${q.obj.map((o, i) => `
· ${objText(o, me.quests[qid][i])}
`).join("")} +
${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`} + ${ready && me.place !== ender ? `` : ""} +
+
`; + }).join("")} +

${me.done.length} completed.

`; +} + +// --- 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 `${d > 0 ? "+" : ""}${d} ${k}`; + }).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 `
${esc(C.slot_names[slot])} + ${it ? `${esc(it.name)} ${itemLine(it).split(" · ").slice(2).join(" · ")}` : '—'}
`; + }).join(""); + const bags = me.bags.map((it) => { + const on = selected === it.id; + const atHub = place(me.place).hub; + return `
+
${esc(it.name)}${it.qty > 1 ? ` ×${it.qty}` : ""} +
${itemLine(it)}${it.req > me.level ? ` · requires ${it.req}` : ""}
+ ${on && it.slot ? `
${compare(it)}
` : ""} + ${on ? `
+ ${it.slot ? `` : ""} +
` : ""} +
`; + }).join(""); + return `
+

${esc(me.name)} level ${me.level} ${esc(spec.name)} ${esc(C.classes[me.cls].name)} · ${ROLE[spec.role]}

+
+
${st.max_hp}health
+
${st.power}power
+
${st.armor}armor
+
${st.res_max}${st.res}
+
${st.dps}weapon dps
+
${me.xp_next ? `${me.xp} / ${me.xp_next}` : "max"}xp
+
+
+

Abilities

+

Abilities on Auto cast themselves when their rule says so, on a ${C.auto_cd}× cooldown. Set to You to press them yourself on the shorter cooldown. When you look away for ${C.watch}s, yours go back to auto.

+
${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i], me)).join("")}
+

Equipment

${slots}
+

Bags

${bags ? `
${bags}
` : '

Empty.

'}
`; +} + +// --- group -------------------------------------------------------------- + +function groupPage() { + const g = S.group; + let mine; + if (g) { + const here = place(S.me.place); + mine = `

Your group

+ ${g.members.map((m) => `
+ ${esc(m.name)}${m.id === g.leader ? " 👑" : ""} + ${m.level} ${esc(C.classes[m.cls].name)} · ${m.inside ? "inside" : esc(place(m.place).name)}${m.busy ? ` · ${esc(m.busy.replace("You're ", ""))}` : ""} +
${bar(m.hp, m.max_hp, "thin")}
`).join("")} +
+
+ ${!g.dungeon && here.dungeon && g.leader === S.uid ? `` : ""} + ${!g.dungeon && !here.dungeon ? `Meet at Moonbrook for the Deadmines` : ""} +
+ ${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`; + } else { + const open = S.groups.filter((x) => !x.inside && x.size < 5); + mine = `

Group

+

Dungeons take a group. Meet at the entrance; the leader leads you in.

+
${open.map((x) => `
${esc(x.leader)}'s group ${x.size} / 5 +
`).join("")}
+
`; + } + const people = S.players.map((p) => `
+ ${p.online ? "🟢" : "⚪"}${esc(p.name)} + ${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" : ""}
`).join(""); + return `${mine}

Adventurers

${people}
`; +} + +// --- 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 `
${esc(p ? p.name : m.who)}${esc(m.text)} ${ago(m.t)}
`; + }).join(""); + const events = S.log.slice().reverse().map((l) => `
${esc(l.text)} · ${ago(l.t)}
`).join(""); + return `

Guild chat

+
+
${msgs || 'Quiet in here.'}
+

World

${events}
`; +} + +// --- 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 = `

+
— vs —
+
`; + 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 = `
`; + 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) => `
${esc(l.text)}
`).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 ? 'Boss' : u.elite ? 'Elite' : "", u.rare ? 'Rare' : ""].join(""); + const low = u.hp / u.max_hp < 0.3; + let html = `
${u.icon ? u.icon + " " : ""}${esc(u.name)}${u.level}${tags}
`; + html += bar(u.hp, u.max_hp, low ? "low" : "", u.dead ? "dead" : `${u.hp} / ${u.max_hp}`); + if (u.res && u.res.type) html += `
`; + if (u.cast) { + const target = byId[u.cast.target]; + html += `
+ ${u.cast.icon} ${esc(u.cast.name)}${target && target.id !== u.id ? " → " + esc(target.name) : ""}
`; + } + const auras = u.auras.map((a) => `${a.icon}`).join(""); + html += `
${auras}${u.combo ? `${"🔸".repeat(u.combo)}` : ""}${u.stun ? "💫" : ""}${u.root ? "🧊" : ""}
`; + 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 += `
${on ? `→ ${esc(on.name)}` : ""}${mine ? ` · your threat ${pct(mine[1])}` : ""}
`; + } + if (u.cc) html += `${u.cc}`; + 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 = `
${spec.abilities.map((id, i) => { + const a = C.abilities[id]; + const known = me.abilities.includes(id); + const manual = S.me.manual.includes(id); + return `
+ ${a.icon}${known ? esc(a.name) : `level ${C.unlock[i]}`} + ${known ? `${manual ? "YOU" : "AUTO"}` : ""} +
`; + }).join("")}
+
Tap a frame to target it · tap AUTO / YOU to switch
`; + } + 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 ? `
${me.cast.icon} ${esc(me.cast.name)}
` : ""; + 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(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3674e07 --- /dev/null +++ b/static/index.html @@ -0,0 +1,20 @@ + + + + + + + + World of Guildhall + + + + +
+
+
+ +
+ + + diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..5ca1a49 --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "World of Guildhall", + "short_name": "WoG", + "start_url": "/", + "display": "standalone", + "background_color": "#14110d", + "theme_color": "#14110d" +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..6f2db59 --- /dev/null +++ b/static/styles.css @@ -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; } +} diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..af05368 --- /dev/null +++ b/tests/test_game.py @@ -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() diff --git a/tools/balance.py b/tools/balance.py new file mode 100644 index 0000000..d2f1577 --- /dev/null +++ b/tools/balance.py @@ -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)