"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 openQuest = null; // quest offer shown in full
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]) =>
`${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("")}
` : ""}
Enter the world
`;
}
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 ? `
${manual ? "You" : "Auto"} ` : ""}
`;
}
// --- adventure ----------------------------------------------------------
function adventurePage() {
const me = S.me;
const act = me.activity;
const parts = [statusCard()];
if (!act || act.kind !== "dungeon") parts.push(zoneCard(zoneOf(me.place)));
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 "";
const waiting = !S.fight && me.next_pull > t ? `resting · ` : "";
if (act.kind === "journey") {
const road = C.roads[act.road];
const stops = act.to === road.zones[1] ? road.stops : road.stops.slice().reverse();
return `
🛤️ On the road to ${esc(C.zones[act.to].name)} ${waiting}
Turn back
${stops.map((stop, i) => `
${i < act.leg ? "✅" : i === act.leg ? "➡️" : "▫️"} ${esc(stop.name)}
`).join("")}
${act.leg >= stops.length ? "✅" : "▫️"} ${esc(C.places[C.zones[act.to].hub].name)}
`;
}
if (act.kind === "hunt") {
return `
🎯 Hunting ${esc(C.mobs[act.hunt].name)} ${esc(place(me.place).name)}
${waiting}
Stop
`;
}
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 ? `Pull now ` : `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}
Leave
${status}
${pulls}
`;
}
function zoneOf(placeId) { return C.places[placeId].zone; }
function zoneDungeon(zone) {
return Object.keys(C.dungeons).find((id) => zoneOf(C.dungeons[id].entrance) === zone);
}
function zoneCard(zone) {
const me = S.me;
const z = C.zones[zone];
const act = me.activity;
const away = act && act.kind === "journey";
const hunting = act && act.kind === "hunt" ? act.hunt : null;
const subzones = Object.entries(C.places).filter(([, p]) => p.zone === zone && p.hunts).map(([pid, p]) => {
const rows = Object.entries(p.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 ? `
Hunting `
: `
Hunt `}
`;
}).join("");
return `${esc(p.name)} · ${p.lv[0]}-${p.lv[1]}
${rows}
`;
}).join("");
// Nearest first; quests far above you wait until they're within reach.
const offers = me.available.filter((q) => C.quests[q].lv <= me.level + 5)
.sort((a, b) => C.quests[a].lv - C.quests[b].lv).map((q) => questOffer(q)).join("");
const turnIns = Object.keys(me.quests).filter((q) => zoneOf(C.quests[q].ender || C.quests[q].giver) === zone && questReady(q))
.map((q) => questTurnIn(q)).join("");
const dungeon = zoneDungeon(zone);
const junk = me.bags.filter((b) => b.q === "poor");
const junkValue = junk.reduce((sum, b) => sum + b.value * b.qty, 0);
const roads = Object.entries(C.roads).filter(([, r]) => r.zones.includes(zone)).map(([, r]) => {
const to = r.zones.find((x) => x !== zone);
return `
🛤️ ${esc(C.zones[to].name)} lv ${C.zones[to].lv[0]}-${C.zones[to].lv[1]} · ${esc(r.name)}, ${r.stops.length} fights
Set out
`;
}).join("");
const flights = me.zones.filter((x) => x !== zone).map((to) =>
`🦅 ${esc(C.zones[to].name)} ${money(C.flight_cost)} `).join("");
return `
${esc(z.name)} lv ${z.lv[0]}-${z.lv[1]}
🪙 Sell junk${junk.length ? " " + money(junkValue) : ""}
${turnIns ? `
Turn in ${turnIns}
` : ""}
${offers ? `
Quests ${offers}
` : ""}
${subzones ? `
Hunt ${subzones}` : ""}
${dungeon ? dungeonEntrance(dungeon) : ""}
${roads ? `
Roads ${roads}
` : ""}
${flights ? `
Flight master ${flights}
` : ""}
`;
}
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 = `The leader summons you `;
else action = `Summon & enter `;
return `Dungeon
🕳️ ${esc(spec.name)} ${esc(place(spec.entrance).name)} · lv ${spec.lv[0]}-${spec.lv[1]} · ${spec.size} players · ${spec.pulls.filter((p) => p.boss).length} bosses
The meeting stone summons your whole group, wherever they are.
${action}
`;
}
function questOffer(qid) {
const q = C.quests[qid];
const tooHigh = q.lv > S.me.level + 3;
const open = openQuest === qid;
return `
! ${esc(q.name)} lv ${q.lv}
${open ? "" : `Accept `}
${open ? `
${esc(q.npc)}, ${esc(place(q.giver).name)}: “${esc(q.text)}”
${q.obj.map((o) => `
· ${objText(o)}
`).join("")}
${q.xp} xp · ${money(q.copper)}${q.choice ? " · a reward of your choice" : ""}
Accept
` : ""}
`;
}
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}
` : ""}
Complete
`;
}
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 `Reach ${esc(C.zones[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
${users.map((u) => `${u} `).join("")}
Set level + gear
+10m
+1h
${Object.entries(C.places).map(([id, p]) => `${esc(p.name)} `).join("")}
Teleport
`;
}
// --- 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"} with ${esc(q.ender_npc || q.npc)}, ${esc(place(ender).name)}${zoneOf(ender) !== zoneOf(me.place) ? `, ${esc(C.zones[zoneOf(ender)].name)}` : ""}
Abandon
`;
}).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;
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 ? `${it.fit ? esc(it.fit) : "Equip"} ` : ""}
Sell ${money(it.value)}
` : ""}
`;
}).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("")}
Bags ${bags ? `
${bags}
` : '
Empty.
'}
`;
}
// --- group --------------------------------------------------------------
function groupPage() {
const g = S.group;
let mine;
if (g) {
const dungeon = zoneDungeon(zoneOf(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 && dungeon && g.leader === S.uid ? `Summon & enter ${esc(C.dungeons[dungeon].name)} ` : ""}
${!g.dungeon && !dungeon ? `The leader summons everyone from the meeting stone in the dungeon's zone ` : ""}
Leave group
${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. The leader summons everyone from the dungeon's meeting stone.
${open.map((x) => `
${esc(x.leader)}'s group ${x.size} / 5
Join
`).join("")}
Start a group
`;
}
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 === "journey" ? " · on the road" : ""}
`).join("");
return `${mine}`;
}
// --- 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 `
`;
}
// --- 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 = ``;
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 countdowns 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("[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 "journey": api("journey", { id }); break;
case "fly": api("fly", { id }); break;
case "accept": api("accept", { id }); break;
case "open-quest": openQuest = openQuest === id ? null : id; render(); 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();