2026-10-23 22:52:42 +01:00
"use strict" ;
// World of Guildhall client. The server is the source of truth; between polls the
// client only animates bars and cooldowns forward from the last state it saw.
const POLL _MS = 3000 ;
const FIGHT _POLL _MS = 300 ; // fights are live: keep everyone's view close
const ROLE = { tank : "Tank" , healer : "Healer" , dps : "Damage" } ;
const RULES = {
always : ( r ) => ( r . save ? ` whenever it's ready (in a group: only above ${ pct ( r . save ) } mana) ` : "whenever it's ready" ) ,
dot : ( r ) => ( r . save ? ` keeps it on the target (in a group: only above ${ pct ( r . save ) } mana) ` : "keeps it on the target" ) ,
heal : ( r ) => ` someone under ${ pct ( r . pct ) } ` ,
hot : ( r ) => ` someone under ${ pct ( r . pct ) } without it ` ,
shield : ( r ) => ` whoever's being hit, or under ${ pct ( r . pct ) } ` ,
self _below : ( r ) => ` you're under ${ pct ( r . pct ) } ` ,
taunt : ( ) => "a mob hits someone else" ,
aoe : ( r ) => ( r . n > 1 ? ` ${ r . n } + mobs ` : "every fight" ) ,
interrupt : ( ) => "a mob is casting" ,
combo : ( r ) => ` ${ r . n } + combo points ` ,
cc : ( ) => "an extra mob joins" ,
peel : ( ) => "mobs are on you" ,
tanking : ( r ) => ` an elite hits you, or you're under ${ pct ( r . pct ) } ` ,
danger : ( r ) => ` hit while under ${ pct ( r . pct ) } ` ,
buff : ( ) => "keeps it up" ,
boss : ( ) => "on elites, rares and bosses" ,
} ;
let C = null ; // static content
let S = null ; // latest state
let skew = 0 ; // server clock minus client clock
let page = decodeURIComponent ( location . hash . slice ( 1 ) ) || remember ( "page" ) || "adventure" ;
let polledAt = 0 ;
let inflight = false ;
let failures = 0 ;
let selected = null ; // bag item
let regCls = null ;
let pickReward = { } ; // quest id -> reward index
let seenEv = { id : null , t : - 1 } ;
let barKey = "" ;
let seenChat = Number ( remember ( "seenChat" ) || 0 ) ;
const $ = ( sel ) => document . querySelector ( sel ) ;
function remember ( key , value ) {
try {
if ( value === undefined ) return localStorage . getItem ( "wog." + key ) ;
localStorage . setItem ( "wog." + key , value ) ;
} catch ( e ) { /* private mode */ }
return null ;
}
function esc ( text ) {
return String ( text ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } [ c ] ) ) ;
}
function pct ( x ) { return ` ${ Math . round ( x * 100 ) } % ` ; }
function now ( ) { return Date . now ( ) / 1000 + skew ; }
function money ( copper ) {
copper = Math . floor ( copper || 0 ) ;
const g = Math . floor ( copper / 10000 ) , s = Math . floor ( copper / 100 ) % 100 , c = copper % 100 ;
const parts = [ ] ;
if ( g ) parts . push ( ` <span class="g"> ${ g } g</span> ` ) ;
if ( s ) parts . push ( ` <span class="s"> ${ s } s</span> ` ) ;
if ( c || ! parts . length ) parts . push ( ` <span class="c"> ${ c } c</span> ` ) ;
return ` <span class="money"> ${ parts . join ( " " ) } </span> ` ;
}
function clock ( seconds ) {
seconds = Math . max ( 0 , Math . round ( seconds ) ) ;
if ( seconds < 60 ) return ` ${ seconds } s ` ;
const m = Math . floor ( seconds / 60 ) , s = seconds % 60 ;
if ( m < 60 ) return ` ${ m } : ${ String ( s ) . padStart ( 2 , "0" ) } ` ;
return ` ${ Math . floor ( m / 60 ) } h ${ m % 60 } m ` ;
}
function toast ( text , ok = false ) {
const el = $ ( "#toast" ) ;
el . textContent = text ;
el . className = "show" + ( ok ? " ok" : "" ) ;
clearTimeout ( toast . timer ) ;
toast . timer = setTimeout ( ( ) => ( el . className = "" ) , 2600 ) ;
}
function clsColor ( cls ) { return C . classes [ cls ] ? . color || "#ddd" ; }
function place ( id ) { return C . places [ id ] ; }
function specOf ( cls ) { return C . specs [ C . classes [ cls ] . spec ] ; }
// --- server -------------------------------------------------------------
async function refresh ( ) {
if ( inflight ) return ;
inflight = true ;
try {
const res = await fetch ( "/api/state" , { cache : "no-store" } ) ;
if ( ! res . ok ) throw new Error ( res . status ) ;
accept ( await res . json ( ) ) ;
failures = 0 ;
} catch ( e ) {
failures ++ ;
} finally {
inflight = false ;
polledAt = Date . now ( ) ;
}
}
async function api ( path , body = { } ) {
try {
const res = await fetch ( "/api/" + path , {
method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( body ) ,
} ) ;
const data = await res . json ( ) ;
if ( ! res . ok ) {
toast ( data . error || "Something went wrong" ) ;
return false ;
}
accept ( data ) ;
return true ;
} catch ( e ) {
toast ( "Can't reach the server" ) ;
return false ;
}
}
function accept ( state ) {
if ( S && S . version !== state . version ) {
location . reload ( ) ;
return ;
}
skew = state . now - Date . now ( ) / 1000 ;
S = state ;
render ( ) ;
}
// --- rendering ----------------------------------------------------------
function render ( ) {
if ( ! C || ! S ) return ;
renderTop ( ) ;
renderNav ( ) ;
renderMain ( ) ;
renderFight ( ) ;
renderBar ( ) ;
}
function myUnit ( ) {
return S . fight ? S . fight . units . find ( ( u ) => u . id === S . uid ) : null ;
}
function renderTop ( ) {
const me = S . me ;
if ( ! me ) {
$ ( "#top" ) . innerHTML = ` <div class="inner"><b>World of Guildhall</b></div> ` ;
return ;
}
const cls = C . classes [ me . cls ] ;
const unit = myUnit ( ) ;
const hp = unit ? unit . hp : me . hp , max = unit ? unit . max _hp : me . stats . max _hp ;
const res = unit ? unit . res : null ;
const resType = me . stats . res ;
const resPct = res ? res . cur / res . max : ( resType === "mana" ? me . mana : resType === "energy" ? 1 : 0 ) ;
const xp = me . xp _next ? me . xp / me . xp _next : 1 ;
$ ( "#top" ) . innerHTML = ` <div class="inner">
< div class = "row" >
< span class = "who" style = "color:${cls.color}" > $ { esc ( me . name ) } < / s p a n >
< span class = "muted small" > $ { me . level } $ { esc ( specOf ( me . cls ) . name ) } $ { esc ( cls . name ) } < / s p a n >
< span class = "right small" > $ { money ( me . copper ) } < / s p a n >
< / d i v >
< 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 } ` ) }
< / d i v >
< div class = "xpbar" title = "${me.xp} / ${me.xp_next} xp" > < i style = "width:${xp * 100}%" > < / i > < / d i v >
< / d i v > ` ;
}
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 ) {
2026-10-23 23:02:33 +01:00
// 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" ) || "" ;
}
2026-10-23 22:52:42 +01:00
return ;
}
if ( page === "chat" ) {
seenChat = Math . max ( seenChat , ... S . chat . map ( ( m ) => m . t ) , 0 ) ;
remember ( "seenChat" , seenChat ) ;
}
2026-10-23 23:02:33 +01:00
// 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 ;
2026-10-23 22:52:42 +01:00
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 ;
2026-10-23 23:02:33 +01:00
animate ( ) ;
2026-10-23 22:52:42 +01:00
}
}
// --- register -----------------------------------------------------------
function registerPage ( ) {
const pick = regCls ? C . classes [ regCls ] : null ;
const spec = pick ? specOf ( regCls ) : null ;
return ` <div class="card">
< h2 > Create your character < / h 2 >
< p class = "muted small" > You wake in Northshire Abbey . The Defias Brotherhood stirs in Westfall . < / p >
2026-10-23 23:02:33 +01:00
< div class = "row" > < input id = "name" placeholder = "Name" maxlength = "16" class = "grow" autocomplete = "off" autocapitalize = "words" > < / d i v >
2026-10-23 22:52:42 +01:00
< h3 > Class < / h 3 >
< div class = "classes" > $ { Object . entries ( C . classes ) . map ( ( [ id , c ] ) => `
< div class = "cls ${regCls === id ? " on " : " "}" data - cls = "${id}" >
< span class = "ic" > $ { c . icon } < / s p a n >
< b style = "color:${c.color}" > $ { esc ( c . name ) } < / b >
< span class = "small muted" > $ { esc ( specOf ( id ) . name ) } · $ { ROLE [ specOf ( id ) . role ] } < / s p a n >
< / d i v > ` ) . j o i n ( " " ) }
< / d i v >
$ { 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 ( "" ) } < / d i v > ` : " " }
< div class = "row" style = "margin-top:12px" > < button class = "btn primary" data - act = "register" $ { regCls ? "" : "disabled" } > Enter the world < / b u t t o n > < / d i v >
< / d i v > ` ;
}
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 } < / s p a n >
< div class = "grow" >
< b > $ { esc ( a . name ) } < / b > < s p a n c l a s s = " s m a l l m u t e d " > $ { k n o w n ? " " : ` l e v e l $ { l e v e l } ` } < / s p a n >
< div class = "tip" > $ { esc ( a . tip ) } < / d i v >
< div class = "tip" > $ { facts . join ( " · " ) } < / d i v >
< div class = "tip" > Auto : $ { RULES [ a . auto . r ] ( a . auto ) } < / d i v >
< / d i v >
$ { me ? ` <button class="btn small ${ manual ? "on" : "" } " data-act="manual" data-id=" ${ id } " data-on=" ${ manual ? 0 : 1 } "> ${ manual ? "You" : "Auto" } </button> ` : "" }
< / d i v > ` ;
}
// --- 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 ) {
2026-10-23 23:02:33 +01:00
return ` <div class="card"><b>💀 You died.</b> <span class="muted">Running back · <span data-until=" ${ me . dead _until } "></span></span></div> ` ;
2026-10-23 22:52:42 +01:00
}
if ( ! act ) return "" ;
if ( act . kind === "travel" ) {
return ` <div class="card">
2026-10-23 23:02:33 +01:00
< div class = "row" > < b > 🐎 On the road to $ { esc ( place ( act . to ) . name ) } < / b > < s p a n c l a s s = " r i g h t m u t e d s m a l l " d a t a - u n t i l = " $ { a c t . e n d } " > < / s p a n > < / d i v >
< div class = "bar road" style = "margin-top:6px" data - from = "${act.start}" data - to = "${act.end}" > < i > < / i > < s p a n > < / s p a n > < / d i v >
2026-10-23 22:52:42 +01:00
< div class = "small muted" style = "margin-top:4px" > $ { act . path . map ( ( p ) => esc ( place ( p ) . name ) ) . join ( " → " ) } < / d i v >
< / d i v > ` ;
}
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 >
2026-10-23 23:02:33 +01:00
< span class = "muted small" > $ { waiting ? ` resting · <span data-until=" ${ me . next _pull } "></span> ` : "" } < / s p a n >
2026-10-23 22:52:42 +01:00
< button class = "btn small right" data - act = "stop" > Stop < / b u t t o n > < / d i v >
< / d i v > ` ;
}
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 ( ", " ) : "" } < / s p a n > < / d i v > ` ;
} ) . join ( "" ) ;
let status = "" ;
if ( d . state === "rest" ) {
2026-10-23 23:02:33 +01:00
status = ` <div class="row"><span> ${ d . last && ! d . last . won ? "💀 Wiped. " : "" } Next pull in <b data-until=" ${ d . next } "></b></span>
2026-10-23 22:52:42 +01:00
$ { leader ? ` <button class="btn primary small right" data-act="pull">Pull now</button> ` : ` <span class="right small muted">the leader can pull early</span> ` } < / d i v > ` ;
} 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 ) } < / h 2 > < s p a n c l a s s = " m u t e d s m a l l " > w i p e s $ { d . w i p e s } < / s p a n >
< button class = "btn small danger right" data - act = "leave-dungeon" > Leave < / b u t t o n > < / d i v >
< div style = "margin:8px 0" > $ { status } < / d i v >
$ { pulls }
< / d i v > ` ;
}
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 > < s p a n c l a s s = " s m a l l m u t e d " > l v $ { l v } $ { o t h e r s . l e n g t h ? " · w i t h " + e s c ( o t h e r s . j o i n ( " , " ) ) : " " } < / s p a n >
$ { hot ? '<span class="qmark" title="Quest target">!</span>' : "" } < / d i v >
$ { 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> ` }
< / d i v > ` ;
} ) . 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 ) : "" } < / b u t t o n > < / d i v > ` : " " ;
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 ) } < / h 2 > < s p a n c l a s s = " m u t e d s m a l l " > $ { e s c ( h e r e . z o n e ) } · $ { h e r e . l v [ 0 ] } - $ { h e r e . l v [ 1 ] } < / s p a n > < / d i v >
$ { 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 < / h 3 >
< div class = "row wrap" > $ { roads } < / d i v >
< div class = "row" style = "margin-top:8px" > < select id = "far" class = "grow" $ { travelling ? "disabled" : "" } > < option value = "" > Travel further … < / o p t i o n > $ { f a r } < / s e l e c t >
< button class = "btn small" data - act = "travel-far" $ { travelling ? "disabled" : "" } > Go < / b u t t o n > < / d i v >
< / d i v > ` ;
}
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 > < s p a n c l a s s = " s m a l l m u t e d " > l v $ { s p e c . l v [ 0 ] } - $ { s p e c . l v [ 1 ] } · $ { s p e c . s i z e } p l a y e r s · $ { s p e c . p u l l s . f i l t e r ( ( p ) = > p . b o s s ) . l e n g t h } b o s s e s < / s p a n > < / d i v >
$ { action } < / d i v > ` ;
}
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" > ! < / s p a n > < b c l a s s = " g r o w " > $ { e s c ( q . n a m e ) } < / b > < s p a n c l a s s = " s m a l l $ { t o o H i g h ? " s t a t - d o w n " : " m u t e d " } " > l v $ { q . l v } < / s p a n > < / d i v >
< div class = "small muted" style = "margin:4px 0" > $ { esc ( q . npc ) } : “ $ { esc ( q . text ) } ” < / d i v >
$ { 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" : "" } < / s p a n >
< button class = "btn small primary right" data - act = "accept" data - id = "${qid}" > Accept < / b u t t o n > < / d i v >
< / d i v > ` ;
}
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 ) } < / s p a n > < s p a n c l a s s = " s m a l l m u t e d " > $ { i t e m L i n e ( i t ) } < / s p a n >
$ { fit ? "" : '<span class="small stat-down right">can\'t use</span>' } < / d i v > ` ;
} ) . join ( "" ) ;
return ` <div class="quest ready">
< div class = "row" > < span class = "qmark" > ? < / s p a n > < b c l a s s = " g r o w " > $ { e s c ( q . n a m e ) } < / b > < / d i v >
< div class = "small muted" > $ { q . xp } xp · $ { money ( q . copper ) } < / d i v >
$ { 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 < / b u t t o n > < / d i v >
< / d i v > ` ;
}
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 ( "" ) } < / d i v > < / d i v > ` ;
}
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 ( "" ) } < / s e l e c t >
< 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 < / b u t t o n >
< button class = "btn small" data - act = "dev-advance" data - m = "10" > + 10 m < / b u t t o n >
< button class = "btn small" data - act = "dev-advance" data - m = "60" > + 1 h < / b u t t o n >
< select id = "dev-place" > $ { Object . entries ( C . places ) . map ( ( [ id , p ] ) => ` <option value=" ${ id } " ${ id === S . me . place ? "selected" : "" } > ${ esc ( p . name ) } </option> ` ) . join ( "" ) } < / s e l e c t >
< button class = "btn small" data - act = "dev-teleport" > Teleport < / b u t t o n >
< / d i v > ` ;
}
// --- 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 > < / d i v > ` ;
}
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 > < s p a n c l a s s = " s m a l l m u t e d " > l v $ { q . l v } < / s p a n > < / d i v >
$ { 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 ) } ) ` } < / s p a n >
$ { 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 < / b u t t o n > < / d i v >
< / d i v > ` ;
} ) . join ( "" ) }
< / d i v > < p c l a s s = " s m a l l m u t e d " > $ { m e . d o n e . l e n g t h } c o m p l e t e d . < / p > < / d i v > ` ;
}
// --- 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>' } < / s p a n > < / d i v > ` ;
} ) . 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 ) } < / s p a n > $ { i t . q t y > 1 ? ` < s p a n c l a s s = " m u t e d " > × $ { i t . q t y } < / s p a n > ` : " " }
< div class = "tip" > $ { itemLine ( it ) } $ { it . req > me . level ? ` · <span class="stat-down">requires ${ it . req } </span> ` : "" } < / d i v >
$ { 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" } < / b u t t o n > < / d i v > ` : " " }
< / d i v > < / d i v > ` ;
} ) . 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 ] } < / s p a n > < / h 2 >
< div class = "statgrid" >
< div > < b > $ { st . max _hp } < / b > < s p a n > h e a l t h < / s p a n > < / d i v >
< div > < b > $ { st . power } < / b > < s p a n > p o w e r < / s p a n > < / d i v >
< div > < b > $ { st . armor } < / b > < s p a n > a r m o r < / s p a n > < / d i v >
< div > < b > $ { st . res _max } < / b > < s p a n > $ { s t . r e s } < / s p a n > < / d i v >
< div > < b > $ { st . dps } < / b > < s p a n > w e a p o n d p s < / s p a n > < / d i v >
< div > < b > $ { me . xp _next ? ` ${ me . xp } / ${ me . xp _next } ` : "max" } < / b > < s p a n > x p < / s p a n > < / d i v >
< / d i v >
< / d i v >
< div class = "card" > < h2 > Abilities < / h 2 >
< p class = "tip" > Abilities on < b > Auto < / b > c a s t t h e m s e l v e s w h e n t h e i r r u l e s a y s s o , o n a $ { C . a u t o _ c d } × c o o l d o w n . S e t t o < b > Y o u < / b > t o p r e s s t h e m y o u r s e l f o n t h e s h o r t e r c o o l d o w n . W h e n y o u l o o k a w a y f o r $ { C . w a t c h } s , y o u r s g o b a c k t o a u t o . < / p >
< div class = "list" > $ { spec . abilities . map ( ( a , i ) => abilityRow ( a , C . unlock [ i ] , me ) ) . join ( "" ) } < / d i v > < / d i v >
< div class = "card" > < h2 > Equipment < / h 2 > < d i v c l a s s = " l i s t " > $ { s l o t s } < / d i v > < / d i v >
< div class = "card" > < h2 > Bags < / h 2 > $ { b a g s ? ` < d i v c l a s s = " l i s t " > $ { b a g s } < / d i v > ` : ' < p c l a s s = " m u t e d " > E m p t y . < / p > ' } < / d i v > ` ;
}
// --- 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 . i d = = = g . l e a d e r ? " 👑 " : " " }
< 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 " , "" ) ) } ` : "" } < / s p a n >
< div style = "width:80px" > $ { bar ( m . hp , m . max _hp , "thin" ) } < / d i v > < / d i v > ` ) . j o i n ( " " ) }
< / d i v >
< 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 < / b u t t o n > < / d i v > < / d i v >
$ { 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 < / b u t t o n > < / d i v > ` ) . j o i n ( " " ) } < / d i v >
< div class = "row" style = "margin-top:8px" > < button class = "btn primary small" data - act = "create-group" > Start a group < / b u t t o n > < / d i v > < / d i v > ` ;
}
const people = S . players . map ( ( p ) => ` <div class="item-row" style="cursor:default">
< span > $ { p . online ? "🟢" : "⚪" } < / s p a n > < b s t y l e = " c o l o r : $ { c l s C o l o r ( p . c l s ) } " > $ { e s c ( p . n a m e ) } < / 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" : "" } < / s p a n > < / d i v > ` ) . j o i n ( " " ) ;
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 < / b u t t o n > < / d i v >
2026-10-23 23:02:33 +01:00
< div class = "chat" id = "chatlog" style = "margin-top:8px" > $ { msgs || '<span class="muted">Quiet in here.</span>' } < / d i v > < / d i v >
< div class = "card" > < h2 > World < / h 2 > < d i v c l a s s = " l o g " i d = " w o r l d l o g " s t y l e = " m a x - h e i g h t : n o n e ; f l e x - d i r e c t i o n : c o l u m n " > $ { e v e n t s } < / d i v > < / d i v > ` ;
2026-10-23 22:52:42 +01:00
}
// --- 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" > < / d i v > < d i v c l a s s = " v s " > — v s — < / d i v > < d i v c l a s s = " s i d e " i d = " f - p a r t y " > < / d i v >
< div class = "log" id = "f-log" > < / d i v > < / d i v > ` ;
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 > < s p a n > $ { u . c a s t . i c o n } $ { e s c ( u . c a s t . n a m e ) } $ { t a r g e t & & t a r g e t . i d ! = = u . i d ? " → " + e s c ( t a r g e t . n a m e ) : " " } < / s p a n > < / d i v > ` ;
}
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 } < / s p a n > < s p a n c l a s s = " l a b e l " > $ { k n o w n ? e s c ( a . n a m e ) : ` l e v e l $ { C . u n l o c k [ i ] } ` } < / s p a n >
$ { known ? ` <span class="mode" data-mode=" ${ id } "> ${ manual ? "YOU" : "AUTO" } </span> ` : "" }
< span class = "gcd" > < / s p a n > < / d i v > ` ;
} ) . join ( "" ) } < / d i v >
< div class = "bar-hint" > Tap a frame to target it · tap AUTO / YOU to switch < / d i v > < / d i v > ` ;
}
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 ;
2026-10-23 23:02:33 +01:00
document . activeElement ? . blur ( ) ;
2026-10-23 22:52:42 +01:00
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 } ) ) ;
}
} ) ;
2026-10-23 23:02:33 +01:00
document . addEventListener ( "input" , ( ev ) => {
if ( ev . target . id === "name" ) remember ( "draftName" , ev . target . value ) ;
} ) ;
2026-10-23 22:52:42 +01:00
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 ( ) ;