"use strict"; // World of Guildhall client. The server is the source of truth; between polls the // client only animates bars and cooldowns forward from the last state it saw. const POLL_MS = 3000; const FIGHT_POLL_MS = 300; // fights are live: keep everyone's view close const ROLE = { tank: "Tank", healer: "Healer", dps: "Damage" }; const RULES = { always: (r) => (r.save ? `whenever it's ready (in a group: only above ${pct(r.save)} mana)` : "whenever it's ready"), dot: (r) => (r.save ? `keeps it on the target (in a group: only above ${pct(r.save)} mana)` : "keeps it on the target"), heal: (r) => `someone under ${pct(r.pct)}`, hot: (r) => `someone under ${pct(r.pct)} without it`, shield: (r) => `whoever's being hit, or under ${pct(r.pct)}`, self_below: (r) => `you're under ${pct(r.pct)}`, taunt: () => "a mob hits someone else", aoe: (r) => (r.n > 1 ? `${r.n}+ mobs` : "every fight"), interrupt: () => "a mob is casting", combo: (r) => `${r.n}+ combo points`, cc: () => "an extra mob joins", peel: () => "mobs are on you", tanking: (r) => `an elite hits you, or you're under ${pct(r.pct)}`, danger: (r) => `hit while under ${pct(r.pct)}`, buff: () => "keeps it up", boss: () => "on elites, rares and bosses", }; let C = null; // static content let S = null; // latest state let skew = 0; // server clock minus client clock let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "adventure"; let polledAt = 0; let inflight = false; let failures = 0; let selected = null; // bag item let regCls = null; let pickReward = {}; // quest id -> reward index let seenEv = { id: null, t: -1 }; let barKey = ""; let seenChat = Number(remember("seenChat") || 0); const $ = (sel) => document.querySelector(sel); function remember(key, value) { try { if (value === undefined) return localStorage.getItem("wog." + key); localStorage.setItem("wog." + key, value); } catch (e) { /* private mode */ } return null; } function esc(text) { return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function pct(x) { return `${Math.round(x * 100)}%`; } function now() { return Date.now() / 1000 + skew; } function money(copper) { copper = Math.floor(copper || 0); const g = Math.floor(copper / 10000), s = Math.floor(copper / 100) % 100, c = copper % 100; const parts = []; if (g) parts.push(`${g}g`); if (s) parts.push(`${s}s`); if (c || !parts.length) parts.push(`${c}c`); return `${parts.join(" ")}`; } function clock(seconds) { seconds = Math.max(0, Math.round(seconds)); if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60), s = seconds % 60; if (m < 60) return `${m}:${String(s).padStart(2, "0")}`; return `${Math.floor(m / 60)}h ${m % 60}m`; } function toast(text, ok = false) { const el = $("#toast"); el.textContent = text; el.className = "show" + (ok ? " ok" : ""); clearTimeout(toast.timer); toast.timer = setTimeout(() => (el.className = ""), 2600); } function clsColor(cls) { return C.classes[cls]?.color || "#ddd"; } function place(id) { return C.places[id]; } function specOf(cls) { return C.specs[C.classes[cls].spec]; } // --- server ------------------------------------------------------------- async function refresh() { if (inflight) return; inflight = true; try { const res = await fetch("/api/state", { cache: "no-store" }); if (!res.ok) throw new Error(res.status); accept(await res.json()); failures = 0; } catch (e) { failures++; } finally { inflight = false; polledAt = Date.now(); } } async function api(path, body = {}) { try { const res = await fetch("/api/" + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { toast(data.error || "Something went wrong"); return false; } accept(data); return true; } catch (e) { toast("Can't reach the server"); return false; } } function accept(state) { if (S && S.version !== state.version) { location.reload(); return; } skew = state.now - Date.now() / 1000; S = state; render(); } // --- rendering ---------------------------------------------------------- function render() { if (!C || !S) return; renderTop(); renderNav(); renderMain(); renderFight(); renderBar(); } function myUnit() { return S.fight ? S.fight.units.find((u) => u.id === S.uid) : null; } function renderTop() { const me = S.me; if (!me) { $("#top").innerHTML = `
World of Guildhall
`; return; } const cls = C.classes[me.cls]; const unit = myUnit(); const hp = unit ? unit.hp : me.hp, max = unit ? unit.max_hp : me.stats.max_hp; const res = unit ? unit.res : null; const resType = me.stats.res; const resPct = res ? res.cur / res.max : (resType === "mana" ? me.mana : resType === "energy" ? 1 : 0); const xp = me.xp_next ? me.xp / me.xp_next : 1; $("#top").innerHTML = `
${esc(me.name)} ${me.level} ${esc(specOf(me.cls).name)} ${esc(cls.name)} ${money(me.copper)}
${bar(hp, max, hp / max < 0.3 ? "low" : "", `${Math.round(hp)} / ${max}`)} ${bar(resPct, 1, resType, resType === "mana" ? pct(resPct) : `${Math.round(resPct * 100)} ${resType}`)}
`; } function bar(value, max, cls = "", text = "") { const w = max ? Math.max(0, Math.min(1, value / max)) * 100 : 0; return `
${text}
`; } function renderNav() { const me = S.me; if (!me) { $("#nav").innerHTML = ""; return; } const ready = Object.keys(me.quests).some((q) => questReady(q)); const newChat = S.chat.some((m) => m.t > seenChat && m.who !== S.uid); const tabs = [ ["adventure", "⚔️", "Adventure", false], ["quests", "📜", "Quests", ready], ["character", "🧍", "Character", false], ["group", "👥", "Group", !!S.group?.dungeon], ["chat", "💬", "Guild", newChat && page !== "chat"], ]; $("#nav").innerHTML = tabs.map(([id, icon, label, dot]) => ``).join(""); } function renderMain() { const main = $("#main"); if (!main.querySelector("#fightbox")) { main.innerHTML = `
`; } const el = $("#page"); if (!S.me) { // 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 `

Create your character

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

Class

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

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

${spec.abilities.map((a, i) => abilityRow(a, C.unlock[i])).join("")}
` : ""}
`; } function abilityRow(id, level, me) { const a = C.abilities[id]; const known = !me || me.known.includes(id); const manual = me && me.manual.includes(id); const owner = Object.values(C.specs).find((sp) => sp.abilities.includes(id)); const cost = a.cost ? (a.cost < 1 ? `${Math.round(a.cost * 100)}% mana` : `${a.cost} ${C.classes[owner.cls].res}`) : ""; const facts = [cost, a.cast ? `${a.cast}s cast` : "instant", a.cd ? `${a.cd}s cooldown (auto ${Math.round(a.cd * C.auto_cd)}s)` : ""].filter(Boolean); return `
${a.icon}
${esc(a.name)} ${known ? "" : `level ${level}`}
${esc(a.tip)}
${facts.join(" · ")}
Auto: ${RULES[a.auto.r](a.auto)}
${me ? `` : ""}
`; } // --- adventure ---------------------------------------------------------- function adventurePage() { const me = S.me; const here = place(me.place); const act = me.activity; const parts = [statusCard()]; if (!act || act.kind !== "dungeon") { parts.push(placeCard(here)); } parts.push(logCard()); if (S.dev) parts.push(devCard()); return parts.join(""); } function statusCard() { const me = S.me; const act = me.activity; const t = now(); if (me.dead_until > t) { return `
💀 You died. Running back ·
`; } if (!act) return ""; if (act.kind === "travel") { return `
🐎 On the road to ${esc(place(act.to).name)}
${act.path.map((p) => esc(place(p).name)).join(" → ")}
`; } if (act.kind === "hunt") { const mob = C.mobs[act.hunt]; const waiting = !S.fight && me.next_pull > t; return `
🎯 Hunting ${esc(mob.name)} ${waiting ? `resting · ` : ""}
`; } if (act.kind === "dungeon") return dungeonCard(); return ""; } function dungeonCard() { const g = S.group; if (!g || !g.dungeon) return ""; const d = g.dungeon; const spec = C.dungeons[d.id]; const t = now(); const leader = g.leader === S.uid; const pulls = spec.pulls.map((p, i) => { const mark = i < d.pull ? "✅" : i === d.pull && d.state !== "done" ? "➡️" : "▫️"; return `
${mark} ${p.boss ? "" : ""}${esc(p.name)}${p.boss ? "" : ""} ${i === d.pull && d.state !== "done" ? "· " + p.mobs.map((m) => esc(C.mobs[m].name)).join(", ") : ""}
`; }).join(""); let status = ""; if (d.state === "rest") { status = `
${d.last && !d.last.won ? "💀 Wiped. " : ""}Next pull in ${leader ? `` : `the leader can pull early`}
`; } else if (d.state === "done") { status = `
🏆 ${esc(spec.name)} cleared!
`; } else { status = `
Fighting: ${esc(spec.pulls[d.pull].name)}
`; } return `

${esc(spec.name)}

wipes ${d.wipes}
${status}
${pulls}
`; } function placeCard(here) { const me = S.me; const act = me.activity; const travelling = act && act.kind === "travel"; const hunting = act && act.kind === "hunt" ? act.hunt : null; const hunts = Object.entries(here.hunts || {}).map(([id, pulls]) => { const mob = C.mobs[id]; const lv = mob.lv[0] === mob.lv[1] ? mob.lv[0] : `${mob.lv[0]}-${mob.lv[1]}`; const others = [...new Set(pulls.flat())].filter((m) => m !== id).map((m) => C.mobs[m].name); const hot = Object.keys(me.quests).some((q) => C.quests[q].obj.some((o) => o.kill === id || (o.from || []).includes(id))); return `
${esc(mob.name)} lv ${lv}${others.length ? " · with " + esc(others.join(", ")) : ""} ${hot ? '!' : ""}
${hunting === id ? `` : ``}
`; }).join(""); const offers = me.available.map((q) => questOffer(q)).join(""); const turnIns = Object.keys(me.quests).filter((q) => (C.quests[q].ender || C.quests[q].giver) === me.place && questReady(q)) .map((q) => questTurnIn(q)).join(""); const dungeon = here.dungeon ? dungeonEntrance(here.dungeon) : ""; const junk = me.bags.filter((b) => b.q === "poor"); const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0); const vendor = here.hub ? `
🪙 Vendor
` : ""; const roads = Object.entries(here.links).map(([to, minutes]) => ``).join(""); const far = Object.entries(C.places).filter(([id]) => id !== me.place && !here.links[id]) .map(([id, p]) => ``).join(""); return `

${esc(here.name)}

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

Hunt

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

Turn in

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

Quests

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

Roads

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

Dungeon

🕳️ ${esc(spec.name)} lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses
${action}
`; } function questOffer(qid) { const q = C.quests[qid]; const tooHigh = q.lv > S.me.level + 3; return `
!${esc(q.name)}lv ${q.lv}
${esc(q.npc)}: “${esc(q.text)}”
${q.obj.map((o) => `
· ${objText(o)}
`).join("")}
${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}
`; } function questTurnIn(qid) { const q = C.quests[qid]; const choices = (q.choice || []).map((id, i) => { const it = C.items[id]; const on = pickReward[qid] === i; const fit = fitsMe(it); return `
${esc(it.name)}${itemLine(it)} ${fit ? "" : 'can\'t use'}
`; }).join(""); return `
?${esc(q.name)}
${q.xp} xp · ${money(q.copper)}
${choices ? `
${choices}
` : ""}
`; } function objText(o, have) { const need = o.n || 1; const count = have === undefined ? "" : ` ${have} / ${need}`; if (o.kill) return `Slay ${esc(C.mobs[o.kill].name)}${count || (need > 1 ? ` ×${need}` : "")}`; if (o.loot) return `${esc(o.loot)}${count || ` ×${need}`} from ${o.from.map((m) => esc(C.mobs[m].name)).join(", ")}`; if (o.visit) return `Go to ${esc(place(o.visit).name)}${count}`; return ""; } function questReady(qid) { const progress = S.me.quests[qid]; return C.quests[qid].obj.every((o, i) => progress[i] >= (o.n || 1)); } function logCard() { const lines = S.me.log.slice(-12).reverse(); if (!lines.length) return ""; return `

