First slice: classes, combat, questing, Deadmines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
71dd99795a
17 changed files with 4522 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__pycache__/
|
||||||
|
*.sqlite3
|
||||||
|
result
|
||||||
25
CLAUDE.md
Normal file
25
CLAUDE.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# World of Guildhall
|
||||||
|
|
||||||
|
WoW Classic–style co-op adventure for Fred's friend group. Stdlib Python server
|
||||||
|
(`app.py`), content tables (`content.py`), fight engine (`combat.py`), world
|
||||||
|
rules (`game.py`), vanilla JS client (`static/`). The world is one JSON doc in
|
||||||
|
SQLite.
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
- Every ability is auto (rule, AUTO_CD × cooldown) or manual (pressed, real
|
||||||
|
cooldown). Manual only holds while the player is watching (`WATCH`); never
|
||||||
|
let an unwatched player's abilities sit unused.
|
||||||
|
- Dungeons are group-only and are where active play matters: tune bosses so an
|
||||||
|
all-auto group struggles (`tools/balance.py dungeon`), trash so it doesn't.
|
||||||
|
- Use Classic's names for places, mobs, quests, NPCs and items.
|
||||||
|
- In-game text is terse: icons and numbers over prose, details in tips.
|
||||||
|
- Every requirement/progress ratio is **have / need** (`3 / 10`).
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m unittest discover -s tests
|
||||||
|
python3 tools/balance.py solo
|
||||||
|
python3 tools/balance.py dungeon
|
||||||
|
```
|
||||||
57
README.md
Normal file
57
README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# World of Guildhall
|
||||||
|
|
||||||
|
A co-op adventure for a handful of friends, set in WoW Classic's Elwynn Forest
|
||||||
|
and Westfall. Quest and hunt your way from Northshire to the Deadmines: your
|
||||||
|
character keeps adventuring while you're away, and dungeons are where you play
|
||||||
|
together, live.
|
||||||
|
|
||||||
|
Prototype. The first slice: levels 1–20, two zones, one dungeon.
|
||||||
|
|
||||||
|
## What's in it
|
||||||
|
|
||||||
|
- **Classes:** all nine of Classic's, one spec each for now. Tanks: Protection
|
||||||
|
Warrior, Protection Paladin, Enhancement Shaman. Healers: Restoration Druid,
|
||||||
|
Holy Priest. Damage: Frost Mage, Combat Rogue, Beast Mastery Hunter (with a
|
||||||
|
wolf), Affliction Warlock (a Voidwalker alone, an Imp in a group). Each spec
|
||||||
|
has four signature abilities, learned at levels 1, 3, 6 and 10.
|
||||||
|
- **Auto or you:** every ability is either on auto (a rule decides: "someone
|
||||||
|
under 50%", "a mob is casting", "a mob hits someone else") on a 1.5× cooldown,
|
||||||
|
or in your hands on the real one. Manual abilities only fire when you press
|
||||||
|
them, and only while you're watching: look away for 12 seconds and they go
|
||||||
|
back to auto, so nobody idles into a wipe.
|
||||||
|
- **Combat:** live, in quarter-second steps on the server. Mobs pick targets by
|
||||||
|
threat (tanks make 1.8× threat, healing makes half), cast telegraphed spells
|
||||||
|
you can interrupt, and break out of crowd control when hit. Bosses have
|
||||||
|
phases: Mr. Smite stomps and swaps weapons, VanCleef calls his Blackguards.
|
||||||
|
Tap a frame to target it; tap a party member to aim your heals.
|
||||||
|
- **Questing:** 24 quests with Classic's names and chains, from Kobold Camp
|
||||||
|
Cleanup to The Defias Brotherhood. Kill, loot (quest items only drop while
|
||||||
|
you're on the quest) and travel objectives; rewards are a choice of gear.
|
||||||
|
- **Travel:** places are joined by roads that take real minutes. Hunts are
|
||||||
|
where the mobs are, quest givers stand where they stood in Classic, and the
|
||||||
|
dungeon door is in Moonbrook.
|
||||||
|
- **Idle:** start a hunt and leave. You fight, rest and fight again for up to
|
||||||
|
12 hours, finishing quest objectives as you go. Three deaths in a row and you
|
||||||
|
stop.
|
||||||
|
- **Dungeons:** the Deadmines, nine pulls and five bosses, for a group of
|
||||||
|
2–5 (packs shrink for smaller groups). Everyone meets at the door; the leader
|
||||||
|
leads you in and can pull early. Bosses give every player their own loot roll
|
||||||
|
at gear they can use.
|
||||||
|
- **Gear:** seven slots, cloth/leather/mail by class, Classic-style generated
|
||||||
|
greens (`Rough Leather Boots of the Monkey`) and named blues from the
|
||||||
|
Deadmines.
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```
|
||||||
|
nix run . -- --dev --port 8100 --db ./wog.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dev` trusts a `wog_user` cookie (a user switcher on the Adventure page) and
|
||||||
|
adds cheats: set level with matching greens, skip time, teleport.
|
||||||
|
|
||||||
|
`tools/balance.py solo|dungeon [runs] [perfect]` simulates solo hunts for every
|
||||||
|
class and all-auto Deadmines runs.
|
||||||
|
|
||||||
|
In production it sits behind Authelia, which passes the signed-in user in
|
||||||
|
`Remote-User`. The NixOS module is `nixosModules.default` (`services.wog`).
|
||||||
411
app.py
Normal file
411
app.py
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small, dependency-free server for World of Guildhall."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import date
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import combat
|
||||||
|
import content
|
||||||
|
import game
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
MAX_BODY = 16 * 1024
|
||||||
|
NAME = re.compile(r"^[A-Za-z][A-Za-z'-]{1,15}$")
|
||||||
|
TICK_SECONDS = 0.25
|
||||||
|
SAVE_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def build_version() -> str:
|
||||||
|
"""Changes whenever the code or client does; open tabs reload when it does."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in sorted([*APP_DIR.glob("*.py"), *STATIC_DIR.rglob("*")]):
|
||||||
|
if path.is_file():
|
||||||
|
digest.update(path.name.encode())
|
||||||
|
digest.update(path.read_bytes())
|
||||||
|
return digest.hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = build_version()
|
||||||
|
|
||||||
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
"""The world lives in memory and is written whole to SQLite every few seconds."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, dev: bool):
|
||||||
|
self.path = path
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.rng = random.Random()
|
||||||
|
self.saved = 0.0
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with closing(sqlite3.connect(path)) as db, db:
|
||||||
|
db.execute("CREATE TABLE IF NOT EXISTS world (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)")
|
||||||
|
row = db.execute("SELECT data FROM world WHERE id = 1").fetchone()
|
||||||
|
self.world = json.loads(row[0]) if row else game.new_world(time.time())
|
||||||
|
self.world["config"]["dev"] = dev
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def now(self) -> float:
|
||||||
|
return time.time() + self.world["config"]["clock_offset"]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
data = json.dumps(self.world, separators=(",", ":"))
|
||||||
|
with closing(sqlite3.connect(self.path)) as db, db:
|
||||||
|
db.execute("INSERT INTO world (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data",
|
||||||
|
(data,))
|
||||||
|
self.saved = time.time()
|
||||||
|
|
||||||
|
def backup(self, destination: Path, keep: int) -> Path:
|
||||||
|
"""Write today's consistent snapshot and prune all but the newest `keep`."""
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = destination / f"wog-{date.today().isoformat()}.sqlite3"
|
||||||
|
partial = target.with_suffix(".partial")
|
||||||
|
with closing(sqlite3.connect(self.path)) as source, closing(sqlite3.connect(partial)) as copy:
|
||||||
|
source.backup(copy)
|
||||||
|
partial.replace(target)
|
||||||
|
snapshots = sorted(destination.glob("wog-*.sqlite3"))
|
||||||
|
for old in snapshots[:-keep] if keep > 0 else []:
|
||||||
|
old.unlink()
|
||||||
|
return target
|
||||||
|
|
||||||
|
def tick(self) -> None:
|
||||||
|
with self.lock:
|
||||||
|
game.process(self.world, self.now(), self.rng)
|
||||||
|
if time.time() - self.saved > SAVE_SECONDS:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
def static_content() -> dict:
|
||||||
|
referenced = set(content.ITEMS)
|
||||||
|
for quest in content.QUESTS.values():
|
||||||
|
referenced.update(quest.get("choice", []))
|
||||||
|
return {
|
||||||
|
"classes": content.CLASSES, "specs": content.SPECS, "abilities": content.ABILITIES, "unlock": content.UNLOCK,
|
||||||
|
"places": content.PLACES, "quests": content.QUESTS, "dungeons": content.DUNGEONS,
|
||||||
|
"mobs": {k: {"name": m["name"], "lv": m["lv"], "rare": m.get("rare", False), "boss": m.get("boss", False),
|
||||||
|
"elite": m.get("elite", False)} for k, m in content.MOBS.items()},
|
||||||
|
"enemy_abilities": content.ENEMY_ABILITIES, "slots": content.SLOTS, "slot_names": content.SLOT_NAMES,
|
||||||
|
"xp": content.XP_TO_NEXT, "max_level": content.MAX_LEVEL,
|
||||||
|
"items": {i: game.item(i) for i in sorted(referenced)},
|
||||||
|
"auto_cd": combat.AUTO_CD, "watch": combat.WATCH, "gcd": combat.GCD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONTENT = json.dumps(static_content(), separators=(",", ":")).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def player_view(world: dict, player: dict, now: float) -> dict:
|
||||||
|
st = game.stats(player)
|
||||||
|
hp, mana = game.hp_now(player, now)
|
||||||
|
view = {key: player[key] for key in ("id", "name", "cls", "level", "xp", "copper", "place", "activity",
|
||||||
|
"quests", "done", "manual", "group", "dead_until", "next_pull")}
|
||||||
|
view["hp"], view["mana"] = round(hp), round(mana, 3)
|
||||||
|
view["stats"] = st
|
||||||
|
view["xp_next"] = content.XP_TO_NEXT[player["level"]]
|
||||||
|
view["known"] = game.known(player)
|
||||||
|
view["equip"] = {slot: game.item(i) if i else None for slot, i in player["equip"].items()}
|
||||||
|
bags = []
|
||||||
|
for item_id, qty in sorted(player["bags"].items()):
|
||||||
|
spec = game.item(item_id)
|
||||||
|
if spec:
|
||||||
|
bags.append({**spec, "qty": qty, "fit": game.can_wear(player, spec) if "slot" in spec else None})
|
||||||
|
view["bags"] = bags
|
||||||
|
view["available"] = game.available_quests(player)
|
||||||
|
view["log"] = player["log"][-40:]
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def group_view(world: dict, group: dict | None, now: float) -> dict | None:
|
||||||
|
if not group:
|
||||||
|
return None
|
||||||
|
members = []
|
||||||
|
for uid in group["members"]:
|
||||||
|
member = world["players"][uid]
|
||||||
|
hp, mana = game.hp_now(member, now)
|
||||||
|
members.append({"id": uid, "name": member["name"], "cls": member["cls"], "level": member["level"],
|
||||||
|
"place": member["place"], "hp": round(hp), "max_hp": game.stats(member)["max_hp"],
|
||||||
|
"mana": round(mana, 2), "inside": bool(member["activity"]
|
||||||
|
and member["activity"]["kind"] == "dungeon"),
|
||||||
|
"busy": game._busy(member)})
|
||||||
|
dungeon = {k: v for k, v in group["dungeon"].items() if k != "last_fight"} if group["dungeon"] else None
|
||||||
|
return {"id": group["id"], "leader": group["leader"], "dungeon": dungeon, "members": members}
|
||||||
|
|
||||||
|
|
||||||
|
def roster(world: dict, now: float) -> list[dict]:
|
||||||
|
people = []
|
||||||
|
for player in world["players"].values():
|
||||||
|
activity = player["activity"]
|
||||||
|
people.append({"id": player["id"], "name": player["name"], "cls": player["cls"], "level": player["level"],
|
||||||
|
"place": player["place"], "group": player["group"],
|
||||||
|
"doing": activity["kind"] if activity else None,
|
||||||
|
"hunt": activity.get("hunt") if activity else None,
|
||||||
|
"online": now - player["last_active"] < 120})
|
||||||
|
return sorted(people, key=lambda p: (-p["level"], p["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "WoG"
|
||||||
|
store: Store
|
||||||
|
dev = False
|
||||||
|
user_header = "Remote-User"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
logging.debug("%s - %s", self.address_string(), fmt % args)
|
||||||
|
|
||||||
|
# --- plumbing --------------------------------------------------------
|
||||||
|
|
||||||
|
def identity(self) -> str | None:
|
||||||
|
if self.dev:
|
||||||
|
cookie = self.headers.get("Cookie", "")
|
||||||
|
match = re.search(r"(?:^|;\s*)wog_user=([^;]+)", cookie)
|
||||||
|
return unquote(match.group(1)) if match else "fred"
|
||||||
|
user = self.headers.get(self.user_header, "").strip()
|
||||||
|
return user or None
|
||||||
|
|
||||||
|
def send_bytes(self, body: bytes, kind: str = "application/json", cache: str = "no-store") -> None:
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", kind)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", cache)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def send_json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def fail(self, message: str, status: HTTPStatus = HTTPStatus.BAD_REQUEST) -> None:
|
||||||
|
self.send_json({"error": message}, status)
|
||||||
|
|
||||||
|
def read_body(self) -> dict | None:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if length > MAX_BODY:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
def send_static(self, path: str) -> None:
|
||||||
|
name = "index.html" if path in ("", "/") else path.lstrip("/").removeprefix("static/")
|
||||||
|
target = (STATIC_DIR / name).resolve()
|
||||||
|
if STATIC_DIR.resolve() not in target.parents or not target.is_file():
|
||||||
|
target = STATIC_DIR / "index.html"
|
||||||
|
body = target.read_bytes()
|
||||||
|
if target.name == "index.html":
|
||||||
|
# Versioned URLs, so no cache (a home-screen app's included) can serve an old client.
|
||||||
|
for asset in (b"/static/app.js", b"/static/styles.css"):
|
||||||
|
body = body.replace(asset + b'"', asset + b"?v=" + VERSION.encode() + b'"')
|
||||||
|
self.send_bytes(body, mimetypes.guess_type(target.name)[0] or "application/octet-stream", "no-cache")
|
||||||
|
|
||||||
|
# --- routes ----------------------------------------------------------
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/content":
|
||||||
|
return self.send_bytes(CONTENT, cache="no-cache")
|
||||||
|
if path == "/api/state":
|
||||||
|
uid = self.identity()
|
||||||
|
if not uid:
|
||||||
|
return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED)
|
||||||
|
with self.store.lock:
|
||||||
|
world, now = self.store.world, self.store.now()
|
||||||
|
player = world["players"].get(uid)
|
||||||
|
if player:
|
||||||
|
player["last_active"] = player["watch"] = now
|
||||||
|
game.process(world, now, self.store.rng)
|
||||||
|
return self.send_json(self.state(uid))
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
return self.fail("Not found", HTTPStatus.NOT_FOUND)
|
||||||
|
self.send_static(path)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
uid = self.identity()
|
||||||
|
if not uid:
|
||||||
|
return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED)
|
||||||
|
body = self.read_body()
|
||||||
|
if body is None:
|
||||||
|
return self.fail("Bad request body")
|
||||||
|
with self.store.lock:
|
||||||
|
world, now = self.store.world, self.store.now()
|
||||||
|
game.process(world, now, self.store.rng)
|
||||||
|
player = world["players"].get(uid)
|
||||||
|
if path == "/api/register":
|
||||||
|
error = self.register(uid, body, now)
|
||||||
|
elif not player:
|
||||||
|
return self.fail("Create your character first", HTTPStatus.CONFLICT)
|
||||||
|
elif path.startswith("/api/dev/"):
|
||||||
|
error = self.dev_route(path, player, body, now) if self.dev else "Not found"
|
||||||
|
else:
|
||||||
|
player["last_active"] = player["watch"] = now
|
||||||
|
error = self.player_route(path, player, body, now)
|
||||||
|
if error:
|
||||||
|
return self.fail(error)
|
||||||
|
game.process(world, now, self.store.rng)
|
||||||
|
if path not in ("/api/press", "/api/target"):
|
||||||
|
self.store.save()
|
||||||
|
self.send_json(self.state(uid))
|
||||||
|
|
||||||
|
def player_route(self, path: str, player: dict, body: dict, now: float) -> str | None:
|
||||||
|
world = self.store.world
|
||||||
|
arg = body.get("id")
|
||||||
|
arg = str(arg) if arg is not None else ""
|
||||||
|
if path == "/api/press":
|
||||||
|
fight = game.current_fight(world, player)
|
||||||
|
if not fight:
|
||||||
|
return "You're not fighting"
|
||||||
|
target = body.get("target")
|
||||||
|
return combat.press(fight, player["id"], arg, now, str(target) if target else None)
|
||||||
|
if path == "/api/target":
|
||||||
|
fight = game.current_fight(world, player)
|
||||||
|
return combat.set_target(fight, player["id"], arg) if fight else "You're not fighting"
|
||||||
|
if path == "/api/manual":
|
||||||
|
return game.set_manual(world, player, arg, bool(body.get("on")))
|
||||||
|
if path == "/api/hunt":
|
||||||
|
return game.start_hunt(world, player, arg, now)
|
||||||
|
if path == "/api/stop":
|
||||||
|
return game.stop(world, player, now)
|
||||||
|
if path == "/api/travel":
|
||||||
|
return game.travel(world, player, arg, now)
|
||||||
|
if path == "/api/equip":
|
||||||
|
return game.equip(player, arg)
|
||||||
|
if path == "/api/unequip":
|
||||||
|
return game.unequip(player, arg)
|
||||||
|
if path == "/api/sell":
|
||||||
|
qty = body.get("qty", 1)
|
||||||
|
return game.sell(player, arg, qty if qty == "all" else (qty if isinstance(qty, int) else -1))
|
||||||
|
if path == "/api/sell_junk":
|
||||||
|
return game.sell_junk(player)
|
||||||
|
if path == "/api/accept":
|
||||||
|
return game.accept(player, arg)
|
||||||
|
if path == "/api/abandon":
|
||||||
|
return game.abandon(player, arg)
|
||||||
|
if path == "/api/turn_in":
|
||||||
|
return game.turn_in(world, player, arg, body.get("choice"), now)
|
||||||
|
if path == "/api/group/create":
|
||||||
|
return game.create_group(world, player)
|
||||||
|
if path == "/api/group/join":
|
||||||
|
return game.join_group(world, player, arg)
|
||||||
|
if path == "/api/group/leave":
|
||||||
|
return game.leave_group(world, player, now)
|
||||||
|
if path == "/api/dungeon/enter":
|
||||||
|
return game.enter_dungeon(world, player, now)
|
||||||
|
if path == "/api/dungeon/pull":
|
||||||
|
return game.pull_now(world, player, now)
|
||||||
|
if path == "/api/dungeon/leave":
|
||||||
|
return game.leave_dungeon(world, player, now)
|
||||||
|
if path == "/api/say":
|
||||||
|
return game.say(world, player, str(body.get("text", "")), now)
|
||||||
|
return "Not found"
|
||||||
|
|
||||||
|
def register(self, uid: str, body: dict, now: float) -> str | None:
|
||||||
|
world = self.store.world
|
||||||
|
if uid in world["players"]:
|
||||||
|
return "You already have a character"
|
||||||
|
name = str(body.get("name", "")).strip().capitalize()
|
||||||
|
cls = body.get("cls")
|
||||||
|
if not NAME.match(name):
|
||||||
|
return "Names are 2 to 16 letters"
|
||||||
|
if any(p["name"].lower() == name.lower() for p in world["players"].values()):
|
||||||
|
return "That name is taken"
|
||||||
|
if cls not in content.CLASSES:
|
||||||
|
return "Pick a class"
|
||||||
|
world["players"][uid] = game.new_player(uid, name, cls, now)
|
||||||
|
game.world_log(world, now, f"{name} the {content.CLASSES[cls]['name']} arrived in Northshire", "join", uid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dev_route(self, path: str, player: dict, body: dict, now: float) -> str | None:
|
||||||
|
world = self.store.world
|
||||||
|
if path == "/api/dev/level":
|
||||||
|
return game.dev_level(world, player, int(body.get("level", 1)), now)
|
||||||
|
if path == "/api/dev/advance":
|
||||||
|
world["config"]["clock_offset"] += float(body.get("minutes", 10)) * game.MINUTE
|
||||||
|
return None
|
||||||
|
if path == "/api/dev/teleport":
|
||||||
|
if str(body.get("id")) not in content.PLACES:
|
||||||
|
return "No such place"
|
||||||
|
game.stop(world, player, now)
|
||||||
|
player["place"] = str(body["id"])
|
||||||
|
return None
|
||||||
|
return "Not found"
|
||||||
|
|
||||||
|
def state(self, uid: str) -> dict:
|
||||||
|
world = self.store.world
|
||||||
|
now = self.store.now()
|
||||||
|
player = world["players"].get(uid)
|
||||||
|
fight = game.shown_fight(world, player, now) if player else None
|
||||||
|
group = world["groups"].get(player["group"] or "") if player else None
|
||||||
|
return {
|
||||||
|
"now": now, "dev": self.dev, "uid": uid, "version": VERSION,
|
||||||
|
"me": player_view(world, player, now) if player else None,
|
||||||
|
"fight": combat.view(fight) if fight else None,
|
||||||
|
"group": group_view(world, group, now),
|
||||||
|
"groups": [{"id": g["id"], "leader": world["players"][g["leader"]]["name"], "size": len(g["members"]),
|
||||||
|
"inside": bool(g["dungeon"])} for g in world["groups"].values()],
|
||||||
|
"players": roster(world, now),
|
||||||
|
"log": world["log"][-30:],
|
||||||
|
"chat": world["chat"][-60:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8100)
|
||||||
|
parser.add_argument("--db", type=Path, default=Path("wog.sqlite3"))
|
||||||
|
parser.add_argument("--dev", action="store_true", help="trust a wog_user cookie and enable cheats")
|
||||||
|
parser.add_argument("--user-header", default="Remote-User")
|
||||||
|
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
|
||||||
|
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
|
||||||
|
store = Store(args.db, args.dev)
|
||||||
|
if args.backup_to:
|
||||||
|
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
|
||||||
|
return
|
||||||
|
Handler.store = store
|
||||||
|
Handler.dev = args.dev
|
||||||
|
Handler.user_header = args.user_header
|
||||||
|
|
||||||
|
def ticker() -> None:
|
||||||
|
while True:
|
||||||
|
time.sleep(TICK_SECONDS)
|
||||||
|
try:
|
||||||
|
store.tick()
|
||||||
|
except Exception:
|
||||||
|
logging.exception("tick failed")
|
||||||
|
|
||||||
|
threading.Thread(target=ticker, daemon=True).start()
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
logging.info("World of Guildhall listening on http://%s:%d%s", args.host, args.port, " (dev)" if args.dev else "")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
798
combat.py
Normal file
798
combat.py
Normal file
|
|
@ -0,0 +1,798 @@
|
||||||
|
"""The fight engine: a party against a pack, stepped in fixed slices of time.
|
||||||
|
|
||||||
|
A fight is one JSON-able dict. It only ever moves forward through `advance`, which
|
||||||
|
replays the slices between its last step and now; each slice draws from a random
|
||||||
|
seeded by the fight and the slice number, so the same presses give the same fight.
|
||||||
|
|
||||||
|
Every ability is either on auto (a rule decides when, on a longer cooldown) or in
|
||||||
|
the player's hands. A player's hands only count while they're watching: once they
|
||||||
|
look away, everything they hold falls back to auto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from content import ABILITIES, BOSS_DMG, BOSS_HP, ELITE_DMG, ELITE_HP, ENEMY_ABILITIES, MOBS
|
||||||
|
|
||||||
|
STEP = 0.25 # seconds per slice
|
||||||
|
GCD = 1.5
|
||||||
|
ENERGY_GCD = 1.0 # rogues
|
||||||
|
AUTO_DELAY = 0.5 # auto waits this long after it could act
|
||||||
|
AUTO_CD = 1.5 # ...and runs cooldowns this much slower
|
||||||
|
AUTO_REACT = 1.0 # seconds into a cast before auto interrupts it
|
||||||
|
WATCH = 12 # seconds since a player last looked that their manual choices still hold
|
||||||
|
QUEUE = 1.5 # a press waits this long for the global cooldown before it's dropped
|
||||||
|
CRIT, CRIT_MULT = 0.05, 1.5
|
||||||
|
SPREAD = 0.1
|
||||||
|
HEAL_THREAT = 0.5
|
||||||
|
STICKY = 1.1 # a mob only turns when someone has 10% more threat than its target
|
||||||
|
TANK_THREAT = 1.8
|
||||||
|
PET_THREAT = 0.5
|
||||||
|
PET_TANK_THREAT = 3.0
|
||||||
|
MANA_REGEN = 0.02 # share of max mana per second
|
||||||
|
ENERGY_REGEN = 10
|
||||||
|
RAGE_PER_SWING = 7
|
||||||
|
RAGE_TAKEN = 40 # rage for taking a whole health bar of damage
|
||||||
|
MAX_TIME = 300
|
||||||
|
LOG = 40
|
||||||
|
EVENTS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def mob_hp(level: int) -> float:
|
||||||
|
return 35 + 15 * level + 0.8 * level * level
|
||||||
|
|
||||||
|
|
||||||
|
def mob_swing(level: int) -> float:
|
||||||
|
"""Damage of an ordinary mob's 2s swing at this level."""
|
||||||
|
return 3 + 1.5 * level
|
||||||
|
|
||||||
|
|
||||||
|
# --- building a fight ------------------------------------------------------
|
||||||
|
|
||||||
|
def mob_unit(uid: str, mob_id: str, level: int, hp_scale: float = 1.0, dmg_scale: float = 1.0) -> dict:
|
||||||
|
mob = MOBS[mob_id]
|
||||||
|
hp = mob_hp(level) * mob.get("hp", 1)
|
||||||
|
swing = mob_swing(level) * mob.get("dmg", 1)
|
||||||
|
if mob.get("elite"):
|
||||||
|
hp, swing = hp * ELITE_HP, swing * ELITE_DMG
|
||||||
|
if mob.get("boss"):
|
||||||
|
hp, swing = hp * BOSS_HP, swing * BOSS_DMG
|
||||||
|
hp = max(1, round(hp * hp_scale))
|
||||||
|
return {
|
||||||
|
"id": uid, "mob": mob_id, "name": mob["name"], "side": "enemy", "kind": mob["kind"], "level": level,
|
||||||
|
"hp": hp, "max_hp": hp, "power": 0, "armor": 0,
|
||||||
|
"swing": {"dmg": swing * dmg_scale, "every": 2.0, "next": 0.5, "school": "phys"},
|
||||||
|
"auras": [], "cast": None, "threat": {}, "target": None, "taunt": None,
|
||||||
|
"stun": 0, "cc": 0, "root": 0, "dead": False,
|
||||||
|
"boss": bool(mob.get("boss")), "elite": bool(mob.get("elite")), "rare": bool(mob.get("rare")),
|
||||||
|
"abil": [{"id": a, "next": ENEMY_ABILITIES[a]["first"]} for a in mob.get("abilities", [])],
|
||||||
|
"phases": [{**phase, "done": False} for phase in mob.get("phases", [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_fight(fid: str, seed: int, at: float, party: list[dict], enemies: list[dict], kind: str,
|
||||||
|
name: str = "", scale: tuple = (1.0, 1.0)) -> dict:
|
||||||
|
fight = {"id": fid, "seed": seed, "at": at, "t": 0.0, "n": 0, "state": "on", "kind": kind, "name": name,
|
||||||
|
"units": {}, "log": [], "ev": [], "kills": [], "next": 0, "scale": list(scale), "ended": None}
|
||||||
|
for unit in party + enemies:
|
||||||
|
fight["units"][unit["id"]] = unit
|
||||||
|
for enemy in enemies:
|
||||||
|
_engage(fight, enemy)
|
||||||
|
return fight
|
||||||
|
|
||||||
|
|
||||||
|
def _engage(fight: dict, enemy: dict) -> None:
|
||||||
|
"""Who a fresh mob starts on: the tank, else the first one in."""
|
||||||
|
party = alive(fight, "party")
|
||||||
|
for unit in party:
|
||||||
|
enemy["threat"][unit["id"]] = 10.0 if _tanks(unit) else 0.0
|
||||||
|
if party and not any(enemy["threat"].values()):
|
||||||
|
enemy["threat"][party[0]["id"]] = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _tanks(unit: dict) -> bool:
|
||||||
|
return unit.get("role") == "tank" or unit.get("pet_tank", False)
|
||||||
|
|
||||||
|
|
||||||
|
def alive(fight: dict, side: str) -> list[dict]:
|
||||||
|
return [u for u in fight["units"].values() if u["side"] == side and not u["dead"]]
|
||||||
|
|
||||||
|
|
||||||
|
def players(fight: dict) -> list[dict]:
|
||||||
|
return [u for u in fight["units"].values() if u.get("kind") == "player"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the outside world's handles ------------------------------------------
|
||||||
|
|
||||||
|
def advance(fight: dict, now: float, watch: dict | None = None) -> None:
|
||||||
|
"""Run every slice up to `now` (game time)."""
|
||||||
|
end = now - fight["at"]
|
||||||
|
watch = watch or {}
|
||||||
|
while fight["state"] == "on" and fight["t"] + STEP <= end + 1e-9:
|
||||||
|
_step(fight, watch)
|
||||||
|
|
||||||
|
|
||||||
|
def end_time(fight: dict) -> float:
|
||||||
|
return fight["at"] + fight["t"]
|
||||||
|
|
||||||
|
|
||||||
|
def press(fight: dict, uid: str, ability: str, now: float, target: str | None = None) -> str | None:
|
||||||
|
unit = fight["units"].get(uid)
|
||||||
|
if fight["state"] != "on" or not unit or unit["dead"]:
|
||||||
|
return "You're not fighting"
|
||||||
|
if ability not in unit["abilities"]:
|
||||||
|
return "You don't know that"
|
||||||
|
unit["queued"] = {"ab": ability, "target": target, "at": now - fight["at"]}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_target(fight: dict, uid: str, target: str) -> str | None:
|
||||||
|
unit, other = fight["units"].get(uid), fight["units"].get(target)
|
||||||
|
if not unit or not other or other["dead"]:
|
||||||
|
return "No such target"
|
||||||
|
if other["side"] == "enemy":
|
||||||
|
unit["target"], unit["picked"] = target, True
|
||||||
|
else:
|
||||||
|
unit["ftarget"] = target
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- a slice ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _step(fight: dict, watch: dict) -> None:
|
||||||
|
fight["t"] = t = round(fight["t"] + STEP, 4)
|
||||||
|
rng = random.Random(f"{fight['seed']}:{fight['n']}")
|
||||||
|
fight["n"] += 1
|
||||||
|
now = fight["at"] + t
|
||||||
|
units = list(fight["units"].values())
|
||||||
|
for unit in units:
|
||||||
|
if not unit["dead"]:
|
||||||
|
_tick(fight, unit, t, rng)
|
||||||
|
for unit in units:
|
||||||
|
if not unit["dead"] and unit["cast"] and unit["cast"]["end"] <= t + 1e-9:
|
||||||
|
_finish_cast(fight, unit, t, rng)
|
||||||
|
for unit in units:
|
||||||
|
if not unit["dead"] and unit["side"] == "party":
|
||||||
|
seen = watch.get(unit.get("owner") or unit["id"])
|
||||||
|
_think(fight, unit, t, rng, seen is not None and now - seen < WATCH)
|
||||||
|
for unit in units:
|
||||||
|
if not unit["dead"] and unit["side"] == "enemy":
|
||||||
|
_enemy_think(fight, unit, t, rng)
|
||||||
|
for unit in units:
|
||||||
|
if not unit["dead"]:
|
||||||
|
_swing(fight, unit, t, rng)
|
||||||
|
_phases(fight, t, rng)
|
||||||
|
_check_end(fight, t)
|
||||||
|
|
||||||
|
|
||||||
|
def _tick(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||||
|
res = unit.get("res")
|
||||||
|
if res and res["type"] == "mana":
|
||||||
|
res["cur"] = min(res["max"], res["cur"] + res["max"] * MANA_REGEN * STEP)
|
||||||
|
elif res and res["type"] == "energy":
|
||||||
|
res["cur"] = min(res["max"], res["cur"] + ENERGY_REGEN * STEP)
|
||||||
|
for aura in list(unit["auras"]):
|
||||||
|
if aura["kind"] in ("dot", "hot", "ground") and t >= aura["next"] - 1e-9:
|
||||||
|
aura["next"] += aura["every"]
|
||||||
|
source = fight["units"].get(aura["by"])
|
||||||
|
if aura["kind"] == "dot":
|
||||||
|
_damage(fight, source, unit, aura["amount"], aura["school"], t, rng, crit=False)
|
||||||
|
elif aura["kind"] == "hot":
|
||||||
|
_heal(fight, source, unit, aura["amount"], t, rng, crit=False)
|
||||||
|
else:
|
||||||
|
for enemy in alive(fight, "enemy"):
|
||||||
|
if enemy["cc"] <= t:
|
||||||
|
_damage(fight, unit, enemy, aura["amount"], aura["school"], t, rng, crit=False,
|
||||||
|
threat=aura.get("threat", 1))
|
||||||
|
if unit["dead"]:
|
||||||
|
return
|
||||||
|
if t >= aura["until"] - 1e-9 and aura in unit["auras"]:
|
||||||
|
unit["auras"].remove(aura)
|
||||||
|
if aura["kind"] == "enrage":
|
||||||
|
unit["swing"]["dmg"] /= aura["mult"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- player side -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _think(fight: dict, unit: dict, t: float, rng: random.Random, watching: bool) -> None:
|
||||||
|
if unit["stun"] > t or unit["cast"]:
|
||||||
|
return
|
||||||
|
_pick_target(fight, unit, t)
|
||||||
|
if unit["kind"] == "pet":
|
||||||
|
return
|
||||||
|
queued = unit.get("queued")
|
||||||
|
if queued and watching:
|
||||||
|
if t - queued["at"] > QUEUE:
|
||||||
|
unit["queued"] = None
|
||||||
|
else:
|
||||||
|
why = _not_ready(unit, queued["ab"], t, manual=True)
|
||||||
|
if why is None:
|
||||||
|
target = _manual_target(fight, unit, queued["ab"], queued["target"], t)
|
||||||
|
unit["queued"] = None
|
||||||
|
if target is not False:
|
||||||
|
_use(fight, unit, queued["ab"], target, t, rng, manual=True)
|
||||||
|
if unit["cast"] or not ABILITIES[queued["ab"]].get("offgcd"):
|
||||||
|
return
|
||||||
|
elif why == "mana":
|
||||||
|
unit["queued"] = None
|
||||||
|
_event(fight, t, unit["id"], 0, "oom")
|
||||||
|
elif queued:
|
||||||
|
unit["queued"] = None
|
||||||
|
for ab_id in unit["priority"]:
|
||||||
|
if ab_id not in unit["abilities"] or (watching and ab_id in unit["manual"]):
|
||||||
|
continue
|
||||||
|
if _not_ready(unit, ab_id, t, manual=False):
|
||||||
|
continue
|
||||||
|
target = _rule(fight, unit, ab_id, t)
|
||||||
|
if target is False:
|
||||||
|
continue
|
||||||
|
_use(fight, unit, ab_id, target, t, rng, manual=False)
|
||||||
|
if unit["cast"] or not ABILITIES[ab_id].get("offgcd"):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _not_ready(unit: dict, ab_id: str, t: float, manual: bool) -> str | None:
|
||||||
|
ab = ABILITIES[ab_id]
|
||||||
|
if unit["cds"].get(ab_id, 0) > t + 1e-9:
|
||||||
|
return "cooldown"
|
||||||
|
if not ab.get("offgcd") and unit["busy"] + (0 if manual else AUTO_DELAY) > t + 1e-9:
|
||||||
|
return "gcd"
|
||||||
|
cost = _cost(unit, ab)
|
||||||
|
if cost and unit["res"]["cur"] < cost:
|
||||||
|
return unit["res"]["type"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cost(unit: dict, ab: dict) -> float:
|
||||||
|
cost = ab.get("cost", 0)
|
||||||
|
if cost and cost < 1:
|
||||||
|
return cost * unit["res"]["max"]
|
||||||
|
return cost
|
||||||
|
|
||||||
|
|
||||||
|
def _use(fight: dict, unit: dict, ab_id: str, target, t: float, rng: random.Random, manual: bool) -> None:
|
||||||
|
ab = ABILITIES[ab_id]
|
||||||
|
cost = _cost(unit, ab)
|
||||||
|
if cost:
|
||||||
|
unit["res"]["cur"] -= cost
|
||||||
|
unit["cds"][ab_id] = t + ab.get("cd", 0) * (1 if manual else AUTO_CD)
|
||||||
|
cast = ab.get("cast", 0)
|
||||||
|
if not ab.get("offgcd"):
|
||||||
|
gcd = ENERGY_GCD if unit["res"] and unit["res"]["type"] == "energy" else GCD
|
||||||
|
unit["busy"] = t + max(gcd, cast)
|
||||||
|
if cast:
|
||||||
|
unit["cast"] = {"ab": ab_id, "name": ab["name"], "icon": ab["icon"], "target": target,
|
||||||
|
"start": t, "end": t + cast}
|
||||||
|
else:
|
||||||
|
_apply(fight, unit, ab_id, target, t, rng)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_cast(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||||
|
cast, unit["cast"] = unit["cast"], None
|
||||||
|
if unit["side"] == "party":
|
||||||
|
_apply(fight, unit, cast["ab"], cast["target"], t, rng)
|
||||||
|
else:
|
||||||
|
_enemy_effect(fight, unit, cast["ab"], cast["target"], t, rng)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(fight: dict, unit: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||||||
|
ab = ABILITIES[ab_id]
|
||||||
|
units = fight["units"]
|
||||||
|
target = units.get(target_id) if target_id else None
|
||||||
|
if ab["target"] in ("enemy", "ally", "pet") and (not target or target["dead"]):
|
||||||
|
return
|
||||||
|
power = unit["power"]
|
||||||
|
threat = ab.get("threat", 1)
|
||||||
|
on = f" on {target['name']}" if target and target is not unit else ""
|
||||||
|
if ab["target"] != "enemy" or any(e["t"] in ("cc", "interrupt", "stun", "taunt") for e in ab["effects"]):
|
||||||
|
_log(fight, t, f"{unit['name']} · {ab['name']}{on}", "cast", unit["id"])
|
||||||
|
for eff in ab["effects"]:
|
||||||
|
kind = eff["t"]
|
||||||
|
if kind == "dmg":
|
||||||
|
_damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, threat=threat, label=ab["name"])
|
||||||
|
elif kind == "aoe":
|
||||||
|
for enemy in alive(fight, "enemy"):
|
||||||
|
if enemy["cc"] <= t:
|
||||||
|
_damage(fight, unit, enemy, eff["c"] * power, eff["school"], t, rng, threat=threat,
|
||||||
|
label=ab["name"])
|
||||||
|
elif kind in ("dot", "hot"):
|
||||||
|
_add_aura(target, {"id": ab_id, "kind": kind, "by": unit["id"], "amount": eff["c"] * power,
|
||||||
|
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||||||
|
"school": eff.get("school"), "name": ab["name"], "icon": ab["icon"]})
|
||||||
|
elif kind == "ground":
|
||||||
|
_add_aura(unit, {"id": ab_id, "kind": "ground", "by": unit["id"], "amount": eff["c"] * power,
|
||||||
|
"every": eff["every"], "next": t + eff["every"], "until": t + eff["dur"] + 1e-6,
|
||||||
|
"school": eff["school"], "threat": threat, "name": ab["name"], "icon": ab["icon"]})
|
||||||
|
elif kind == "heal":
|
||||||
|
_heal(fight, unit, target, eff["c"] * power, t, rng, label=ab["name"])
|
||||||
|
elif kind == "shield":
|
||||||
|
if any(a["kind"] == "weakened" for a in target["auras"]):
|
||||||
|
continue
|
||||||
|
_add_aura(target, {"id": ab_id, "kind": "shield", "by": unit["id"], "amount": eff["c"] * power,
|
||||||
|
"until": t + eff["dur"], "name": ab["name"], "icon": ab["icon"]})
|
||||||
|
_add_aura(target, {"id": "weakened_soul", "kind": "weakened", "by": unit["id"], "until": t + 15,
|
||||||
|
"name": "Weakened Soul", "icon": "🩹"})
|
||||||
|
_heal_threat(fight, unit, eff["c"] * power * 0.5)
|
||||||
|
elif kind == "aura":
|
||||||
|
holder = target or unit
|
||||||
|
spec = {k: v for k, v in eff.items() if k not in ("t", "dur")}
|
||||||
|
_add_aura(holder, {**spec, "id": ab_id, "by": unit["id"], "until": t + eff["dur"]})
|
||||||
|
elif kind == "taunt":
|
||||||
|
top = max(target["threat"].values(), default=0)
|
||||||
|
target["threat"][unit["id"]] = top * STICKY + 1
|
||||||
|
target["taunt"] = {"by": unit["id"], "until": t + eff["dur"]}
|
||||||
|
target["target"] = unit["id"]
|
||||||
|
elif kind == "stun":
|
||||||
|
_stop_cast(fight, target, unit, t, force=not target["boss"])
|
||||||
|
if not target["boss"]:
|
||||||
|
target["stun"] = t + eff["dur"]
|
||||||
|
elif kind == "interrupt":
|
||||||
|
_stop_cast(fight, target, unit, t)
|
||||||
|
elif kind == "cc":
|
||||||
|
if target["boss"]:
|
||||||
|
_log(fight, t, f"{target['name']} is immune", "miss")
|
||||||
|
continue
|
||||||
|
_stop_cast(fight, target, unit, t, force=True, quiet=True)
|
||||||
|
target["cc"] = t + eff["dur"]
|
||||||
|
target["cc_icon"] = ab["icon"]
|
||||||
|
for other in alive(fight, "party"):
|
||||||
|
if other["target"] == target["id"] and not other.get("picked"):
|
||||||
|
other["target"] = None
|
||||||
|
elif kind == "freeze":
|
||||||
|
for enemy in alive(fight, "enemy"):
|
||||||
|
if not enemy["boss"]:
|
||||||
|
enemy["root"] = t + eff["dur"]
|
||||||
|
elif kind == "combo":
|
||||||
|
unit["combo"] = min(5, unit["combo"] + eff["n"])
|
||||||
|
elif kind == "finisher":
|
||||||
|
points, unit["combo"] = unit["combo"], 0
|
||||||
|
if points:
|
||||||
|
_damage(fight, unit, target, eff["c"] * power * points, "phys", t, rng, threat=threat,
|
||||||
|
label=ab["name"])
|
||||||
|
elif kind == "drain":
|
||||||
|
dealt = _damage(fight, unit, target, eff["c"] * power, eff["school"], t, rng, label=ab["name"])
|
||||||
|
_heal(fight, unit, unit, dealt, t, rng, crit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_cast(fight: dict, enemy: dict, by: dict, t: float, force: bool = False, quiet: bool = False) -> None:
|
||||||
|
cast = enemy.get("cast")
|
||||||
|
if not cast:
|
||||||
|
return
|
||||||
|
if not force and not ENEMY_ABILITIES[cast["ab"]]["interrupt"]:
|
||||||
|
return
|
||||||
|
enemy["cast"] = None
|
||||||
|
if not quiet:
|
||||||
|
_log(fight, t, f"{by['name']} interrupts {enemy['name']}'s {cast['name']}", "interrupt", by["id"])
|
||||||
|
_event(fight, t, enemy["id"], 0, "interrupt")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_aura(unit: dict, aura: dict) -> None:
|
||||||
|
unit["auras"] = [a for a in unit["auras"] if not (a["id"] == aura["id"] and a["by"] == aura["by"])]
|
||||||
|
unit["auras"].append(aura)
|
||||||
|
|
||||||
|
|
||||||
|
def _pet_of(fight: dict, unit: dict) -> dict | None:
|
||||||
|
return next((u for u in alive(fight, "party") if u.get("owner") == unit["id"]), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_target(fight: dict, unit: dict, t: float) -> None:
|
||||||
|
current = fight["units"].get(unit.get("target") or "")
|
||||||
|
if current and not current["dead"] and (unit.get("picked") or current["cc"] <= t):
|
||||||
|
return
|
||||||
|
unit["picked"] = False
|
||||||
|
enemies = alive(fight, "enemy")
|
||||||
|
free = [e for e in enemies if e["cc"] <= t] or enemies
|
||||||
|
if not free:
|
||||||
|
unit["target"] = None
|
||||||
|
return
|
||||||
|
owner = fight["units"].get(unit.get("owner") or "")
|
||||||
|
if unit["kind"] == "pet" and not unit.get("pet_tank") and owner and not owner["dead"] and owner.get("target"):
|
||||||
|
unit["target"] = owner["target"]
|
||||||
|
return
|
||||||
|
if _tanks(unit):
|
||||||
|
tanks = {u["id"] for u in alive(fight, "party") if _tanks(u)}
|
||||||
|
loose = [e for e in free if e["target"] not in tanks]
|
||||||
|
unit["target"] = (loose or sorted(free, key=lambda e: e["hp"] / e["max_hp"]))[0]["id"]
|
||||||
|
return
|
||||||
|
tank = next((u for u in alive(fight, "party") if _tanks(u) and u.get("target")), None)
|
||||||
|
focus = fight["units"].get(tank["target"]) if tank else None
|
||||||
|
if focus and not focus["dead"] and focus["cc"] <= t:
|
||||||
|
unit["target"] = focus["id"]
|
||||||
|
else:
|
||||||
|
unit["target"] = min(free, key=lambda e: e["hp"] / e["max_hp"])["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _hurt(unit: dict) -> float:
|
||||||
|
return unit["hp"] / unit["max_hp"]
|
||||||
|
|
||||||
|
|
||||||
|
def _has(unit: dict, ab_id: str, by: str | None = None) -> bool:
|
||||||
|
return any(a["id"] == ab_id and (by is None or a["by"] == by) for a in unit["auras"])
|
||||||
|
|
||||||
|
|
||||||
|
def _rule(fight: dict, unit: dict, ab_id: str, t: float):
|
||||||
|
"""Auto's decision: the target to use this on, None for no target, False for not now."""
|
||||||
|
ab = ABILITIES[ab_id]
|
||||||
|
rule = ab["auto"]
|
||||||
|
r = rule["r"]
|
||||||
|
enemies = alive(fight, "enemy")
|
||||||
|
party = alive(fight, "party")
|
||||||
|
target = fight["units"].get(unit.get("target") or "")
|
||||||
|
if target and target["dead"]:
|
||||||
|
target = None
|
||||||
|
on_me = [e for e in enemies if e["target"] == unit["id"] and e["cc"] <= t]
|
||||||
|
if rule.get("save") and unit.get("role") == "healer" and len(players(fight)) > 1 and \
|
||||||
|
unit["res"]["cur"] < rule["save"] * unit["res"]["max"]:
|
||||||
|
return False # a group's healer keeps their mana for healing
|
||||||
|
if r == "always":
|
||||||
|
if ab["target"] == "enemy":
|
||||||
|
return target["id"] if target else False
|
||||||
|
return None
|
||||||
|
if r == "dot":
|
||||||
|
return target["id"] if target and not _has(target, ab_id, unit["id"]) and _hurt(target) > 0.2 else False
|
||||||
|
if r == "heal":
|
||||||
|
low = min(party, key=_hurt, default=None)
|
||||||
|
return low["id"] if low and _hurt(low) < rule["pct"] else False
|
||||||
|
if r == "hot":
|
||||||
|
hurt = sorted((u for u in party if _hurt(u) < rule["pct"] and not _has(u, ab_id, unit["id"])), key=_hurt)
|
||||||
|
return hurt[0]["id"] if hurt else False
|
||||||
|
if r == "shield":
|
||||||
|
targeted = {e["target"] for e in enemies}
|
||||||
|
hurt = [u for u in party if not any(a["kind"] in ("shield", "weakened") for a in u["auras"])
|
||||||
|
and (u["id"] in targeted and _hurt(u) < 0.95 or _hurt(u) < rule["pct"])]
|
||||||
|
return min(hurt, key=_hurt)["id"] if hurt else False
|
||||||
|
if r == "self_below":
|
||||||
|
return target["id"] if target and _hurt(unit) < rule["pct"] else False
|
||||||
|
if r == "taunt":
|
||||||
|
if not _tanks(unit):
|
||||||
|
return False
|
||||||
|
loose = [e for e in enemies if e["cc"] <= t and e["target"] and e["target"] != unit["id"]
|
||||||
|
and not _tanks(fight["units"][e["target"]]) and t - e.get("since", 0) >= AUTO_REACT]
|
||||||
|
return loose[0]["id"] if loose else False
|
||||||
|
if r == "aoe":
|
||||||
|
return None if sum(1 for e in enemies if e["cc"] <= t) >= rule["n"] else False
|
||||||
|
if r == "interrupt":
|
||||||
|
casting = [e for e in enemies if e["cast"] and ENEMY_ABILITIES[e["cast"]["ab"]]["interrupt"]
|
||||||
|
and t - e["cast"]["start"] >= AUTO_REACT]
|
||||||
|
return casting[0]["id"] if casting else False
|
||||||
|
if r == "combo":
|
||||||
|
return target["id"] if target and unit["combo"] >= rule["n"] else False
|
||||||
|
if r == "cc":
|
||||||
|
free = [e for e in enemies if e["cc"] <= t]
|
||||||
|
if len(free) < 2:
|
||||||
|
return False
|
||||||
|
focus = {u.get("target") for u in party if _tanks(u)} | {unit.get("target")}
|
||||||
|
spare = [e for e in free if not e["boss"] and e["id"] not in focus and _hurt(e) > 0.5]
|
||||||
|
return max(spare, key=lambda e: e["hp"])["id"] if spare else False
|
||||||
|
if r == "peel":
|
||||||
|
return None if on_me and not _tanks(unit) and any(not e["boss"] for e in on_me) else False
|
||||||
|
if r == "tanking":
|
||||||
|
return None if on_me and (any(e["boss"] or e["elite"] for e in on_me) or _hurt(unit) < rule["pct"]) else False
|
||||||
|
if r == "danger":
|
||||||
|
return None if on_me and _hurt(unit) < rule["pct"] else False
|
||||||
|
if r == "buff":
|
||||||
|
return None if not _has(unit, ab_id) else False
|
||||||
|
if r == "boss":
|
||||||
|
pet = _pet_of(fight, unit)
|
||||||
|
if not pet or not target:
|
||||||
|
return False
|
||||||
|
return pet["id"] if target["boss"] or target["elite"] or target["rare"] else False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_target(fight: dict, unit: dict, ab_id: str, given: str | None, t: float):
|
||||||
|
ab = ABILITIES[ab_id]
|
||||||
|
units = fight["units"]
|
||||||
|
kind = ab["target"]
|
||||||
|
if kind in ("self", "aoe"):
|
||||||
|
return None
|
||||||
|
if kind == "pet":
|
||||||
|
pet = _pet_of(fight, unit)
|
||||||
|
return pet["id"] if pet else False
|
||||||
|
if kind == "ally":
|
||||||
|
for choice in (given, unit.get("ftarget")):
|
||||||
|
other = units.get(choice or "")
|
||||||
|
if other and not other["dead"] and other["side"] == "party":
|
||||||
|
return other["id"]
|
||||||
|
return min(alive(fight, "party"), key=_hurt)["id"]
|
||||||
|
other = units.get(given or "")
|
||||||
|
if other and not other["dead"] and other["side"] == "enemy":
|
||||||
|
return other["id"]
|
||||||
|
smart = _rule(fight, unit, ab_id, t) if ab["auto"]["r"] in ("interrupt", "cc", "taunt") else False
|
||||||
|
if smart:
|
||||||
|
return smart
|
||||||
|
target = units.get(unit.get("target") or "")
|
||||||
|
return target["id"] if target and not target["dead"] else False
|
||||||
|
|
||||||
|
|
||||||
|
# --- enemy side ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _enemy_think(fight: dict, enemy: dict, t: float, rng: random.Random) -> None:
|
||||||
|
if enemy["stun"] > t or enemy["cc"] > t:
|
||||||
|
enemy["cast"] = None
|
||||||
|
return
|
||||||
|
_enemy_target(fight, enemy, t)
|
||||||
|
if enemy["cast"] or not enemy["target"]:
|
||||||
|
return
|
||||||
|
for slot in enemy["abil"]:
|
||||||
|
if t < slot["next"]:
|
||||||
|
continue
|
||||||
|
spec = ENEMY_ABILITIES[slot["id"]]
|
||||||
|
target = _enemy_ability_target(fight, enemy, spec, rng)
|
||||||
|
if target is False:
|
||||||
|
slot["next"] = t + 2
|
||||||
|
continue
|
||||||
|
slot["next"] = t + spec["every"]
|
||||||
|
if spec["cast"]:
|
||||||
|
enemy["cast"] = {"ab": slot["id"], "name": spec["name"], "icon": spec["icon"], "target": target,
|
||||||
|
"start": t, "end": t + spec["cast"], "interrupt": spec["interrupt"]}
|
||||||
|
else:
|
||||||
|
_enemy_effect(fight, enemy, slot["id"], target, t, rng)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _enemy_target(fight: dict, enemy: dict, t: float) -> None:
|
||||||
|
units = fight["units"]
|
||||||
|
taunt = enemy.get("taunt")
|
||||||
|
if taunt and taunt["until"] > t and not units[taunt["by"]]["dead"]:
|
||||||
|
enemy["target"] = taunt["by"]
|
||||||
|
return
|
||||||
|
table = {uid: v for uid, v in enemy["threat"].items() if not units[uid]["dead"]}
|
||||||
|
if not table:
|
||||||
|
party = alive(fight, "party")
|
||||||
|
enemy["target"] = party[0]["id"] if party else None
|
||||||
|
if party:
|
||||||
|
enemy["threat"][party[0]["id"]] = 1.0
|
||||||
|
return
|
||||||
|
top = max(table, key=table.get)
|
||||||
|
current = enemy.get("target")
|
||||||
|
if current in table and table[top] <= table[current] * STICKY:
|
||||||
|
return
|
||||||
|
enemy["target"], enemy["since"] = top, t
|
||||||
|
|
||||||
|
|
||||||
|
def _enemy_ability_target(fight: dict, enemy: dict, spec: dict, rng: random.Random):
|
||||||
|
kind = spec["target"]
|
||||||
|
if kind == "target":
|
||||||
|
return enemy["target"]
|
||||||
|
if kind == "all":
|
||||||
|
return None
|
||||||
|
if kind == "hurt_ally":
|
||||||
|
hurt = [e for e in alive(fight, "enemy") if _hurt(e) < 0.7]
|
||||||
|
return min(hurt, key=_hurt)["id"] if hurt else False
|
||||||
|
if kind == "random_soft":
|
||||||
|
soft = [u for u in alive(fight, "party") if u["kind"] == "player" and not _tanks(u)]
|
||||||
|
return rng.choice(soft)["id"] if soft else enemy["target"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _enemy_effect(fight: dict, enemy: dict, ab_id: str, target_id, t: float, rng: random.Random) -> None:
|
||||||
|
spec = ENEMY_ABILITIES[ab_id]
|
||||||
|
kind, amount, school = spec["effect"]
|
||||||
|
target = fight["units"].get(target_id or "")
|
||||||
|
swing = enemy["swing"]["dmg"]
|
||||||
|
if kind == "hit":
|
||||||
|
if target and not target["dead"]:
|
||||||
|
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||||||
|
_damage(fight, enemy, target, swing * amount, school, t, rng, label=spec["name"])
|
||||||
|
elif kind == "aoe":
|
||||||
|
_log(fight, t, f"{enemy['name']} · {spec['name']}", "enemy", enemy["id"])
|
||||||
|
for unit in alive(fight, "party"):
|
||||||
|
_damage(fight, enemy, unit, swing * amount, school, t, rng, label=spec["name"])
|
||||||
|
elif kind == "heal":
|
||||||
|
if target and not target["dead"]:
|
||||||
|
_log(fight, t, f"{enemy['name']} · {spec['name']} on {target['name']}", "enemy", enemy["id"])
|
||||||
|
gain = min(target["max_hp"] - target["hp"], round(target["max_hp"] * amount))
|
||||||
|
target["hp"] += gain
|
||||||
|
_event(fight, t, target["id"], gain, "heal")
|
||||||
|
elif kind == "disarm":
|
||||||
|
if target and not target["dead"]:
|
||||||
|
_log(fight, t, f"{enemy['name']} disarms {target['name']}", "enemy", enemy["id"])
|
||||||
|
_add_aura(target, {"id": "disarm", "kind": "disarm", "by": enemy["id"], "until": t + amount,
|
||||||
|
"name": "Disarmed", "icon": "🫳"})
|
||||||
|
|
||||||
|
|
||||||
|
def _phases(fight: dict, t: float, rng: random.Random) -> None:
|
||||||
|
for enemy in alive(fight, "enemy"):
|
||||||
|
for phase in enemy["phases"]:
|
||||||
|
if phase["done"] or _hurt(enemy) > phase["at"]:
|
||||||
|
continue
|
||||||
|
phase["done"] = True
|
||||||
|
_log(fight, t, f"{enemy['name']} yells: {phase['say']}", "yell", enemy["id"])
|
||||||
|
for what, arg in phase["do"]:
|
||||||
|
if what == "stun_all":
|
||||||
|
for unit in alive(fight, "party"):
|
||||||
|
unit["stun"] = t + arg
|
||||||
|
unit["cast"] = None
|
||||||
|
elif what == "enrage":
|
||||||
|
enemy["swing"]["dmg"] *= arg
|
||||||
|
enemy["auras"].append({"id": f"enrage{t}", "kind": "enrage", "by": enemy["id"], "mult": arg,
|
||||||
|
"until": t + MAX_TIME, "name": "Enraged", "icon": "😡"})
|
||||||
|
elif what == "summon":
|
||||||
|
_summon(fight, enemy, arg, t)
|
||||||
|
|
||||||
|
|
||||||
|
def _summon(fight: dict, by: dict, mobs: list, t: float) -> None:
|
||||||
|
hp_scale, dmg_scale = fight["scale"]
|
||||||
|
for mob_id in mobs:
|
||||||
|
fight["next"] += 1
|
||||||
|
unit = mob_unit(f"s{fight['next']}", mob_id, MOBS[mob_id]["lv"][1], hp_scale, dmg_scale)
|
||||||
|
unit["swing"]["next"] = t + 1.0
|
||||||
|
fight["units"][unit["id"]] = unit
|
||||||
|
_engage(fight, unit)
|
||||||
|
_log(fight, t, f"{unit['name']} joins the fight", "enemy", unit["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared mechanics ------------------------------------------------------
|
||||||
|
|
||||||
|
def _swing(fight: dict, unit: dict, t: float, rng: random.Random) -> None:
|
||||||
|
swing = unit.get("swing")
|
||||||
|
if not swing or t < swing["next"] - 1e-9 or unit["cast"] or unit["stun"] > t or unit["cc"] > t:
|
||||||
|
return
|
||||||
|
if unit["side"] == "enemy" and unit["root"] > t:
|
||||||
|
return
|
||||||
|
target = fight["units"].get(unit.get("target") or "")
|
||||||
|
if not target or target["dead"]:
|
||||||
|
return
|
||||||
|
swing["next"] = t + swing["every"]
|
||||||
|
amount = swing["dmg"]
|
||||||
|
if any(a["kind"] == "disarm" for a in unit["auras"]):
|
||||||
|
amount *= 0.5
|
||||||
|
_damage(fight, unit, target, amount, swing["school"], t, rng, swing=True)
|
||||||
|
res = unit.get("res")
|
||||||
|
if res and res["type"] == "rage":
|
||||||
|
res["cur"] = min(res["max"], res["cur"] + RAGE_PER_SWING)
|
||||||
|
|
||||||
|
|
||||||
|
def _damage(fight: dict, source: dict | None, target: dict, amount: float, school: str, t: float,
|
||||||
|
rng: random.Random, threat: float = 1.0, crit: bool = True, swing: bool = False,
|
||||||
|
label: str = "") -> int:
|
||||||
|
if target["dead"] or amount <= 0:
|
||||||
|
return 0
|
||||||
|
critted = crit and source is not None and source["side"] == "party" and rng.random() < CRIT
|
||||||
|
if critted:
|
||||||
|
amount *= CRIT_MULT
|
||||||
|
amount *= 1 + rng.uniform(-SPREAD, SPREAD)
|
||||||
|
if source:
|
||||||
|
for aura in source["auras"]:
|
||||||
|
if aura["kind"] == "done":
|
||||||
|
amount *= aura["mult"]
|
||||||
|
for aura in target["auras"]:
|
||||||
|
if aura["kind"] == "vuln" and aura["school"] == school:
|
||||||
|
amount *= aura["mult"]
|
||||||
|
elif aura["kind"] == "taken" and aura["school"] in (school, "all"):
|
||||||
|
amount *= aura["mult"]
|
||||||
|
if school == "phys" and target["side"] == "party" and source:
|
||||||
|
armor = target["armor"]
|
||||||
|
amount *= 1 - armor / (armor + 400 + 85 * source["level"])
|
||||||
|
amount = max(1, round(amount))
|
||||||
|
dealt = amount
|
||||||
|
for aura in list(target["auras"]):
|
||||||
|
if aura["kind"] == "shield" and amount:
|
||||||
|
soak = min(amount, round(aura["amount"]))
|
||||||
|
aura["amount"] -= soak
|
||||||
|
amount -= soak
|
||||||
|
if aura["amount"] < 1:
|
||||||
|
target["auras"].remove(aura)
|
||||||
|
target["hp"] -= amount
|
||||||
|
_event(fight, t, target["id"], dealt, "crit" if critted else ("absorb" if not amount else "dmg"),
|
||||||
|
source["id"] if source else None, label)
|
||||||
|
if target["side"] == "enemy" and source:
|
||||||
|
target["threat"][source["id"]] = target["threat"].get(source["id"], 0) + \
|
||||||
|
dealt * threat * source.get("threat_mult", 1)
|
||||||
|
if target["cc"] > t:
|
||||||
|
target["cc"] = 0
|
||||||
|
_log(fight, t, f"{target['name']} breaks free", "miss", target["id"])
|
||||||
|
res = target.get("res")
|
||||||
|
if res and res["type"] == "rage" and amount:
|
||||||
|
res["cur"] = min(res["max"], res["cur"] + amount / target["max_hp"] * RAGE_TAKEN)
|
||||||
|
if swing and source and source["side"] == "enemy":
|
||||||
|
for aura in list(target["auras"]):
|
||||||
|
if aura["kind"] == "reflect":
|
||||||
|
aura["charges"] -= 1
|
||||||
|
if aura["charges"] <= 0:
|
||||||
|
target["auras"].remove(aura)
|
||||||
|
_damage(fight, target, source, aura["c"] * target["power"], aura["school"], t, rng,
|
||||||
|
threat=2.0, label=aura["name"])
|
||||||
|
break
|
||||||
|
if target["hp"] <= 0:
|
||||||
|
_die(fight, target, t)
|
||||||
|
return dealt
|
||||||
|
|
||||||
|
|
||||||
|
def _heal(fight: dict, source: dict | None, target: dict, amount: float, t: float, rng: random.Random,
|
||||||
|
crit: bool = True, label: str = "") -> int:
|
||||||
|
if target["dead"] or amount <= 0:
|
||||||
|
return 0
|
||||||
|
critted = crit and rng.random() < CRIT
|
||||||
|
if critted:
|
||||||
|
amount *= CRIT_MULT
|
||||||
|
amount = round(amount * (1 + rng.uniform(-SPREAD, SPREAD)))
|
||||||
|
gain = max(0, min(amount, target["max_hp"] - target["hp"]))
|
||||||
|
target["hp"] += gain
|
||||||
|
_event(fight, t, target["id"], amount, "heal", source["id"] if source else None, label)
|
||||||
|
if source and gain:
|
||||||
|
_heal_threat(fight, source, gain)
|
||||||
|
return gain
|
||||||
|
|
||||||
|
|
||||||
|
def _heal_threat(fight: dict, source: dict, amount: float) -> None:
|
||||||
|
enemies = alive(fight, "enemy")
|
||||||
|
for enemy in enemies:
|
||||||
|
enemy["threat"][source["id"]] = enemy["threat"].get(source["id"], 0) + amount * HEAL_THREAT / len(enemies)
|
||||||
|
|
||||||
|
|
||||||
|
def _die(fight: dict, unit: dict, t: float) -> None:
|
||||||
|
unit["dead"], unit["hp"], unit["cast"], unit["auras"] = True, 0, None, []
|
||||||
|
_log(fight, t, f"{unit['name']} dies", "death", unit["id"])
|
||||||
|
if unit["side"] == "enemy":
|
||||||
|
fight["kills"].append(unit["mob"])
|
||||||
|
for other in alive(fight, "party"):
|
||||||
|
if other.get("target") == unit["id"]:
|
||||||
|
other["target"], other["picked"] = None, False
|
||||||
|
for mob_id in MOBS[unit["mob"]].get("on_death", []):
|
||||||
|
_summon(fight, unit, [mob_id], t)
|
||||||
|
else:
|
||||||
|
for enemy in alive(fight, "enemy"):
|
||||||
|
enemy["threat"].pop(unit["id"], None)
|
||||||
|
if enemy["target"] == unit["id"]:
|
||||||
|
enemy["target"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_end(fight: dict, t: float) -> None:
|
||||||
|
if not alive(fight, "enemy"):
|
||||||
|
fight["state"] = "won"
|
||||||
|
elif not any(not u["dead"] for u in players(fight)):
|
||||||
|
fight["state"] = "lost"
|
||||||
|
elif t >= MAX_TIME:
|
||||||
|
fight["state"] = "lost"
|
||||||
|
_log(fight, t, "The fight drags on too long. You fall back.", "death")
|
||||||
|
if fight["state"] != "on":
|
||||||
|
fight["ended"] = fight["at"] + t
|
||||||
|
|
||||||
|
|
||||||
|
def _log(fight: dict, t: float, text: str, kind: str, who: str | None = None) -> None:
|
||||||
|
fight["log"].append({"t": t, "text": text, "kind": kind, "who": who})
|
||||||
|
del fight["log"][:-LOG]
|
||||||
|
|
||||||
|
|
||||||
|
def _event(fight: dict, t: float, uid: str, amount: float, kind: str, source: str | None = None,
|
||||||
|
label: str = "") -> None:
|
||||||
|
fight["ev"].append({"t": t, "u": uid, "n": round(amount), "k": kind, "s": source, "l": label})
|
||||||
|
del fight["ev"][:-EVENTS]
|
||||||
|
|
||||||
|
|
||||||
|
# --- what the client sees --------------------------------------------------
|
||||||
|
|
||||||
|
def view(fight: dict) -> dict:
|
||||||
|
units = []
|
||||||
|
for unit in fight["units"].values():
|
||||||
|
item = {key: unit.get(key) for key in ("id", "name", "side", "kind", "cls", "role", "level", "hp",
|
||||||
|
"max_hp", "target", "boss", "elite", "rare", "dead", "owner",
|
||||||
|
"combo", "busy", "icon", "mob")}
|
||||||
|
item["hp"] = max(0, round(unit["hp"]))
|
||||||
|
if unit.get("res"):
|
||||||
|
item["res"] = {"type": unit["res"]["type"], "cur": round(unit["res"]["cur"]), "max": unit["res"]["max"]}
|
||||||
|
if unit["cast"]:
|
||||||
|
item["cast"] = {k: unit["cast"].get(k) for k in ("name", "icon", "start", "end", "target", "interrupt")}
|
||||||
|
item["auras"] = [{"icon": a.get("icon"), "name": a.get("name"), "until": round(a["until"], 2),
|
||||||
|
"kind": a["kind"], "mine": a["by"]} for a in unit["auras"] if a.get("icon")]
|
||||||
|
t = fight["t"]
|
||||||
|
item["cc"] = unit.get("cc_icon") if unit["cc"] > t else None
|
||||||
|
item["stun"] = unit["stun"] > t
|
||||||
|
item["root"] = unit.get("root", 0) > t
|
||||||
|
if unit["side"] == "enemy":
|
||||||
|
table = {k: v for k, v in unit["threat"].items() if not fight["units"][k]["dead"]}
|
||||||
|
top = max(table.values(), default=0) or 1
|
||||||
|
item["threat"] = sorted(([k, round(v / top, 2)] for k, v in table.items() if v > 0),
|
||||||
|
key=lambda kv: -kv[1])[:4]
|
||||||
|
if unit.get("kind") == "player":
|
||||||
|
item["cds"] = {k: round(v, 2) for k, v in unit["cds"].items() if v > t}
|
||||||
|
item["manual"] = unit["manual"]
|
||||||
|
item["abilities"] = unit["abilities"]
|
||||||
|
item["ftarget"] = unit.get("ftarget")
|
||||||
|
item["queued"] = (unit.get("queued") or {}).get("ab")
|
||||||
|
units.append(item)
|
||||||
|
return {"id": fight["id"], "t": fight["t"], "at": fight["at"], "state": fight["state"], "kind": fight["kind"],
|
||||||
|
"name": fight["name"], "units": units, "log": fight["log"][-14:], "ev": fight["ev"][-30:]}
|
||||||
656
content.py
Normal file
656
content.py
Normal file
|
|
@ -0,0 +1,656 @@
|
||||||
|
"""Static game content: classes, abilities, mobs, places, quests, items.
|
||||||
|
|
||||||
|
Everything here is data. Numbers that scale with a character are coefficients of
|
||||||
|
their power (P); numbers that scale with a mob are multiples of its level's swing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# --- characters ------------------------------------------------------------
|
||||||
|
|
||||||
|
UNLOCK = [1, 3, 6, 10] # level each of a spec's four abilities is learned at
|
||||||
|
MAX_LEVEL = 20
|
||||||
|
|
||||||
|
# Half of Classic's curve: an evening and a night idle reaches the Deadmines.
|
||||||
|
XP_TO_NEXT = [0, 200, 450, 700, 1050, 1400, 1800, 2250, 2700, 3250, 3800, 4400, 5050, 5700,
|
||||||
|
6450, 7200, 8000, 8850, 9700, 10650, 0]
|
||||||
|
|
||||||
|
ARMOUR_TYPES = {"cloth": 1.2, "leather": 2.5, "mail": 5.0} # armour per item level, before the slot's share
|
||||||
|
SLOTS = ["weapon", "head", "chest", "hands", "legs", "feet", "back"]
|
||||||
|
SLOT_NAMES = {"weapon": "Weapon", "head": "Head", "chest": "Chest", "hands": "Hands", "legs": "Legs",
|
||||||
|
"feet": "Feet", "back": "Back"}
|
||||||
|
SLOT_BUDGET = {"weapon": 1.0, "head": 0.8, "chest": 1.0, "hands": 0.6, "legs": 0.9, "feet": 0.7, "back": 0.5}
|
||||||
|
SLOT_ARMOUR = {"weapon": 0, "head": 0.8, "chest": 1.0, "hands": 0.6, "legs": 0.9, "feet": 0.7, "back": 0.4}
|
||||||
|
WEAPON_SPEED = {"sword": 2.5, "mace": 2.7, "axe": 2.7, "dagger": 1.8, "staff": 3.0, "bow": 2.8, "gun": 2.8}
|
||||||
|
|
||||||
|
CLASSES = {
|
||||||
|
"warrior": {"name": "Warrior", "color": "#c69b6d", "icon": "⚔️", "armour": "mail", "res": "rage",
|
||||||
|
"weapons": ["sword", "axe", "mace", "dagger"], "hp": (60, 20), "shield": True,
|
||||||
|
"spec": "protection"},
|
||||||
|
"paladin": {"name": "Paladin", "color": "#f48cba", "icon": "🔨", "armour": "mail", "res": "mana",
|
||||||
|
"weapons": ["mace", "sword", "axe"], "hp": (55, 19), "shield": True, "spec": "protection_p"},
|
||||||
|
"shaman": {"name": "Shaman", "color": "#0070dd", "icon": "⚡", "armour": "leather", "res": "mana",
|
||||||
|
"weapons": ["mace", "axe", "staff", "dagger"], "hp": (55, 18), "shield": True, "spec": "enhancement"},
|
||||||
|
"druid": {"name": "Druid", "color": "#ff7c0a", "icon": "🌿", "armour": "leather", "res": "mana",
|
||||||
|
"weapons": ["staff", "mace", "dagger"], "hp": (45, 16), "spec": "restoration"},
|
||||||
|
"priest": {"name": "Priest", "color": "#f0f0f0", "icon": "✨", "armour": "cloth", "res": "mana",
|
||||||
|
"weapons": ["staff", "mace", "dagger"], "hp": (40, 15), "spec": "holy"},
|
||||||
|
"mage": {"name": "Mage", "color": "#3fc7eb", "icon": "❄️", "armour": "cloth", "res": "mana",
|
||||||
|
"weapons": ["staff", "dagger", "sword"], "hp": (40, 14), "spec": "frost"},
|
||||||
|
"rogue": {"name": "Rogue", "color": "#fff468", "icon": "🗡️", "armour": "leather", "res": "energy",
|
||||||
|
"weapons": ["dagger", "sword", "mace"], "hp": (50, 17), "spec": "combat"},
|
||||||
|
"hunter": {"name": "Hunter", "color": "#aad372", "icon": "🏹", "armour": "leather", "res": "mana",
|
||||||
|
"weapons": ["bow", "gun"], "hp": (50, 17), "spec": "beast_mastery", "pet": "wolf"},
|
||||||
|
"warlock": {"name": "Warlock", "color": "#8788ee", "icon": "💀", "armour": "cloth", "res": "mana",
|
||||||
|
"weapons": ["staff", "dagger", "sword"], "hp": (45, 15), "spec": "affliction", "pet": "demon"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dmg(c, school="phys"):
|
||||||
|
return {"t": "dmg", "c": c, "school": school}
|
||||||
|
|
||||||
|
|
||||||
|
def aoe(c, school="phys"):
|
||||||
|
return {"t": "aoe", "c": c, "school": school}
|
||||||
|
|
||||||
|
|
||||||
|
def dot(c, dur, school="magic", every=3):
|
||||||
|
return {"t": "dot", "c": c, "dur": dur, "every": every, "school": school}
|
||||||
|
|
||||||
|
|
||||||
|
def heal(c):
|
||||||
|
return {"t": "heal", "c": c}
|
||||||
|
|
||||||
|
|
||||||
|
def hot(c, dur, every=3):
|
||||||
|
return {"t": "hot", "c": c, "dur": dur, "every": every}
|
||||||
|
|
||||||
|
|
||||||
|
def aura(kind, dur, **extra):
|
||||||
|
return {"t": "aura", "kind": kind, "dur": dur, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
# Each ability: who it targets, what it costs, its cooldown when pressed by hand
|
||||||
|
# (auto runs it AUTO_CD times slower), and the rule auto uses to decide to cast it.
|
||||||
|
# Costs are a share of max mana, or flat rage / energy.
|
||||||
|
ABILITIES = {
|
||||||
|
# Warrior: Protection
|
||||||
|
"sunder": {"name": "Sunder Armor", "icon": "🪓", "target": "enemy", "cost": 12, "threat": 2.5,
|
||||||
|
"effects": [dmg(0.35)], "auto": {"r": "always"},
|
||||||
|
"tip": "A hit that is mostly threat. The tank's filler."},
|
||||||
|
"taunt": {"name": "Taunt", "icon": "😤", "target": "enemy", "cd": 8, "offgcd": True,
|
||||||
|
"effects": [{"t": "taunt", "dur": 3}], "auto": {"r": "taunt"},
|
||||||
|
"tip": "Forces a mob to attack you for 3s and puts you on top of its threat."},
|
||||||
|
"thunder_clap": {"name": "Thunder Clap", "icon": "⚡", "target": "aoe", "cost": 20, "cd": 6, "threat": 1.75,
|
||||||
|
"effects": [aoe(0.4)], "auto": {"r": "aoe", "n": 2},
|
||||||
|
"tip": "Hits every mob. Holds a pack."},
|
||||||
|
"shield_block": {"name": "Shield Block", "icon": "🛡️", "target": "self", "cost": 10, "cd": 10, "offgcd": True,
|
||||||
|
"effects": [aura("taken", 5, mult=0.5, school="phys", name="Shield Block", icon="🛡️")],
|
||||||
|
"auto": {"r": "tanking", "pct": 0.7},
|
||||||
|
"tip": "Halves physical damage taken for 5s. Time it for the big hit."},
|
||||||
|
|
||||||
|
# Paladin: Protection
|
||||||
|
"judgement": {"name": "Judgement", "icon": "⚖️", "target": "enemy", "cost": 0.05, "cd": 8, "threat": 1.5,
|
||||||
|
"effects": [dmg(0.8, "holy")], "auto": {"r": "always"},
|
||||||
|
"tip": "Holy strike. Your seal's power, released."},
|
||||||
|
"holy_light": {"name": "Holy Light", "icon": "💛", "target": "ally", "cost": 0.14, "cast": 2.5,
|
||||||
|
"effects": [heal(1.6)], "auto": {"r": "heal", "pct": 0.45},
|
||||||
|
"tip": "A slow, big heal on anyone."},
|
||||||
|
"consecration": {"name": "Consecration", "icon": "🔥", "target": "aoe", "cost": 0.12, "cd": 8, "threat": 1.5,
|
||||||
|
"effects": [{"t": "ground", "c": 0.15, "dur": 8, "every": 1, "school": "holy"}],
|
||||||
|
"auto": {"r": "aoe", "n": 1}, "tip": "Holy ground burns every mob for 8s."},
|
||||||
|
"hammer_of_justice": {"name": "Hammer of Justice", "icon": "🔨", "target": "enemy", "cost": 0.05, "cd": 30,
|
||||||
|
"offgcd": True, "effects": [{"t": "stun", "dur": 4}], "auto": {"r": "interrupt"},
|
||||||
|
"tip": "Stuns a mob for 4s, stopping its cast. Bosses only lose the cast."},
|
||||||
|
|
||||||
|
# Shaman: Enhancement
|
||||||
|
"stormstrike": {"name": "Stormstrike", "icon": "🌩️", "target": "enemy", "cost": 0.06, "cd": 8,
|
||||||
|
"effects": [dmg(1.1), aura("vuln", 8, mult=1.2, school="nature", name="Stormstrike", icon="🌩️")],
|
||||||
|
"auto": {"r": "always"}, "tip": "Two-weapon strike. Nature hits on the target are 20% stronger."},
|
||||||
|
"flame_shock": {"name": "Flame Shock", "icon": "🔥", "target": "enemy", "cost": 0.08, "cd": 6, "threat": 1.5,
|
||||||
|
"effects": [dmg(0.3, "fire"), dot(0.18, 12, "fire")], "auto": {"r": "dot"},
|
||||||
|
"tip": "Fire burst, then burns for 12s."},
|
||||||
|
"earth_shock": {"name": "Earth Shock", "icon": "🪨", "target": "enemy", "cost": 0.07, "cd": 6, "threat": 2.0,
|
||||||
|
"offgcd": True, "effects": [dmg(0.6, "nature"), {"t": "interrupt"}], "auto": {"r": "interrupt"},
|
||||||
|
"tip": "Nature hit that stops a cast. Big threat."},
|
||||||
|
"lightning_shield": {"name": "Lightning Shield", "icon": "🔵", "target": "self", "cost": 0.05,
|
||||||
|
"effects": [aura("reflect", 60, charges=3, c=0.3, school="nature", name="Lightning Shield",
|
||||||
|
icon="🔵")],
|
||||||
|
"auto": {"r": "buff"}, "tip": "3 charges: each mob that hits you takes nature damage."},
|
||||||
|
|
||||||
|
# Druid: Restoration
|
||||||
|
"wrath": {"name": "Wrath", "icon": "🌞", "target": "enemy", "cost": 0.06, "cast": 1.75,
|
||||||
|
"effects": [dmg(0.7, "nature")], "auto": {"r": "always", "save": 0.7}, "tip": "Nature bolt."},
|
||||||
|
"regrowth": {"name": "Regrowth", "icon": "🌱", "target": "ally", "cost": 0.15, "cast": 2,
|
||||||
|
"effects": [heal(0.9), hot(0.12, 12)], "auto": {"r": "heal", "pct": 0.5},
|
||||||
|
"tip": "Heals, then keeps healing for 12s."},
|
||||||
|
"moonfire": {"name": "Moonfire", "icon": "🌙", "target": "enemy", "cost": 0.07,
|
||||||
|
"effects": [dmg(0.3, "arcane"), dot(0.12, 9, "arcane")], "auto": {"r": "dot", "save": 0.7},
|
||||||
|
"tip": "Arcane hit, then burns for 9s."},
|
||||||
|
"rejuvenation": {"name": "Rejuvenation", "icon": "🍃", "target": "ally", "cost": 0.08,
|
||||||
|
"effects": [hot(0.25, 12)], "auto": {"r": "hot", "pct": 0.85},
|
||||||
|
"tip": "Heals over 12s. Cheap, instant, stacks with Regrowth."},
|
||||||
|
|
||||||
|
# Priest: Holy
|
||||||
|
"smite": {"name": "Smite", "icon": "☀️", "target": "enemy", "cost": 0.06, "cast": 1.75,
|
||||||
|
"effects": [dmg(0.7, "holy")], "auto": {"r": "always", "save": 0.7}, "tip": "Holy bolt."},
|
||||||
|
"heal": {"name": "Heal", "icon": "💖", "target": "ally", "cost": 0.13, "cast": 2.5,
|
||||||
|
"effects": [heal(1.5)], "auto": {"r": "heal", "pct": 0.5}, "tip": "The big, slow heal."},
|
||||||
|
"pw_shield": {"name": "Power Word: Shield", "icon": "🔰", "target": "ally", "cost": 0.12, "cd": 4,
|
||||||
|
"effects": [{"t": "shield", "c": 1.1, "dur": 30}], "auto": {"r": "shield", "pct": 0.7},
|
||||||
|
"tip": "Absorbs damage. 15s before the same target can be shielded again."},
|
||||||
|
"renew": {"name": "Renew", "icon": "💫", "target": "ally", "cost": 0.08,
|
||||||
|
"effects": [hot(0.22, 15)], "auto": {"r": "hot", "pct": 0.85}, "tip": "Heals over 15s."},
|
||||||
|
|
||||||
|
# Mage: Frost
|
||||||
|
"frostbolt": {"name": "Frostbolt", "icon": "❄️", "target": "enemy", "cost": 0.06, "cast": 2,
|
||||||
|
"effects": [dmg(1.0, "frost")], "auto": {"r": "always"}, "tip": "Frost bolt. Your filler."},
|
||||||
|
"frost_nova": {"name": "Frost Nova", "icon": "🧊", "target": "aoe", "cost": 0.08, "cd": 20,
|
||||||
|
"effects": [aoe(0.2, "frost"), {"t": "freeze", "dur": 6}], "auto": {"r": "peel"},
|
||||||
|
"tip": "Freezes every mob for 6s. Damage breaks it."},
|
||||||
|
"polymorph": {"name": "Polymorph", "icon": "🐑", "target": "enemy", "cost": 0.1, "cast": 1.5, "cd": 3,
|
||||||
|
"effects": [{"t": "cc", "dur": 20}], "auto": {"r": "cc"},
|
||||||
|
"tip": "Turns a mob into a sheep for 20s. Damage breaks it. Not bosses."},
|
||||||
|
"counterspell": {"name": "Counterspell", "icon": "🚫", "target": "enemy", "cost": 0.07, "cd": 15, "offgcd": True,
|
||||||
|
"effects": [{"t": "interrupt"}], "auto": {"r": "interrupt"}, "tip": "Stops a cast."},
|
||||||
|
|
||||||
|
# Rogue: Combat
|
||||||
|
"sinister_strike": {"name": "Sinister Strike", "icon": "🗡️", "target": "enemy", "cost": 40,
|
||||||
|
"effects": [dmg(0.75), {"t": "combo", "n": 1}], "auto": {"r": "always"},
|
||||||
|
"tip": "Hit, +1 combo point."},
|
||||||
|
"eviscerate": {"name": "Eviscerate", "icon": "🩸", "target": "enemy", "cost": 35,
|
||||||
|
"effects": [{"t": "finisher", "c": 0.42}], "auto": {"r": "combo", "n": 4},
|
||||||
|
"tip": "Spends combo points: more points, more damage."},
|
||||||
|
"kick": {"name": "Kick", "icon": "🦶", "target": "enemy", "cost": 25, "cd": 10, "offgcd": True,
|
||||||
|
"effects": [dmg(0.2), {"t": "interrupt"}], "auto": {"r": "interrupt"}, "tip": "Stops a cast."},
|
||||||
|
"evasion": {"name": "Evasion", "icon": "💨", "target": "self", "cd": 60, "offgcd": True,
|
||||||
|
"effects": [aura("taken", 10, mult=0.4, school="phys", name="Evasion", icon="💨")],
|
||||||
|
"auto": {"r": "danger", "pct": 0.4}, "tip": "Dodge most attacks for 10s."},
|
||||||
|
|
||||||
|
# Hunter: Beast Mastery
|
||||||
|
"serpent_sting": {"name": "Serpent Sting", "icon": "🐍", "target": "enemy", "cost": 0.07,
|
||||||
|
"effects": [dot(0.2, 15, "nature")], "auto": {"r": "dot"}, "tip": "Poison over 15s."},
|
||||||
|
"arcane_shot": {"name": "Arcane Shot", "icon": "🔮", "target": "enemy", "cost": 0.06, "cd": 6,
|
||||||
|
"effects": [dmg(0.8, "arcane")], "auto": {"r": "always"}, "tip": "Instant arcane shot."},
|
||||||
|
"freezing_trap": {"name": "Freezing Trap", "icon": "🪤", "target": "enemy", "cost": 0.1, "cd": 15,
|
||||||
|
"effects": [{"t": "cc", "dur": 15}], "auto": {"r": "cc"},
|
||||||
|
"tip": "Freezes a mob solid for 15s. Damage breaks it. Not bosses."},
|
||||||
|
"bestial_wrath": {"name": "Bestial Wrath", "icon": "🐺", "target": "pet", "cost": 0.08, "cd": 60, "offgcd": True,
|
||||||
|
"effects": [aura("done", 15, mult=1.6, name="Bestial Wrath", icon="🐺")],
|
||||||
|
"auto": {"r": "boss"}, "tip": "Your pet hits 60% harder for 15s."},
|
||||||
|
|
||||||
|
# Warlock: Affliction
|
||||||
|
"shadow_bolt": {"name": "Shadow Bolt", "icon": "🌑", "target": "enemy", "cost": 0.07, "cast": 2.5,
|
||||||
|
"effects": [dmg(1.1, "shadow")], "auto": {"r": "always"}, "tip": "Shadow bolt."},
|
||||||
|
"corruption": {"name": "Corruption", "icon": "🦠", "target": "enemy", "cost": 0.07,
|
||||||
|
"effects": [dot(0.28, 18, "shadow")], "auto": {"r": "dot"}, "tip": "Shadow over 18s."},
|
||||||
|
"fear": {"name": "Fear", "icon": "😱", "target": "enemy", "cost": 0.08, "cast": 1.5, "cd": 3,
|
||||||
|
"effects": [{"t": "cc", "dur": 10}], "auto": {"r": "cc"},
|
||||||
|
"tip": "Sends a mob running for 10s. Damage breaks it. Not bosses."},
|
||||||
|
"drain_life": {"name": "Drain Life", "icon": "🩸", "target": "enemy", "cost": 0.1, "cast": 3, "cd": 8,
|
||||||
|
"effects": [{"t": "drain", "c": 0.9, "school": "shadow"}], "auto": {"r": "self_below", "pct": 0.6},
|
||||||
|
"tip": "Steals health over 3s."},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specs list their abilities in the order they're learned (UNLOCK), and auto works
|
||||||
|
# through `priority` top-down.
|
||||||
|
SPECS = {
|
||||||
|
"protection": {"cls": "warrior", "name": "Protection", "role": "tank",
|
||||||
|
"abilities": ["sunder", "taunt", "thunder_clap", "shield_block"],
|
||||||
|
"priority": ["taunt", "shield_block", "thunder_clap", "sunder"]},
|
||||||
|
"protection_p": {"cls": "paladin", "name": "Protection", "role": "tank",
|
||||||
|
"abilities": ["judgement", "holy_light", "consecration", "hammer_of_justice"],
|
||||||
|
"priority": ["hammer_of_justice", "holy_light", "consecration", "judgement"]},
|
||||||
|
"enhancement": {"cls": "shaman", "name": "Enhancement", "role": "tank",
|
||||||
|
"abilities": ["stormstrike", "flame_shock", "earth_shock", "lightning_shield"],
|
||||||
|
"priority": ["earth_shock", "lightning_shield", "stormstrike", "flame_shock"]},
|
||||||
|
"restoration": {"cls": "druid", "name": "Restoration", "role": "healer",
|
||||||
|
"abilities": ["wrath", "regrowth", "moonfire", "rejuvenation"],
|
||||||
|
"priority": ["regrowth", "rejuvenation", "moonfire", "wrath"]},
|
||||||
|
"holy": {"cls": "priest", "name": "Holy", "role": "healer",
|
||||||
|
"abilities": ["smite", "heal", "pw_shield", "renew"],
|
||||||
|
"priority": ["pw_shield", "heal", "renew", "smite"]},
|
||||||
|
"frost": {"cls": "mage", "name": "Frost", "role": "dps",
|
||||||
|
"abilities": ["frostbolt", "frost_nova", "polymorph", "counterspell"],
|
||||||
|
"priority": ["counterspell", "frost_nova", "polymorph", "frostbolt"]},
|
||||||
|
"combat": {"cls": "rogue", "name": "Combat", "role": "dps",
|
||||||
|
"abilities": ["sinister_strike", "eviscerate", "kick", "evasion"],
|
||||||
|
"priority": ["kick", "evasion", "eviscerate", "sinister_strike"]},
|
||||||
|
"beast_mastery": {"cls": "hunter", "name": "Beast Mastery", "role": "dps",
|
||||||
|
"abilities": ["serpent_sting", "arcane_shot", "freezing_trap", "bestial_wrath"],
|
||||||
|
"priority": ["bestial_wrath", "freezing_trap", "serpent_sting", "arcane_shot"]},
|
||||||
|
"affliction": {"cls": "warlock", "name": "Affliction", "role": "dps",
|
||||||
|
"abilities": ["shadow_bolt", "corruption", "fear", "drain_life"],
|
||||||
|
"priority": ["fear", "drain_life", "corruption", "shadow_bolt"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
PETS = {
|
||||||
|
# dmg: each 2s swing as a share of the owner's power
|
||||||
|
"wolf": {"name": "Wolf", "icon": "🐺", "hp": 0.65, "dmg": 0.3, "swing": 2.0, "school": "phys"},
|
||||||
|
"voidwalker": {"name": "Voidwalker", "icon": "👤", "hp": 0.9, "dmg": 0.12, "swing": 2.0, "school": "shadow"},
|
||||||
|
"imp": {"name": "Imp", "icon": "😈", "hp": 0.35, "dmg": 0.28, "swing": 2.0, "school": "fire"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- mobs ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enemy abilities. `effect` is (kind, amount, school): hit/aoe amounts are swings,
|
||||||
|
# heal amounts are a share of max health.
|
||||||
|
ENEMY_ABILITIES = {
|
||||||
|
"fireball": {"name": "Fireball", "icon": "🔥", "cast": 2.5, "every": 10, "first": 3, "interrupt": True,
|
||||||
|
"target": "target", "effect": ("hit", 2.2, "fire")},
|
||||||
|
"frostbolt": {"name": "Frostbolt", "icon": "❄️", "cast": 2.5, "every": 10, "first": 3, "interrupt": True,
|
||||||
|
"target": "target", "effect": ("hit", 2.0, "frost")},
|
||||||
|
"healing_wave": {"name": "Healing Wave", "icon": "💧", "cast": 2.5, "every": 9, "first": 5, "interrupt": True,
|
||||||
|
"target": "hurt_ally", "effect": ("heal", 0.3, "nature")},
|
||||||
|
"slam": {"name": "Rhahk'Zor Slam", "icon": "💥", "cast": 2.0, "every": 12, "first": 7, "interrupt": False,
|
||||||
|
"target": "target", "effect": ("hit", 3.2, "phys")},
|
||||||
|
"bomb": {"name": "Throw Bomb", "icon": "💣", "cast": 2.0, "every": 11, "first": 4, "interrupt": True,
|
||||||
|
"target": "all", "effect": ("aoe", 0.9, "fire")},
|
||||||
|
"saw_blades": {"name": "Saw Blades", "icon": "⚙️", "cast": 1.5, "every": 14, "first": 6, "interrupt": False,
|
||||||
|
"target": "all", "effect": ("aoe", 1.0, "phys")},
|
||||||
|
"molten_metal": {"name": "Molten Metal", "icon": "🌋", "cast": 3.0, "every": 13, "first": 5, "interrupt": True,
|
||||||
|
"target": "random_soft", "effect": ("hit", 3.5, "fire")},
|
||||||
|
"thrash": {"name": "Thrash", "icon": "🌀", "cast": 0, "every": 9, "first": 6, "interrupt": False,
|
||||||
|
"target": "target", "effect": ("hit", 2.0, "phys")},
|
||||||
|
"disarm": {"name": "Disarm", "icon": "🫳", "cast": 0, "every": 15, "first": 8, "interrupt": False,
|
||||||
|
"target": "target", "effect": ("disarm", 6, "phys")},
|
||||||
|
"hogger_bite": {"name": "Hogger's Bite", "icon": "🦷", "cast": 1.5, "every": 12, "first": 6, "interrupt": False,
|
||||||
|
"target": "target", "effect": ("hit", 2.5, "phys")},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mob stats: level range, kind, multipliers on the level's health and swing.
|
||||||
|
MOBS = {
|
||||||
|
"young_wolf": {"name": "Young Wolf", "lv": (1, 2), "kind": "beast", "junk": "Ruined Pelt"},
|
||||||
|
"kobold_vermin": {"name": "Kobold Vermin", "lv": (1, 2), "kind": "humanoid", "junk": "Ragged Kobold Vest"},
|
||||||
|
"kobold_worker": {"name": "Kobold Worker", "lv": (3, 4), "kind": "humanoid", "junk": "Dull Kobold Pick"},
|
||||||
|
"defias_thug": {"name": "Defias Thug", "lv": (3, 5), "kind": "humanoid", "junk": "Frayed Burlap"},
|
||||||
|
"garrick_padfoot": {"name": "Garrick Padfoot", "lv": (5, 5), "kind": "humanoid", "hp": 2.0, "dmg": 1.2,
|
||||||
|
"rare": True, "junk": "Garrick's Head"},
|
||||||
|
"kobold_laborer": {"name": "Kobold Laborer", "lv": (4, 5), "kind": "humanoid", "junk": "Cracked Candle"},
|
||||||
|
"kobold_tunneler": {"name": "Kobold Tunneler", "lv": (6, 7), "kind": "humanoid", "junk": "Kobold Mining Helmet"},
|
||||||
|
"kobold_miner": {"name": "Kobold Miner", "lv": (7, 8), "kind": "humanoid", "junk": "Worn Mining Shovel"},
|
||||||
|
"murloc_streamrunner": {"name": "Murloc Streamrunner", "lv": (7, 8), "kind": "humanoid", "junk": "Slimy Scale"},
|
||||||
|
"murloc_forager": {"name": "Murloc Forager", "lv": (8, 9), "kind": "humanoid", "junk": "Murloc Fin"},
|
||||||
|
"defias_bandit": {"name": "Defias Bandit", "lv": (8, 9), "kind": "humanoid", "junk": "Bandit Cinch"},
|
||||||
|
"princess": {"name": "Princess", "lv": (9, 9), "kind": "beast", "hp": 2.5, "dmg": 1.3, "rare": True,
|
||||||
|
"junk": "Princess's Collar"},
|
||||||
|
"porcine_entourage": {"name": "Porcine Entourage", "lv": (6, 7), "kind": "beast", "junk": "Chunk of Boar Meat"},
|
||||||
|
"riverpaw_gnoll": {"name": "Riverpaw Gnoll", "lv": (9, 10), "kind": "humanoid", "junk": "Gnoll Tooth"},
|
||||||
|
"riverpaw_mystic": {"name": "Riverpaw Mystic", "lv": (10, 10), "kind": "humanoid", "hp": 0.8,
|
||||||
|
"abilities": ["healing_wave"], "junk": "Mystic's Charm"},
|
||||||
|
"hogger": {"name": "Hogger", "lv": (11, 11), "kind": "humanoid", "hp": 3.0, "dmg": 1.5, "rare": True,
|
||||||
|
"abilities": ["hogger_bite"], "junk": "Huge Gnoll Claw"},
|
||||||
|
"harvest_watcher": {"name": "Harvest Watcher", "lv": (10, 12), "kind": "mechanical", "junk": "Scrap Metal"},
|
||||||
|
"young_fleshripper": {"name": "Young Fleshripper", "lv": (10, 11), "kind": "beast", "junk": "Tattered Feather"},
|
||||||
|
"goretusk": {"name": "Goretusk", "lv": (12, 13), "kind": "beast", "junk": "Cracked Tusk"},
|
||||||
|
"defias_trapper": {"name": "Defias Trapper", "lv": (12, 13), "kind": "humanoid", "junk": "Rusty Trap"},
|
||||||
|
"defias_smuggler": {"name": "Defias Smuggler", "lv": (13, 14), "kind": "humanoid", "junk": "Contraband Crate"},
|
||||||
|
"riverpaw_brute": {"name": "Riverpaw Brute", "lv": (14, 16), "kind": "humanoid", "dmg": 1.1, "junk": "Gnoll Paw"},
|
||||||
|
"riverpaw_herbalist": {"name": "Riverpaw Herbalist", "lv": (15, 15), "kind": "humanoid", "hp": 0.8,
|
||||||
|
"abilities": ["healing_wave"], "junk": "Bundle of Herbs"},
|
||||||
|
"murloc_coastrunner": {"name": "Murloc Coastrunner", "lv": (15, 16), "kind": "humanoid", "junk": "Shiny Shell"},
|
||||||
|
"murloc_tidecaller": {"name": "Murloc Tidecaller", "lv": (16, 16), "kind": "humanoid", "hp": 0.8,
|
||||||
|
"abilities": ["frostbolt"], "junk": "Tidecaller's Pearl"},
|
||||||
|
"defias_pillager": {"name": "Defias Pillager", "lv": (16, 17), "kind": "humanoid", "hp": 0.8,
|
||||||
|
"abilities": ["fireball"], "junk": "Scorched Rag"},
|
||||||
|
"defias_looter": {"name": "Defias Looter", "lv": (16, 18), "kind": "humanoid", "junk": "Stolen Silverware"},
|
||||||
|
"defias_pathstalker": {"name": "Defias Pathstalker", "lv": (17, 18), "kind": "humanoid", "junk": "Muddy Boots"},
|
||||||
|
"defias_highwayman": {"name": "Defias Highwayman", "lv": (18, 19), "kind": "humanoid", "dmg": 1.1,
|
||||||
|
"junk": "Highwayman's Mask"},
|
||||||
|
|
||||||
|
# The Deadmines. Everything in here is elite.
|
||||||
|
"defias_miner": {"name": "Defias Miner", "lv": (17, 18), "kind": "humanoid", "elite": True, "junk": "Miner's Lamp"},
|
||||||
|
"defias_overseer": {"name": "Defias Overseer", "lv": (18, 18), "kind": "humanoid", "elite": True,
|
||||||
|
"dmg": 1.15, "junk": "Overseer's Whistle"},
|
||||||
|
"rhahkzor": {"name": "Rhahk'Zor", "lv": (19, 19), "kind": "giant", "boss": True, "hp": 3.0, "dmg": 1.4,
|
||||||
|
"abilities": ["slam"], "junk": "Ogre Tooth"},
|
||||||
|
"goblin_woodcarver": {"name": "Goblin Woodcarver", "lv": (18, 18), "kind": "humanoid", "elite": True,
|
||||||
|
"junk": "Wood Shavings"},
|
||||||
|
"goblin_engineer": {"name": "Goblin Engineer", "lv": (18, 18), "kind": "humanoid", "elite": True, "hp": 0.8,
|
||||||
|
"abilities": ["bomb"], "junk": "Gear Assembly"},
|
||||||
|
"sneeds_shredder": {"name": "Sneed's Shredder", "lv": (20, 20), "kind": "mechanical", "boss": True, "hp": 2.6,
|
||||||
|
"dmg": 1.3, "abilities": ["saw_blades"], "on_death": ["sneed"], "junk": "Shredder Saw"},
|
||||||
|
"sneed": {"name": "Sneed", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 1.2, "dmg": 1.2,
|
||||||
|
"abilities": ["disarm"], "junk": "Sneed's Goggles"},
|
||||||
|
"defias_evoker": {"name": "Defias Evoker", "lv": (18, 18), "kind": "humanoid", "elite": True, "hp": 0.75,
|
||||||
|
"abilities": ["fireball"], "junk": "Evoker's Robe Scrap"},
|
||||||
|
"defias_taskmaster": {"name": "Defias Taskmaster", "lv": (18, 18), "kind": "humanoid", "elite": True,
|
||||||
|
"junk": "Taskmaster's Whip"},
|
||||||
|
"defias_strip_miner": {"name": "Defias Strip Miner", "lv": (17, 17), "kind": "humanoid", "elite": True,
|
||||||
|
"junk": "Ore Sack"},
|
||||||
|
"gilnid": {"name": "Gilnid", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 3.2, "dmg": 1.2,
|
||||||
|
"abilities": ["molten_metal"], "junk": "Smelting Tongs"},
|
||||||
|
"defias_pirate": {"name": "Defias Pirate", "lv": (18, 19), "kind": "humanoid", "elite": True, "junk": "Pirate Hat"},
|
||||||
|
"defias_squallshaper": {"name": "Defias Squallshaper", "lv": (19, 19), "kind": "humanoid", "elite": True,
|
||||||
|
"hp": 0.75, "abilities": ["healing_wave"], "junk": "Water-logged Rune"},
|
||||||
|
"mr_smite": {"name": "Mr. Smite", "lv": (20, 20), "kind": "humanoid", "boss": True, "hp": 3.8, "dmg": 1.25,
|
||||||
|
"phases": [{"at": 0.66, "say": "You landlubbers are tougher than I thought! I'll have to improvise!",
|
||||||
|
"do": [("stun_all", 2.5), ("enrage", 1.25)]},
|
||||||
|
{"at": 0.33, "say": "D'ah! Now you're making me angry!",
|
||||||
|
"do": [("stun_all", 2.5), ("enrage", 1.25)]}],
|
||||||
|
"junk": "Smite's Anchor Chain"},
|
||||||
|
"defias_blackguard": {"name": "Defias Blackguard", "lv": (19, 19), "kind": "humanoid", "elite": True,
|
||||||
|
"hp": 0.7, "junk": "Blackguard Cowl"},
|
||||||
|
"edwin_vancleef": {"name": "Edwin VanCleef", "lv": (21, 21), "kind": "humanoid", "boss": True, "hp": 4.6,
|
||||||
|
"dmg": 1.35, "abilities": ["thrash"],
|
||||||
|
"phases": [{"at": 0.5, "say": "Lapdogs, all of you!",
|
||||||
|
"do": [("summon", ["defias_blackguard", "defias_blackguard"])]}],
|
||||||
|
"junk": "VanCleef's Bandana"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ELITE_HP, ELITE_DMG = 3.2, 2.0
|
||||||
|
BOSS_HP, BOSS_DMG = 5.0, 2.8 # on top of the mob's own multipliers
|
||||||
|
|
||||||
|
# --- the world -------------------------------------------------------------
|
||||||
|
|
||||||
|
# Hunts are fights you can repeat. `pulls` are the packs a hunt draws from.
|
||||||
|
PLACES = {
|
||||||
|
"northshire_abbey": {"name": "Northshire Abbey", "zone": "Elwynn Forest", "hub": True, "lv": (1, 5),
|
||||||
|
"links": {"northshire_valley": 1, "northshire_vineyards": 1, "echo_ridge": 1,
|
||||||
|
"goldshire": 4}},
|
||||||
|
"northshire_valley": {"name": "Northshire Valley", "zone": "Elwynn Forest", "lv": (1, 3),
|
||||||
|
"hunts": {"young_wolf": [["young_wolf"]], "kobold_vermin": [["kobold_vermin"]]},
|
||||||
|
"links": {"northshire_abbey": 1}},
|
||||||
|
"northshire_vineyards": {"name": "Northshire Vineyards", "zone": "Elwynn Forest", "lv": (3, 5),
|
||||||
|
"hunts": {"defias_thug": [["defias_thug"]],
|
||||||
|
"garrick_padfoot": [["garrick_padfoot", "defias_thug"]]},
|
||||||
|
"links": {"northshire_abbey": 1}},
|
||||||
|
"echo_ridge": {"name": "Echo Ridge Mine", "zone": "Elwynn Forest", "lv": (3, 5),
|
||||||
|
"hunts": {"kobold_worker": [["kobold_worker"]], "kobold_laborer": [["kobold_laborer"]]},
|
||||||
|
"links": {"northshire_abbey": 1}},
|
||||||
|
"goldshire": {"name": "Goldshire", "zone": "Elwynn Forest", "hub": True, "lv": (5, 10),
|
||||||
|
"links": {"northshire_abbey": 4, "fargodeep": 2, "crystal_lake": 2, "brackwell": 3,
|
||||||
|
"forests_edge": 3, "sentinel_hill": 7}},
|
||||||
|
"fargodeep": {"name": "Fargodeep Mine", "zone": "Elwynn Forest", "lv": (6, 8),
|
||||||
|
"hunts": {"kobold_tunneler": [["kobold_tunneler"]],
|
||||||
|
"kobold_miner": [["kobold_miner"], ["kobold_miner", "kobold_tunneler"]]},
|
||||||
|
"links": {"goldshire": 2}},
|
||||||
|
"crystal_lake": {"name": "Crystal Lake", "zone": "Elwynn Forest", "lv": (7, 9),
|
||||||
|
"hunts": {"murloc_streamrunner": [["murloc_streamrunner"]],
|
||||||
|
"murloc_forager": [["murloc_forager"], ["murloc_forager", "murloc_streamrunner"]]},
|
||||||
|
"links": {"goldshire": 2}},
|
||||||
|
"brackwell": {"name": "Brackwell Pumpkin Patch", "zone": "Elwynn Forest", "lv": (8, 10),
|
||||||
|
"hunts": {"defias_bandit": [["defias_bandit"]],
|
||||||
|
"princess": [["princess", "porcine_entourage", "porcine_entourage"]]},
|
||||||
|
"links": {"goldshire": 3, "forests_edge": 2}},
|
||||||
|
"forests_edge": {"name": "Forest's Edge", "zone": "Elwynn Forest", "lv": (9, 11),
|
||||||
|
"hunts": {"riverpaw_gnoll": [["riverpaw_gnoll"], ["riverpaw_gnoll", "riverpaw_mystic"]],
|
||||||
|
"hogger": [["hogger"]]},
|
||||||
|
"links": {"goldshire": 3, "brackwell": 2, "sentinel_hill": 5}},
|
||||||
|
"sentinel_hill": {"name": "Sentinel Hill", "zone": "Westfall", "hub": True, "lv": (10, 20),
|
||||||
|
"links": {"goldshire": 7, "forests_edge": 5, "saldeans_farm": 2, "molsen_farm": 2,
|
||||||
|
"dust_plains": 3, "gold_coast": 3, "moonbrook": 3}},
|
||||||
|
"saldeans_farm": {"name": "Saldean's Farm", "zone": "Westfall", "lv": (10, 13),
|
||||||
|
"hunts": {"harvest_watcher": [["harvest_watcher"]],
|
||||||
|
"young_fleshripper": [["young_fleshripper"]], "goretusk": [["goretusk"]]},
|
||||||
|
"links": {"sentinel_hill": 2}},
|
||||||
|
"molsen_farm": {"name": "The Molsen Farm", "zone": "Westfall", "lv": (12, 14),
|
||||||
|
"hunts": {"defias_trapper": [["defias_trapper"]],
|
||||||
|
"defias_smuggler": [["defias_smuggler"], ["defias_smuggler", "defias_trapper"]]},
|
||||||
|
"links": {"sentinel_hill": 2}},
|
||||||
|
"dust_plains": {"name": "The Dust Plains", "zone": "Westfall", "lv": (14, 16),
|
||||||
|
"hunts": {"riverpaw_brute": [["riverpaw_brute"], ["riverpaw_brute", "riverpaw_herbalist"]]},
|
||||||
|
"links": {"sentinel_hill": 3, "dagger_hills": 2}},
|
||||||
|
"gold_coast": {"name": "Gold Coast Quarry", "zone": "Westfall", "lv": (15, 16),
|
||||||
|
"hunts": {"murloc_coastrunner": [["murloc_coastrunner"], ["murloc_coastrunner", "murloc_tidecaller"]]},
|
||||||
|
"links": {"sentinel_hill": 3}},
|
||||||
|
"moonbrook": {"name": "Moonbrook", "zone": "Westfall", "lv": (16, 18), "dungeon": "deadmines",
|
||||||
|
"hunts": {"defias_pillager": [["defias_pillager"], ["defias_looter", "defias_pillager"]],
|
||||||
|
"defias_looter": [["defias_looter"]]},
|
||||||
|
"links": {"sentinel_hill": 3, "dagger_hills": 2}},
|
||||||
|
"dagger_hills": {"name": "The Dagger Hills", "zone": "Westfall", "lv": (17, 19),
|
||||||
|
"hunts": {"defias_pathstalker": [["defias_pathstalker"]],
|
||||||
|
"defias_highwayman": [["defias_highwayman"], ["defias_highwayman", "defias_pathstalker"]]},
|
||||||
|
"links": {"moonbrook": 2, "dust_plains": 2}},
|
||||||
|
}
|
||||||
|
|
||||||
|
GRAVEYARD = {"Elwynn Forest": "goldshire", "Westfall": "sentinel_hill"}
|
||||||
|
|
||||||
|
DUNGEONS = {
|
||||||
|
"deadmines": {
|
||||||
|
"name": "The Deadmines", "entrance": "moonbrook", "lv": (17, 21), "size": 5,
|
||||||
|
"pulls": [
|
||||||
|
{"name": "Mine entrance", "mobs": ["defias_miner", "defias_miner", "defias_overseer"]},
|
||||||
|
{"name": "Rhahk'Zor", "mobs": ["rhahkzor"], "boss": True},
|
||||||
|
{"name": "Goblin workshop", "mobs": ["goblin_woodcarver", "goblin_engineer", "goblin_woodcarver"]},
|
||||||
|
{"name": "Sneed's Shredder", "mobs": ["sneeds_shredder"], "boss": True},
|
||||||
|
{"name": "The foundry", "mobs": ["defias_evoker", "defias_taskmaster", "defias_strip_miner"]},
|
||||||
|
{"name": "Gilnid", "mobs": ["gilnid", "defias_strip_miner"], "boss": True},
|
||||||
|
{"name": "The ship's deck", "mobs": ["defias_pirate", "defias_squallshaper", "defias_pirate"]},
|
||||||
|
{"name": "Mr. Smite", "mobs": ["mr_smite"], "boss": True},
|
||||||
|
{"name": "Edwin VanCleef", "mobs": ["edwin_vancleef"], "boss": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- quests ----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Objectives: kill a mob, loot an item that only drops while you're on the quest,
|
||||||
|
# or visit a place. `ender` defaults to the giver's place.
|
||||||
|
QUESTS = {
|
||||||
|
"wolves_across_the_border": {
|
||||||
|
"name": "Wolves Across the Border", "giver": "northshire_abbey", "npc": "Eagan Peltskinner", "lv": 2,
|
||||||
|
"text": "The wolves are thinning the herds. Bring me their meat.",
|
||||||
|
"obj": [{"loot": "Tough Wolf Meat", "n": 8, "from": ["young_wolf"], "chance": 0.6}],
|
||||||
|
"xp": 250, "copper": 40},
|
||||||
|
"kobold_camp_cleanup": {
|
||||||
|
"name": "Kobold Camp Cleanup", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 2,
|
||||||
|
"text": "Kobold vermin infest the valley. Thin them out.",
|
||||||
|
"obj": [{"kill": "kobold_vermin", "n": 10}], "xp": 280, "copper": 50,
|
||||||
|
"choice": ["w/feet/cloth/4", "w/feet/leather/4", "w/feet/mail/4"]},
|
||||||
|
"investigate_echo_ridge": {
|
||||||
|
"name": "Investigate Echo Ridge", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 4,
|
||||||
|
"req": ["kobold_camp_cleanup"], "text": "More kobolds dig at Echo Ridge. Find out why.",
|
||||||
|
"obj": [{"kill": "kobold_worker", "n": 10}], "xp": 450, "copper": 80,
|
||||||
|
"choice": ["g/hands/cloth/eagle/6", "g/hands/leather/monkey/6", "g/hands/mail/bear/6"]},
|
||||||
|
"brotherhood_of_thieves": {
|
||||||
|
"name": "Brotherhood of Thieves", "giver": "northshire_abbey", "npc": "Deputy Willem", "lv": 4,
|
||||||
|
"text": "Defias thugs are stealing from the vineyards. Their red bandanas will prove the work.",
|
||||||
|
"obj": [{"loot": "Red Burlap Bandana", "n": 12, "from": ["defias_thug"], "chance": 0.5}],
|
||||||
|
"xp": 450, "copper": 90},
|
||||||
|
"bounty_on_garrick_padfoot": {
|
||||||
|
"name": "Bounty on Garrick Padfoot", "giver": "northshire_abbey", "npc": "Deputy Willem", "lv": 5,
|
||||||
|
"req": ["brotherhood_of_thieves"], "text": "Their leader hides in the vineyards. Bring him down.",
|
||||||
|
"obj": [{"kill": "garrick_padfoot", "n": 1}], "xp": 550, "copper": 150,
|
||||||
|
"choice": ["g/weapon/sword/tiger/7", "g/weapon/staff/eagle/7", "g/weapon/dagger/monkey/7",
|
||||||
|
"g/weapon/bow/monkey/7"]},
|
||||||
|
"report_to_goldshire": {
|
||||||
|
"name": "Report to Goldshire", "giver": "northshire_abbey", "npc": "Marshal McBride", "lv": 5,
|
||||||
|
"req": ["investigate_echo_ridge"], "ender": "goldshire", "ender_npc": "Marshal Dughan",
|
||||||
|
"text": "Take word of the kobolds to Marshal Dughan in Goldshire.",
|
||||||
|
"obj": [{"visit": "goldshire"}], "xp": 200, "copper": 50},
|
||||||
|
"gold_dust_exchange": {
|
||||||
|
"name": "Gold Dust Exchange", "giver": "goldshire", "npc": "Remy \"Two Times\"", "lv": 7,
|
||||||
|
"text": "The kobolds at Fargodeep pan for gold dust. Bring me some, and no questions.",
|
||||||
|
"obj": [{"loot": "Gold Dust", "n": 10, "from": ["kobold_tunneler", "kobold_miner"], "chance": 0.4}],
|
||||||
|
"xp": 700, "copper": 250},
|
||||||
|
"kobold_candles": {
|
||||||
|
"name": "Kobold Candles", "giver": "goldshire", "npc": "William Pestle", "lv": 8,
|
||||||
|
"text": "Kobold candles make fine wax for my potions.",
|
||||||
|
"obj": [{"loot": "Large Candle", "n": 8, "from": ["kobold_miner"], "chance": 0.5}], "xp": 800, "copper": 220,
|
||||||
|
"choice": ["g/legs/cloth/eagle/9", "g/legs/leather/monkey/9", "g/legs/mail/bear/9"]},
|
||||||
|
"bounty_on_murlocs": {
|
||||||
|
"name": "Bounty on Murlocs", "giver": "crystal_lake", "npc": "Guard Thomas", "lv": 9,
|
||||||
|
"text": "Murlocs raid the lake shore. Bring me their fins.",
|
||||||
|
"obj": [{"loot": "Torn Murloc Fin", "n": 8, "from": ["murloc_streamrunner", "murloc_forager"], "chance": 0.5}],
|
||||||
|
"xp": 900, "copper": 300,
|
||||||
|
"choice": ["g/head/cloth/eagle/10", "g/head/leather/tiger/10", "g/head/mail/bear/10"]},
|
||||||
|
"princess_must_die": {
|
||||||
|
"name": "Princess Must Die!", "giver": "brackwell", "npc": "Ma Stonefield", "lv": 9,
|
||||||
|
"text": "That prize sow Princess has been eating my pumpkins. End her.",
|
||||||
|
"obj": [{"kill": "princess", "n": 1}], "xp": 1000, "copper": 350},
|
||||||
|
"the_defias_bandits": {
|
||||||
|
"name": "Bandits in the Patch", "giver": "brackwell", "npc": "Ma Stonefield", "lv": 9,
|
||||||
|
"text": "Defias bandits camp in the pumpkin patch. Run them off.",
|
||||||
|
"obj": [{"kill": "defias_bandit", "n": 12}], "xp": 950, "copper": 320},
|
||||||
|
"riverpaw_gnoll_bounty": {
|
||||||
|
"name": "Riverpaw Gnoll Bounty", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 10,
|
||||||
|
"text": "Gnolls from Westfall raid the forest's edge. Bring their armbands.",
|
||||||
|
"obj": [{"loot": "Painted Gnoll Armband", "n": 8, "from": ["riverpaw_gnoll", "riverpaw_mystic"],
|
||||||
|
"chance": 0.4}], "xp": 1100, "copper": 400},
|
||||||
|
"wanted_hogger": {
|
||||||
|
"name": "Wanted: Hogger", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 11,
|
||||||
|
"req": ["riverpaw_gnoll_bounty"], "text": "A huge gnoll leads them. Hogger. Bring him down.",
|
||||||
|
"obj": [{"kill": "hogger", "n": 1}], "xp": 1600, "copper": 900,
|
||||||
|
"choice": ["hoggers_trousers", "hoggers_gauntlets", "hoggers_cleaver"]},
|
||||||
|
"report_to_gryan": {
|
||||||
|
"name": "Report to Gryan Stoutmantle", "giver": "goldshire", "npc": "Marshal Dughan", "lv": 10,
|
||||||
|
"req": ["riverpaw_gnoll_bounty"], "ender": "sentinel_hill", "ender_npc": "Gryan Stoutmantle",
|
||||||
|
"text": "The People's Militia at Sentinel Hill needs every sword it can get.",
|
||||||
|
"obj": [{"visit": "sentinel_hill"}], "xp": 400, "copper": 100},
|
||||||
|
"the_killing_fields": {
|
||||||
|
"name": "The Killing Fields", "giver": "saldeans_farm", "npc": "Farmer Saldean", "lv": 12,
|
||||||
|
"text": "Harvest Watchers turned on us. Smash them.",
|
||||||
|
"obj": [{"kill": "harvest_watcher", "n": 12}], "xp": 1300, "copper": 400,
|
||||||
|
"choice": ["g/feet/cloth/eagle/12", "g/feet/leather/monkey/12", "g/feet/mail/bear/12"]},
|
||||||
|
"goretusk_liver_pie": {
|
||||||
|
"name": "Goretusk Liver Pie", "giver": "saldeans_farm", "npc": "Salma Saldean", "lv": 12,
|
||||||
|
"text": "Bring me goretusk livers and I'll bake you a pie that sticks to the ribs.",
|
||||||
|
"obj": [{"loot": "Goretusk Liver", "n": 8, "from": ["goretusk"], "chance": 0.5}], "xp": 1250, "copper": 380},
|
||||||
|
"the_peoples_militia": {
|
||||||
|
"name": "The People's Militia", "giver": "sentinel_hill", "npc": "Lieutenant Horatio Laine", "lv": 13,
|
||||||
|
"text": "The Defias hold the Molsen farm. Strike back.",
|
||||||
|
"obj": [{"kill": "defias_trapper", "n": 10}, {"kill": "defias_smuggler", "n": 10}], "xp": 1700,
|
||||||
|
"copper": 600, "choice": ["g/chest/cloth/eagle/14", "g/chest/leather/monkey/14", "g/chest/mail/bear/14"]},
|
||||||
|
"patrolling_westfall": {
|
||||||
|
"name": "Patrolling Westfall", "giver": "sentinel_hill", "npc": "Captain Danuvin", "lv": 15,
|
||||||
|
"text": "Riverpaw brutes roam the plains. Bring back their paws.",
|
||||||
|
"obj": [{"loot": "Gnoll Paw", "n": 8, "from": ["riverpaw_brute", "riverpaw_herbalist"], "chance": 0.5}],
|
||||||
|
"xp": 1900, "copper": 700},
|
||||||
|
"the_coast_isnt_clear": {
|
||||||
|
"name": "The Coast Isn't Clear", "giver": "sentinel_hill", "npc": "Captain Grayson", "lv": 16,
|
||||||
|
"text": "Murlocs overran the quarry on the Gold Coast.",
|
||||||
|
"obj": [{"kill": "murloc_coastrunner", "n": 10}, {"kill": "murloc_tidecaller", "n": 5}], "xp": 2100,
|
||||||
|
"copper": 750, "choice": ["g/back/cloth/tiger/16", "g/back/cloth/bear/16", "g/back/cloth/eagle/16"]},
|
||||||
|
"red_silk_bandanas": {
|
||||||
|
"name": "Red Silk Bandanas", "giver": "sentinel_hill", "npc": "Scout Riell", "lv": 17,
|
||||||
|
"req": ["the_peoples_militia"], "text": "The Defias in Moonbrook wear red silk. Bring me ten.",
|
||||||
|
"obj": [{"loot": "Red Silk Bandana", "n": 10, "from": ["defias_pillager", "defias_looter"], "chance": 0.5}],
|
||||||
|
"xp": 2300, "copper": 900},
|
||||||
|
"the_peoples_militia_2": {
|
||||||
|
"name": "The People's Militia, Part 2", "giver": "sentinel_hill", "npc": "Lieutenant Horatio Laine", "lv": 18,
|
||||||
|
"req": ["the_peoples_militia"], "text": "Their scouts watch from the Dagger Hills. Clear them out.",
|
||||||
|
"obj": [{"kill": "defias_pathstalker", "n": 10}, {"kill": "defias_highwayman", "n": 5}], "xp": 2500,
|
||||||
|
"copper": 1000, "choice": ["g/weapon/staff/eagle/19", "g/weapon/sword/tiger/19", "g/weapon/mace/bear/19",
|
||||||
|
"g/weapon/bow/monkey/19", "g/weapon/dagger/monkey/19"]},
|
||||||
|
"underground_assault": {
|
||||||
|
"name": "Underground Assault", "giver": "sentinel_hill", "npc": "Shoni the Shilent", "lv": 18,
|
||||||
|
"text": "Sneed's Shredder runs on a gnome's stolen gear. Bring me Gnoam Sprecklesprocket.",
|
||||||
|
"obj": [{"loot": "Gnoam Sprecklesprocket", "n": 1, "from": ["sneeds_shredder"], "chance": 1.0}],
|
||||||
|
"xp": 2400, "copper": 1100},
|
||||||
|
"collecting_memories": {
|
||||||
|
"name": "Collecting Memories", "giver": "sentinel_hill", "npc": "Wilder Thistlenettle", "lv": 18,
|
||||||
|
"text": "My brother's miners' union cards are down in that mine.",
|
||||||
|
"obj": [{"loot": "Miner's Union Card", "n": 4, "from": ["defias_miner", "defias_strip_miner"],
|
||||||
|
"chance": 0.5}], "xp": 2400, "copper": 900},
|
||||||
|
"the_defias_brotherhood": {
|
||||||
|
"name": "The Defias Brotherhood", "giver": "sentinel_hill", "npc": "Gryan Stoutmantle", "lv": 20,
|
||||||
|
"req": ["red_silk_bandanas"], "text": "Edwin VanCleef leads them from the Deadmines. End this.",
|
||||||
|
"obj": [{"kill": "edwin_vancleef", "n": 1}], "xp": 4000, "copper": 2500,
|
||||||
|
"choice": ["chausses_of_westfall", "tunic_of_westfall", "staff_of_westfall"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- items -----------------------------------------------------------------
|
||||||
|
|
||||||
|
QUALITY = {"poor": 0, "common": 0.55, "uncommon": 1.0, "rare": 1.3, "epic": 1.6}
|
||||||
|
|
||||||
|
# Suffix: (name, share of the stat budget that goes to stamina; the rest is power)
|
||||||
|
SUFFIXES = {
|
||||||
|
"stamina": ("of Stamina", 1.0), "bear": ("of the Bear", 0.6), "eagle": ("of the Eagle", 0.5),
|
||||||
|
"monkey": ("of the Monkey", 0.4), "tiger": ("of the Tiger", 0.3), "power": ("of Power", 0.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Base names for generated gear, by item-level band (1-7, 8-14, 15+).
|
||||||
|
BASE_NAMES = {
|
||||||
|
("head", "cloth"): ["Linen Cap", "Woolen Hood", "Silk Turban"],
|
||||||
|
("chest", "cloth"): ["Linen Robe", "Woolen Robe", "Silk-threaded Robe"],
|
||||||
|
("hands", "cloth"): ["Linen Gloves", "Woolen Gloves", "Silk Gloves"],
|
||||||
|
("legs", "cloth"): ["Linen Pants", "Woolen Leggings", "Spellbinder Pants"],
|
||||||
|
("feet", "cloth"): ["Linen Shoes", "Woolen Boots", "Silk Slippers"],
|
||||||
|
("back", "cloth"): ["Frayed Cloak", "Woolen Cape", "Silk Cloak"],
|
||||||
|
("head", "leather"): ["Rough Leather Cap", "Embossed Leather Cap", "Dark Leather Hood"],
|
||||||
|
("chest", "leather"): ["Rough Leather Vest", "Embossed Leather Vest", "Dark Leather Tunic"],
|
||||||
|
("hands", "leather"): ["Rough Leather Gloves", "Embossed Leather Gloves", "Dark Leather Gloves"],
|
||||||
|
("legs", "leather"): ["Rough Leather Pants", "Embossed Leather Pants", "Dark Leather Pants"],
|
||||||
|
("feet", "leather"): ["Rough Leather Boots", "Embossed Leather Boots", "Dark Leather Boots"],
|
||||||
|
("head", "mail"): ["Rusted Chain Coif", "Chainmail Coif", "Banded Helm"],
|
||||||
|
("chest", "mail"): ["Rusted Chain Vest", "Chainmail Armor", "Banded Hauberk"],
|
||||||
|
("hands", "mail"): ["Rusted Chain Gloves", "Chainmail Gloves", "Banded Gauntlets"],
|
||||||
|
("legs", "mail"): ["Rusted Chain Leggings", "Chainmail Pants", "Banded Leggings"],
|
||||||
|
("feet", "mail"): ["Rusted Chain Boots", "Chainmail Boots", "Banded Boots"],
|
||||||
|
("weapon", "sword"): ["Short Sword", "Broadsword", "Cutlass"],
|
||||||
|
("weapon", "mace"): ["Club", "Morning Star", "Warhammer"],
|
||||||
|
("weapon", "axe"): ["Hatchet", "Battle Axe", "Cleaver"],
|
||||||
|
("weapon", "dagger"): ["Dirk", "Stiletto", "Kris"],
|
||||||
|
("weapon", "staff"): ["Walking Stick", "Quarter Staff", "Oaken Staff"],
|
||||||
|
("weapon", "bow"): ["Short Bow", "Hunting Bow", "Recurve Bow"],
|
||||||
|
("weapon", "gun"): ["Blunderbuss", "Flintlock", "Hunting Rifle"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Named items. `kind` is the armour type for armour, the weapon type for weapons.
|
||||||
|
ITEMS = {
|
||||||
|
"hoggers_trousers": {"name": "Hogger's Trousers", "slot": "legs", "kind": "leather", "ilvl": 14, "q": "rare",
|
||||||
|
"sta": 0.5},
|
||||||
|
"hoggers_gauntlets": {"name": "Hogger's Gauntlets", "slot": "hands", "kind": "mail", "ilvl": 14, "q": "rare",
|
||||||
|
"sta": 0.6},
|
||||||
|
"hoggers_cleaver": {"name": "Hogger's Cleaver", "slot": "weapon", "kind": "axe", "ilvl": 14, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"chausses_of_westfall": {"name": "Chausses of Westfall", "slot": "legs", "kind": "mail", "ilvl": 22, "q": "rare",
|
||||||
|
"sta": 0.6},
|
||||||
|
"tunic_of_westfall": {"name": "Tunic of Westfall", "slot": "chest", "kind": "leather", "ilvl": 22, "q": "rare",
|
||||||
|
"sta": 0.4},
|
||||||
|
"staff_of_westfall": {"name": "Staff of Westfall", "slot": "weapon", "kind": "staff", "ilvl": 22, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
# The Deadmines
|
||||||
|
"rhahkzors_hammer": {"name": "Rhahk'Zor's Hammer", "slot": "weapon", "kind": "mace", "ilvl": 20, "q": "rare",
|
||||||
|
"sta": 0.4},
|
||||||
|
"ironjaw_bracers": {"name": "Ironjaw's Bracers", "slot": "hands", "kind": "mail", "ilvl": 20, "q": "rare",
|
||||||
|
"sta": 0.6},
|
||||||
|
"buzzer_blade": {"name": "Buzzer Blade", "slot": "weapon", "kind": "dagger", "ilvl": 21, "q": "rare", "sta": 0.2},
|
||||||
|
"gold_flecked_gloves": {"name": "Gold-flecked Gloves", "slot": "hands", "kind": "cloth", "ilvl": 21, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"buzzsaw": {"name": "Buzzsaw", "slot": "weapon", "kind": "sword", "ilvl": 21, "q": "rare", "sta": 0.3},
|
||||||
|
"smelting_pants": {"name": "Smelting Pants", "slot": "legs", "kind": "leather", "ilvl": 21, "q": "rare",
|
||||||
|
"sta": 0.4},
|
||||||
|
"gilnids_robe": {"name": "Gilnid's Smelting Robe", "slot": "chest", "kind": "cloth", "ilvl": 21, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"smites_reaver": {"name": "Smite's Reaver", "slot": "weapon", "kind": "axe", "ilvl": 22, "q": "rare", "sta": 0.4},
|
||||||
|
"thiefs_blade": {"name": "Thief's Blade", "slot": "weapon", "kind": "sword", "ilvl": 22, "q": "rare", "sta": 0.2},
|
||||||
|
"smites_mighty_hammer": {"name": "Smite's Mighty Hammer", "slot": "weapon", "kind": "mace", "ilvl": 22, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"cruel_barb": {"name": "Cruel Barb", "slot": "weapon", "kind": "sword", "ilvl": 24, "q": "rare", "sta": 0.2},
|
||||||
|
"cape_of_the_brotherhood": {"name": "Cape of the Brotherhood", "slot": "back", "kind": "cloth", "ilvl": 24,
|
||||||
|
"q": "rare", "sta": 0.4},
|
||||||
|
"blackened_defias_armor": {"name": "Blackened Defias Armor", "slot": "chest", "kind": "leather", "ilvl": 24,
|
||||||
|
"q": "rare", "sta": 0.3},
|
||||||
|
"corsairs_overshirt": {"name": "Corsair's Overshirt", "slot": "chest", "kind": "cloth", "ilvl": 23, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"vancleefs_helm": {"name": "VanCleef's Battlehelm", "slot": "head", "kind": "mail", "ilvl": 24, "q": "rare",
|
||||||
|
"sta": 0.6},
|
||||||
|
"emberstone_staff": {"name": "Emberstone Staff", "slot": "weapon", "kind": "staff", "ilvl": 23, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"defias_hunting_bow": {"name": "Defias Hunting Bow", "slot": "weapon", "kind": "bow", "ilvl": 22, "q": "rare",
|
||||||
|
"sta": 0.3},
|
||||||
|
"blackened_defias_gloves": {"name": "Blackened Defias Gloves", "slot": "hands", "kind": "leather", "ilvl": 19,
|
||||||
|
"q": "uncommon", "sta": 0.4},
|
||||||
|
"blackened_defias_boots": {"name": "Blackened Defias Boots", "slot": "feet", "kind": "leather", "ilvl": 19,
|
||||||
|
"q": "uncommon", "sta": 0.4},
|
||||||
|
"miners_cape": {"name": "Miner's Cape", "slot": "back", "kind": "cloth", "ilvl": 19, "q": "uncommon", "sta": 0.5},
|
||||||
|
"foremans_boots": {"name": "Foreman's Boots", "slot": "feet", "kind": "mail", "ilvl": 19, "q": "uncommon",
|
||||||
|
"sta": 0.6},
|
||||||
|
"silk_mask": {"name": "Squallshaper's Hood", "slot": "head", "kind": "cloth", "ilvl": 20, "q": "uncommon",
|
||||||
|
"sta": 0.4},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Each boss gives every player one roll at its table, so nobody fights over loot.
|
||||||
|
BOSS_LOOT = {
|
||||||
|
"rhahkzor": ["rhahkzors_hammer", "ironjaw_bracers", "foremans_boots"],
|
||||||
|
"sneed": ["buzzer_blade", "gold_flecked_gloves", "buzzsaw", "blackened_defias_gloves"],
|
||||||
|
"gilnid": ["smelting_pants", "gilnids_robe", "blackened_defias_boots", "defias_hunting_bow"],
|
||||||
|
"mr_smite": ["smites_reaver", "thiefs_blade", "smites_mighty_hammer", "silk_mask"],
|
||||||
|
"edwin_vancleef": ["cruel_barb", "cape_of_the_brotherhood", "blackened_defias_armor", "corsairs_overshirt",
|
||||||
|
"vancleefs_helm", "emberstone_staff"],
|
||||||
|
}
|
||||||
|
BOSS_LOOT_CHANCE = 0.6 # each player's chance at something they can use, per boss
|
||||||
|
TRASH_LOOT = ["blackened_defias_gloves", "blackened_defias_boots", "miners_cape", "foremans_boots", "silk_mask"]
|
||||||
|
TRASH_LOOT_CHANCE = 0.08
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1792615934,
|
||||||
|
"narHash": "sha256-MlvRfa1O5Cqc2/a+gG+l9TSuowEoCKCmrJ1d9oAVm0I=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
36
flake.nix
Normal file
36
flake.nix
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
description = "World of Guildhall: a co-op adventure for a small group of friends";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let pkgs = import nixpkgs { inherit system; };
|
||||||
|
in {
|
||||||
|
default = pkgs.callPackage ./package.nix { };
|
||||||
|
wog = self.packages.${system}.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
nixosModules.default = import ./module.nix self;
|
||||||
|
nixosModules.wog = self.nixosModules.default;
|
||||||
|
|
||||||
|
checks = forAllSystems (system:
|
||||||
|
let pkgs = import nixpkgs { inherit system; };
|
||||||
|
in {
|
||||||
|
inherit (self.packages.${system}) default;
|
||||||
|
tests = pkgs.runCommand "wog-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } ''
|
||||||
|
cp -r ${self} source
|
||||||
|
chmod -R u+w source
|
||||||
|
cd source
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
python3 -m py_compile app.py game.py combat.py content.py
|
||||||
|
node --check static/app.js
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
885
game.py
Normal file
885
game.py
Normal file
|
|
@ -0,0 +1,885 @@
|
||||||
|
"""World of Guildhall: characters, gear, places, quests, groups and dungeons.
|
||||||
|
|
||||||
|
The world is one JSON-able dict. Time moves it forward: `process` runs everyone's
|
||||||
|
hunts and dungeon runs up to now, fight by fight, so a player who looks away keeps
|
||||||
|
adventuring for up to OFFLINE_CAP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import random
|
||||||
|
|
||||||
|
import combat
|
||||||
|
from content import (ABILITIES, ARMOUR_TYPES, BASE_NAMES, BOSS_LOOT, BOSS_LOOT_CHANCE, CLASSES, DUNGEONS,
|
||||||
|
GRAVEYARD, ITEMS, MAX_LEVEL, MOBS, PETS, PLACES, QUALITY, QUESTS, SLOT_ARMOUR, SLOT_BUDGET,
|
||||||
|
SLOTS, SPECS, SUFFIXES, TRASH_LOOT, TRASH_LOOT_CHANCE, UNLOCK, WEAPON_SPEED, XP_TO_NEXT)
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
MINUTE = 60
|
||||||
|
HOUR = 3600
|
||||||
|
OFFLINE_CAP = 12 * HOUR
|
||||||
|
REST = 0.06 # share of health and mana back per second out of combat
|
||||||
|
REVIVE = 30 # seconds dead before running back
|
||||||
|
DEATHS_TO_STOP = 3 # in a row on one hunt
|
||||||
|
DUNGEON_REST = 25 # seconds between pulls before the next one starts by itself
|
||||||
|
WIPE_DELAY = 15
|
||||||
|
PARTY_MAX = 5
|
||||||
|
MAX_QUESTS = 12
|
||||||
|
MAX_LOG = 80
|
||||||
|
MAX_CHAT = 200
|
||||||
|
SHOW_ENDED = 4 # seconds a finished fight stays on screen
|
||||||
|
GREEN_ODDS = 0.025
|
||||||
|
JUNK_ODDS = 0.5
|
||||||
|
ARMOUR_RANK = {"cloth": 0, "leather": 1, "mail": 2}
|
||||||
|
|
||||||
|
|
||||||
|
# --- items -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _band(ilvl: int) -> int:
|
||||||
|
return 0 if ilvl < 8 else 1 if ilvl < 15 else 2
|
||||||
|
|
||||||
|
|
||||||
|
def _gear(item_id: str, name: str, slot: str, kind: str, ilvl: int, quality: str, share: float) -> dict:
|
||||||
|
budget = ilvl * SLOT_BUDGET[slot] * QUALITY[quality] * 0.9
|
||||||
|
item = {"id": item_id, "name": name, "slot": slot, "kind": kind, "ilvl": ilvl, "q": quality,
|
||||||
|
"req": max(1, ilvl - 3 if quality != "common" else ilvl - 1),
|
||||||
|
"sta": round(budget * share), "power": round(budget * (1 - share)), "armor": 0,
|
||||||
|
"value": round(ilvl ** 1.5 * 4 * (QUALITY[quality] + 0.3))}
|
||||||
|
if slot == "weapon":
|
||||||
|
item["dps"] = round((1 + 0.45 * ilvl) * (0.8 + 0.2 * QUALITY[quality]), 1)
|
||||||
|
item["speed"] = WEAPON_SPEED[kind]
|
||||||
|
else:
|
||||||
|
item["armor"] = round(ilvl * ARMOUR_TYPES[kind] * SLOT_ARMOUR[slot] * (0.85 + 0.15 * QUALITY[quality]))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def item(item_id: str) -> dict | None:
|
||||||
|
"""Every item is described by its id: named, generated (w/ white, g/ green), junk (j/) or quest (q/)."""
|
||||||
|
if item_id in ITEMS:
|
||||||
|
spec = ITEMS[item_id]
|
||||||
|
return _gear(item_id, spec["name"], spec["slot"], spec["kind"], spec["ilvl"], spec["q"], spec["sta"])
|
||||||
|
parts = item_id.split("/")
|
||||||
|
try:
|
||||||
|
if parts[0] == "w":
|
||||||
|
slot, kind, ilvl = parts[1], parts[2], int(parts[3])
|
||||||
|
return _gear(item_id, BASE_NAMES[(slot, kind)][_band(ilvl)], slot, kind, ilvl, "common", 0.5)
|
||||||
|
if parts[0] == "g":
|
||||||
|
slot, kind, suffix, ilvl = parts[1], parts[2], parts[3], int(parts[4])
|
||||||
|
name = f"{BASE_NAMES[(slot, kind)][_band(ilvl)]} {SUFFIXES[suffix][0]}"
|
||||||
|
return _gear(item_id, name, slot, kind, ilvl, "uncommon", SUFFIXES[suffix][1])
|
||||||
|
if parts[0] == "j":
|
||||||
|
mob = MOBS[parts[1]]
|
||||||
|
return {"id": item_id, "name": mob["junk"], "q": "poor", "value": 2 + mob["lv"][1] * 4}
|
||||||
|
except (KeyError, IndexError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def random_green(level: int, rng: random.Random) -> str:
|
||||||
|
slot = rng.choice(SLOTS)
|
||||||
|
suffix = rng.choice(list(SUFFIXES))
|
||||||
|
ilvl = max(2, level + rng.randint(-1, 1))
|
||||||
|
if slot == "weapon":
|
||||||
|
kind = rng.choice(list(WEAPON_SPEED))
|
||||||
|
elif slot == "back":
|
||||||
|
kind = "cloth"
|
||||||
|
else:
|
||||||
|
kind = rng.choice(list(ARMOUR_TYPES))
|
||||||
|
return f"g/{slot}/{kind}/{suffix}/{ilvl}"
|
||||||
|
|
||||||
|
|
||||||
|
def fits(player: dict, spec: dict) -> bool:
|
||||||
|
"""The class can use it at some level (best armour type only, like loot rules in Classic)."""
|
||||||
|
cls = CLASSES[player["cls"]]
|
||||||
|
if spec["slot"] == "weapon":
|
||||||
|
return spec["kind"] in cls["weapons"]
|
||||||
|
return spec["slot"] == "back" or spec["kind"] == cls["armour"]
|
||||||
|
|
||||||
|
|
||||||
|
def can_wear(player: dict, spec: dict) -> str | None:
|
||||||
|
cls = CLASSES[player["cls"]]
|
||||||
|
if "slot" not in spec:
|
||||||
|
return "That can't be worn"
|
||||||
|
if spec["slot"] == "weapon":
|
||||||
|
if spec["kind"] not in cls["weapons"]:
|
||||||
|
return f"{cls['name']}s can't use a {spec['kind']}"
|
||||||
|
elif ARMOUR_RANK[spec["kind"]] > ARMOUR_RANK[cls["armour"]]:
|
||||||
|
return f"{cls['name']}s can't wear {spec['kind']}"
|
||||||
|
if spec["req"] > player["level"]:
|
||||||
|
return f"Requires level {spec['req']}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- characters ------------------------------------------------------------
|
||||||
|
|
||||||
|
def spec_of(player: dict) -> dict:
|
||||||
|
return SPECS[CLASSES[player["cls"]]["spec"]]
|
||||||
|
|
||||||
|
|
||||||
|
def known(player: dict) -> list[str]:
|
||||||
|
return [ab for ab, level in zip(spec_of(player)["abilities"], UNLOCK) if player["level"] >= level]
|
||||||
|
|
||||||
|
|
||||||
|
def stats(player: dict) -> dict:
|
||||||
|
cls = CLASSES[player["cls"]]
|
||||||
|
level = player["level"]
|
||||||
|
worn = [item(i) for i in player["equip"].values() if i]
|
||||||
|
worn = [w for w in worn if w]
|
||||||
|
sta = sum(w["sta"] for w in worn)
|
||||||
|
gear_power = sum(w["power"] for w in worn)
|
||||||
|
armor = sum(w["armor"] for w in worn)
|
||||||
|
if cls.get("shield"):
|
||||||
|
armor += 8 * level
|
||||||
|
if spec_of(player)["role"] == "tank":
|
||||||
|
armor = round(armor * 1.6)
|
||||||
|
weapon = item(player["equip"].get("weapon") or "") or {"dps": 1 + 0.3 * level, "speed": 2.0}
|
||||||
|
power = 10 + 5 * level + gear_power
|
||||||
|
max_hp = cls["hp"][0] + cls["hp"][1] * level + sta * 8
|
||||||
|
res = cls["res"]
|
||||||
|
res_max = 100 if res in ("rage", "energy") else 40 + 15 * level + 3 * gear_power
|
||||||
|
swing = weapon["speed"] * (weapon["dps"] + power * 0.05)
|
||||||
|
return {"max_hp": max_hp, "power": power, "armor": armor, "sta": sta, "res": res, "res_max": res_max,
|
||||||
|
"swing": round(swing, 1), "speed": weapon["speed"], "dps": weapon["dps"]}
|
||||||
|
|
||||||
|
|
||||||
|
def hp_now(player: dict, now: float) -> tuple[float, float]:
|
||||||
|
"""Health and mana share, resting since the last fight ended."""
|
||||||
|
st = stats(player)
|
||||||
|
if player["dead_until"] > now:
|
||||||
|
return 0.0, 0.0
|
||||||
|
rest = player["rest"]
|
||||||
|
gone = max(0.0, now - rest["at"])
|
||||||
|
hp = min(st["max_hp"], rest["hp"] + st["max_hp"] * REST * gone)
|
||||||
|
mana = min(1.0, rest["mana"] + REST * gone)
|
||||||
|
return hp, mana
|
||||||
|
|
||||||
|
|
||||||
|
def new_player(uid: str, name: str, cls: str, now: float) -> dict:
|
||||||
|
spec = CLASSES[cls]
|
||||||
|
armour = spec["armour"]
|
||||||
|
player = {
|
||||||
|
"id": uid, "name": name, "cls": cls, "level": 1, "xp": 0, "copper": 0,
|
||||||
|
"place": "northshire_abbey", "activity": None, "fight": None, "group": None,
|
||||||
|
"rest": {"at": now, "hp": 1e9, "mana": 1.0}, "dead_until": 0, "next_pull": 0, "deaths": 0,
|
||||||
|
"quests": {}, "done": [], "bags": {}, "manual": [],
|
||||||
|
"equip": {slot: None for slot in SLOTS},
|
||||||
|
"last_active": now, "watch": 0, "log": [], "joined": now,
|
||||||
|
}
|
||||||
|
player["equip"]["weapon"] = f"w/weapon/{spec['weapons'][0]}/2"
|
||||||
|
player["equip"]["chest"] = f"w/chest/{armour}/2"
|
||||||
|
player["equip"]["legs"] = f"w/legs/{armour}/2"
|
||||||
|
player["rest"]["hp"] = stats(player)["max_hp"]
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def player_unit(player: dict, now: float, group: bool) -> list[dict]:
|
||||||
|
"""The player (and their pet) as fight units."""
|
||||||
|
st = stats(player)
|
||||||
|
spec = spec_of(player)
|
||||||
|
hp, mana = hp_now(player, now)
|
||||||
|
res = {"type": st["res"], "max": st["res_max"]}
|
||||||
|
res["cur"] = {"rage": 20, "energy": 100}.get(st["res"], round(mana * st["res_max"]))
|
||||||
|
ranged = player["cls"] == "hunter"
|
||||||
|
unit = {
|
||||||
|
"id": player["id"], "name": player["name"], "side": "party", "kind": "player", "cls": player["cls"],
|
||||||
|
"role": spec["role"], "level": player["level"], "hp": max(1, round(hp)), "max_hp": st["max_hp"],
|
||||||
|
"power": st["power"], "armor": st["armor"], "res": res,
|
||||||
|
"swing": {"dmg": st["swing"] * (1.1 if ranged else 1), "every": st["speed"], "next": 0.25,
|
||||||
|
"school": "phys"},
|
||||||
|
"abilities": known(player), "priority": spec["priority"], "manual": list(player["manual"]),
|
||||||
|
"auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False,
|
||||||
|
"ftarget": None, "queued": None, "stun": 0, "cc": 0, "dead": False,
|
||||||
|
"threat_mult": combat.TANK_THREAT if spec["role"] == "tank" else 1.0,
|
||||||
|
}
|
||||||
|
units = [unit]
|
||||||
|
pet_kind = CLASSES[player["cls"]].get("pet")
|
||||||
|
if pet_kind:
|
||||||
|
pet_id = "wolf" if pet_kind == "wolf" else ("imp" if group else "voidwalker")
|
||||||
|
pet = PETS[pet_id]
|
||||||
|
tank = not group
|
||||||
|
pet_hp = round(st["max_hp"] * pet["hp"])
|
||||||
|
units.append({
|
||||||
|
"id": f"{player['id']}:pet", "name": pet["name"], "icon": pet["icon"], "side": "party", "kind": "pet",
|
||||||
|
"owner": player["id"], "level": player["level"], "hp": pet_hp, "max_hp": pet_hp, "power": st["power"],
|
||||||
|
"armor": st["armor"] * (2 if tank else 1) + 10 * player["level"], "res": None,
|
||||||
|
"swing": {"dmg": pet["dmg"] * st["power"], "every": pet["swing"], "next": 0.5, "school": pet["school"]},
|
||||||
|
"auras": [], "cast": None, "cds": {}, "busy": 0, "combo": 0, "target": None, "picked": False,
|
||||||
|
"stun": 0, "cc": 0, "dead": False, "pet_tank": tank,
|
||||||
|
"threat_mult": combat.PET_TANK_THREAT if tank else combat.PET_THREAT,
|
||||||
|
})
|
||||||
|
return units
|
||||||
|
|
||||||
|
|
||||||
|
def kill_xp(player_level: int, mob_level: int, mob: dict) -> int:
|
||||||
|
diff = mob_level - player_level
|
||||||
|
gray = 5 + player_level // 10
|
||||||
|
if diff <= -gray:
|
||||||
|
return 0
|
||||||
|
mult = 1 + 0.05 * min(diff, 4) if diff >= 0 else 1 + diff / (gray + 1)
|
||||||
|
if mob.get("elite"):
|
||||||
|
mult *= 2
|
||||||
|
if mob.get("boss"):
|
||||||
|
mult *= 3
|
||||||
|
if mob.get("rare"):
|
||||||
|
mult *= 2
|
||||||
|
return round((45 + 5 * mob_level) * mult)
|
||||||
|
|
||||||
|
|
||||||
|
def log(player: dict, now: float, text: str, kind: str = "info") -> None:
|
||||||
|
player["log"].append({"t": now, "text": text, "kind": kind})
|
||||||
|
del player["log"][:-MAX_LOG]
|
||||||
|
|
||||||
|
|
||||||
|
def world_log(world: dict, now: float, text: str, kind: str = "info", who: str | None = None) -> None:
|
||||||
|
world["log"].append({"t": now, "text": text, "kind": kind, "who": who})
|
||||||
|
del world["log"][:-MAX_LOG]
|
||||||
|
|
||||||
|
|
||||||
|
def gain_xp(world: dict, player: dict, xp: int, now: float) -> None:
|
||||||
|
if player["level"] >= MAX_LEVEL or xp <= 0:
|
||||||
|
return
|
||||||
|
player["xp"] += xp
|
||||||
|
while player["level"] < MAX_LEVEL and player["xp"] >= XP_TO_NEXT[player["level"]]:
|
||||||
|
player["xp"] -= XP_TO_NEXT[player["level"]]
|
||||||
|
player["level"] += 1
|
||||||
|
learned = [ABILITIES[ab]["name"] for ab, lv in zip(spec_of(player)["abilities"], UNLOCK)
|
||||||
|
if lv == player["level"]]
|
||||||
|
extra = f" · learned {', '.join(learned)}" if learned else ""
|
||||||
|
log(player, now, f"Level {player['level']}!{extra}", "level")
|
||||||
|
world_log(world, now, f"{player['name']} reached level {player['level']}", "level", player["id"])
|
||||||
|
if player["level"] >= MAX_LEVEL:
|
||||||
|
player["xp"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
def bag_add(player: dict, item_id: str, qty: int = 1) -> None:
|
||||||
|
player["bags"][item_id] = player["bags"].get(item_id, 0) + qty
|
||||||
|
|
||||||
|
|
||||||
|
def bag_take(player: dict, item_id: str, qty: int = 1) -> None:
|
||||||
|
player["bags"][item_id] -= qty
|
||||||
|
if player["bags"][item_id] <= 0:
|
||||||
|
del player["bags"][item_id]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the world -------------------------------------------------------------
|
||||||
|
|
||||||
|
def new_world(now: float) -> dict:
|
||||||
|
return {"version": VERSION, "players": {}, "fights": {}, "groups": {}, "next": 1, "log": [], "chat": [],
|
||||||
|
"config": {"clock_offset": 0}, "created": now}
|
||||||
|
|
||||||
|
|
||||||
|
def _next_id(world: dict, prefix: str) -> str:
|
||||||
|
world["next"] += 1
|
||||||
|
return f"{prefix}{world['next']}"
|
||||||
|
|
||||||
|
|
||||||
|
def watch_map(world: dict) -> dict:
|
||||||
|
return {uid: p["watch"] for uid, p in world["players"].items()}
|
||||||
|
|
||||||
|
|
||||||
|
def process(world: dict, now: float, rng: random.Random) -> None:
|
||||||
|
for group in list(world["groups"].values()):
|
||||||
|
if group.get("dungeon"):
|
||||||
|
_process_dungeon(world, group, now, rng)
|
||||||
|
for player in world["players"].values():
|
||||||
|
_process_player(world, player, now, rng)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_player(world: dict, player: dict, now: float, rng: random.Random) -> None:
|
||||||
|
for _ in range(100000):
|
||||||
|
activity = player["activity"]
|
||||||
|
if activity and activity["kind"] == "dungeon":
|
||||||
|
return
|
||||||
|
if player["fight"]:
|
||||||
|
fight = world["fights"][player["fight"]]
|
||||||
|
combat.advance(fight, now, watch_map(world))
|
||||||
|
if fight["state"] == "on":
|
||||||
|
return
|
||||||
|
_end_hunt_fight(world, player, fight, rng)
|
||||||
|
continue
|
||||||
|
if not activity:
|
||||||
|
return
|
||||||
|
if activity["kind"] == "travel":
|
||||||
|
if now < activity["end"]:
|
||||||
|
return
|
||||||
|
_arrive(world, player, activity["to"], activity["end"])
|
||||||
|
continue
|
||||||
|
if activity["kind"] == "hunt":
|
||||||
|
start = max(player["next_pull"], player["dead_until"])
|
||||||
|
if start > now:
|
||||||
|
return
|
||||||
|
if start > player["last_active"] + OFFLINE_CAP:
|
||||||
|
player["activity"] = None
|
||||||
|
log(player, start, "You made camp. Come back to keep going.", "info")
|
||||||
|
return
|
||||||
|
_start_hunt_fight(world, player, activity["hunt"], start, rng)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _start_hunt_fight(world: dict, player: dict, hunt: str, at: float, rng: random.Random) -> None:
|
||||||
|
pulls = PLACES[player["place"]]["hunts"][hunt]
|
||||||
|
pull = rng.choice(pulls)
|
||||||
|
enemies = []
|
||||||
|
for index, mob_id in enumerate(pull):
|
||||||
|
low, high = MOBS[mob_id]["lv"]
|
||||||
|
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high))
|
||||||
|
unit["swing"]["next"] = 0.6 + 0.4 * index
|
||||||
|
enemies.append(unit)
|
||||||
|
fid = _next_id(world, "f")
|
||||||
|
name = MOBS[pull[0]]["name"] if len(pull) == 1 else f"{MOBS[pull[0]]['name']} and friends"
|
||||||
|
world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, player_unit(player, at, False),
|
||||||
|
enemies, "hunt", name)
|
||||||
|
player["fight"] = fid
|
||||||
|
|
||||||
|
|
||||||
|
def _end_hunt_fight(world: dict, player: dict, fight: dict, rng: random.Random) -> None:
|
||||||
|
end = fight["ended"]
|
||||||
|
me = fight["units"][player["id"]]
|
||||||
|
player["fight"] = None
|
||||||
|
player["last_fight"] = world["fights"].pop(fight["id"])
|
||||||
|
st = stats(player)
|
||||||
|
if fight["state"] == "won":
|
||||||
|
player["deaths"] = 0
|
||||||
|
_reward_kills(world, player, fight, 1, end, rng)
|
||||||
|
player["rest"] = {"at": end, "hp": me["hp"],
|
||||||
|
"mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0}
|
||||||
|
hp, mana = hp_now(player, end)
|
||||||
|
missing = max(1 - hp / st["max_hp"], 1 - mana)
|
||||||
|
player["next_pull"] = end + max(2.0, missing / REST * 0.85)
|
||||||
|
else:
|
||||||
|
player["deaths"] += 1
|
||||||
|
player["dead_until"] = end + REVIVE
|
||||||
|
player["rest"] = {"at": end + REVIVE, "hp": st["max_hp"] * 0.5, "mana": 0.5}
|
||||||
|
player["next_pull"] = end + REVIVE + 0.5 / REST
|
||||||
|
log(player, end, f"Killed by {fight['name']}", "death")
|
||||||
|
if player["deaths"] >= DEATHS_TO_STOP:
|
||||||
|
player["activity"] = None
|
||||||
|
player["deaths"] = 0
|
||||||
|
log(player, end, "Died three times in a row. You stopped hunting here.", "death")
|
||||||
|
|
||||||
|
|
||||||
|
def _reward_kills(world: dict, player: dict, fight: dict, share: float, now: float, rng: random.Random,
|
||||||
|
dungeon: bool = False) -> None:
|
||||||
|
xp = 0
|
||||||
|
for unit in fight["units"].values():
|
||||||
|
if unit["side"] != "enemy" or not unit["dead"]:
|
||||||
|
continue
|
||||||
|
mob = MOBS[unit["mob"]]
|
||||||
|
xp += round(kill_xp(player["level"], unit["level"], mob) * share)
|
||||||
|
_quest_kill(player, unit["mob"], now, rng)
|
||||||
|
if dungeon:
|
||||||
|
loot = None
|
||||||
|
if mob.get("boss") and unit["mob"] in BOSS_LOOT and rng.random() < BOSS_LOOT_CHANCE:
|
||||||
|
usable = [i for i in BOSS_LOOT[unit["mob"]] if fits(player, item(i))]
|
||||||
|
loot = rng.choice(usable or BOSS_LOOT[unit["mob"]])
|
||||||
|
elif not mob.get("boss") and rng.random() < TRASH_LOOT_CHANCE:
|
||||||
|
loot = rng.choice(TRASH_LOOT)
|
||||||
|
if loot:
|
||||||
|
bag_add(player, loot)
|
||||||
|
log(player, now, f"Loot: {item(loot)['name']}", "loot")
|
||||||
|
world_log(world, now, f"{player['name']} looted {item(loot)['name']}", "loot", player["id"])
|
||||||
|
if not mob.get("boss") and rng.random() < JUNK_ODDS:
|
||||||
|
bag_add(player, f"j/{unit['mob']}")
|
||||||
|
if unit["kind"] == "humanoid":
|
||||||
|
player["copper"] += round(rng.randint(unit["level"], unit["level"] * 4) * (2 if dungeon else 1))
|
||||||
|
if rng.random() < (0.5 if mob.get("rare") else GREEN_ODDS):
|
||||||
|
green = random_green(unit["level"], rng)
|
||||||
|
bag_add(player, green)
|
||||||
|
log(player, now, f"Loot: {item(green)['name']}", "loot")
|
||||||
|
if mob.get("rare") and not dungeon:
|
||||||
|
world_log(world, now, f"{player['name']} slew {mob['name']}", "rare", player["id"])
|
||||||
|
gain_xp(world, player, xp, now)
|
||||||
|
|
||||||
|
|
||||||
|
def _arrive(world: dict, player: dict, place: str, at: float) -> None:
|
||||||
|
player["place"] = place
|
||||||
|
player["activity"] = None
|
||||||
|
log(player, at, f"Arrived at {PLACES[place]['name']}", "travel")
|
||||||
|
for qid, progress in player["quests"].items():
|
||||||
|
for index, objective in enumerate(QUESTS[qid]["obj"]):
|
||||||
|
if objective.get("visit") == place:
|
||||||
|
progress[index] = 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- actions ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _leave_fight(world: dict, player: dict, now: float) -> None:
|
||||||
|
"""Flee a solo fight: no rewards, keep what health you have."""
|
||||||
|
fid = player["fight"]
|
||||||
|
if not fid:
|
||||||
|
return
|
||||||
|
fight = world["fights"].pop(fid, None)
|
||||||
|
player["fight"] = None
|
||||||
|
if fight:
|
||||||
|
me = fight["units"][player["id"]]
|
||||||
|
player["rest"] = {"at": now, "hp": max(1, me["hp"]),
|
||||||
|
"mana": me["res"]["cur"] / me["res"]["max"] if me["res"]["type"] == "mana" else 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def start_hunt(world: dict, player: dict, hunt: str, now: float) -> str | None:
|
||||||
|
if _busy(player):
|
||||||
|
return _busy(player)
|
||||||
|
if hunt not in PLACES[player["place"]].get("hunts", {}):
|
||||||
|
return "Nothing to hunt like that here"
|
||||||
|
_leave_fight(world, player, now)
|
||||||
|
player["activity"] = {"kind": "hunt", "hunt": hunt, "since": now}
|
||||||
|
player["deaths"] = 0
|
||||||
|
player["next_pull"] = max(player["next_pull"], now)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stop(world: dict, player: dict, now: float) -> str | None:
|
||||||
|
if player["activity"] and player["activity"]["kind"] == "dungeon":
|
||||||
|
return "Leave the dungeon first"
|
||||||
|
_leave_fight(world, player, now)
|
||||||
|
player["activity"] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _busy(player: dict) -> str | None:
|
||||||
|
activity = player["activity"]
|
||||||
|
if activity and activity["kind"] == "dungeon":
|
||||||
|
return "You're in a dungeon"
|
||||||
|
if activity and activity["kind"] == "travel":
|
||||||
|
return "You're on the road"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def route(start: str, goal: str) -> tuple[float, list[str]] | None:
|
||||||
|
queue = [(0.0, start, [start])]
|
||||||
|
seen = set()
|
||||||
|
while queue:
|
||||||
|
cost, here, path = heapq.heappop(queue)
|
||||||
|
if here == goal:
|
||||||
|
return cost, path
|
||||||
|
if here in seen:
|
||||||
|
continue
|
||||||
|
seen.add(here)
|
||||||
|
for nxt, minutes in PLACES[here]["links"].items():
|
||||||
|
if nxt not in seen:
|
||||||
|
heapq.heappush(queue, (cost + minutes, nxt, path + [nxt]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def travel(world: dict, player: dict, to: str, now: float) -> str | None:
|
||||||
|
if _busy(player):
|
||||||
|
return _busy(player)
|
||||||
|
if to not in PLACES or to == player["place"]:
|
||||||
|
return "Pick somewhere else"
|
||||||
|
found = route(player["place"], to)
|
||||||
|
if not found:
|
||||||
|
return "No road there"
|
||||||
|
if player["dead_until"] > now:
|
||||||
|
return "You're dead"
|
||||||
|
minutes, path = found
|
||||||
|
_leave_fight(world, player, now)
|
||||||
|
player["activity"] = {"kind": "travel", "from": player["place"], "to": to, "path": path,
|
||||||
|
"start": now, "end": now + minutes * MINUTE}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def equip(player: dict, item_id: str) -> str | None:
|
||||||
|
if player["bags"].get(item_id, 0) < 1:
|
||||||
|
return "You don't have that"
|
||||||
|
spec = item(item_id)
|
||||||
|
if not spec:
|
||||||
|
return "No such item"
|
||||||
|
problem = can_wear(player, spec)
|
||||||
|
if problem:
|
||||||
|
return problem
|
||||||
|
if player["fight"]:
|
||||||
|
return "Not in the middle of a fight"
|
||||||
|
old = player["equip"][spec["slot"]]
|
||||||
|
bag_take(player, item_id)
|
||||||
|
if old:
|
||||||
|
bag_add(player, old)
|
||||||
|
player["equip"][spec["slot"]] = item_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unequip(player: dict, slot: str) -> str | None:
|
||||||
|
if slot not in player["equip"] or not player["equip"][slot]:
|
||||||
|
return "Nothing there"
|
||||||
|
if player["fight"]:
|
||||||
|
return "Not in the middle of a fight"
|
||||||
|
bag_add(player, player["equip"][slot])
|
||||||
|
player["equip"][slot] = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sell(player: dict, item_id: str, qty: int | str) -> str | None:
|
||||||
|
if not PLACES[player["place"]].get("hub"):
|
||||||
|
return "Find a vendor in a town"
|
||||||
|
have = player["bags"].get(item_id, 0)
|
||||||
|
if qty == "all":
|
||||||
|
qty = have
|
||||||
|
if not isinstance(qty, int) or qty < 1 or qty > have:
|
||||||
|
return "Bad quantity"
|
||||||
|
spec = item(item_id)
|
||||||
|
if not spec:
|
||||||
|
return "No such item"
|
||||||
|
bag_take(player, item_id, qty)
|
||||||
|
player["copper"] += spec["value"] * qty
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sell_junk(player: dict) -> str | None:
|
||||||
|
if not PLACES[player["place"]].get("hub"):
|
||||||
|
return "Find a vendor in a town"
|
||||||
|
junk = [i for i in player["bags"] if i.startswith("j/")]
|
||||||
|
if not junk:
|
||||||
|
return "No junk to sell"
|
||||||
|
for item_id in junk:
|
||||||
|
player["copper"] += item(item_id)["value"] * player["bags"].pop(item_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_manual(world: dict, player: dict, ability: str, manual: bool) -> str | None:
|
||||||
|
if ability not in spec_of(player)["abilities"]:
|
||||||
|
return "Not one of yours"
|
||||||
|
if manual and ability not in player["manual"]:
|
||||||
|
player["manual"].append(ability)
|
||||||
|
elif not manual and ability in player["manual"]:
|
||||||
|
player["manual"].remove(ability)
|
||||||
|
fight = current_fight(world, player)
|
||||||
|
if fight and player["id"] in fight["units"]:
|
||||||
|
fight["units"][player["id"]]["manual"] = list(player["manual"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def current_fight(world: dict, player: dict) -> dict | None:
|
||||||
|
if player["fight"]:
|
||||||
|
return world["fights"].get(player["fight"])
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
if group and group.get("dungeon") and group["dungeon"].get("fight") and \
|
||||||
|
player["activity"] and player["activity"]["kind"] == "dungeon":
|
||||||
|
return world["fights"].get(group["dungeon"]["fight"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def shown_fight(world: dict, player: dict, now: float) -> dict | None:
|
||||||
|
"""The live fight, or the one that just ended so its last blows can be seen."""
|
||||||
|
fight = current_fight(world, player)
|
||||||
|
if fight:
|
||||||
|
return fight
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
inside = player["activity"] and player["activity"]["kind"] == "dungeon"
|
||||||
|
last = group["dungeon"].get("last_fight") if inside and group and group.get("dungeon") else \
|
||||||
|
player.get("last_fight")
|
||||||
|
if last and now - last["ended"] < SHOW_ENDED:
|
||||||
|
return last
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- quests ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def available_quests(player: dict) -> list[str]:
|
||||||
|
return [qid for qid, q in QUESTS.items()
|
||||||
|
if q["giver"] == player["place"] and qid not in player["done"] and qid not in player["quests"]
|
||||||
|
and all(req in player["done"] for req in q.get("req", []))]
|
||||||
|
|
||||||
|
|
||||||
|
def quest_done(player: dict, qid: str) -> bool:
|
||||||
|
progress = player["quests"][qid]
|
||||||
|
return all(progress[i] >= o.get("n", 1) for i, o in enumerate(QUESTS[qid]["obj"]))
|
||||||
|
|
||||||
|
|
||||||
|
def accept(player: dict, qid: str) -> str | None:
|
||||||
|
if qid not in available_quests(player):
|
||||||
|
return "Nobody here offers that"
|
||||||
|
if len(player["quests"]) >= MAX_QUESTS:
|
||||||
|
return f"Your quest log is full ({MAX_QUESTS})"
|
||||||
|
player["quests"][qid] = [0] * len(QUESTS[qid]["obj"])
|
||||||
|
for index, objective in enumerate(QUESTS[qid]["obj"]):
|
||||||
|
if objective.get("visit") == player["place"]:
|
||||||
|
player["quests"][qid][index] = 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def abandon(player: dict, qid: str) -> str | None:
|
||||||
|
if qid not in player["quests"]:
|
||||||
|
return "Not on that quest"
|
||||||
|
del player["quests"][qid]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def turn_in(world: dict, player: dict, qid: str, choice, now: float) -> str | None:
|
||||||
|
quest = QUESTS.get(qid)
|
||||||
|
if not quest or qid not in player["quests"]:
|
||||||
|
return "Not on that quest"
|
||||||
|
if player["place"] != quest.get("ender", quest["giver"]):
|
||||||
|
return f"Turn it in at {PLACES[quest.get('ender', quest['giver'])]['name']}"
|
||||||
|
if not quest_done(player, qid):
|
||||||
|
return "Not finished yet"
|
||||||
|
options = quest.get("choice", [])
|
||||||
|
if options:
|
||||||
|
if not isinstance(choice, int) or not 0 <= choice < len(options):
|
||||||
|
return "Pick a reward"
|
||||||
|
bag_add(player, options[choice])
|
||||||
|
log(player, now, f"Reward: {item(options[choice])['name']}", "loot")
|
||||||
|
del player["quests"][qid]
|
||||||
|
player["done"].append(qid)
|
||||||
|
player["copper"] += quest.get("copper", 0)
|
||||||
|
log(player, now, f"Quest complete: {quest['name']}", "quest")
|
||||||
|
world_log(world, now, f"{player['name']} completed {quest['name']}", "quest", player["id"])
|
||||||
|
gain_xp(world, player, quest["xp"], now)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _quest_kill(player: dict, mob_id: str, now: float, rng: random.Random) -> None:
|
||||||
|
for qid, progress in player["quests"].items():
|
||||||
|
for index, objective in enumerate(QUESTS[qid]["obj"]):
|
||||||
|
if progress[index] >= objective.get("n", 1):
|
||||||
|
continue
|
||||||
|
if objective.get("kill") == mob_id:
|
||||||
|
progress[index] += 1
|
||||||
|
elif mob_id in objective.get("from", []) and rng.random() < objective["chance"]:
|
||||||
|
progress[index] += 1
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if quest_done(player, qid):
|
||||||
|
log(player, now, f"{QUESTS[qid]['name']}: done, turn it in", "quest")
|
||||||
|
|
||||||
|
|
||||||
|
# --- groups and dungeons ---------------------------------------------------
|
||||||
|
|
||||||
|
def create_group(world: dict, player: dict) -> str | None:
|
||||||
|
if player["group"]:
|
||||||
|
return "You're already in a group"
|
||||||
|
gid = _next_id(world, "g")
|
||||||
|
world["groups"][gid] = {"id": gid, "leader": player["id"], "members": [player["id"]], "dungeon": None}
|
||||||
|
player["group"] = gid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def join_group(world: dict, player: dict, gid: str) -> str | None:
|
||||||
|
group = world["groups"].get(gid)
|
||||||
|
if not group:
|
||||||
|
return "That group is gone"
|
||||||
|
if player["group"]:
|
||||||
|
return "Leave your group first"
|
||||||
|
if len(group["members"]) >= PARTY_MAX:
|
||||||
|
return "That group is full"
|
||||||
|
if group["dungeon"]:
|
||||||
|
return "They're already inside"
|
||||||
|
group["members"].append(player["id"])
|
||||||
|
player["group"] = gid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def leave_group(world: dict, player: dict, now: float) -> str | None:
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
if not group:
|
||||||
|
return "You're not in a group"
|
||||||
|
_exit_dungeon(world, group, player, now)
|
||||||
|
group["members"].remove(player["id"])
|
||||||
|
player["group"] = None
|
||||||
|
if not group["members"]:
|
||||||
|
_close_dungeon(world, group)
|
||||||
|
del world["groups"][group["id"]]
|
||||||
|
elif group["leader"] == player["id"]:
|
||||||
|
group["leader"] = group["members"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _exit_dungeon(world: dict, group: dict, player: dict, now: float) -> None:
|
||||||
|
if not (player["activity"] and player["activity"]["kind"] == "dungeon"):
|
||||||
|
return
|
||||||
|
dungeon = group["dungeon"]
|
||||||
|
fight = world["fights"].get(dungeon["fight"] or "") if dungeon else None
|
||||||
|
if fight and player["id"] in fight["units"]:
|
||||||
|
unit = fight["units"][player["id"]]
|
||||||
|
unit["dead"], unit["hp"] = True, 0
|
||||||
|
for pet in fight["units"].values():
|
||||||
|
if pet.get("owner") == player["id"]:
|
||||||
|
pet["dead"] = True
|
||||||
|
player["activity"] = None
|
||||||
|
player["place"] = DUNGEONS[dungeon["id"]]["entrance"] if dungeon else player["place"]
|
||||||
|
player["rest"] = {"at": now, "hp": stats(player)["max_hp"] * 0.5, "mana": 0.5}
|
||||||
|
player["dead_until"] = 0
|
||||||
|
log(player, now, "Left the dungeon", "travel")
|
||||||
|
if dungeon and not any(world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon"
|
||||||
|
for m in group["members"] if m != player["id"]):
|
||||||
|
_close_dungeon(world, group)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_dungeon(world: dict, group: dict) -> None:
|
||||||
|
dungeon = group.get("dungeon")
|
||||||
|
if dungeon and dungeon.get("fight"):
|
||||||
|
world["fights"].pop(dungeon["fight"], None)
|
||||||
|
group["dungeon"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def min_party(world: dict) -> int:
|
||||||
|
return 1 if world["config"].get("dev") else 2
|
||||||
|
|
||||||
|
|
||||||
|
def enter_dungeon(world: dict, player: dict, now: float) -> str | None:
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
if not group:
|
||||||
|
return "Form a group first"
|
||||||
|
if group["leader"] != player["id"]:
|
||||||
|
return "Only the leader can lead you in"
|
||||||
|
if group["dungeon"]:
|
||||||
|
return "You're already inside"
|
||||||
|
dungeon_id = PLACES[player["place"]].get("dungeon")
|
||||||
|
if not dungeon_id:
|
||||||
|
return "There's no dungeon here"
|
||||||
|
if len(group["members"]) < min_party(world):
|
||||||
|
return f"Dungeons need at least {min_party(world)} players"
|
||||||
|
members = [world["players"][m] for m in group["members"]]
|
||||||
|
away = [m["name"] for m in members if m["place"] != player["place"] or _busy(m)]
|
||||||
|
if away:
|
||||||
|
return f"Waiting on {', '.join(away)} to reach {PLACES[player['place']]['name']}"
|
||||||
|
dead = [m["name"] for m in members if m["dead_until"] > now]
|
||||||
|
if dead:
|
||||||
|
return f"{', '.join(dead)} is dead"
|
||||||
|
for member in members:
|
||||||
|
_leave_fight(world, member, now)
|
||||||
|
member["activity"] = {"kind": "dungeon", "group": group["id"]}
|
||||||
|
log(member, now, f"Entered {DUNGEONS[dungeon_id]['name']}", "travel")
|
||||||
|
group["dungeon"] = {"id": dungeon_id, "pull": 0, "state": "rest", "next": now + DUNGEON_REST, "fight": None,
|
||||||
|
"started": now, "wipes": 0, "last": None}
|
||||||
|
world_log(world, now, f"{', '.join(m['name'] for m in members)} entered {DUNGEONS[dungeon_id]['name']}",
|
||||||
|
"dungeon", player["id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pull_now(world: dict, player: dict, now: float) -> str | None:
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
if not group or not group["dungeon"]:
|
||||||
|
return "You're not in a dungeon"
|
||||||
|
if group["leader"] != player["id"]:
|
||||||
|
return "Only the leader pulls"
|
||||||
|
dungeon = group["dungeon"]
|
||||||
|
if dungeon["state"] != "rest":
|
||||||
|
return "Not now"
|
||||||
|
dungeon["next"] = now
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def leave_dungeon(world: dict, player: dict, now: float) -> str | None:
|
||||||
|
group = world["groups"].get(player["group"] or "")
|
||||||
|
if not group or not group["dungeon"]:
|
||||||
|
return "You're not in a dungeon"
|
||||||
|
_exit_dungeon(world, group, player, now)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dungeon_members(world: dict, group: dict) -> list[dict]:
|
||||||
|
return [world["players"][m] for m in group["members"]
|
||||||
|
if world["players"][m]["activity"] and world["players"][m]["activity"]["kind"] == "dungeon"]
|
||||||
|
|
||||||
|
|
||||||
|
def dungeon_scale(size: int) -> tuple[float, float]:
|
||||||
|
"""Packs are tuned for five; smaller groups face lighter ones."""
|
||||||
|
return 0.1 + 0.18 * size, 0.5 + 0.1 * size
|
||||||
|
|
||||||
|
|
||||||
|
def _process_dungeon(world: dict, group: dict, now: float, rng: random.Random) -> None:
|
||||||
|
for _ in range(1000):
|
||||||
|
dungeon = group["dungeon"]
|
||||||
|
if not dungeon:
|
||||||
|
return
|
||||||
|
members = _dungeon_members(world, group)
|
||||||
|
if not members:
|
||||||
|
_close_dungeon(world, group)
|
||||||
|
return
|
||||||
|
if dungeon["fight"]:
|
||||||
|
fight = world["fights"][dungeon["fight"]]
|
||||||
|
combat.advance(fight, now, watch_map(world))
|
||||||
|
if fight["state"] == "on":
|
||||||
|
return
|
||||||
|
_end_dungeon_fight(world, group, fight, rng)
|
||||||
|
continue
|
||||||
|
if dungeon["state"] == "rest" and now >= dungeon["next"]:
|
||||||
|
_start_pull(world, group, dungeon["next"], rng)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _start_pull(world: dict, group: dict, at: float, rng: random.Random) -> None:
|
||||||
|
dungeon = group["dungeon"]
|
||||||
|
spec = DUNGEONS[dungeon["id"]]
|
||||||
|
pull = spec["pulls"][dungeon["pull"]]
|
||||||
|
members = _dungeon_members(world, group)
|
||||||
|
hp_scale, dmg_scale = dungeon_scale(len(members))
|
||||||
|
party = []
|
||||||
|
for member in members:
|
||||||
|
party += player_unit(member, at, True)
|
||||||
|
enemies = []
|
||||||
|
for index, mob_id in enumerate(pull["mobs"]):
|
||||||
|
low, high = MOBS[mob_id]["lv"]
|
||||||
|
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale)
|
||||||
|
unit["swing"]["next"] = 0.8 + 0.4 * index
|
||||||
|
enemies.append(unit)
|
||||||
|
fid = _next_id(world, "f")
|
||||||
|
world["fights"][fid] = combat.new_fight(fid, rng.randrange(2 ** 31), at, party, enemies, "dungeon",
|
||||||
|
pull["name"], (hp_scale, dmg_scale))
|
||||||
|
dungeon["fight"] = fid
|
||||||
|
dungeon["state"] = "fight"
|
||||||
|
|
||||||
|
|
||||||
|
def _end_dungeon_fight(world: dict, group: dict, fight: dict, rng: random.Random) -> None:
|
||||||
|
dungeon = group["dungeon"]
|
||||||
|
spec = DUNGEONS[dungeon["id"]]
|
||||||
|
end = fight["ended"]
|
||||||
|
members = _dungeon_members(world, group)
|
||||||
|
dungeon["last_fight"] = world["fights"].pop(fight["id"])
|
||||||
|
dungeon["fight"] = None
|
||||||
|
present = [m for m in members if m["id"] in fight["units"]]
|
||||||
|
if fight["state"] == "won":
|
||||||
|
share = (1 + 0.1 * (len(present) - 1)) / max(1, len(present))
|
||||||
|
for member in present:
|
||||||
|
unit = fight["units"][member["id"]]
|
||||||
|
hp = unit["hp"] if not unit["dead"] else stats(member)["max_hp"] * 0.3
|
||||||
|
mana = unit["res"]["cur"] / unit["res"]["max"] if unit["res"]["type"] == "mana" else 1.0
|
||||||
|
member["rest"] = {"at": end, "hp": hp, "mana": mana if not unit["dead"] else 0.3}
|
||||||
|
_reward_kills(world, member, fight, share, end, rng, dungeon=True)
|
||||||
|
dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": True, "t": round(fight["t"])}
|
||||||
|
dungeon["pull"] += 1
|
||||||
|
if dungeon["pull"] >= len(spec["pulls"]):
|
||||||
|
dungeon["state"] = "done"
|
||||||
|
world_log(world, end, f"{', '.join(m['name'] for m in present)} cleared {spec['name']}!", "dungeon")
|
||||||
|
for member in present:
|
||||||
|
log(member, end, f"Cleared {spec['name']}", "dungeon")
|
||||||
|
else:
|
||||||
|
dungeon["state"] = "rest"
|
||||||
|
dungeon["next"] = end + DUNGEON_REST
|
||||||
|
else:
|
||||||
|
dungeon["wipes"] += 1
|
||||||
|
dungeon["last"] = {"pull": spec["pulls"][dungeon["pull"]]["name"], "won": False, "t": round(fight["t"])}
|
||||||
|
for member in present:
|
||||||
|
member["rest"] = {"at": end + WIPE_DELAY, "hp": stats(member)["max_hp"] * 0.5, "mana": 0.5}
|
||||||
|
log(member, end, f"Wiped on {spec['pulls'][dungeon['pull']]['name']}", "death")
|
||||||
|
dungeon["state"] = "rest"
|
||||||
|
dungeon["next"] = end + WIPE_DELAY + DUNGEON_REST
|
||||||
|
|
||||||
|
|
||||||
|
# --- chat ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def say(world: dict, player: dict, text: str, now: float) -> str | None:
|
||||||
|
text = text.strip()[:300]
|
||||||
|
if not text:
|
||||||
|
return "Say something"
|
||||||
|
world["chat"].append({"t": now, "who": player["id"], "text": text})
|
||||||
|
del world["chat"][:-MAX_CHAT]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- dev helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
def dev_level(world: dict, player: dict, level: int, now: float) -> str | None:
|
||||||
|
if not 1 <= level <= MAX_LEVEL:
|
||||||
|
return "Bad level"
|
||||||
|
player["level"], player["xp"] = level, 0
|
||||||
|
cls = CLASSES[player["cls"]]
|
||||||
|
armour = cls["armour"]
|
||||||
|
for slot in SLOTS:
|
||||||
|
kind = cls["weapons"][0] if slot == "weapon" else ("cloth" if slot == "back" else armour)
|
||||||
|
suffix = "bear" if spec_of(player)["role"] == "tank" else "eagle"
|
||||||
|
player["equip"][slot] = f"g/{slot}/{kind}/{suffix}/{level}"
|
||||||
|
player["rest"] = {"at": now, "hp": stats(player)["max_hp"], "mana": 1.0}
|
||||||
|
return None
|
||||||
97
module.nix
Normal file
97
module.nix
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
self: { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.wog;
|
||||||
|
database = "/var/lib/wog/wog.sqlite3";
|
||||||
|
hardening = {
|
||||||
|
User = "wog";
|
||||||
|
Group = "wog";
|
||||||
|
StateDirectory = "wog";
|
||||||
|
StateDirectoryMode = "0700";
|
||||||
|
UMask = "0077";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.wog = {
|
||||||
|
enable = lib.mkEnableOption "World of Guildhall";
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
defaultText = lib.literalExpression "wog.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||||
|
description = "The wog package to run.";
|
||||||
|
};
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8100;
|
||||||
|
description = "Loopback TCP port used by the game.";
|
||||||
|
};
|
||||||
|
userHeader = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "Remote-User";
|
||||||
|
description = "Request header carrying the signed-in user, set by the authenticating proxy.";
|
||||||
|
};
|
||||||
|
backup = {
|
||||||
|
directory = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Directory for nightly world snapshots; null disables backups.";
|
||||||
|
};
|
||||||
|
keep = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Number of nightly snapshots to keep.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||||
|
{
|
||||||
|
users.users.wog = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "wog";
|
||||||
|
description = "World of Guildhall service user";
|
||||||
|
};
|
||||||
|
users.groups.wog = { };
|
||||||
|
|
||||||
|
systemd.services.wog = {
|
||||||
|
description = "World of Guildhall";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
serviceConfig = hardening // {
|
||||||
|
ExecStart = "${cfg.package}/bin/wog --host 127.0.0.1 --port ${toString cfg.port} --db ${database} --user-header ${lib.escapeShellArg cfg.userHeader}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(lib.mkIf (cfg.backup.directory != null) {
|
||||||
|
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 wog wog -" ];
|
||||||
|
|
||||||
|
systemd.services.wog-backup = {
|
||||||
|
description = "Snapshot World of Guildhall";
|
||||||
|
unitConfig.RequiresMountsFor = [ cfg.backup.directory ];
|
||||||
|
serviceConfig = hardening // {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${cfg.package}/bin/wog --db ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}";
|
||||||
|
ReadWritePaths = [ cfg.backup.directory ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.wog-backup = {
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = "*-*-* 03:25:00";
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
26
package.nix
Normal file
26
package.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{ lib, stdenvNoCC, makeWrapper, python3 }:
|
||||||
|
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "wog";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out/share/wog $out/bin
|
||||||
|
cp app.py game.py combat.py content.py $out/share/wog/
|
||||||
|
cp -r static $out/share/wog/static
|
||||||
|
makeWrapper ${python3}/bin/python3 $out/bin/wog \
|
||||||
|
--add-flags "$out/share/wog/app.py"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Co-op adventure for a small group of friends";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
mainProgram = "wog";
|
||||||
|
};
|
||||||
|
}
|
||||||
910
static/app.js
Normal file
910
static/app.js
Normal file
|
|
@ -0,0 +1,910 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// World of Guildhall client. The server is the source of truth; between polls the
|
||||||
|
// client only animates bars and cooldowns forward from the last state it saw.
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const FIGHT_POLL_MS = 300; // fights are live: keep everyone's view close
|
||||||
|
const ROLE = { tank: "Tank", healer: "Healer", dps: "Damage" };
|
||||||
|
const RULES = {
|
||||||
|
always: (r) => (r.save ? `whenever it's ready (in a group: only above ${pct(r.save)} mana)` : "whenever it's ready"),
|
||||||
|
dot: (r) => (r.save ? `keeps it on the target (in a group: only above ${pct(r.save)} mana)` : "keeps it on the target"),
|
||||||
|
heal: (r) => `someone under ${pct(r.pct)}`,
|
||||||
|
hot: (r) => `someone under ${pct(r.pct)} without it`,
|
||||||
|
shield: (r) => `whoever's being hit, or under ${pct(r.pct)}`,
|
||||||
|
self_below: (r) => `you're under ${pct(r.pct)}`,
|
||||||
|
taunt: () => "a mob hits someone else",
|
||||||
|
aoe: (r) => (r.n > 1 ? `${r.n}+ mobs` : "every fight"),
|
||||||
|
interrupt: () => "a mob is casting",
|
||||||
|
combo: (r) => `${r.n}+ combo points`,
|
||||||
|
cc: () => "an extra mob joins",
|
||||||
|
peel: () => "mobs are on you",
|
||||||
|
tanking: (r) => `an elite hits you, or you're under ${pct(r.pct)}`,
|
||||||
|
danger: (r) => `hit while under ${pct(r.pct)}`,
|
||||||
|
buff: () => "keeps it up",
|
||||||
|
boss: () => "on elites, rares and bosses",
|
||||||
|
};
|
||||||
|
|
||||||
|
let C = null; // static content
|
||||||
|
let S = null; // latest state
|
||||||
|
let skew = 0; // server clock minus client clock
|
||||||
|
let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "adventure";
|
||||||
|
let polledAt = 0;
|
||||||
|
let inflight = false;
|
||||||
|
let failures = 0;
|
||||||
|
let selected = null; // bag item
|
||||||
|
let regCls = null;
|
||||||
|
let pickReward = {}; // quest id -> reward index
|
||||||
|
let seenEv = { id: null, t: -1 };
|
||||||
|
let barKey = "";
|
||||||
|
let seenChat = Number(remember("seenChat") || 0);
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
function remember(key, value) {
|
||||||
|
try {
|
||||||
|
if (value === undefined) return localStorage.getItem("wog." + key);
|
||||||
|
localStorage.setItem("wog." + key, value);
|
||||||
|
} catch (e) { /* private mode */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(text) {
|
||||||
|
return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(x) { return `${Math.round(x * 100)}%`; }
|
||||||
|
function now() { return Date.now() / 1000 + skew; }
|
||||||
|
|
||||||
|
function money(copper) {
|
||||||
|
copper = Math.floor(copper || 0);
|
||||||
|
const g = Math.floor(copper / 10000), s = Math.floor(copper / 100) % 100, c = copper % 100;
|
||||||
|
const parts = [];
|
||||||
|
if (g) parts.push(`<span class="g">${g}g</span>`);
|
||||||
|
if (s) parts.push(`<span class="s">${s}s</span>`);
|
||||||
|
if (c || !parts.length) parts.push(`<span class="c">${c}c</span>`);
|
||||||
|
return `<span class="money">${parts.join(" ")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clock(seconds) {
|
||||||
|
seconds = Math.max(0, Math.round(seconds));
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||||
|
if (m < 60) return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(text, ok = false) {
|
||||||
|
const el = $("#toast");
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = "show" + (ok ? " ok" : "");
|
||||||
|
clearTimeout(toast.timer);
|
||||||
|
toast.timer = setTimeout(() => (el.className = ""), 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clsColor(cls) { return C.classes[cls]?.color || "#ddd"; }
|
||||||
|
function place(id) { return C.places[id]; }
|
||||||
|
function specOf(cls) { return C.specs[C.classes[cls].spec]; }
|
||||||
|
|
||||||
|
// --- server -------------------------------------------------------------
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (inflight) return;
|
||||||
|
inflight = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/state", { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(res.status);
|
||||||
|
accept(await res.json());
|
||||||
|
failures = 0;
|
||||||
|
} catch (e) {
|
||||||
|
failures++;
|
||||||
|
} finally {
|
||||||
|
inflight = false;
|
||||||
|
polledAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, body = {}) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/" + path, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
toast(data.error || "Something went wrong");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
accept(data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
toast("Can't reach the server");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(state) {
|
||||||
|
if (S && S.version !== state.version) {
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skew = state.now - Date.now() / 1000;
|
||||||
|
S = state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rendering ----------------------------------------------------------
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!C || !S) return;
|
||||||
|
renderTop();
|
||||||
|
renderNav();
|
||||||
|
renderMain();
|
||||||
|
renderFight();
|
||||||
|
renderBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function myUnit() {
|
||||||
|
return S.fight ? S.fight.units.find((u) => u.id === S.uid) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTop() {
|
||||||
|
const me = S.me;
|
||||||
|
if (!me) {
|
||||||
|
$("#top").innerHTML = `<div class="inner"><b>World of Guildhall</b></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cls = C.classes[me.cls];
|
||||||
|
const unit = myUnit();
|
||||||
|
const hp = unit ? unit.hp : me.hp, max = unit ? unit.max_hp : me.stats.max_hp;
|
||||||
|
const res = unit ? unit.res : null;
|
||||||
|
const resType = me.stats.res;
|
||||||
|
const resPct = res ? res.cur / res.max : (resType === "mana" ? me.mana : resType === "energy" ? 1 : 0);
|
||||||
|
const xp = me.xp_next ? me.xp / me.xp_next : 1;
|
||||||
|
$("#top").innerHTML = `<div class="inner">
|
||||||
|
<div class="row">
|
||||||
|
<span class="who" style="color:${cls.color}">${esc(me.name)}</span>
|
||||||
|
<span class="muted small">${me.level} ${esc(specOf(me.cls).name)} ${esc(cls.name)}</span>
|
||||||
|
<span class="right small">${money(me.copper)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="minibars">
|
||||||
|
${bar(hp, max, hp / max < 0.3 ? "low" : "", `${Math.round(hp)} / ${max}`)}
|
||||||
|
${bar(resPct, 1, resType, resType === "mana" ? pct(resPct) : `${Math.round(resPct * 100)} ${resType}`)}
|
||||||
|
</div>
|
||||||
|
<div class="xpbar" title="${me.xp} / ${me.xp_next} xp"><i style="width:${xp * 100}%"></i></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(value, max, cls = "", text = "") {
|
||||||
|
const w = max ? Math.max(0, Math.min(1, value / max)) * 100 : 0;
|
||||||
|
return `<div class="bar ${cls}"><i style="width:${w}%"></i><span>${text}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNav() {
|
||||||
|
const me = S.me;
|
||||||
|
if (!me) {
|
||||||
|
$("#nav").innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ready = Object.keys(me.quests).some((q) => questReady(q));
|
||||||
|
const newChat = S.chat.some((m) => m.t > seenChat && m.who !== S.uid);
|
||||||
|
const tabs = [
|
||||||
|
["adventure", "⚔️", "Adventure", false],
|
||||||
|
["quests", "📜", "Quests", ready],
|
||||||
|
["character", "🧍", "Character", false],
|
||||||
|
["group", "👥", "Group", !!S.group?.dungeon],
|
||||||
|
["chat", "💬", "Guild", newChat && page !== "chat"],
|
||||||
|
];
|
||||||
|
$("#nav").innerHTML = tabs.map(([id, icon, label, dot]) =>
|
||||||
|
`<button data-page="${id}" class="${page === id ? "on" : ""}"><span class="i">${icon}</span>${label}${dot ? '<span class="dot"></span>' : ""}</button>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMain() {
|
||||||
|
const main = $("#main");
|
||||||
|
if (!main.querySelector("#fightbox")) {
|
||||||
|
main.innerHTML = `<div id="fightbox"></div><div id="page"></div>`;
|
||||||
|
}
|
||||||
|
const el = $("#page");
|
||||||
|
if (!S.me) {
|
||||||
|
el.innerHTML = registerPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (page === "chat") {
|
||||||
|
seenChat = Math.max(seenChat, ...S.chat.map((m) => m.t), 0);
|
||||||
|
remember("seenChat", seenChat);
|
||||||
|
}
|
||||||
|
const html = { adventure: adventurePage, quests: questsPage, character: characterPage, group: groupPage, chat: chatPage }[page]();
|
||||||
|
// Keep a half-typed chat message across re-renders.
|
||||||
|
const typing = document.activeElement?.id === "say" ? document.activeElement.value : null;
|
||||||
|
if (typing !== null && page === "chat") return;
|
||||||
|
if (el.dataset.html !== html) {
|
||||||
|
el.innerHTML = html;
|
||||||
|
el.dataset.html = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- register -----------------------------------------------------------
|
||||||
|
|
||||||
|
function registerPage() {
|
||||||
|
const pick = regCls ? C.classes[regCls] : null;
|
||||||
|
const spec = pick ? specOf(regCls) : null;
|
||||||
|
return `<div class="card">
|
||||||
|
<h2>Create your character</h2>
|
||||||
|
<p class="muted small">You wake in Northshire Abbey. The Defias Brotherhood stirs in Westfall.</p>
|
||||||
|
<div class="row"><input id="name" placeholder="Name" maxlength="16" class="grow" value="${esc(remember("draftName") || "")}"></div>
|
||||||
|
<h3>Class</h3>
|
||||||
|
<div class="classes">${Object.entries(C.classes).map(([id, c]) => `
|
||||||
|
<div class="cls ${regCls === id ? "on" : ""}" data-cls="${id}">
|
||||||
|
<span class="ic">${c.icon}</span>
|
||||||
|
<b style="color:${c.color}">${esc(c.name)}</b>
|
||||||
|
<span class="small muted">${esc(specOf(id).name)} · ${ROLE[specOf(id).role]}</span>
|
||||||
|
</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
${spec ? `<h3>${esc(spec.name)} ${esc(pick.name)} · ${ROLE[spec.role]}</h3>
|
||||||
|
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i])).join("")}</div>` : ""}
|
||||||
|
<div class="row" style="margin-top:12px"><button class="btn primary" data-act="register" ${regCls ? "" : "disabled"}>Enter the world</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abilityRow(id, level, me) {
|
||||||
|
const a = C.abilities[id];
|
||||||
|
const known = !me || me.known.includes(id);
|
||||||
|
const manual = me && me.manual.includes(id);
|
||||||
|
const owner = Object.values(C.specs).find((sp) => sp.abilities.includes(id));
|
||||||
|
const cost = a.cost ? (a.cost < 1 ? `${Math.round(a.cost * 100)}% mana` : `${a.cost} ${C.classes[owner.cls].res}`) : "";
|
||||||
|
const facts = [cost, a.cast ? `${a.cast}s cast` : "instant", a.cd ? `${a.cd}s cooldown (auto ${Math.round(a.cd * C.auto_cd)}s)` : ""].filter(Boolean);
|
||||||
|
return `<div class="item-row" style="cursor:default; ${known ? "" : "opacity:.5"}">
|
||||||
|
<span style="font-size:22px">${a.icon}</span>
|
||||||
|
<div class="grow">
|
||||||
|
<b>${esc(a.name)}</b> <span class="small muted">${known ? "" : `level ${level}`}</span>
|
||||||
|
<div class="tip">${esc(a.tip)}</div>
|
||||||
|
<div class="tip">${facts.join(" · ")}</div>
|
||||||
|
<div class="tip">Auto: ${RULES[a.auto.r](a.auto)}</div>
|
||||||
|
</div>
|
||||||
|
${me ? `<button class="btn small ${manual ? "on" : ""}" data-act="manual" data-id="${id}" data-on="${manual ? 0 : 1}">${manual ? "You" : "Auto"}</button>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- adventure ----------------------------------------------------------
|
||||||
|
|
||||||
|
function adventurePage() {
|
||||||
|
const me = S.me;
|
||||||
|
const here = place(me.place);
|
||||||
|
const act = me.activity;
|
||||||
|
const parts = [statusCard()];
|
||||||
|
if (!act || act.kind !== "dungeon") {
|
||||||
|
parts.push(placeCard(here));
|
||||||
|
}
|
||||||
|
parts.push(logCard());
|
||||||
|
if (S.dev) parts.push(devCard());
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCard() {
|
||||||
|
const me = S.me;
|
||||||
|
const act = me.activity;
|
||||||
|
const t = now();
|
||||||
|
if (me.dead_until > t) {
|
||||||
|
return `<div class="card"><b>💀 You died.</b> <span class="muted">Running back · ${clock(me.dead_until - t)}</span></div>`;
|
||||||
|
}
|
||||||
|
if (!act) return "";
|
||||||
|
if (act.kind === "travel") {
|
||||||
|
const done = (t - act.start) / (act.end - act.start);
|
||||||
|
return `<div class="card">
|
||||||
|
<div class="row"><b>🐎 On the road to ${esc(place(act.to).name)}</b><span class="right muted small" data-until="${act.end}">${clock(act.end - t)}</span></div>
|
||||||
|
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i style="width:${done * 100}%"></i><span></span></div>
|
||||||
|
<div class="small muted" style="margin-top:4px">${act.path.map((p) => esc(place(p).name)).join(" → ")}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (act.kind === "hunt") {
|
||||||
|
const mob = C.mobs[act.hunt];
|
||||||
|
const waiting = !S.fight && me.next_pull > t;
|
||||||
|
return `<div class="card"><div class="row">
|
||||||
|
<b>🎯 Hunting ${esc(mob.name)}</b>
|
||||||
|
<span class="muted small">${waiting ? `resting · ${clock(me.next_pull - t)}` : ""}</span>
|
||||||
|
<button class="btn small right" data-act="stop">Stop</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (act.kind === "dungeon") return dungeonCard();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonCard() {
|
||||||
|
const g = S.group;
|
||||||
|
if (!g || !g.dungeon) return "";
|
||||||
|
const d = g.dungeon;
|
||||||
|
const spec = C.dungeons[d.id];
|
||||||
|
const t = now();
|
||||||
|
const leader = g.leader === S.uid;
|
||||||
|
const pulls = spec.pulls.map((p, i) => {
|
||||||
|
const mark = i < d.pull ? "✅" : i === d.pull && d.state !== "done" ? "➡️" : "▫️";
|
||||||
|
return `<div class="small ${i < d.pull ? "muted" : ""}">${mark} ${p.boss ? "<b>" : ""}${esc(p.name)}${p.boss ? "</b>" : ""}
|
||||||
|
<span class="muted">${i === d.pull && d.state !== "done" ? "· " + p.mobs.map((m) => esc(C.mobs[m].name)).join(", ") : ""}</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
let status = "";
|
||||||
|
if (d.state === "rest") {
|
||||||
|
status = `<div class="row"><span>${d.last && !d.last.won ? "💀 Wiped. " : ""}Next pull in <b data-until="${d.next}">${clock(d.next - t)}</b></span>
|
||||||
|
${leader ? `<button class="btn primary small right" data-act="pull">Pull now</button>` : `<span class="right small muted">the leader can pull early</span>`}</div>`;
|
||||||
|
} else if (d.state === "done") {
|
||||||
|
status = `<div><b>🏆 ${esc(spec.name)} cleared!</b></div>`;
|
||||||
|
} else {
|
||||||
|
status = `<div class="muted small">Fighting: ${esc(spec.pulls[d.pull].name)}</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="card">
|
||||||
|
<div class="row"><h2 style="margin:0">${esc(spec.name)}</h2><span class="muted small">wipes ${d.wipes}</span>
|
||||||
|
<button class="btn small danger right" data-act="leave-dungeon">Leave</button></div>
|
||||||
|
<div style="margin:8px 0">${status}</div>
|
||||||
|
${pulls}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeCard(here) {
|
||||||
|
const me = S.me;
|
||||||
|
const act = me.activity;
|
||||||
|
const travelling = act && act.kind === "travel";
|
||||||
|
const hunting = act && act.kind === "hunt" ? act.hunt : null;
|
||||||
|
const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => {
|
||||||
|
const mob = C.mobs[id];
|
||||||
|
const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`;
|
||||||
|
const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name);
|
||||||
|
const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id)));
|
||||||
|
return `<div class="item-row" style="cursor:default">
|
||||||
|
<div class="grow"><b class="${mob.rare ? "q-rare" : ""}">${esc(mob.name)}</b> <span class="small muted">lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""}</span>
|
||||||
|
${hot ? '<span class="qmark" title="Quest target">!</span>' : ""}</div>
|
||||||
|
${hunting === id ? `<button class="btn small on" data-act="stop">Hunting</button>`
|
||||||
|
: `<button class="btn small" data-act="hunt" data-id="${id}" ${travelling ? "disabled" : ""}>Hunt</button>`}
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
const offers = me.available.map((q) => questOffer(q)).join("");
|
||||||
|
const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q))
|
||||||
|
.map((q) => questTurnIn(q)).join("");
|
||||||
|
const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : "";
|
||||||
|
const junk = me.bags.filter((b) => b.q === "poor");
|
||||||
|
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
|
||||||
|
const vendor = here.hub ? `<div class="row" style="margin-top:8px"><span class="muted small">🪙 Vendor</span>
|
||||||
|
<button class="btn small right" data-act="sell-junk" ${junk.length ? "" : "disabled"}>Sell junk ${junk.length ? money(junkValue) : ""}</button></div>` : "";
|
||||||
|
const roads = Object.entries(here.links).map(([to, minutes]) =>
|
||||||
|
`<button class="btn small" data-act="travel" data-id="${to}" ${travelling ? "disabled" : ""}>${esc(place(to).name)} <span class="muted">${minutes}m</span></button>`).join("");
|
||||||
|
const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id])
|
||||||
|
.map(([id, p]) => `<option value="${id}">${esc(p.name)} (${esc(p.zone)}, ${p.lv[0]}-${p.lv[1]})</option>`).join("");
|
||||||
|
return `<div class="card">
|
||||||
|
<div class="row"><h2 style="margin:0">${esc(here.name)}</h2><span class="muted small">${esc(here.zone)} · ${here.lv[0]}-${here.lv[1]}</span></div>
|
||||||
|
${hunts ? `<h3>Hunt</h3><div class="list">${hunts}</div>` : ""}
|
||||||
|
${turnIns ? `<h3>Turn in</h3><div class="list">${turnIns}</div>` : ""}
|
||||||
|
${offers ? `<h3>Quests</h3><div class="list">${offers}</div>` : ""}
|
||||||
|
${dungeon}
|
||||||
|
${vendor}
|
||||||
|
<h3>Roads</h3>
|
||||||
|
<div class="row wrap">${roads}</div>
|
||||||
|
<div class="row" style="margin-top:8px"><select id="far" class="grow" ${travelling ? "disabled" : ""}><option value="">Travel further…</option>${far}</select>
|
||||||
|
<button class="btn small" data-act="travel-far" ${travelling ? "disabled" : ""}>Go</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dungeonEntrance(id) {
|
||||||
|
const spec = C.dungeons[id];
|
||||||
|
const g = S.group;
|
||||||
|
let action;
|
||||||
|
if (!g) action = `<span class="small muted">Form a group (👥) to go in</span>`;
|
||||||
|
else if (g.leader !== S.uid) action = `<span class="small muted">Waiting for the leader</span>`;
|
||||||
|
else action = `<button class="btn primary small" data-act="enter">Enter</button>`;
|
||||||
|
return `<h3>Dungeon</h3><div class="item-row" style="cursor:default">
|
||||||
|
<div class="grow"><b>🕳️ ${esc(spec.name)}</b> <span class="small muted">lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses</span></div>
|
||||||
|
${action}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questOffer(qid) {
|
||||||
|
const q = C.quests[qid];
|
||||||
|
const tooHigh = q.lv > S.me.level + 3;
|
||||||
|
return `<div class="quest">
|
||||||
|
<div class="row"><span class="qmark">!</span><b class="grow">${esc(q.name)}</b><span class="small ${tooHigh ? "stat-down" : "muted"}">lv ${q.lv}</span></div>
|
||||||
|
<div class="small muted" style="margin:4px 0">${esc(q.npc)}: “${esc(q.text)}”</div>
|
||||||
|
${q.obj.map((o) => `<div class="obj">· ${objText(o)}</div>`).join("")}
|
||||||
|
<div class="row" style="margin-top:6px"><span class="small muted">${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}</span>
|
||||||
|
<button class="btn small primary right" data-act="accept" data-id="${qid}">Accept</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function questTurnIn(qid) {
|
||||||
|
const q = C.quests[qid];
|
||||||
|
const choices = (q.choice || []).map((id, i) => {
|
||||||
|
const it = C.items[id];
|
||||||
|
const on = pickReward[qid] === i;
|
||||||
|
const fit = fitsMe(it);
|
||||||
|
return `<div class="item-row ${on ? "sel" : ""}" data-act="pick-reward" data-id="${qid}" data-i="${i}" style="${on ? "border-color:var(--gold)" : ""}">
|
||||||
|
<span class="q-${it.q}">${esc(it.name)}</span><span class="small muted">${itemLine(it)}</span>
|
||||||
|
${fit ? "" : '<span class="small stat-down right">can\'t use</span>'}</div>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="quest ready">
|
||||||
|
<div class="row"><span class="qmark">?</span><b class="grow">${esc(q.name)}</b></div>
|
||||||
|
<div class="small muted">${q.xp} xp · ${money(q.copper)}</div>
|
||||||
|
${choices ? `<div class="list" style="margin-top:6px">${choices}</div>` : ""}
|
||||||
|
<div class="row" style="margin-top:6px"><button class="btn small primary right" data-act="turn-in" data-id="${qid}">Complete</button></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objText(o, have) {
|
||||||
|
const need = o.n || 1;
|
||||||
|
const count = have === undefined ? "" : ` ${have} / ${need}`;
|
||||||
|
if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`;
|
||||||
|
if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} <span class="muted">from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}</span>`;
|
||||||
|
if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function questReady(qid) {
|
||||||
|
const progress = S.me.quests[qid];
|
||||||
|
return C.quests[qid].obj.every((o, i) => progress[i] >= (o.n || 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logCard() {
|
||||||
|
const lines = S.me.log.slice(-12).reverse();
|
||||||
|
if (!lines.length) return "";
|
||||||
|
return `<div class="card"><h3 style="margin-top:0">Lately</h3><div class="log" style="max-height:none; flex-direction:column">
|
||||||
|
${lines.map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("")}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(t) {
|
||||||
|
const s = now() - t;
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function devCard() {
|
||||||
|
const users = ["fred", "kayla", "apollon", "tester"];
|
||||||
|
const current = (document.cookie.match(/wog_user=([^;]+)/) || [])[1] || "fred";
|
||||||
|
return `<div class="card devbar">
|
||||||
|
<b>Dev</b>
|
||||||
|
<select id="dev-user">${users.map((u) => `<option ${u === current ? "selected" : ""}>${u}</option>`).join("")}</select>
|
||||||
|
<input id="dev-level" type="number" min="1" max="20" value="${S.me.level}" style="width:56px"><button class="btn small" data-act="dev-level">Set level + gear</button>
|
||||||
|
<button class="btn small" data-act="dev-advance" data-m="10">+10m</button>
|
||||||
|
<button class="btn small" data-act="dev-advance" data-m="60">+1h</button>
|
||||||
|
<select id="dev-place">${Object.entries(C.places).map(([id, p]) => `<option value="${id}" ${id === S.me.place ? "selected" : ""}>${esc(p.name)}</option>`).join("")}</select>
|
||||||
|
<button class="btn small" data-act="dev-teleport">Teleport</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- quests -------------------------------------------------------------
|
||||||
|
|
||||||
|
function questsPage() {
|
||||||
|
const me = S.me;
|
||||||
|
const ids = Object.keys(me.quests);
|
||||||
|
if (!ids.length) {
|
||||||
|
return `<div class="card"><h2>Quest log</h2><p class="muted">No quests. Look for <span class="qmark">!</span> at the places you visit.</p>
|
||||||
|
<p class="small muted">${me.done.length} completed.</p></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="card"><h2>Quest log <span class="muted small">${ids.length} / 12</span></h2><div class="list">
|
||||||
|
${ids.map((qid) => {
|
||||||
|
const q = C.quests[qid];
|
||||||
|
const ready = questReady(qid);
|
||||||
|
const ender = q.ender || q.giver;
|
||||||
|
return `<div class="quest ${ready ? "ready" : ""}">
|
||||||
|
<div class="row"><b class="grow">${ready ? '<span class="qmark">?</span> ' : ""}${esc(q.name)}</b><span class="small muted">lv ${q.lv}</span></div>
|
||||||
|
${q.obj.map((o, i) => `<div class="obj ${me.quests[qid][i] >= (o.n || 1) ? "done" : ""}">· ${objText(o, me.quests[qid][i])}</div>`).join("")}
|
||||||
|
<div class="row" style="margin-top:6px"><span class="small muted">${ready ? "Turn in" : "Ends"} at ${esc(place(ender).name)}${q.ender_npc ? ` (${esc(q.ender_npc)})` : ` (${esc(q.npc)})`}</span>
|
||||||
|
${ready && me.place !== ender ? `<button class="btn small" data-act="travel" data-id="${ender}">Go</button>` : ""}
|
||||||
|
<button class="btn small danger right" data-act="abandon" data-id="${qid}">Abandon</button></div>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}
|
||||||
|
</div><p class="small muted">${me.done.length} completed.</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- character ----------------------------------------------------------
|
||||||
|
|
||||||
|
function itemLine(it) {
|
||||||
|
if (!it.slot) return it.value ? `sells ${money(it.value)}` : "";
|
||||||
|
const bits = [];
|
||||||
|
if (it.dps) bits.push(`${it.dps} dps`);
|
||||||
|
if (it.armor) bits.push(`${it.armor} armor`);
|
||||||
|
if (it.sta) bits.push(`+${it.sta} sta`);
|
||||||
|
if (it.power) bits.push(`+${it.power} power`);
|
||||||
|
return `${esc(C.slot_names[it.slot])} · ${esc(it.kind)} · ${bits.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitsMe(it) {
|
||||||
|
if (!it.slot) return false;
|
||||||
|
const cls = C.classes[S.me.cls];
|
||||||
|
if (it.slot === "weapon") return cls.weapons.includes(it.kind);
|
||||||
|
const rank = { cloth: 0, leather: 1, mail: 2 };
|
||||||
|
return rank[it.kind] <= rank[cls.armour];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(it) {
|
||||||
|
const worn = S.me.equip[it.slot];
|
||||||
|
const diff = (key) => (it[key] || 0) - ((worn && worn[key]) || 0);
|
||||||
|
const parts = ["sta", "power", "armor", "dps"].map((k) => {
|
||||||
|
const d = Math.round(diff(k) * 10) / 10;
|
||||||
|
if (!d) return "";
|
||||||
|
return `<span class="${d > 0 ? "stat-up" : "stat-down"}">${d > 0 ? "+" : ""}${d} ${k}</span>`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
return parts.length ? `vs worn: ${parts.join(" ")}` : "same as worn";
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterPage() {
|
||||||
|
const me = S.me;
|
||||||
|
const st = me.stats;
|
||||||
|
const spec = specOf(me.cls);
|
||||||
|
const slots = C.slots.map((slot) => {
|
||||||
|
const it = me.equip[slot];
|
||||||
|
return `<div class="slot" ${it ? `data-act="unequip" data-id="${slot}"` : ""}><span class="k">${esc(C.slot_names[slot])}</span>
|
||||||
|
<span>${it ? `<span class="q-${it.q}">${esc(it.name)}</span> <span class="small muted">${itemLine(it).split(" · ").slice(2).join(" · ")}</span>` : '<span class="muted">—</span>'}</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
const bags = me.bags.map((it) => {
|
||||||
|
const on = selected === it.id;
|
||||||
|
const atHub = place(me.place).hub;
|
||||||
|
return `<div class="item-row ${on ? "sel" : ""}" data-act="select" data-id="${esc(it.id)}">
|
||||||
|
<div class="grow"><span class="q-${it.q}">${esc(it.name)}</span>${it.qty > 1 ? ` <span class="muted">×${it.qty}</span>` : ""}
|
||||||
|
<div class="tip">${itemLine(it)}${it.req > me.level ? ` · <span class="stat-down">requires ${it.req}</span>` : ""}</div>
|
||||||
|
${on && it.slot ? `<div class="tip">${compare(it)}</div>` : ""}
|
||||||
|
${on ? `<div class="row" style="margin-top:6px">
|
||||||
|
${it.slot ? `<button class="btn small primary" data-act="equip" data-id="${esc(it.id)}" ${it.fit ? "disabled" : ""}>${it.fit ? esc(it.fit) : "Equip"}</button>` : ""}
|
||||||
|
<button class="btn small" data-act="sell" data-id="${esc(it.id)}" ${atHub ? "" : "disabled"}>${atHub ? `Sell ${money(it.value)}` : "Sell in town"}</button></div>` : ""}
|
||||||
|
</div></div>`;
|
||||||
|
}).join("");
|
||||||
|
return `<div class="card">
|
||||||
|
<h2 style="color:${clsColor(me.cls)}">${esc(me.name)} <span class="muted small">level ${me.level} ${esc(spec.name)} ${esc(C.classes[me.cls].name)} · ${ROLE[spec.role]}</span></h2>
|
||||||
|
<div class="statgrid">
|
||||||
|
<div><b>${st.max_hp}</b><span>health</span></div>
|
||||||
|
<div><b>${st.power}</b><span>power</span></div>
|
||||||
|
<div><b>${st.armor}</b><span>armor</span></div>
|
||||||
|
<div><b>${st.res_max}</b><span>${st.res}</span></div>
|
||||||
|
<div><b>${st.dps}</b><span>weapon dps</span></div>
|
||||||
|
<div><b>${me.xp_next ? `${me.xp} / ${me.xp_next}` : "max"}</b><span>xp</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><h2>Abilities</h2>
|
||||||
|
<p class="tip">Abilities on <b>Auto</b> cast themselves when their rule says so, on a ${C.auto_cd}× cooldown. Set to <b>You</b> to press them yourself on the shorter cooldown. When you look away for ${C.watch}s, yours go back to auto.</p>
|
||||||
|
<div class="list">${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i], me)).join("")}</div></div>
|
||||||
|
<div class="card"><h2>Equipment</h2><div class="list">${slots}</div></div>
|
||||||
|
<div class="card"><h2>Bags</h2>${bags ? `<div class="list">${bags}</div>` : '<p class="muted">Empty.</p>'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- group --------------------------------------------------------------
|
||||||
|
|
||||||
|
function groupPage() {
|
||||||
|
const g = S.group;
|
||||||
|
let mine;
|
||||||
|
if (g) {
|
||||||
|
const here = place(S.me.place);
|
||||||
|
mine = `<div class="card"><h2>Your group</h2><div class="list">
|
||||||
|
${g.members.map((m) => `<div class="item-row" style="cursor:default">
|
||||||
|
<b style="color:${clsColor(m.cls)}">${esc(m.name)}</b>${m.id === g.leader ? " 👑" : ""}
|
||||||
|
<span class="small muted grow">${m.level} ${esc(C.classes[m.cls].name)} · ${m.inside ? "inside" : esc(place(m.place).name)}${m.busy ? ` · ${esc(m.busy.replace("You're ", ""))}` : ""}</span>
|
||||||
|
<div style="width:80px">${bar(m.hp, m.max_hp, "thin")}</div></div>`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
${!g.dungeon && here.dungeon && g.leader === S.uid ? `<button class="btn primary small" data-act="enter">Enter ${esc(C.dungeons[here.dungeon].name)}</button>` : ""}
|
||||||
|
${!g.dungeon && !here.dungeon ? `<span class="small muted">Meet at Moonbrook for the Deadmines</span>` : ""}
|
||||||
|
<button class="btn small danger right" data-act="leave-group">Leave group</button></div></div>
|
||||||
|
${g.dungeon && !(S.me.activity && S.me.activity.kind === "dungeon") ? "" : ""}`;
|
||||||
|
} else {
|
||||||
|
const open = S.groups.filter((x) => !x.inside && x.size < 5);
|
||||||
|
mine = `<div class="card"><h2>Group</h2>
|
||||||
|
<p class="tip">Dungeons take a group. Meet at the entrance; the leader leads you in.</p>
|
||||||
|
<div class="list">${open.map((x) => `<div class="item-row" style="cursor:default"><span class="grow">${esc(x.leader)}'s group <span class="muted small">${x.size} / 5</span></span>
|
||||||
|
<button class="btn small" data-act="join" data-id="${x.id}">Join</button></div>`).join("")}</div>
|
||||||
|
<div class="row" style="margin-top:8px"><button class="btn primary small" data-act="create-group">Start a group</button></div></div>`;
|
||||||
|
}
|
||||||
|
const people = S.players.map((p) => `<div class="item-row" style="cursor:default">
|
||||||
|
<span>${p.online ? "🟢" : "⚪"}</span><b style="color:${clsColor(p.cls)}">${esc(p.name)}</b>
|
||||||
|
<span class="small muted grow">${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}</span></div>`).join("");
|
||||||
|
return `${mine}<div class="card"><h2>Adventurers</h2><div class="list">${people}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- chat ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function chatPage() {
|
||||||
|
const names = Object.fromEntries(S.players.map((p) => [p.id, p]));
|
||||||
|
const msgs = S.chat.slice().reverse().map((m) => {
|
||||||
|
const p = names[m.who];
|
||||||
|
return `<div class="msg"><b style="color:${p ? clsColor(p.cls) : "#ddd"}">${esc(p ? p.name : m.who)}</b>${esc(m.text)} <span class="small muted">${ago(m.t)}</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
const events = S.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)} <span class="small">· ${ago(l.t)}</span></div>`).join("");
|
||||||
|
return `<div class="card"><h2>Guild chat</h2>
|
||||||
|
<div class="row"><input id="say" class="grow" placeholder="Say something" maxlength="300"><button class="btn primary small" data-act="say">Send</button></div>
|
||||||
|
<div class="chat" style="margin-top:8px">${msgs || '<span class="muted">Quiet in here.</span>'}</div></div>
|
||||||
|
<div class="card"><h2>World</h2><div class="log" style="max-height:none; flex-direction:column">${events}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the fight ----------------------------------------------------------
|
||||||
|
|
||||||
|
function fightTime() {
|
||||||
|
return S.fight ? now() - S.fight.at : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFight() {
|
||||||
|
const box = $("#fightbox");
|
||||||
|
if (!box) return;
|
||||||
|
const f = S.fight;
|
||||||
|
box.style.display = page === "adventure" ? "" : "none";
|
||||||
|
if (!f) {
|
||||||
|
if (box.dataset.id) {
|
||||||
|
box.innerHTML = "";
|
||||||
|
box.dataset.id = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (box.dataset.id !== f.id) {
|
||||||
|
box.dataset.id = f.id;
|
||||||
|
box.innerHTML = `<div class="card"><div class="fight-head"><h2 style="margin:0" id="f-name"></h2><span class="muted small" id="f-state"></span></div>
|
||||||
|
<div class="side" id="f-enemy" style="margin-top:8px"></div><div class="vs">— vs —</div><div class="side" id="f-party"></div>
|
||||||
|
<div class="log" id="f-log"></div></div>`;
|
||||||
|
seenEv = { id: f.id, t: Math.max(-1, ...f.ev.map((e) => e.t)) - 0.5 };
|
||||||
|
}
|
||||||
|
$("#f-name").textContent = f.name;
|
||||||
|
$("#f-state").textContent = f.state === "on" ? clock(f.t) : f.state === "won" ? "Victory" : "Defeat";
|
||||||
|
const me = myUnit();
|
||||||
|
const byId = Object.fromEntries(f.units.map((u) => [u.id, u]));
|
||||||
|
for (const side of ["enemy", "party"]) {
|
||||||
|
const holder = $(`#f-${side}`);
|
||||||
|
const units = f.units.filter((u) => u.side === side);
|
||||||
|
const keep = new Set(units.map((u) => u.id));
|
||||||
|
for (const el of [...holder.children]) if (!keep.has(el.dataset.u)) el.remove();
|
||||||
|
for (const u of units) {
|
||||||
|
let el = holder.querySelector(`[data-u="${CSS.escape(u.id)}"]`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.className = "frame";
|
||||||
|
el.dataset.u = u.id;
|
||||||
|
el.dataset.act = "target";
|
||||||
|
el.innerHTML = `<div class="body"></div><div class="floats"></div>`;
|
||||||
|
holder.appendChild(el);
|
||||||
|
}
|
||||||
|
el.classList.toggle("dead", !!u.dead);
|
||||||
|
el.classList.toggle("target", !!me && me.target === u.id && side === "enemy");
|
||||||
|
el.classList.toggle("ftarget", !!me && me.ftarget === u.id && side === "party");
|
||||||
|
const html = frameHtml(u, byId, me);
|
||||||
|
const body = el.querySelector(".body");
|
||||||
|
if (body.dataset.html !== html) {
|
||||||
|
body.innerHTML = html;
|
||||||
|
body.dataset.html = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("#f-log").innerHTML = f.log.slice().reverse().map((l) => `<div class="${l.kind}">${esc(l.text)}</div>`).join("");
|
||||||
|
floats(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameHtml(u, byId, me) {
|
||||||
|
const isPlayer = u.kind === "player";
|
||||||
|
const color = isPlayer ? clsColor(u.cls) : u.side === "enemy" ? "#f0b8a8" : "#cfe3c0";
|
||||||
|
const tags = [u.boss ? '<span class="tag boss">Boss</span>' : u.elite ? '<span class="tag">Elite</span>' : "", u.rare ? '<span class="tag">Rare</span>' : ""].join("");
|
||||||
|
const low = u.hp / u.max_hp < 0.3;
|
||||||
|
let html = `<div class="row"><span class="name" style="color:${color}">${u.icon ? u.icon + " " : ""}${esc(u.name)}</span><span class="lv">${u.level}</span>${tags}</div>`;
|
||||||
|
html += bar(u.hp, u.max_hp, low ? "low" : "", u.dead ? "dead" : `${u.hp} / ${u.max_hp}`);
|
||||||
|
if (u.res && u.res.type) html += `<div class="bar thin ${u.res.type}" style="margin-top:2px"><i style="width:${(u.res.cur / u.res.max) * 100}%"></i></div>`;
|
||||||
|
if (u.cast) {
|
||||||
|
const target = byId[u.cast.target];
|
||||||
|
html += `<div class="bar cast ${u.side === "enemy" && !u.cast.interrupt ? "safe" : ""}" data-start="${u.cast.start}" data-end="${u.cast.end}">
|
||||||
|
<i style="width:0"></i><span>${u.cast.icon} ${esc(u.cast.name)}${target && target.id !== u.id ? " → " + esc(target.name) : ""}</span></div>`;
|
||||||
|
}
|
||||||
|
const auras = u.auras.map((a) => `<span class="${a.mine === S.uid ? "mine" : ""}" title="${esc(a.name)}">${a.icon}</span>`).join("");
|
||||||
|
html += `<div class="auras">${auras}${u.combo ? `<span title="Combo points">${"🔸".repeat(u.combo)}</span>` : ""}${u.stun ? "<span title='Stunned'>💫</span>" : ""}${u.root ? "<span title='Frozen'>🧊</span>" : ""}</div>`;
|
||||||
|
if (u.side === "enemy" && !u.dead) {
|
||||||
|
const on = byId[u.target];
|
||||||
|
const safe = on && (on.role === "tank" || on.kind === "pet");
|
||||||
|
const mine = me && (u.threat || []).find(([id]) => id === S.uid);
|
||||||
|
html += `<div class="on-whom">${on ? `→ <b class="${safe ? "ok" : ""}">${esc(on.name)}</b>` : ""}${mine ? ` · your threat ${pct(mine[1])}` : ""}</div>`;
|
||||||
|
}
|
||||||
|
if (u.cc) html += `<span class="cc">${u.cc}</span>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floats(f) {
|
||||||
|
for (const e of f.ev) {
|
||||||
|
if (e.t <= seenEv.t) continue;
|
||||||
|
seenEv.t = Math.max(seenEv.t, e.t);
|
||||||
|
const frame = document.querySelector(`[data-u="${CSS.escape(e.u)}"] .floats`);
|
||||||
|
if (!frame) continue;
|
||||||
|
const unit = f.units.find((u) => u.id === e.u);
|
||||||
|
let kind = e.k, text = String(e.n);
|
||||||
|
if (kind === "heal") text = "+" + e.n;
|
||||||
|
else if (kind === "interrupt") text = "Interrupted!";
|
||||||
|
else if (kind === "oom") text = "Not enough mana";
|
||||||
|
else if (kind === "absorb") text = "Absorbed";
|
||||||
|
else if (unit && unit.side === "party") { kind = "taken"; text = "-" + e.n; }
|
||||||
|
if (kind === "crit") text += "!";
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = `float ${kind}`;
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.left = `${35 + Math.random() * 30}%`;
|
||||||
|
frame.appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBar() {
|
||||||
|
const el = $("#bar");
|
||||||
|
const me = myUnit();
|
||||||
|
const show = !!(S.me && me && !me.dead && S.fight.state === "on");
|
||||||
|
el.classList.toggle("show", show);
|
||||||
|
document.documentElement.style.setProperty("--bar-h", show ? "112px" : "0px");
|
||||||
|
if (!show) {
|
||||||
|
barKey = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spec = specOf(S.me.cls);
|
||||||
|
const key = spec.abilities.map((a) => `${a}:${me.abilities.includes(a)}:${S.me.manual.includes(a)}`).join("|");
|
||||||
|
if (key !== barKey) {
|
||||||
|
barKey = key;
|
||||||
|
el.innerHTML = `<div class="inner"><div id="mycast"></div><div class="abilities">${spec.abilities.map((id, i) => {
|
||||||
|
const a = C.abilities[id];
|
||||||
|
const known = me.abilities.includes(id);
|
||||||
|
const manual = S.me.manual.includes(id);
|
||||||
|
return `<div class="ab ${known ? (manual ? "manual" : "auto") : "locked"}" data-ab="${id}">
|
||||||
|
<span class="icon">${a.icon}</span><span class="label">${known ? esc(a.name) : `level ${C.unlock[i]}`}</span>
|
||||||
|
${known ? `<span class="mode" data-mode="${id}">${manual ? "YOU" : "AUTO"}</span>` : ""}
|
||||||
|
<span class="gcd"></span></div>`;
|
||||||
|
}).join("")}</div>
|
||||||
|
<div class="bar-hint">Tap a frame to target it · tap AUTO / YOU to switch</div></div>`;
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves cast bars, cooldowns and travel along between polls.
|
||||||
|
function animate() {
|
||||||
|
if (!S) return;
|
||||||
|
const t = fightTime();
|
||||||
|
for (const el of document.querySelectorAll(".bar.cast[data-start]")) {
|
||||||
|
const start = Number(el.dataset.start), end = Number(el.dataset.end);
|
||||||
|
el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`;
|
||||||
|
}
|
||||||
|
for (const el of document.querySelectorAll(".bar.road[data-from]")) {
|
||||||
|
const from = Number(el.dataset.from), to = Number(el.dataset.to);
|
||||||
|
el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`;
|
||||||
|
}
|
||||||
|
for (const el of document.querySelectorAll("[data-until]")) {
|
||||||
|
el.textContent = clock(Number(el.dataset.until) - now());
|
||||||
|
}
|
||||||
|
const me = myUnit();
|
||||||
|
if (!me || !$("#bar").classList.contains("show")) return;
|
||||||
|
const cast = $("#mycast");
|
||||||
|
if (cast) {
|
||||||
|
const html = me.cast ? `<div class="bar cast casting" data-start="${me.cast.start}" data-end="${me.cast.end}"><i style="width:${Math.min(1, (t - me.cast.start) / (me.cast.end - me.cast.start)) * 100}%"></i><span>${me.cast.icon} ${esc(me.cast.name)}</span></div>` : "";
|
||||||
|
if (cast.dataset.html !== html) { cast.innerHTML = html; cast.dataset.html = html; }
|
||||||
|
}
|
||||||
|
const gcdLeft = Math.max(0, (me.busy || 0) - t);
|
||||||
|
for (const el of document.querySelectorAll(".ab[data-ab]")) {
|
||||||
|
const id = el.dataset.ab;
|
||||||
|
const a = C.abilities[id];
|
||||||
|
const ready = (me.cds || {})[id] || 0;
|
||||||
|
const left = ready - t;
|
||||||
|
let cd = el.querySelector(".cdm");
|
||||||
|
if (left > 0.05) {
|
||||||
|
if (!cd) {
|
||||||
|
cd = document.createElement("span");
|
||||||
|
cd.className = "cdm";
|
||||||
|
el.appendChild(cd);
|
||||||
|
}
|
||||||
|
cd.textContent = left >= 10 ? Math.ceil(left) : left.toFixed(1);
|
||||||
|
} else if (cd) cd.remove();
|
||||||
|
const cost = a.cost ? (a.cost < 1 ? a.cost * me.res.max : a.cost) : 0;
|
||||||
|
el.classList.toggle("nores", !!(cost && me.res.cur < cost));
|
||||||
|
el.classList.toggle("queued", me.queued === id);
|
||||||
|
const gcd = el.querySelector(".gcd");
|
||||||
|
if (gcd) gcd.style.width = a.offgcd ? "0" : `${Math.min(1, gcdLeft / 1.5) * 100}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- input --------------------------------------------------------------
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", (ev) => {
|
||||||
|
const mode = ev.target.closest("[data-mode]");
|
||||||
|
if (mode) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const id = mode.dataset.mode;
|
||||||
|
api("manual", { id, on: !S.me.manual.includes(id) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ab = ev.target.closest(".ab[data-ab]");
|
||||||
|
if (ab) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const id = ab.dataset.ab;
|
||||||
|
if (ab.classList.contains("locked")) return;
|
||||||
|
if (!S.me.manual.includes(id)) {
|
||||||
|
toast("On auto. Tap AUTO to take it yourself.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ab.classList.add("queued");
|
||||||
|
api("press", { id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = ev.target.closest("[data-act=target]");
|
||||||
|
if (frame) {
|
||||||
|
ev.preventDefault();
|
||||||
|
api("target", { id: frame.dataset.u });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", async (ev) => {
|
||||||
|
const nav = ev.target.closest("[data-page]");
|
||||||
|
if (nav) {
|
||||||
|
page = nav.dataset.page;
|
||||||
|
remember("page", page);
|
||||||
|
render();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cls = ev.target.closest("[data-cls]");
|
||||||
|
if (cls) {
|
||||||
|
remember("draftName", $("#name")?.value || "");
|
||||||
|
regCls = cls.dataset.cls;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = ev.target.closest("[data-act]");
|
||||||
|
if (!el || el.dataset.act === "target") return;
|
||||||
|
const id = el.dataset.id;
|
||||||
|
switch (el.dataset.act) {
|
||||||
|
case "register":
|
||||||
|
if (await api("register", { name: $("#name").value, cls: regCls })) toast("Welcome to Azeroth", true);
|
||||||
|
break;
|
||||||
|
case "hunt": api("hunt", { id }); break;
|
||||||
|
case "stop": api("stop"); break;
|
||||||
|
case "travel": api("travel", { id }); break;
|
||||||
|
case "travel-far": if ($("#far").value) api("travel", { id: $("#far").value }); break;
|
||||||
|
case "accept": api("accept", { id }); break;
|
||||||
|
case "abandon": if (confirm("Abandon this quest?")) api("abandon", { id }); break;
|
||||||
|
case "pick-reward": pickReward[id] = Number(el.dataset.i); render(); break;
|
||||||
|
case "turn-in": api("turn_in", { id, choice: pickReward[id] }); break;
|
||||||
|
case "sell-junk": api("sell_junk"); break;
|
||||||
|
case "select": if (!ev.target.closest("button")) { selected = selected === id ? null : id; render(); } break;
|
||||||
|
case "equip": api("equip", { id }); break;
|
||||||
|
case "unequip": api("unequip", { id }); break;
|
||||||
|
case "sell": api("sell", { id, qty: "all" }); break;
|
||||||
|
case "manual": api("manual", { id, on: el.dataset.on === "1" }); break;
|
||||||
|
case "create-group": api("group/create"); break;
|
||||||
|
case "join": api("group/join", { id }); break;
|
||||||
|
case "leave-group": if (confirm("Leave the group?")) api("group/leave"); break;
|
||||||
|
case "enter": api("dungeon/enter"); break;
|
||||||
|
case "pull": api("dungeon/pull"); break;
|
||||||
|
case "leave-dungeon": if (confirm("Leave the dungeon?")) api("dungeon/leave"); break;
|
||||||
|
case "say": {
|
||||||
|
const input = $("#say");
|
||||||
|
if (input.value.trim() && await api("say", { text: input.value })) input.value = "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dev-level": api("dev/level", { level: Number($("#dev-level").value) }); break;
|
||||||
|
case "dev-advance": api("dev/advance", { minutes: Number(el.dataset.m) }); break;
|
||||||
|
case "dev-teleport": api("dev/teleport", { id: $("#dev-place").value }); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter" && ev.target.id === "say") $("[data-act=say]").click();
|
||||||
|
if (ev.target.matches("input, textarea, select")) return;
|
||||||
|
const n = Number(ev.key);
|
||||||
|
if (n >= 1 && n <= 4) {
|
||||||
|
const ab = document.querySelectorAll(".ab[data-ab]")[n - 1];
|
||||||
|
if (ab) ab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("change", (ev) => {
|
||||||
|
if (ev.target.id === "dev-user") {
|
||||||
|
document.cookie = `wog_user=${encodeURIComponent(ev.target.value)}; path=/; max-age=31536000`;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- boot ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const res = await fetch("/api/content");
|
||||||
|
C = await res.json();
|
||||||
|
await refresh();
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const fast = S && S.fight && S.fight.state === "on";
|
||||||
|
if (Date.now() - polledAt >= (fast ? FIGHT_POLL_MS : POLL_MS)) refresh();
|
||||||
|
}, 100);
|
||||||
|
setInterval(animate, 100);
|
||||||
|
setInterval(() => { if (S && page === "adventure" && !S.fight) renderMain(); }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
20
static/index.html
Normal file
20
static/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#14110d">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<title>World of Guildhall</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.webmanifest">
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="top"></header>
|
||||||
|
<main id="main"></main>
|
||||||
|
<div id="bar"></div>
|
||||||
|
<nav id="nav"></nav>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
static/manifest.webmanifest
Normal file
8
static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "World of Guildhall",
|
||||||
|
"short_name": "WoG",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#14110d",
|
||||||
|
"theme_color": "#14110d"
|
||||||
|
}
|
||||||
204
static/styles.css
Normal file
204
static/styles.css
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
:root {
|
||||||
|
--bg: #14110d;
|
||||||
|
--panel: #1e1913;
|
||||||
|
--panel-2: #272017;
|
||||||
|
--line: #3b3125;
|
||||||
|
--text: #ebe0c8;
|
||||||
|
--muted: #9a8c74;
|
||||||
|
--gold: #f6cf6a;
|
||||||
|
--hp: #2fae4e;
|
||||||
|
--hp-low: #c8372d;
|
||||||
|
--mana: #2f6fdb;
|
||||||
|
--rage: #c23b30;
|
||||||
|
--energy: #e3c33b;
|
||||||
|
--cast: #e8a93a;
|
||||||
|
--bad: #e2574c;
|
||||||
|
--good: #59c96b;
|
||||||
|
--q-poor: #9d9d9d;
|
||||||
|
--q-common: #f2f2f2;
|
||||||
|
--q-uncommon: #1eff00;
|
||||||
|
--q-rare: #3d9bff;
|
||||||
|
--q-epic: #b462f5;
|
||||||
|
--nav-h: 58px;
|
||||||
|
--bar-h: 0px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; background: var(--bg); color: var(--text); }
|
||||||
|
body {
|
||||||
|
font: 15px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
padding-bottom: calc(var(--nav-h) + var(--bar-h) + env(safe-area-inset-bottom) + 12px);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; }
|
||||||
|
h2 { font-size: 15px; margin: 0 0 8px; color: var(--gold); letter-spacing: .02em; }
|
||||||
|
h3 { font-size: 13px; margin: 10px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.grow { flex: 1; min-width: 0; }
|
||||||
|
.right { margin-left: auto; }
|
||||||
|
.wrap { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
main { max-width: 760px; margin: 0 auto; padding: 8px 12px; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 10px 0; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px;
|
||||||
|
cursor: pointer; touch-action: manipulation;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #5a4a36; }
|
||||||
|
.btn.primary { background: #4a3714; border-color: #8a6a2a; color: var(--gold); }
|
||||||
|
.btn.danger { border-color: #6b2a24; color: #f19a90; }
|
||||||
|
.btn.small { padding: 4px 9px; font-size: 13px; }
|
||||||
|
.btn[disabled] { opacity: .45; cursor: default; }
|
||||||
|
.btn.on { border-color: var(--gold); box-shadow: inset 0 0 0 1px var(--gold); }
|
||||||
|
|
||||||
|
.list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.item-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 8px; background: var(--panel-2);
|
||||||
|
border: 1px solid transparent; cursor: pointer; }
|
||||||
|
.item-row.sel { border-color: var(--line); }
|
||||||
|
|
||||||
|
/* top bar */
|
||||||
|
#top { position: sticky; top: 0; z-index: 5; background: linear-gradient(#1b1610, #14110dee); border-bottom: 1px solid var(--line);
|
||||||
|
padding: 8px 12px calc(6px); backdrop-filter: blur(4px); }
|
||||||
|
#top .inner { max-width: 760px; margin: 0 auto; }
|
||||||
|
.who { font-weight: 700; }
|
||||||
|
.money { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.g { color: #ffd34d; } .s { color: #d6d6d6; } .c { color: #d0874a; }
|
||||||
|
.xpbar { height: 5px; border-radius: 3px; background: #2a2318; overflow: hidden; margin-top: 6px; }
|
||||||
|
.xpbar > i { display: block; height: 100%; background: linear-gradient(90deg, #8e4ec6, #b07ae8); }
|
||||||
|
.minibars { display: flex; gap: 6px; margin-top: 5px; }
|
||||||
|
.minibars .bar { flex: 1; }
|
||||||
|
|
||||||
|
/* bars */
|
||||||
|
.bar { position: relative; height: 16px; border-radius: 4px; background: #0d0b08; overflow: hidden; border: 1px solid #00000080; }
|
||||||
|
.bar > i { position: absolute; inset: 0 auto 0 0; background: var(--hp); transition: width .25s linear; }
|
||||||
|
.bar > span { position: relative; display: block; text-align: center; font-size: 11px; line-height: 14px; font-weight: 600;
|
||||||
|
text-shadow: 0 1px 2px #000; font-variant-numeric: tabular-nums; }
|
||||||
|
.bar.thin { height: 7px; }
|
||||||
|
.bar.thin > span { display: none; }
|
||||||
|
.bar.mana > i { background: var(--mana); }
|
||||||
|
.bar.rage > i { background: var(--rage); }
|
||||||
|
.bar.energy > i { background: var(--energy); }
|
||||||
|
.bar.low > i { background: var(--hp-low); }
|
||||||
|
.bar.cast > i { background: var(--cast); transition: none; }
|
||||||
|
.bar.cast.safe > i { background: #8b8b8b; }
|
||||||
|
.bar.road > i { background: #8a6a2a; transition: none; }
|
||||||
|
|
||||||
|
/* fight */
|
||||||
|
.fight-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.side { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 6px; }
|
||||||
|
.frame { position: relative; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 6px 7px;
|
||||||
|
cursor: pointer; user-select: none; }
|
||||||
|
.frame.target { border-color: #e2c25a; box-shadow: 0 0 0 1px #e2c25a inset; }
|
||||||
|
.frame.ftarget { border-color: #59c96b; box-shadow: 0 0 0 1px #59c96b inset; }
|
||||||
|
.frame.dead { opacity: .4; }
|
||||||
|
.frame .name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.frame .lv { font-size: 11px; color: var(--muted); }
|
||||||
|
.frame .bar { margin-top: 4px; }
|
||||||
|
.frame .auras { display: flex; gap: 2px; flex-wrap: wrap; margin-top: 3px; min-height: 16px; font-size: 13px; }
|
||||||
|
.frame .auras .mine { filter: drop-shadow(0 0 2px #fff6); }
|
||||||
|
.frame .tag { font-size: 10px; padding: 0 5px; border-radius: 6px; background: #3a2c1a; color: var(--gold); margin-left: 4px; }
|
||||||
|
.frame .tag.boss { background: #4d1d1a; color: #ff9a8a; }
|
||||||
|
.frame .on-whom { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.frame .on-whom b { color: #ff9a8a; font-weight: 600; }
|
||||||
|
.frame .on-whom b.ok { color: var(--muted); }
|
||||||
|
.frame .cc { position: absolute; right: 6px; top: 4px; font-size: 18px; }
|
||||||
|
.floats { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
|
||||||
|
.float { position: absolute; left: 50%; top: 18px; transform: translateX(-50%); font-weight: 800; font-size: 14px;
|
||||||
|
text-shadow: 0 1px 2px #000, 0 0 4px #000; animation: rise 1.1s ease-out forwards; white-space: nowrap; }
|
||||||
|
.float.dmg { color: #ffffff; } .float.crit { color: #ffd34d; font-size: 18px; } .float.heal { color: #5cff7a; }
|
||||||
|
.float.taken { color: #ff6b5e; } .float.absorb { color: #9fd2ff; } .float.interrupt { color: #7fe3ff; }
|
||||||
|
.float.oom { color: #79a5ff; }
|
||||||
|
@keyframes rise { 0% { opacity: 0; transform: translate(-50%, 6px) scale(.9); } 15% { opacity: 1; transform: translate(-50%, 0) scale(1.08); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -26px) scale(1); } }
|
||||||
|
.vs { text-align: center; color: var(--muted); font-size: 11px; margin: 6px 0; letter-spacing: .2em; }
|
||||||
|
.log { font-size: 12px; max-height: 140px; overflow-y: auto; display: flex; flex-direction: column-reverse; margin-top: 8px; }
|
||||||
|
.log div { padding: 1px 0; color: var(--muted); }
|
||||||
|
.log .yell { color: #ff6b5e; font-weight: 600; } .log .interrupt { color: #7fe3ff; } .log .death { color: #e2574c; }
|
||||||
|
.log .enemy { color: #e8a93a; } .log .cast { color: #c8bca4; } .log .loot { color: var(--q-uncommon); }
|
||||||
|
.log .level { color: var(--gold); font-weight: 600; } .log .quest { color: #f0d080; }
|
||||||
|
|
||||||
|
/* action bar */
|
||||||
|
#bar { position: fixed; left: 0; right: 0; bottom: calc(var(--nav-h) + env(safe-area-inset-bottom)); z-index: 6;
|
||||||
|
background: linear-gradient(#14110d00, #14110d 18%); padding: 14px 10px 6px; display: none; }
|
||||||
|
#bar.show { display: block; }
|
||||||
|
#bar .inner { max-width: 760px; margin: 0 auto; }
|
||||||
|
.abilities { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
|
.ab { position: relative; height: 74px; border-radius: 10px; background: #231c13; border: 1px solid #4a3c2a; display: flex;
|
||||||
|
flex-direction: column; align-items: center; justify-content: center; cursor: pointer; overflow: hidden; touch-action: manipulation;
|
||||||
|
user-select: none; -webkit-user-select: none; }
|
||||||
|
.ab .icon { font-size: 26px; line-height: 1; }
|
||||||
|
.ab .label { font-size: 10.5px; margin-top: 3px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 94%; }
|
||||||
|
.ab .cdm { position: absolute; inset: 0; background: #000a; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 800; font-size: 20px; text-shadow: 0 1px 2px #000; }
|
||||||
|
.ab .gcd { position: absolute; left: 0; bottom: 0; height: 3px; background: var(--gold); }
|
||||||
|
.ab .mode { position: absolute; top: 3px; right: 3px; font-size: 9px; font-weight: 800; padding: 1px 5px; border-radius: 6px;
|
||||||
|
background: #3b3125; color: var(--muted); letter-spacing: .04em; }
|
||||||
|
.ab.manual { border-color: #b8913a; background: #2e2414; }
|
||||||
|
.ab.manual .mode { background: var(--gold); color: #1a1408; }
|
||||||
|
.ab.auto .icon { opacity: .75; }
|
||||||
|
.ab.nores .icon { filter: grayscale(1) brightness(.6); }
|
||||||
|
.ab.queued { box-shadow: 0 0 0 2px #fff inset; }
|
||||||
|
.ab.locked { opacity: .35; }
|
||||||
|
.ab:active { transform: scale(.97); }
|
||||||
|
.bar-hint { font-size: 11px; color: var(--muted); text-align: center; margin-top: 5px; }
|
||||||
|
.casting { margin-bottom: 6px; }
|
||||||
|
|
||||||
|
/* nav */
|
||||||
|
#nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 7; height: calc(var(--nav-h) + env(safe-area-inset-bottom));
|
||||||
|
padding-bottom: env(safe-area-inset-bottom); background: #100d0a; border-top: 1px solid var(--line); display: flex; }
|
||||||
|
#nav button { flex: 1; background: none; border: 0; color: var(--muted); font-size: 11px; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 2px; cursor: pointer; position: relative; }
|
||||||
|
#nav button .i { font-size: 20px; }
|
||||||
|
#nav button.on { color: var(--gold); }
|
||||||
|
#nav .dot { position: absolute; top: 8px; right: calc(50% - 18px); width: 8px; height: 8px; border-radius: 50%; background: var(--gold); }
|
||||||
|
|
||||||
|
/* items */
|
||||||
|
.q-poor { color: var(--q-poor); } .q-common { color: var(--q-common); } .q-uncommon { color: var(--q-uncommon); }
|
||||||
|
.q-rare { color: var(--q-rare); } .q-epic { color: var(--q-epic); }
|
||||||
|
.tip { font-size: 12px; color: var(--muted); }
|
||||||
|
.stat-up { color: var(--good); } .stat-down { color: var(--bad); }
|
||||||
|
.slot { display: grid; grid-template-columns: 70px 1fr; gap: 8px; align-items: center; padding: 6px 8px; background: var(--panel-2);
|
||||||
|
border-radius: 8px; cursor: pointer; }
|
||||||
|
.slot .k { color: var(--muted); font-size: 12px; }
|
||||||
|
.statgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||||
|
.statgrid div { background: var(--panel-2); border-radius: 8px; padding: 6px 8px; }
|
||||||
|
.statgrid b { display: block; font-size: 16px; }
|
||||||
|
.statgrid span { font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* quests */
|
||||||
|
.quest { padding: 8px; background: var(--panel-2); border-radius: 8px; }
|
||||||
|
.quest .obj { font-size: 13px; color: var(--muted); }
|
||||||
|
.quest .obj.done { color: var(--good); }
|
||||||
|
.quest.ready { border: 1px solid #8a6a2a; }
|
||||||
|
.qmark { color: #ffd34d; font-weight: 900; }
|
||||||
|
|
||||||
|
/* register */
|
||||||
|
.classes { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||||
|
.cls { padding: 10px; border-radius: 10px; background: var(--panel-2); border: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.cls.on { border-color: var(--gold); box-shadow: inset 0 0 0 1px var(--gold); }
|
||||||
|
.cls .ic { font-size: 22px; }
|
||||||
|
.cls b { display: block; }
|
||||||
|
input, select, textarea { font: inherit; color: var(--text); background: #0f0c09; border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
|
||||||
|
input:focus, textarea:focus { outline: 1px solid #8a6a2a; }
|
||||||
|
|
||||||
|
/* chat */
|
||||||
|
.chat { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; }
|
||||||
|
.chat .msg b { margin-right: 6px; }
|
||||||
|
|
||||||
|
#toast { position: fixed; left: 50%; top: 70px; transform: translateX(-50%); z-index: 20; background: #3a1d19; border: 1px solid #7a3328;
|
||||||
|
color: #ffd2cb; padding: 8px 14px; border-radius: 8px; display: none; max-width: 90vw; text-align: center; }
|
||||||
|
#toast.show { display: block; }
|
||||||
|
#toast.ok { background: #1d3a22; border-color: #2e7a3c; color: #d2ffd8; }
|
||||||
|
|
||||||
|
.devbar { font-size: 12px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.devbar input, .devbar select { padding: 3px 6px; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.side { grid-template-columns: 1fr 1fr; }
|
||||||
|
.frame { padding: 5px 6px; }
|
||||||
|
.ab { height: 68px; }
|
||||||
|
}
|
||||||
260
tests/test_game.py
Normal file
260
tests/test_game.py
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import combat # noqa: E402
|
||||||
|
import content # noqa: E402
|
||||||
|
import game # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def world_with(*players, now=1000.0, dev=True):
|
||||||
|
world = game.new_world(now)
|
||||||
|
world["config"]["dev"] = dev
|
||||||
|
for uid, cls in players:
|
||||||
|
world["players"][uid] = game.new_player(uid, uid.title(), cls, now)
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
def solo_fight(cls, level, mob, seed=1, watch=None, manual=()):
|
||||||
|
player = game.new_player("p", "P", cls, 0)
|
||||||
|
game.dev_level({"config": {}}, player, level, 0)
|
||||||
|
player["manual"] = list(manual)
|
||||||
|
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False),
|
||||||
|
[combat.mob_unit("e0", mob, level)], "hunt")
|
||||||
|
return player, fight
|
||||||
|
|
||||||
|
|
||||||
|
class Content(unittest.TestCase):
|
||||||
|
def test_every_spec_has_four_known_abilities(self):
|
||||||
|
for spec in content.SPECS.values():
|
||||||
|
self.assertEqual(len(spec["abilities"]), 4)
|
||||||
|
self.assertEqual(sorted(spec["abilities"]), sorted(spec["priority"]))
|
||||||
|
for ab in spec["abilities"]:
|
||||||
|
self.assertIn(ab, content.ABILITIES)
|
||||||
|
|
||||||
|
def test_places_link_both_ways_and_hunts_exist(self):
|
||||||
|
for pid, place in content.PLACES.items():
|
||||||
|
for other in place["links"]:
|
||||||
|
self.assertIn(pid, content.PLACES[other]["links"], f"{pid} -> {other}")
|
||||||
|
for hunt, pulls in place.get("hunts", {}).items():
|
||||||
|
self.assertIn(hunt, content.MOBS)
|
||||||
|
for pull in pulls:
|
||||||
|
for mob in pull:
|
||||||
|
self.assertIn(mob, content.MOBS)
|
||||||
|
|
||||||
|
def test_quests_point_at_real_things(self):
|
||||||
|
for qid, quest in content.QUESTS.items():
|
||||||
|
self.assertIn(quest["giver"], content.PLACES)
|
||||||
|
for req in quest.get("req", []):
|
||||||
|
self.assertIn(req, content.QUESTS)
|
||||||
|
for objective in quest["obj"]:
|
||||||
|
if "kill" in objective:
|
||||||
|
self.assertIn(objective["kill"], content.MOBS)
|
||||||
|
for mob in objective.get("from", []):
|
||||||
|
self.assertIn(mob, content.MOBS)
|
||||||
|
for reward in quest.get("choice", []):
|
||||||
|
self.assertIsNotNone(game.item(reward), reward)
|
||||||
|
|
||||||
|
def test_loot_tables_resolve(self):
|
||||||
|
for items in list(content.BOSS_LOOT.values()) + [content.TRASH_LOOT]:
|
||||||
|
for item_id in items:
|
||||||
|
self.assertIsNotNone(game.item(item_id), item_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Items(unittest.TestCase):
|
||||||
|
def test_generated_green_has_name_and_stats(self):
|
||||||
|
green = game.item("g/chest/mail/bear/14")
|
||||||
|
self.assertEqual(green["q"], "uncommon")
|
||||||
|
self.assertIn("of the Bear", green["name"])
|
||||||
|
self.assertGreater(green["sta"], green["power"])
|
||||||
|
self.assertGreater(green["armor"], 0)
|
||||||
|
|
||||||
|
def test_armour_rules(self):
|
||||||
|
mage = game.new_player("m", "M", "mage", 0)
|
||||||
|
self.assertIsNotNone(game.can_wear(mage, game.item("g/chest/mail/bear/2")))
|
||||||
|
self.assertIsNone(game.can_wear(mage, game.item("g/chest/cloth/eagle/2")))
|
||||||
|
warrior = game.new_player("w", "W", "warrior", 0)
|
||||||
|
self.assertIsNone(game.can_wear(warrior, game.item("g/chest/cloth/eagle/2")))
|
||||||
|
self.assertIn("level", game.can_wear(warrior, game.item("g/chest/mail/bear/14")))
|
||||||
|
|
||||||
|
|
||||||
|
class Combat(unittest.TestCase):
|
||||||
|
def test_every_class_beats_an_even_mob_alone(self):
|
||||||
|
for cls in content.CLASSES:
|
||||||
|
_, fight = solo_fight(cls, 10, "riverpaw_gnoll")
|
||||||
|
combat.advance(fight, 300)
|
||||||
|
self.assertEqual(fight["state"], "won", cls)
|
||||||
|
|
||||||
|
def test_same_seed_same_fight(self):
|
||||||
|
a = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
|
||||||
|
b = solo_fight("mage", 8, "kobold_miner", seed=7)[1]
|
||||||
|
combat.advance(a, 300)
|
||||||
|
combat.advance(b, 300)
|
||||||
|
self.assertEqual(a["t"], b["t"])
|
||||||
|
self.assertEqual(a["units"]["p"]["hp"], b["units"]["p"]["hp"])
|
||||||
|
|
||||||
|
def test_manual_ability_waits_for_the_player_while_watched(self):
|
||||||
|
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
|
||||||
|
combat.advance(fight, 6, watch={"p": 5})
|
||||||
|
self.assertNotIn("sunder", fight["units"]["p"]["cds"])
|
||||||
|
combat.press(fight, "p", "sunder", 6)
|
||||||
|
combat.advance(fight, 7, watch={"p": 6})
|
||||||
|
self.assertIn("sunder", fight["units"]["p"]["cds"])
|
||||||
|
|
||||||
|
def test_manual_ability_falls_back_to_auto_when_nobody_watches(self):
|
||||||
|
_, fight = solo_fight("warrior", 10, "riverpaw_gnoll", manual=["sunder"])
|
||||||
|
combat.advance(fight, 6)
|
||||||
|
self.assertIn("sunder", fight["units"]["p"]["cds"])
|
||||||
|
|
||||||
|
def test_pressed_cooldown_is_shorter_than_auto(self):
|
||||||
|
_, fight = solo_fight("shaman", 10, "riverpaw_gnoll", manual=["stormstrike"])
|
||||||
|
combat.press(fight, "p", "stormstrike", 0)
|
||||||
|
combat.advance(fight, 0.25, watch={"p": 0})
|
||||||
|
pressed = fight["units"]["p"]["cds"]["stormstrike"]
|
||||||
|
_, auto = solo_fight("shaman", 10, "riverpaw_gnoll")
|
||||||
|
combat.advance(auto, 3)
|
||||||
|
used = auto["units"]["p"]["cds"]["stormstrike"]
|
||||||
|
self.assertAlmostEqual(pressed - 0.25, content.ABILITIES["stormstrike"]["cd"], places=2)
|
||||||
|
self.assertGreater(used - pressed, content.ABILITIES["stormstrike"]["cd"] * 0.4)
|
||||||
|
|
||||||
|
def test_interrupt_stops_a_cast(self):
|
||||||
|
player = game.new_player("p", "P", "rogue", 0)
|
||||||
|
game.dev_level({"config": {}}, player, 16, 0)
|
||||||
|
player["manual"] = ["kick"]
|
||||||
|
fight = combat.new_fight("f", 3, 0, game.player_unit(player, 0, False),
|
||||||
|
[combat.mob_unit("e0", "defias_pillager", 16)], "hunt")
|
||||||
|
for step in range(40):
|
||||||
|
combat.advance(fight, (step + 1) * combat.STEP, watch={"p": step * combat.STEP})
|
||||||
|
if fight["units"]["e0"]["cast"]:
|
||||||
|
break
|
||||||
|
self.assertTrue(fight["units"]["e0"]["cast"], "the pillager should start a Fireball")
|
||||||
|
combat.press(fight, "p", "kick", fight["t"], "e0")
|
||||||
|
combat.advance(fight, fight["t"] + 0.5, watch={"p": fight["t"]})
|
||||||
|
self.assertIsNone(fight["units"]["e0"]["cast"])
|
||||||
|
self.assertTrue(any("interrupts" in line["text"] for line in fight["log"]))
|
||||||
|
|
||||||
|
def test_mob_turns_on_the_healer_and_taunt_takes_it_back(self):
|
||||||
|
tank = game.new_player("tank", "Tank", "warrior", 0)
|
||||||
|
healer = game.new_player("heal", "Heal", "priest", 0)
|
||||||
|
for p in (tank, healer):
|
||||||
|
game.dev_level({"config": {}}, p, 18, 0)
|
||||||
|
party = game.player_unit(tank, 0, True) + game.player_unit(healer, 0, True)
|
||||||
|
fight = combat.new_fight("f", 5, 0, party, [combat.mob_unit("e0", "defias_overseer", 18)], "dungeon")
|
||||||
|
mob = fight["units"]["e0"]
|
||||||
|
mob["threat"]["heal"] = 10000
|
||||||
|
combat.advance(fight, 0.5)
|
||||||
|
self.assertEqual(mob["target"], "heal")
|
||||||
|
combat.advance(fight, 4)
|
||||||
|
self.assertEqual(mob["target"], "tank")
|
||||||
|
self.assertTrue(any("Taunt" in line["text"] for line in fight["log"]))
|
||||||
|
|
||||||
|
def test_cc_breaks_on_damage_and_bosses_are_immune(self):
|
||||||
|
player = game.new_player("p", "P", "mage", 0)
|
||||||
|
game.dev_level({"config": {}}, player, 18, 0)
|
||||||
|
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
|
||||||
|
[combat.mob_unit("e0", "defias_miner", 18), combat.mob_unit("e1", "rhahkzor", 19)],
|
||||||
|
"dungeon")
|
||||||
|
rng = random.Random(1)
|
||||||
|
combat._apply(fight, fight["units"]["p"], "polymorph", "e1", 0, rng)
|
||||||
|
self.assertEqual(fight["units"]["e1"]["cc"], 0)
|
||||||
|
combat._apply(fight, fight["units"]["p"], "polymorph", "e0", 0, rng)
|
||||||
|
self.assertGreater(fight["units"]["e0"]["cc"], 0)
|
||||||
|
combat._damage(fight, fight["units"]["p"], fight["units"]["e0"], 10, "frost", 0.5, rng)
|
||||||
|
self.assertEqual(fight["units"]["e0"]["cc"], 0)
|
||||||
|
|
||||||
|
def test_boss_phase_summons_adds(self):
|
||||||
|
player = game.new_player("p", "P", "warrior", 0)
|
||||||
|
game.dev_level({"config": {}}, player, 18, 0)
|
||||||
|
fight = combat.new_fight("f", 2, 0, game.player_unit(player, 0, True),
|
||||||
|
[combat.mob_unit("e0", "edwin_vancleef", 21)], "dungeon")
|
||||||
|
boss = fight["units"]["e0"]
|
||||||
|
boss["hp"] = boss["max_hp"] * 0.49
|
||||||
|
combat.advance(fight, 0.25)
|
||||||
|
self.assertEqual(sum(1 for u in fight["units"].values() if u.get("mob") == "defias_blackguard"), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class World(unittest.TestCase):
|
||||||
|
def test_hunting_earns_xp_and_quest_progress_while_away(self):
|
||||||
|
world = world_with(("fred", "warrior"))
|
||||||
|
fred = world["players"]["fred"]
|
||||||
|
self.assertIsNone(game.accept(fred, "kobold_camp_cleanup"))
|
||||||
|
fred["place"] = "northshire_valley"
|
||||||
|
self.assertIsNone(game.start_hunt(world, fred, "kobold_vermin", 1000))
|
||||||
|
game.process(world, 1000 + 20 * 60, random.Random(1))
|
||||||
|
self.assertGreater(fred["level"], 1)
|
||||||
|
self.assertEqual(fred["quests"]["kobold_camp_cleanup"], [10])
|
||||||
|
|
||||||
|
def test_hunting_stops_after_the_offline_cap(self):
|
||||||
|
world = world_with(("fred", "warrior"))
|
||||||
|
fred = world["players"]["fred"]
|
||||||
|
fred["place"] = "northshire_valley"
|
||||||
|
game.start_hunt(world, fred, "kobold_vermin", 1000)
|
||||||
|
game.process(world, 1000 + game.OFFLINE_CAP + 3600, random.Random(1))
|
||||||
|
self.assertIsNone(fred["activity"])
|
||||||
|
self.assertIsNone(fred["fight"])
|
||||||
|
|
||||||
|
def test_turn_in_needs_the_right_place_and_a_reward_pick(self):
|
||||||
|
world = world_with(("fred", "warrior"))
|
||||||
|
fred = world["players"]["fred"]
|
||||||
|
game.accept(fred, "kobold_camp_cleanup")
|
||||||
|
fred["quests"]["kobold_camp_cleanup"] = [10]
|
||||||
|
fred["place"] = "goldshire"
|
||||||
|
self.assertIn("Turn it in", game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
|
||||||
|
fred["place"] = "northshire_abbey"
|
||||||
|
self.assertEqual(game.turn_in(world, fred, "kobold_camp_cleanup", None, 0), "Pick a reward")
|
||||||
|
self.assertIsNone(game.turn_in(world, fred, "kobold_camp_cleanup", 2, 0))
|
||||||
|
self.assertIn("w/feet/mail/4", fred["bags"])
|
||||||
|
self.assertIn("investigate_echo_ridge", game.available_quests(fred))
|
||||||
|
|
||||||
|
def test_travel_takes_the_shortest_road(self):
|
||||||
|
minutes, path = game.route("northshire_abbey", "moonbrook")
|
||||||
|
self.assertEqual(path, ["northshire_abbey", "goldshire", "sentinel_hill", "moonbrook"])
|
||||||
|
self.assertEqual(minutes, 14)
|
||||||
|
world = world_with(("fred", "warrior"))
|
||||||
|
fred = world["players"]["fred"]
|
||||||
|
self.assertIsNone(game.travel(world, fred, "goldshire", 1000))
|
||||||
|
game.process(world, 1000 + 4 * 60 - 1, random.Random(1))
|
||||||
|
self.assertEqual(fred["place"], "northshire_abbey")
|
||||||
|
game.process(world, 1000 + 4 * 60, random.Random(1))
|
||||||
|
self.assertEqual(fred["place"], "goldshire")
|
||||||
|
self.assertIsNone(fred["activity"])
|
||||||
|
|
||||||
|
def test_a_group_clears_the_deadmines(self):
|
||||||
|
world = world_with(*[(uid, cls) for uid, cls in
|
||||||
|
(("t", "warrior"), ("h", "priest"), ("m", "mage"), ("r", "rogue"), ("x", "hunter"))])
|
||||||
|
for uid, player in world["players"].items():
|
||||||
|
game.dev_level(world, player, 20, 1000)
|
||||||
|
player["place"] = "moonbrook"
|
||||||
|
leader = world["players"]["t"]
|
||||||
|
game.create_group(world, leader)
|
||||||
|
gid = leader["group"]
|
||||||
|
for uid in "hmrx":
|
||||||
|
self.assertIsNone(game.join_group(world, world["players"][uid], gid))
|
||||||
|
self.assertIsNone(game.enter_dungeon(world, leader, 1000))
|
||||||
|
game.process(world, 1000 + 3 * 3600, random.Random(4))
|
||||||
|
dungeon = world["groups"][gid]["dungeon"]
|
||||||
|
self.assertEqual(dungeon["state"], "done", dungeon)
|
||||||
|
loot = sum(len(p["bags"]) for p in world["players"].values())
|
||||||
|
self.assertGreater(loot, 0)
|
||||||
|
|
||||||
|
def test_dungeon_needs_everyone_at_the_door(self):
|
||||||
|
world = world_with(("a", "warrior"), ("b", "priest"), dev=False)
|
||||||
|
a, b = world["players"]["a"], world["players"]["b"]
|
||||||
|
a["place"] = "moonbrook"
|
||||||
|
game.create_group(world, a)
|
||||||
|
self.assertIn("at least 2", game.enter_dungeon(world, a, 0))
|
||||||
|
game.join_group(world, b, a["group"])
|
||||||
|
self.assertIn("Waiting on B", game.enter_dungeon(world, a, 0))
|
||||||
|
b["place"] = "moonbrook"
|
||||||
|
self.assertIsNone(game.enter_dungeon(world, a, 0))
|
||||||
|
self.assertEqual(b["activity"]["kind"], "dungeon")
|
||||||
|
game.leave_group(world, b, 10)
|
||||||
|
self.assertIsNone(b["activity"])
|
||||||
|
self.assertEqual(b["place"], "moonbrook")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
99
tools/balance.py
Normal file
99
tools/balance.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Balance report: solo hunts per class and level, and all-auto Deadmines runs.
|
||||||
|
|
||||||
|
python3 tools/balance.py [solo|dungeon] [runs]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import combat # noqa: E402
|
||||||
|
import game # noqa: E402
|
||||||
|
from content import CLASSES, DUNGEONS, MOBS # noqa: E402
|
||||||
|
|
||||||
|
SOLO_TARGETS = {1: "young_wolf", 5: "defias_thug", 10: "riverpaw_gnoll", 15: "riverpaw_brute",
|
||||||
|
19: "defias_highwayman"}
|
||||||
|
|
||||||
|
|
||||||
|
def hero(cls: str, level: int, uid: str = "p") -> dict:
|
||||||
|
player = game.new_player(uid, cls.title(), cls, 0)
|
||||||
|
game.dev_level({"config": {}}, player, level, 0)
|
||||||
|
return player
|
||||||
|
|
||||||
|
|
||||||
|
def solo(runs: int) -> None:
|
||||||
|
print(f"{'class':9} {'lvl':>3} {'mob':20} {'win':>5} {'secs':>5} {'hp lost':>7}")
|
||||||
|
for cls in CLASSES:
|
||||||
|
for level, mob_id in SOLO_TARGETS.items():
|
||||||
|
wins, times, lost = 0, [], []
|
||||||
|
for seed in range(runs):
|
||||||
|
player = hero(cls, level)
|
||||||
|
rng = random.Random(seed)
|
||||||
|
mob_level = level
|
||||||
|
enemies = [combat.mob_unit("e0", mob_id, mob_level)]
|
||||||
|
fight = combat.new_fight("f", seed, 0, game.player_unit(player, 0, False), enemies, "hunt")
|
||||||
|
combat.advance(fight, 400)
|
||||||
|
me = fight["units"]["p"]
|
||||||
|
if fight["state"] == "won":
|
||||||
|
wins += 1
|
||||||
|
times.append(fight["t"])
|
||||||
|
lost.append(1 - me["hp"] / me["max_hp"])
|
||||||
|
print(f"{cls:9} {level:>3} {MOBS[mob_id]['name']:20} {wins / runs:>5.0%} "
|
||||||
|
f"{statistics.mean(times) if times else 0:>5.1f} {statistics.mean(lost) if lost else 0:>7.0%}")
|
||||||
|
|
||||||
|
|
||||||
|
def dungeon(runs: int, party: list[str], level: int) -> None:
|
||||||
|
pulls = DUNGEONS["deadmines"]["pulls"]
|
||||||
|
results = {p["name"]: {"won": 0, "t": [], "hp": [], "low": [], "mana": []} for p in pulls}
|
||||||
|
for seed in range(runs):
|
||||||
|
rng = random.Random(seed)
|
||||||
|
heroes = [hero(cls, level, f"p{i}") for i, cls in enumerate(party)]
|
||||||
|
for pull in pulls:
|
||||||
|
units = []
|
||||||
|
for h in heroes:
|
||||||
|
units += game.player_unit(h, 0, True)
|
||||||
|
hp_scale, dmg_scale = game.dungeon_scale(len(heroes))
|
||||||
|
enemies = []
|
||||||
|
for index, mob_id in enumerate(pull["mobs"]):
|
||||||
|
low, high = MOBS[mob_id]["lv"]
|
||||||
|
unit = combat.mob_unit(f"e{index}", mob_id, rng.randint(low, high), hp_scale, dmg_scale)
|
||||||
|
unit["swing"]["next"] = 0.8 + 0.4 * index
|
||||||
|
enemies.append(unit)
|
||||||
|
fight = combat.new_fight("f", rng.randrange(2 ** 31), 0, units, enemies, "dungeon", pull["name"],
|
||||||
|
(hp_scale, dmg_scale))
|
||||||
|
while fight["state"] == "on":
|
||||||
|
combat.advance(fight, fight["t"] + combat.STEP)
|
||||||
|
tank = fight["units"]["p0"]
|
||||||
|
fight["low"] = min(fight.get("low", 1), tank["hp"] / tank["max_hp"])
|
||||||
|
row = results[pull["name"]]
|
||||||
|
row["low"].append(fight.get("low", 1))
|
||||||
|
row["mana"].append(fight["units"]["p1"]["res"]["cur"] / fight["units"]["p1"]["res"]["max"])
|
||||||
|
if fight["state"] == "won":
|
||||||
|
row["won"] += 1
|
||||||
|
row["t"].append(fight["t"])
|
||||||
|
row["hp"].append(sum(1 for u in combat.players(fight) if u["dead"]))
|
||||||
|
print(f"party: {', '.join(party)} at {level}")
|
||||||
|
print(f"{'pull':20} {'win':>5} {'secs':>5} {'dead':>5} {'tank low':>8} {'heal mana':>9}")
|
||||||
|
for name, row in results.items():
|
||||||
|
print(f"{name:20} {row['won'] / runs:>5.0%} {statistics.mean(row['t']) if row['t'] else 0:>5.0f} "
|
||||||
|
f"{statistics.mean(row['hp']) if row['hp'] else 0:>5.1f} {statistics.mean(row['low']):>8.0%} "
|
||||||
|
f"{statistics.mean(row['mana']):>9.0%}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "solo"
|
||||||
|
runs = int(sys.argv[2]) if len(sys.argv) > 2 else 20
|
||||||
|
if mode == "solo":
|
||||||
|
solo(runs)
|
||||||
|
else:
|
||||||
|
if "perfect" in sys.argv:
|
||||||
|
combat.AUTO_CD, combat.AUTO_DELAY, combat.AUTO_REACT = 1.0, 0.0, 0.25
|
||||||
|
print("-- perfect hands --")
|
||||||
|
dungeon(runs, ["warrior", "priest", "mage", "rogue", "hunter"], 18)
|
||||||
|
dungeon(runs, ["paladin", "druid", "warlock", "shaman", "rogue"], 18)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue