wog/static/app.js
rope 7150b2f89c Keep the keyboard open: no redraws under focused fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:02:33 +01:00

935 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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) {
// Only redraw when the form itself changes: replacing the name field under
// a phone's keyboard closes the keyboard.
const html = registerPage();
if (el.dataset.html !== html) {
el.innerHTML = html;
el.dataset.html = html;
$("#name").value = remember("draftName") || "";
}
return;
}
if (page === "chat") {
seenChat = Math.max(seenChat, ...S.chat.map((m) => m.t), 0);
remember("seenChat", seenChat);
}
// Never redraw under a focused field (the chat box aside): on a phone that
// closes the keyboard or the open dropdown. The next render catches up.
const focused = document.activeElement;
const typing = focused && el.contains(focused) && focused.matches("input, select, textarea");
if (page === "chat" && el.querySelector("#say")) {
// The chat box stays put (and keeps the keyboard); only the lists refresh.
const holder = document.createElement("div");
holder.innerHTML = chatPage();
for (const id of ["chatlog", "worldlog"]) {
const fresh = holder.querySelector("#" + id).innerHTML;
const old = el.querySelector("#" + id);
if (old.innerHTML !== fresh) old.innerHTML = fresh;
}
return;
}
if (typing) return;
const html = { adventure: adventurePage, quests: questsPage, character: characterPage, group: groupPage, chat: chatPage }[page]();
if (el.dataset.html !== html) {
el.innerHTML = html;
el.dataset.html = html;
animate();
}
}
// --- 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" autocomplete="off" autocapitalize="words"></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 · <span data-until="${me.dead_until}"></span></span></div>`;
}
if (!act) return "";
if (act.kind === "travel") {
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}"></span></div>
<div class="bar road" style="margin-top:6px" data-from="${act.start}" data-to="${act.end}"><i></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 · <span data-until="${me.next_pull}"></span>` : ""}</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}"></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" id="chatlog" style="margin-top:8px">${msgs || '<span class="muted">Quiet in here.</span>'}</div></div>
<div class="card"><h2>World</h2><div class="log" id="worldlog" 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;
document.activeElement?.blur();
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("input", (ev) => {
if (ev.target.id === "name") remember("draftName", ev.target.value);
});
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();