Lately

${lines.map((l) => `
${esc(l.text)} · ${ago(l.t)}
`).join("")}
`; } function ago(t) { const s = now() - t; if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } function devCard() { const users = ["fred", "kayla", "apollon", "tester"]; const current = (document.cookie.match(/wog_user=([^;]+)/) || [])[1] || "fred"; return `
Dev
`; } // --- quests ------------------------------------------------------------- function questsPage() { const me = S.me; const ids = Object.keys(me.quests); if (!ids.length) { return `

Quest log

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

${me.done.length} completed.

`; } return `

Quest log ${ids.length} / 12

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

${me.done.length} completed.

`; } // --- character ---------------------------------------------------------- function itemLine(it) { if (!it.slot) return it.value ? `sells ${money(it.value)}` : ""; const bits = []; if (it.dps) bits.push(`${it.dps} dps`); if (it.armor) bits.push(`${it.armor} armor`); if (it.sta) bits.push(`+${it.sta} sta`); if (it.power) bits.push(`+${it.power} power`); return `${esc(C.slot_names[it.slot])} · ${esc(it.kind)} · ${bits.join(", ")}`; } function fitsMe(it) { if (!it.slot) return false; const cls = C.classes[S.me.cls]; if (it.slot === "weapon") return cls.weapons.includes(it.kind); const rank = { cloth: 0, leather: 1, mail: 2 }; return rank[it.kind] <= rank[cls.armour]; } function compare(it) { const worn = S.me.equip[it.slot]; const diff = (key) => (it[key] || 0) - ((worn && worn[key]) || 0); const parts = ["sta", "power", "armor", "dps"].map((k) => { const d = Math.round(diff(k) * 10) / 10; if (!d) return ""; return `${d > 0 ? "+" : ""}${d} ${k}`; }).filter(Boolean); return parts.length ? `vs worn: ${parts.join(" ")}` : "same as worn"; } function characterPage() { const me = S.me; const st = me.stats; const spec = specOf(me.cls); const slots = C.slots.map((slot) => { const it = me.equip[slot]; return `
${esc(C.slot_names[slot])} ${it ? `${esc(it.name)} ${itemLine(it).split(" · ").slice(2).join(" · ")}` : '—'}
`; }).join(""); const bags = me.bags.map((it) => { const on = selected === it.id; const atHub = place(me.place).hub; return `
${esc(it.name)}${it.qty > 1 ? ` ×${it.qty}` : ""}
${itemLine(it)}${it.req > me.level ? ` · requires ${it.req}` : ""}
${on && it.slot ? `
${compare(it)}
` : ""} ${on ? `
${it.slot ? `` : ""}
` : ""}
`; }).join(""); return `

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

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

Abilities

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

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

Equipment

${slots}

Bags

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

Empty.

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

Your group

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

Group

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

${open.map((x) => `
${esc(x.leader)}'s group ${x.size} / 5
`).join("")}
`; } const people = S.players.map((p) => `
${p.online ? "🟢" : "⚪"}${esc(p.name)} ${p.level} ${esc(C.classes[p.cls].name)} · ${esc(place(p.place).name)}${p.doing === "hunt" ? ` · hunting ${esc(C.mobs[p.hunt].name)}` : p.doing === "dungeon" ? " · in a dungeon" : p.doing === "travel" ? " · travelling" : ""}
`).join(""); return `${mine}

Adventurers

${people}
`; } // --- chat --------------------------------------------------------------- function chatPage() { const names = Object.fromEntries(S.players.map((p) => [p.id, p])); const msgs = S.chat.slice().reverse().map((m) => { const p = names[m.who]; return `
${esc(p ? p.name : m.who)}${esc(m.text)} ${ago(m.t)}
`; }).join(""); const events = S.log.slice().reverse().map((l) => `
${esc(l.text)} · ${ago(l.t)}
`).join(""); return `

Guild chat

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

World

${events}
`; } // --- the fight ---------------------------------------------------------- function fightTime() { return S.fight ? now() - S.fight.at : 0; } function renderFight() { const box = $("#fightbox"); if (!box) return; const f = S.fight; box.style.display = page === "adventure" ? "" : "none"; if (!f) { if (box.dataset.id) { box.innerHTML = ""; box.dataset.id = ""; } return; } if (box.dataset.id !== f.id) { box.dataset.id = f.id; box.innerHTML = `

