commit 71dd99795a28f681f8ac3c48cff3f2cf8606c75e Author: rope Date: Fri Oct 23 22:52:42 2026 +0100 First slice: classes, combat, questing, Deadmines Co-Authored-By: Claude Opus 5.5 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)