First slice: classes, combat, questing, Deadmines

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

910
static/app.js Normal file
View file

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

20
static/index.html Normal file
View file

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

View file

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

204
static/styles.css Normal file
View file

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