— vs —
`; seenEv = { id: f.id, t: Math.max(-1, ...f.ev.map((e) => e.t)) - 0.5 }; } $("#f-name").textContent = f.name; $("#f-state").textContent = f.state === "on" ? clock(f.t) : f.state === "won" ? "Victory" : "Defeat"; const me = myUnit(); const byId = Object.fromEntries(f.units.map((u) => [u.id, u])); for (const side of ["enemy", "party"]) { const holder = $(`#f-${side}`); const units = f.units.filter((u) => u.side === side); const keep = new Set(units.map((u) => u.id)); for (const el of [...holder.children]) if (!keep.has(el.dataset.u)) el.remove(); for (const u of units) { let el = holder.querySelector(`[data-u="${CSS.escape(u.id)}"]`); if (!el) { el = document.createElement("div"); el.className = "frame"; el.dataset.u = u.id; el.dataset.act = "target"; el.innerHTML = `
`; holder.appendChild(el); } el.classList.toggle("dead", !!u.dead); el.classList.toggle("target", !!me && me.target === u.id && side === "enemy"); el.classList.toggle("ftarget", !!me && me.ftarget === u.id && side === "party"); const html = frameHtml(u, byId, me); const body = el.querySelector(".body"); if (body.dataset.html !== html) { body.innerHTML = html; body.dataset.html = html; } } } $("#f-log").innerHTML = f.log.slice().reverse().map((l) => `
${esc(l.text)}
`).join(""); floats(f); } function frameHtml(u, byId, me) { const isPlayer = u.kind === "player"; const color = isPlayer ? clsColor(u.cls) : u.side === "enemy" ? "#f0b8a8" : "#cfe3c0"; const tags = [u.boss ? 'Boss' : u.elite ? 'Elite' : "", u.rare ? 'Rare' : ""].join(""); const low = u.hp / u.max_hp < 0.3; let html = `
${u.icon ? u.icon + " " : ""}${esc(u.name)}${u.level}${tags}
`; html += bar(u.hp, u.max_hp, low ? "low" : "", u.dead ? "dead" : `${u.hp} / ${u.max_hp}`); if (u.res && u.res.type) html += `
`; if (u.cast) { const target = byId[u.cast.target]; html += `
${u.cast.icon} ${esc(u.cast.name)}${target && target.id !== u.id ? " → " + esc(target.name) : ""}
`; } const auras = u.auras.map((a) => `${a.icon}`).join(""); html += `
${auras}${u.combo ? `${"🔸".repeat(u.combo)}` : ""}${u.stun ? "💫" : ""}${u.root ? "🧊" : ""}
`; if (u.side === "enemy" && !u.dead) { const on = byId[u.target]; const safe = on && (on.role === "tank" || on.kind === "pet"); const mine = me && (u.threat || []).find(([id]) => id === S.uid); html += `
${on ? `→ ${esc(on.name)}` : ""}${mine ? ` · your threat ${pct(mine[1])}` : ""}
`; } if (u.cc) html += `${u.cc}`; return html; } function floats(f) { for (const e of f.ev) { if (e.t <= seenEv.t) continue; seenEv.t = Math.max(seenEv.t, e.t); const frame = document.querySelector(`[data-u="${CSS.escape(e.u)}"] .floats`); if (!frame) continue; const unit = f.units.find((u) => u.id === e.u); let kind = e.k, text = String(e.n); if (kind === "heal") text = "+" + e.n; else if (kind === "interrupt") text = "Interrupted!"; else if (kind === "oom") text = "Not enough mana"; else if (kind === "absorb") text = "Absorbed"; else if (unit && unit.side === "party") { kind = "taken"; text = "-" + e.n; } if (kind === "crit") text += "!"; const el = document.createElement("div"); el.className = `float ${kind}`; el.textContent = text; el.style.left = `${35 + Math.random() * 30}%`; frame.appendChild(el); setTimeout(() => el.remove(), 1200); } } function renderBar() { const el = $("#bar"); const me = myUnit(); const show = !!(S.me && me && !me.dead && S.fight.state === "on"); el.classList.toggle("show", show); document.documentElement.style.setProperty("--bar-h", show ? "112px" : "0px"); if (!show) { barKey = ""; return; } const spec = specOf(S.me.cls); const key = spec.abilities.map((a) => `${a}:${me.abilities.includes(a)}:${S.me.manual.includes(a)}`).join("|"); if (key !== barKey) { barKey = key; el.innerHTML = `
${spec.abilities.map((id, i) => { const a = C.abilities[id]; const known = me.abilities.includes(id); const manual = S.me.manual.includes(id); return `
${a.icon}${known ? esc(a.name) : `level ${C.unlock[i]}`} ${known ? `${manual ? "YOU" : "AUTO"}` : ""}
`; }).join("")}
Tap a frame to target it · tap AUTO / YOU to switch
`; } animate(); } // Moves cast bars, cooldowns and travel along between polls. function animate() { if (!S) return; const t = fightTime(); for (const el of document.querySelectorAll(".bar.cast[data-start]")) { const start = Number(el.dataset.start), end = Number(el.dataset.end); el.firstElementChild.style.width = `${Math.min(1, Math.max(0, (t - start) / (end - start))) * 100}%`; } for (const el of document.querySelectorAll(".bar.road[data-from]")) { const from = Number(el.dataset.from), to = Number(el.dataset.to); el.firstElementChild.style.width = `${Math.min(1, (now() - from) / (to - from)) * 100}%`; } for (const el of document.querySelectorAll("[data-until]")) { el.textContent = clock(Number(el.dataset.until) - now()); } const me = myUnit(); if (!me || !$("#bar").classList.contains("show")) return; const cast = $("#mycast"); if (cast) { const html = me.cast ? `
${me.cast.icon} ${esc(me.cast.name)}
` : ""; if (cast.dataset.html !== html) { cast.innerHTML = html; cast.dataset.html = html; } } const gcdLeft = Math.max(0, (me.busy || 0) - t); for (const el of document.querySelectorAll(".ab[data-ab]")) { const id = el.dataset.ab; const a = C.abilities[id]; const ready = (me.cds || {})[id] || 0; const left = ready - t; let cd = el.querySelector(".cdm"); if (left > 0.05) { if (!cd) { cd = document.createElement("span"); cd.className = "cdm"; el.appendChild(cd); } cd.textContent = left >= 10 ? Math.ceil(left) : left.toFixed(1); } else if (cd) cd.remove(); const cost = a.cost ? (a.cost < 1 ? a.cost * me.res.max : a.cost) : 0; el.classList.toggle("nores", !!(cost && me.res.cur < cost)); el.classList.toggle("queued", me.queued === id); const gcd = el.querySelector(".gcd"); if (gcd) gcd.style.width = a.offgcd ? "0" : `${Math.min(1, gcdLeft / 1.5) * 100}%`; } } // --- input -------------------------------------------------------------- document.addEventListener("pointerdown", (ev) => { const mode = ev.target.closest("[data-mode]"); if (mode) { ev.preventDefault(); const id = mode.dataset.mode; api("manual", { id, on: !S.me.manual.includes(id) }); return; } const ab = ev.target.closest(".ab[data-ab]"); if (ab) { ev.preventDefault(); const id = ab.dataset.ab; if (ab.classList.contains("locked")) return; if (!S.me.manual.includes(id)) { toast("On auto. Tap AUTO to take it yourself."); return; } ab.classList.add("queued"); api("press", { id }); return; } const frame = ev.target.closest("[data-act=target]"); if (frame) { ev.preventDefault(); api("target", { id: frame.dataset.u }); } }); document.addEventListener("click", async (ev) => { const nav = ev.target.closest("[data-page]"); if (nav) { page = nav.dataset.page; remember("page", page); render(); window.scrollTo(0, 0); return; } const cls = ev.target.closest("[data-cls]"); if (cls) { remember("draftName", $("#name")?.value || ""); regCls = cls.dataset.cls; 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();