2026-10-18 13:48:56 +01:00
"use strict" ;
// Guildhall client: renders the server's state and animates progress bars
// between polls. The server is the source of truth for everything.
const POLL _MS = 10000 ;
2026-10-21 22:06:49 +01:00
const FIGHT _POLL _MS = 2000 ; // in a fight someone may tag in: keep everyone's view of it close
2026-10-18 13:48:56 +01:00
const SUMMARY _MODAL _AT = 25 ;
let C = null ; // static content tables
let S = null ; // latest state
let skew = 0 ; // server clock minus client clock, seconds
let page = decodeURIComponent ( location . hash . slice ( 1 ) ) || remember ( "page" ) || "town" ;
let selected = null ; // bank item
2026-10-20 15:03:22 +01:00
let dollPick = null ; // equipment slot shown on the character sheet
2026-10-20 18:01:02 +01:00
let orderTab = "take" ; // work orders: for you, all open, yours
let draft = null ; // a work order being written
let seenOrders = null ; // order ids already known, so only new ones toast
let deliveredSeen = { } ; // how much of each order had arrived
2026-10-21 13:40:54 +01:00
let earnedSeen = { } ; // gold each order had paid you for reagents
2026-10-18 13:48:56 +01:00
let pickedClass = null ; // registration
2026-10-18 22:13:50 +01:00
const tabs = { } ; // skill -> selected recipe tab
2026-10-18 13:48:56 +01:00
let refreshing = false ;
2026-10-20 12:54:42 +01:00
let refreshedFor = null ; // action (start and length) a completion refresh was sent for
2026-10-21 22:06:49 +01:00
let polledAt = 0 ;
let tagging = false ; // this page just tagged into someone's fight
2026-10-24 14:21:08 +01:00
// What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with
// every press, so a tap and a press straight after it don't wait on each other.
let aim = { fight : null , foe : null , ally : null } ;
2026-10-20 12:54:42 +01:00
let retriedAt = 0 ; // last time a fight past its kill asked again for the next one
2026-10-18 14:31:41 +01:00
let version = null ; // server build this page was loaded from
let failures = 0 ; // polls in a row that didn't reach the server
2026-10-20 13:12:12 +01:00
let warned = null ; // threat the "at the gates" alarm was raised for
let watching = null ; // raid report open in the modal
2026-10-18 13:48:56 +01:00
const $ = ( sel , root = document ) => root . querySelector ( sel ) ;
2026-10-19 10:04:38 +01:00
// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material.
const glyph = ( name , tint ) => ` <svg class="gi" style="color: ${ tint } " aria-hidden="true"><use href="/static/icons.svg# ${ name } "></use></svg> ` ;
2026-10-24 13:34:59 +01:00
// A player's banner: field and trim colours, with their emblem on it.
const crest = ( look , h = 26 ) => look ? ` <svg class="crest" width=" ${ Math . round ( h * 0.8 ) } " height=" ${ h } " viewBox="0 0 30 38" aria-hidden="true">
< path d = "M2 2h26v24l-13 10L2 26z" fill = "${look.field}" stroke = "${look.trim}" stroke - width = "2.5" > < / p a t h >
$ { look . emblem ? ` <use href="/static/icons.svg# ${ look . emblem } " x="6" y="6" width="18" height="18" style="color: ${ look . trim } "></use> ` : "" } < / s v g > ` : " " ;
const titled = ( p ) => ` ${ crest ( p . look ) } <b> ${ esc ( p . name ) } </b> ${ p . look ? . title ? ` <span class="title"> ${ esc ( p . look . title ) } </span> ` : "" } ` ;
2026-10-21 09:23:19 +01:00
const COIN = glyph ( "two-coins" , "#e0b04a" ) , ANY _FOOD = glyph ( "knife-fork" , "#d9c6a0" ) , FAVOUR = glyph ( "sparkles" , "#b98ce8" ) , HEART = glyph ( "hearts" , "#e2645c" ) ;
2026-10-19 10:04:38 +01:00
function iconise ( content ) {
const entries = [ ... Object . values ( content . items ) , ... content . skills , ... Object . values ( content . classes ) ,
2026-10-24 10:01:41 +01:00
... Object . values ( content . buildings ) , ... content . prayers , ... content . actions ,
... Object . values ( content . abilities ) , ... Object . values ( content . enemy _abilities ) ] ;
2026-10-19 10:04:38 +01:00
for ( const entry of entries ) if ( entry . glyph ) entry . icon = glyph ( entry . glyph , entry . tint ) ;
return content ;
}
2026-10-18 13:48:56 +01:00
const esc = ( s ) => String ( s ? ? "" ) . replace ( /[&<>"']/g , ( c ) => ( { "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" } ) [ c ] ) ;
const fmt = ( n ) => Math . floor ( n ) . toLocaleString ( "en-GB" ) ;
const serverNow = ( ) => Date . now ( ) / 1000 + skew ;
const item = ( id ) => C . items [ id ] || { name : id , icon : "❔" , value : 0 } ;
const skillOf = ( id ) => C . skills . find ( ( s ) => s . id === id ) ;
const playerName = ( uid ) => S . players . find ( ( p ) => p . id === uid ) ? . name || uid ;
function remember ( key , value ) {
try {
if ( value === undefined ) return localStorage . getItem ( "gh." + key ) ;
localStorage . setItem ( "gh." + key , value ) ;
} catch { /* private mode: fine */ }
return null ;
}
// --- server ---------------------------------------------------------------
async function api ( path , body ) {
const options = body === undefined ? { } : {
method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( body ) ,
} ;
const res = await fetch ( path , options ) ;
const data = await res . json ( ) . catch ( ( ) => ( { error : "The server didn't answer properly" } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || res . statusText ) ;
return data ;
}
async function act ( path , body ) {
try {
accept ( await api ( path , body ) ) ;
} catch ( err ) {
toast ( err . message , "bad" ) ;
}
}
async function refresh ( ) {
if ( refreshing ) return ;
refreshing = true ;
2026-10-21 22:06:49 +01:00
polledAt = Date . now ( ) ;
2026-10-18 13:48:56 +01:00
try {
accept ( await api ( "/api/state" ) ) ;
2026-10-18 14:31:41 +01:00
failures = 0 ;
2026-10-18 13:48:56 +01:00
} catch ( err ) {
2026-10-18 14:31:41 +01:00
// A deploy restarts the server for a second or two; only speak up if
// the city stays out of reach.
if ( ++ failures === 3 ) toast ( "Lost contact with the city. Retrying…" , "bad" ) ;
2026-10-18 13:48:56 +01:00
} finally {
refreshing = false ;
}
}
function accept ( state ) {
2026-10-18 14:31:41 +01:00
version ? ? = state . version ;
2026-10-20 18:46:39 +01:00
if ( state . version !== version && ! document . querySelector ( "[data-keep]:focus" ) && $ ( "#modal" ) . hidden && ! draft ) {
2026-10-18 14:31:41 +01:00
location . reload ( ) ; // a new build was deployed
return ;
}
2026-10-21 22:06:49 +01:00
// Someone tagging into your fight (not you tagging into theirs) gets a toast.
const fightingWith = S ? . me && ! tagging ? S . me . action ? . with || [ ] : null ;
tagging = false ;
2026-10-18 13:48:56 +01:00
S = state ;
skew = state . now - Date . now ( ) / 1000 ;
2026-10-21 22:06:49 +01:00
for ( const uid of fightingWith ? state . me ? . action ? . with || [ ] : [ ] ) {
if ( ! fightingWith . includes ( uid ) ) toast ( ` ⚔️ ${ playerName ( uid ) } joined the fight ` , "good" ) ;
}
2026-10-18 13:48:56 +01:00
if ( state . summary ) showSummary ( state . summary ) ;
2026-10-20 18:01:02 +01:00
orderNews ( state ) ;
2026-10-20 13:12:12 +01:00
const ev = state . upcoming ;
if ( ev ? . at _gates && warned !== ev . id ) {
warned = ev . id ;
toast ( ev . kind === "dragon" ? "🐉 The dragon is overhead!" : "⚔️ Raiders at the gates!" , "bad" ) ;
}
2026-10-18 13:48:56 +01:00
render ( ) ;
2026-10-20 13:12:12 +01:00
showThreat ( ) ;
2026-10-18 13:48:56 +01:00
}
// --- time -----------------------------------------------------------------
function dur ( seconds ) {
if ( seconds > 0 && seconds < 10 ) return ` ${ + seconds . toFixed ( 1 ) } s ` ;
seconds = Math . max ( 0 , Math . round ( seconds ) ) ;
const d = Math . floor ( seconds / 86400 ) , h = Math . floor ( seconds % 86400 / 3600 ) ;
const m = Math . floor ( seconds % 3600 / 60 ) , s = seconds % 60 ;
if ( d ) return ` ${ d } d ${ h } h ` ;
if ( h ) return ` ${ h } h ${ m } m ` ;
if ( m ) return ` ${ m } m ${ s } s ` ;
return ` ${ s } s ` ;
}
function clock ( t ) {
return new Date ( ( t - skew ) * 1000 ) . toLocaleString ( "en-GB" , { weekday : "short" , hour : "2-digit" , minute : "2-digit" } ) ;
}
2026-10-18 14:31:41 +01:00
// Coarse on purpose: the markup stays the same between polls (see setHtml).
2026-10-18 13:48:56 +01:00
function ago ( t ) {
const s = serverNow ( ) - t ;
if ( s < 60 ) return "just now" ;
2026-10-18 14:31:41 +01:00
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 ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-18 14:31:41 +01:00
// A live "3h 12m" until `t`, filled in and kept current by animate().
const countdown = ( t ) => ` <span data-countdown=" ${ t } "></span> ` ;
2026-10-18 13:48:56 +01:00
// --- toasts & summaries ---------------------------------------------------
function toast ( text , kind = "" ) {
const el = document . createElement ( "div" ) ;
el . className = "toast " + kind ;
el . textContent = text ;
$ ( "#toasts" ) . append ( el ) ;
setTimeout ( ( ) => el . remove ( ) , kind === "level" ? 6000 : 3500 ) ;
2026-10-18 14:31:41 +01:00
const keep = matchMedia ( "(max-width: 900px)" ) . matches ? 2 : 4 ;
while ( $ ( "#toasts" ) . children . length > keep ) $ ( "#toasts" ) . firstChild . remove ( ) ;
2026-10-18 13:48:56 +01:00
}
function showSummary ( sum ) {
2026-10-21 12:16:22 +01:00
if ( ! sum . done && ! sum . stopped && ! sum . levels . length ) return ;
2026-10-18 13:48:56 +01:00
if ( sum . done >= SUMMARY _MODAL _AT ) return awayModal ( sum ) ;
2026-10-19 10:04:38 +01:00
const got = Object . entries ( sum . items ) . map ( ( [ id , q ] ) => ` + ${ fmt ( q ) } ${ item ( id ) . name } ` ) ;
2026-10-18 13:48:56 +01:00
if ( sum . gold ) got . push ( ` + ${ fmt ( sum . gold ) } gold ` ) ;
if ( got . length ) toast ( got . join ( " · " ) , "good" ) ;
2026-10-18 19:27:56 +01:00
const eaten = Object . entries ( sum . eaten || { } ) ;
if ( eaten . length ) toast ( ` Ate ${ eaten . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ item ( id ) . name . toLowerCase ( ) } ` ) . join ( ", " ) } ` ) ;
2026-10-18 13:48:56 +01:00
if ( sum . lucky ) toast ( sum . lucky === 1 ? "✨ Double yield!" : ` ✨ ${ sum . lucky } double yields! ` , "level" ) ;
const spilled = Object . entries ( sum . overflow ) ;
if ( spilled . length ) toast ( ` 📦 Bank full: ${ spilled . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ item ( id ) . name . toLowerCase ( ) } ` ) . join ( ", " ) } went to the city ` ) ;
2026-10-18 22:40:36 +01:00
const sold = Object . entries ( sum . sold || { } ) ;
if ( sold . length ) toast ( ` 📦 Bank full: sold ${ sold . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ item ( id ) . name . toLowerCase ( ) } ` ) . join ( ", " ) } for ${ fmt ( sum . sold _gold ) } gold ` ) ;
2026-10-18 13:48:56 +01:00
for ( const [ skill , level ] of sum . levels ) toast ( ` 🎉 ${ skillOf ( skill ) . name } level ${ level } ! ` , "level" ) ;
if ( sum . stopped ) toast ( ` Stopped: ${ sum . stopped } ` , "bad" ) ;
2026-10-18 23:12:57 +01:00
for ( const id of sum . potion _out || [ ] ) toast ( ` 🧪 Your ${ item ( id ) . name . toLowerCase ( ) } ran out ` , "bad" ) ;
for ( const id of Object . keys ( sum . potions _drunk || { } ) ) toast ( ` 🧪 Drank another ${ item ( id ) . name . toLowerCase ( ) } ` ) ;
2026-10-20 08:01:57 +01:00
if ( sum . prayer _out ) toast ( "🙏 Out of prayer points" , "bad" ) ;
if ( sum . second _wind ) toast ( "🌬️ Second Wind! Back to half HP" , "level" ) ;
2026-10-18 19:35:21 +01:00
if ( sum . resumed ) toast ( ` Back to ${ C . actions . find ( ( x ) => x . id === sum . resumed ) . name . toLowerCase ( ) } ` ) ;
2026-10-18 13:48:56 +01:00
}
function awayModal ( sum ) {
const items = Object . entries ( sum . items ) . map ( ( [ id , q ] ) => ` <li> ${ item ( id ) . icon } ${ fmt ( q ) } ${ esc ( item ( id ) . name ) } </li> ` ) . join ( "" ) ;
const xp = Object . entries ( sum . xp ) . map ( ( [ id , q ] ) => ` <li> ${ skillOf ( id ) . icon } ${ fmt ( q ) } ${ esc ( skillOf ( id ) . name ) } XP</li> ` ) . join ( "" ) ;
const levels = sum . levels . map ( ( [ id , l ] ) => ` <li>🎉 ${ esc ( skillOf ( id ) . name ) } reached level ${ l } </li> ` ) . join ( "" ) ;
const spilled = Object . entries ( sum . overflow ) . map ( ( [ id , q ] ) => ` <li> ${ item ( id ) . icon } ${ fmt ( q ) } ${ esc ( item ( id ) . name ) } </li> ` ) . join ( "" ) ;
2026-10-18 22:40:36 +01:00
const sold = Object . entries ( sum . sold || { } ) . map ( ( [ id , q ] ) => ` <li> ${ item ( id ) . icon } ${ fmt ( q ) } ${ esc ( item ( id ) . name ) } </li> ` ) . join ( "" ) ;
2026-10-18 13:48:56 +01:00
openModal ( `
< h2 > While you were away < / h 2 >
< p class = "muted" > You completed $ { fmt ( sum . done ) } actions$ { sum . wins || sum . losses ? ` ( ${ sum . wins } fights won, ${ sum . losses } lost) ` : "" } . < / p >
2026-10-21 22:06:49 +01:00
$ { Object . keys ( sum . with || { } ) . length ? ` <p class="muted small">⚔️ ${ Object . entries ( sum . with ) . map ( ( [ name , n ] ) => ` ${ fmt ( n ) } with ${ esc ( name ) } ` ) . join ( " · " ) } </p> ` : "" }
2026-10-18 19:27:56 +01:00
$ { Object . keys ( sum . eaten || { } ) . length ? ` <p class="muted small">Took ${ fmt ( sum . damage ) } damage and ate ${ Object . entries ( sum . eaten ) . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ esc ( item ( id ) . name . toLowerCase ( ) ) } ` ) . join ( ", " ) } .</p> ` : "" }
2026-10-24 09:49:21 +01:00
$ { Object . keys ( sum . used || { } ) . length ? ` <p class="muted small">Used ${ Object . entries ( sum . used ) . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ esc ( item ( id ) . name . toLowerCase ( ) ) } ` ) . join ( ", " ) } .</p> ` : "" }
2026-10-18 13:48:56 +01:00
$ { items ? ` <ul> ${ items } </ul> ` : "" }
$ { sum . gold ? ` <p class="gold">+ ${ fmt ( sum . gold ) } gold</p> ` : "" }
$ { xp ? ` <ul> ${ xp } </ul> ` : "" }
$ { levels ? ` <ul> ${ levels } </ul> ` : "" }
2026-10-20 08:01:57 +01:00
$ { sold ? ` <p class="muted small">Bank full, sold for ${ fmt ( sum . sold _gold ) } gold:</p><ul> ${ sold } </ul> ` : "" }
$ { spilled ? ` <p class="muted small">Bank full, sent to the stockpile:</p><ul> ${ spilled } </ul> ` : "" }
2026-10-18 13:48:56 +01:00
$ { sum . stopped ? ` <p class="tag bad">Stopped: ${ esc ( sum . stopped ) } </p> ` : "" }
< p > < button class = "btn primary" data - close > Back to work < / b u t t o n > < / p > ` ) ;
}
2026-10-20 13:12:12 +01:00
// A raid or dragon attack, played out for everyone once: the clash, then the report.
function showThreat ( ) {
const t = S . threat _report ;
if ( ! t || watching === t . id || ! $ ( "#modal" ) . hidden ) return ;
watching = t . id ;
const r = t . report , dragon = t . kind === "dragon" , won = r . title . startsWith ( "Victory" ) ;
const foe = dragon ? "The dragon" : "Raiders" ;
openModal ( `
< div class = "battle" >
< h2 > $ { dragon ? "🐉 The dragon attacks!" : "⚔️ The raiders attack!" } < / h 2 >
< div class = "battle-side" > < span > < b > Our defence < / b > < s p a n c l a s s = " m u t e d " > $ { f m t ( r . d e f e n c e ) } < / s p a n > < / s p a n >
< div class = "bar thick hp you" > < span data - battle = "us" style = "width:100%" > < / s p a n > < / d i v > < / d i v >
< div class = "battle-side" > < span > < b > $ { foe } < / b > < s p a n c l a s s = " m u t e d " > $ { f m t ( r . a t t a c k ) } < / s p a n > < / s p a n >
< div class = "bar thick hp" > < span data - battle = "them" style = "width:100%" > < / s p a n > < / d i v > < / d i v >
< ul class = "fight-log battle-log" data - battle = "log" > < li class = "muted" > $ { dragon ? "A shadow falls over the walls…" : "Horns sound from the walls…" } < / l i > < / u l >
< div data - battle = "report" hidden >
< div class = "battle-result ${won ? " win " : " loss "}" > $ { esc ( r . title ) } < / d i v >
$ { r . garrison . length ? ` <p class="small muted">Garrison: ${ r . garrison . map ( ( g ) => esc ( g . name ) ) . join ( ", " ) } </p> ` : "" }
$ { r . lines . map ( ( l ) => ` <p class="small"> ${ esc ( l ) } </p> ` ) . join ( "" ) }
< / d i v >
< p class = "btns" > < button class = "btn" data - battle - skip > Skip < / b u t t o n >
< button class = "btn primary" data - raid - seen = "${t.id}" hidden > Close < / b u t t o n > < / p >
< / d i v > ` ) ;
playBattle ( t . id , r . defence , r . attack , won , dragon ) ;
}
// Pure theatre: the outcome is already decided. The loser's bar runs out; the winner's ends
// as high as the gap between the two sides.
function playBattle ( id , us , them , won , dragon ) {
const steps = 10 , every = 900 ;
let seed = id * 9301 + 49297 ;
const rand = ( ) => ( seed = ( seed * 9301 + 49297 ) % 233280 ) / 233280 ;
const weights = Array . from ( { length : steps } , ( ) => 0.5 + rand ( ) ) ;
const total = weights . reduce ( ( a , b ) => a + b , 0 ) ;
const loserEnd = 0 , winnerEnd = Math . max ( 0.1 , 1 - Math . min ( us , them ) / Math . max ( us , them , 1 ) ) ;
const ours = won ? winnerEnd : loserEnd , theirs = won ? loserEnd : winnerEnd ;
const lines = {
good : dragon ? [ "Arrows find the dragon's wing" , "The ballista bolt strikes home" , "Spears drive it back from the walls" ]
: [ "Arrows rain down on the raiders" , "The walls hold" , "The militia drives them back" , "A charge breaks their line" ] ,
bad : dragon ? [ "Fire sweeps the rooftops" , "Its claws tear at the gate" , "The defenders scatter from its breath" ]
: [ "Raiders scale the walls" , "The gate shudders under the ram" , "A volley cuts down defenders" ] ,
} ;
let step = 0 , done = 0 , said = "" ;
const finish = ( ) => {
clearInterval ( timer ) ;
const box = $ ( ".battle" ) ;
if ( ! box ) return ;
$ ( '[data-battle="us"]' , box ) . style . width = ` ${ ours * 100 } % ` ;
$ ( '[data-battle="them"]' , box ) . style . width = ` ${ theirs * 100 } % ` ;
$ ( '[data-battle="report"]' , box ) . hidden = false ;
$ ( "[data-battle-skip]" , box ) . hidden = true ;
$ ( "[data-raid-seen]" , box ) . hidden = false ;
} ;
const timer = setInterval ( ( ) => {
const box = $ ( ".battle" ) ;
if ( ! box ) return clearInterval ( timer ) ;
if ( step >= steps ) return finish ( ) ;
done += weights [ step ] / total ;
step ++ ;
$ ( '[data-battle="us"]' , box ) . style . width = ` ${ ( 1 - ( 1 - ours ) * done ) * 100 } % ` ;
$ ( '[data-battle="them"]' , box ) . style . width = ` ${ ( 1 - ( 1 - theirs ) * done ) * 100 } % ` ;
// Each clash goes the way of the side that ends up ahead, now and then the other.
const ahead = rand ( ) < 0.7 ? won : ! won ;
const pool = ( ahead ? lines . good : lines . bad ) . filter ( ( l ) => l !== said ) ;
const log = $ ( '[data-battle="log"]' , box ) ;
said = pool [ Math . floor ( rand ( ) * pool . length ) ] ;
log . insertAdjacentHTML ( "afterbegin" , ` <li class=" ${ ahead ? "good" : "bad" } "> ${ said } </li> ` ) ;
while ( log . children . length > 4 ) log . lastElementChild . remove ( ) ;
} , every ) ;
playBattle . finish = finish ;
}
2026-10-18 13:48:56 +01:00
function openModal ( html ) {
const modal = $ ( "#modal" ) ;
modal . innerHTML = ` <div class="modal-box"> ${ html } </div> ` ;
modal . hidden = false ;
}
// --- rendering ------------------------------------------------------------
function render ( ) {
const kept = { } ;
for ( const el of document . querySelectorAll ( "[data-keep]" ) ) kept [ el . dataset . keep ] = el . value ;
const focus = document . activeElement ? . dataset ? . keep ;
2026-10-21 15:36:16 +01:00
if ( page === "notes" ) remember ( "notes" , notesKey ( ) ) ;
2026-10-21 14:57:45 +01:00
setHtml ( $ ( "#side" ) , S . me ? sidebar ( ) : ` <div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>beta</small></div> ${ S . dev ? devbar ( ) : "" } ` ) ;
2026-10-18 14:31:41 +01:00
setHtml ( $ ( "#top" ) , topbar ( ) ) ;
2026-10-21 12:29:40 +01:00
// The fires keep burning through a re-render: put the old canvases back in place of the new ones.
const burning = [ ... document . querySelectorAll ( "canvas[data-flame]" ) ] ;
2026-10-18 14:31:41 +01:00
setHtml ( $ ( "#main" ) , S . me ? pageHtml ( ) : register ( ) ) ;
2026-10-21 12:29:40 +01:00
for ( const old of burning ) {
const fresh = document . querySelector ( ` canvas[data-flame=" ${ old . dataset . flame } "] ` ) ;
if ( fresh && fresh !== old ) fresh . replaceWith ( old ) ;
}
fireTextAt = 0 ; // and fill in their bars and times straight away
2026-10-18 13:48:56 +01:00
for ( const [ key , value ] of Object . entries ( kept ) ) {
const el = document . querySelector ( ` [data-keep=" ${ key } "] ` ) ;
if ( el ) el . value = value ;
}
if ( focus ) document . querySelector ( ` [data-keep=" ${ focus } "] ` ) ? . focus ( ) ;
const msgs = $ ( ".msgs" ) ;
if ( msgs ) msgs . scrollTop = msgs . scrollHeight ;
animate ( ) ;
}
2026-10-18 14:31:41 +01:00
// Only touch the DOM when the markup changed. Replacing an element mid-swipe
// cancels the swipe on iOS, and polls arrive every few seconds.
function setHtml ( el , html ) {
if ( el . dataset . html === html ) return ;
const scroll = el . scrollTop ;
el . innerHTML = html ;
el . dataset . html = html ;
el . scrollTop = scroll ;
}
function setMenu ( open ) {
$ ( "#app" ) . classList . toggle ( "open" , open ) ;
document . documentElement . classList . toggle ( "menu-open" , open ) ;
}
2026-10-19 11:23:26 +01:00
function nav ( id , icon , label , meta = "" , extra = "" , tip = "" ) {
return ` <button class="nav ${ page === id ? "on" : "" } " data-nav=" ${ id } " ${ tip ? ` title=" ${ esc ( tip ) } " ` : "" } ><span class="ic"> ${ icon } </span><span class="label"> ${ label } </span> ${ extra } <span class="meta"> ${ meta } </span></button> ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-20 20:06:16 +01:00
function skillNav ( skill , me , doing ) {
const busy = doing ? . skill === skill . id ? ` <span class="dot"></span> ` : "" ;
2026-10-21 12:16:22 +01:00
const fire = fireOfSkill ( skill . id ) , heat = fire ? fireNow ( fire ) . heat : - 1 ;
const flame = heat >= 0 ? ` <span class="fdot" style="--c: ${ flameTint ( heat ) } "></span> ` : "" ;
const low = skill . id === "firekeeping" && Object . keys ( C . fires ) . some ( ( id ) => fireNow ( id ) . low ) ? ` <span class="alert"></span> ` : "" ;
const tip = heat >= 0 ? ` ${ C . fires [ fire ] . name } : ${ C . flames [ heat ] [ 0 ] } ` : low ? "A fire is low" : "" ;
return nav ( "skill:" + skill . id , skill . icon , skill . name , ` ${ me . levels [ skill . id ] } ` , flame + busy + low , tip ) ;
2026-10-20 20:06:16 +01:00
}
function pinnedNav ( me , doing ) {
const pinned = C . skills . filter ( ( s ) => me . pinned . includes ( s . id ) && ( ! s . class || s . class === me . cls ) ) ;
return pinned . length ? ` <div class="navhead">📌 Pinned</div> ${ pinned . map ( ( s ) => skillNav ( s , me , doing ) ) . join ( "" ) } ` : "" ;
}
2026-10-18 13:48:56 +01:00
function sidebar ( ) {
const me = S . me , cls = C . classes [ me . cls ] ;
const doing = me . action && C . actions . find ( ( a ) => a . id === me . action . id ) ;
const raid = S . upcoming ? . kind === "raid" ;
2026-10-20 18:01:02 +01:00
const takeable = ( S . orders || [ ] ) . filter ( canTake ) . length ;
2026-10-18 13:48:56 +01:00
let html = `
2026-10-21 14:57:45 +01:00
< div class = "brand" > < img src = "/static/icon.svg" alt = "" > Guildhall < small > beta < / s m a l l > < / d i v >
2026-10-18 13:48:56 +01:00
< div class = "me" >
< div class = "who" > $ { esc ( me . name ) } < / d i v >
2026-10-19 23:32:07 +01:00
< div class = "cls" > $ { cls . icon } $ { cls . name } · $ { HEART } < span data - live - hp > $ { fmt ( me . hp ) } < /span>/ $ { me . max _hp } < / d i v >
2026-10-20 08:01:57 +01:00
< div class = "purse" > < span class = "gold" title = "Gold" > $ { COIN } $ { fmt ( me . gold ) } < / s p a n > < s p a n c l a s s = " f a v o u r " t i t l e = " F a v o u r : e a r n e d b y d o n a t i n g " > $ { F A V O U R } $ { f m t ( m e . f a v o u r ) } < / s p a n > < / d i v >
2026-10-18 13:48:56 +01:00
< / d i v >
2026-10-20 20:06:16 +01:00
$ { pinnedNav ( me , doing ) }
2026-10-18 13:48:56 +01:00
< div class = "navhead" > City < / d i v >
2026-10-19 11:23:26 +01:00
$ { nav ( "town" , "🏘️" , "Town square" , ` ${ S . city . population } ` , "" , ` Population ${ S . city . population } ` ) }
$ { nav ( "council" , "📜" , "Council & threats" , "" , raid ? ` <span class="alert"></span> ` : "" , raid ? "A threat is coming" : "" ) }
2026-10-20 18:01:02 +01:00
$ { nav ( "orders" , ORDER _ICON ( ) , "Work orders" , "" , takeable ? ` <span class="badge"> ${ takeable } </span> ` : "" , takeable ? ` ${ takeable } for you ` : "" ) }
2026-10-18 13:48:56 +01:00
$ { nav ( "board" , "💬" , "Town board" ) }
2026-10-19 11:23:26 +01:00
$ { nav ( "roster" , "👥" , "Guild roster" , ` ${ S . players . length } ` , "" , ` ${ S . players . length } ${ S . players . length === 1 ? "adventurer" : "adventurers" } ` ) }
2026-10-21 15:36:16 +01:00
$ { nav ( "notes" , "📰" , "Patch notes" , "" , notesUnread ( ) ? ` <span class="badge">new</span> ` : "" , notesUnread ( ) ? ` What's new: ${ C . patch _notes [ 0 ] . title } ` : "" ) }
2026-10-18 13:48:56 +01:00
< div class = "navhead" > You < / d i v >
2026-10-19 11:23:26 +01:00
$ { nav ( "character" , cls . icon , "Character" , ` ${ skillOf ( "combat" ) . icon } ${ me . levels . combat } ` , "" , ` Combat level ${ me . levels . combat } ` ) }
$ { nav ( "bank" , "🏦" , "Bank" , ` ${ me . slots _used } / ${ me . slots } ` , "" , ` ${ me . slots _used } of ${ me . slots } bank slots used ` ) } ` ;
2026-10-18 13:48:56 +01:00
let group = null ;
for ( const skill of C . skills ) {
if ( skill . class && skill . class !== me . cls ) continue ;
if ( skill . group !== group ) {
group = skill . group ;
html += ` <div class="navhead"> ${ group } </div> ` ;
}
2026-10-20 20:06:16 +01:00
html += skillNav ( skill , me , doing ) ;
2026-10-18 13:48:56 +01:00
}
2026-10-19 10:04:38 +01:00
return html + ( S . dev ? devbar ( ) : "" )
+ ` <p class="credit">Icons by Lorc, Delapouite and others at <a href="https://game-icons.net" target="_blank" rel="noopener">game-icons.net</a>, CC BY 3.0</p> ` ;
2026-10-18 13:48:56 +01:00
}
function devbar ( ) {
return ` <div class="devbar"><b>Dev mode</b> · playing as ${ esc ( S . uid ) }
< form data - form = "dev-user" > < input type = "text" data - keep = "dev-user" placeholder = "switch user" > < button class = "btn sm" > Go < / b u t t o n > < / f o r m >
< div class = "row" >
< button class = "btn sm" data - dev - advance = "1" > + 1 h < / b u t t o n >
< button class = "btn sm" data - dev - advance = "8" > + 8 h < / b u t t o n >
< button class = "btn sm" data - dev - advance = "24" > + 1 d < / b u t t o n >
2026-10-19 09:34:18 +01:00
< button class = "btn sm" data - dev - event > Threat now < / b u t t o n >
< button class = "btn sm" data - dev - happen = "fortune" > Fortune < / b u t t o n >
< button class = "btn sm" data - dev - happen = "hardship" > Hardship < / b u t t o n >
< button class = "btn sm" data - dev - happen = "minor" > Problem < / b u t t o n >
2026-10-18 13:48:56 +01:00
< / d i v > < / d i v > ` ;
}
2026-10-21 15:36:16 +01:00
const TITLES = { town : "Town square" , council : "Council & threats" , orders : "Work orders" , board : "Town board" , roster : "Guild roster" , notes : "Patch notes" , bank : "Bank" , character : "Character" } ;
2026-10-18 13:48:56 +01:00
function topbar ( ) {
let title = TITLES [ page ] || "Guildhall" ;
if ( page . startsWith ( "skill:" ) ) title = skillOf ( page . slice ( 6 ) ) ? . name || title ;
2026-10-18 19:35:21 +01:00
if ( page . startsWith ( "character:" ) ) title = S . players . find ( ( p ) => p . id === page . slice ( 10 ) ) ? . name || "Character" ;
2026-10-18 13:48:56 +01:00
if ( ! S . me ) title = "Welcome to Guildhall" ;
let chips = "" ;
if ( S . me ? . action ) {
2026-10-19 23:17:23 +01:00
const action = C . actions . find ( ( a ) => a . id === ( S . me . action . growing || S . me . action . id ) ) ;
2026-10-20 15:30:05 +01:00
chips += ` <button class="chip act" data-nav="skill: ${ action . skill } "> ${ skillOf ( action . skill ) . icon } <span class="label"> ${ esc ( action . name ) } </span><span class="bar mini ${ S . me . action . fight ? "foe" : "" } "><span data-progress></span></span></button> ` ;
2026-10-18 13:48:56 +01:00
} else if ( S . me ) {
chips += ` <span class="chip muted">Idle ${ S . me . stopped ? ` · ${ esc ( S . me . stopped ) } ` : "" } </span> ` ;
}
2026-10-21 12:16:22 +01:00
const fires = S . me ? Object . keys ( C . fires ) . map ( ( id ) => [ id , fireNow ( id ) ] ) : [ ] ;
if ( fires . some ( ( [ , n ] ) => n . heat >= 0 ) ) {
const tip = fires . map ( ( [ id , n ] ) => ` ${ C . fires [ id ] . name } : ${ n . heat >= 0 ? ` ${ C . flames [ n . heat ] [ 0 ] } , ${ dur ( n . left / n . speed ) } ` : "out" } ` ) . join ( "\n" ) ;
chips += ` <button class="chip fire-pips" data-nav="skill:firekeeping" title=" ${ esc ( tip ) } "> ${ fires . map ( ( [ , n ] ) =>
` <i class="pip ${ n . heat >= 0 ? "lit" : "" } ${ n . low ? "low" : "" } " style="--c: ${ flameTint ( n . heat ) } "></i> ` ) . join ( "" ) } < / b u t t o n > ` ;
}
2026-10-19 09:34:18 +01:00
const w = S . happenings ? . weather ;
2026-10-19 11:23:26 +01:00
if ( w ) chips += ` <button class="chip" data-nav="town" title=" ${ esc ( weatherTip ( w ) ) } "> ${ w . icon } </button> ` ;
2026-10-20 13:12:12 +01:00
if ( S . upcoming ) chips += ` <button class="chip ${ [ "raid" , "dragon" ] . includes ( S . upcoming . kind ) ? "threat" : "" } ${ S . upcoming . at _gates ? "alarm" : "" } " data-nav="council"> ${ eventIcon ( S . upcoming . kind ) } ${ S . upcoming . at _gates ? ` <span class="label"> ${ S . upcoming . kind === "dragon" ? "Overhead" : "At the gates" } </span> ` : "" } ${ eventWhen ( S . upcoming , true ) } </button> ` ;
2026-10-20 21:13:46 +01:00
return ` <button class="menu-btn" data-menu aria-label="Menu">☰</button><h1> ${ esc ( title ) } </h1> ${ chips } ${ S . me ? trackedBar ( ) : "" } ` ;
}
// The recipe you're after, under the top bar on every page: have / need for each input.
function trackedBar ( ) {
const me = S . me , a = me . tracked && C . actions . find ( ( x ) => x . id === me . tracked ) ;
if ( ! a ) return "" ;
2026-10-21 12:16:22 +01:00
const ready = Object . entries ( me . fuelled ? . [ a . id ] || a . inputs ) . every ( ( [ id , q ] ) => ( me . bank [ id ] || 0 ) >= q ) ;
2026-10-20 21:13:46 +01:00
const go = ! me . blocked [ a . id ] && me . action ? . id !== a . id ;
return ` <div class="tracked ${ ready ? "ready" : "" } ">
< button class = "tracked-name" data - nav = "skill:${a.skill}" data - nav - group = "${esc(a.group || " ")}" title = "${esc(a.name)}" > $ { a . icon } < span class = "label" > $ { esc ( a . name ) } < / s p a n > < / b u t t o n >
2026-10-21 12:16:22 +01:00
< span class = "io" > $ { inputChips ( a ) } < / s p a n >
2026-10-20 21:13:46 +01:00
$ { go ? ` <button class="btn sm primary" data-start=" ${ a . id } ">Start</button> ` : "" }
< button class = "pin on" data - track = "${a.id}" title = "Untrack" > 📌 < / b u t t o n >
< / d i v > ` ;
2026-10-18 13:48:56 +01:00
}
function eventIcon ( kind ) {
2026-10-19 09:34:18 +01:00
return { raid : "⚔️" , dragon : "🐉" , merchant : "🐪" , refugees : "🧳" } [ kind ] || "❗" ;
2026-10-18 13:48:56 +01:00
}
function eventWhen ( ev , short = false ) {
2026-10-18 14:31:41 +01:00
if ( ev . exact ) return short ? ` in ${ countdown ( ev . exact ) } ` : ` ${ clock ( ev . exact ) } (in ${ countdown ( ev . exact ) } ) ` ;
if ( short ) return ev . earliest <= serverNow ( ) + 60 ? ` within ${ countdown ( ev . latest ) } ` : ` in ${ countdown ( ev . earliest ) } – ${ countdown ( ev . latest ) } ` ;
2026-10-18 13:48:56 +01:00
return ` between ${ clock ( ev . earliest ) } and ${ clock ( ev . latest ) } ` ;
}
function pageHtml ( ) {
2026-10-19 22:45:32 +01:00
if ( S . me . repick ) return repickPage ( ) ;
2026-10-18 13:48:56 +01:00
if ( page . startsWith ( "skill:" ) ) {
const id = page . slice ( 6 ) ;
const skill = skillOf ( id ) ;
if ( skill && ( ! skill . class || skill . class === S . me . cls ) ) return skillPage ( id ) ;
page = "town" ;
}
2026-10-18 19:35:21 +01:00
if ( page . startsWith ( "character:" ) ) return characterSheet ( page . slice ( 10 ) ) ;
if ( page === "character" ) return characterSheet ( S . uid ) ;
2026-10-21 15:36:16 +01:00
return ( { town , council , orders , board , roster , notes , bank } [ page ] || town ) ( ) ;
2026-10-18 13:48:56 +01:00
}
2026-10-20 19:49:30 +01:00
// Recipe input as "have / need"; without `have`, just the quantity.
2026-10-18 13:48:56 +01:00
function itemChip ( id , qty , have ) {
const short = have !== undefined && have < qty ;
2026-10-20 19:49:30 +01:00
return ` <span class="item-chip ${ short ? "short" : "" } " title=" ${ esc ( [ item ( id ) . name , itemStats ( id ) ] . filter ( Boolean ) . join ( ": " ) ) } "> ${ item ( id ) . icon } ${ have !== undefined ? ` ${ fmt ( have ) } <span class="have">/ ${ fmt ( qty ) } </span> ` : fmt ( qty ) } </span> ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-21 12:16:22 +01:00
// A recipe's inputs as "have / need", with the fuel a lit fire pays struck through.
function inputChips ( a ) {
const me = S . me , ins = me . fuelled ? . [ a . id ] || a . inputs ;
return Object . entries ( a . inputs ) . map ( ( [ id , q ] ) => {
const need = ins [ id ] || 0 , have = me . bank [ id ] || 0 ;
if ( need === q ) return itemChip ( id , q , have ) ;
const tip = ` title=" ${ esc ( ` ${ item ( id ) . name } : the ${ C . fires [ a . fire ] . name . toLowerCase ( ) } pays ${ q - need } ` ) } " ` ;
return need ? ` <span class="item-chip ${ have < need ? "short" : "" } " ${ tip } > ${ item ( id ) . icon } ${ fmt ( have ) } <span class="have">/ <s> ${ q } </s> ${ need } </span></span> `
: ` <span class="item-chip paid" ${ tip } > ${ item ( id ) . icon } <s> ${ q } </s></span> ` ;
} ) . join ( "" ) ;
}
2026-10-18 14:44:48 +01:00
// Progress towards a building cost: "have / need".
function costChip ( icon , name , need , have ) {
2026-10-19 11:23:26 +01:00
return ` <span class="item-chip ${ have < need ? "short" : "" } " title=" ${ esc ( ` ${ name } : ${ have < need ? ` ${ fmt ( need - have ) } more needed ` : "done" } ` ) } "> ${ icon } ${ fmt ( have ) } <span class="have">/ ${ fmt ( need ) } </span></span> ` ;
2026-10-18 14:44:48 +01:00
}
2026-10-23 22:13:32 +01:00
// What the city still needs of each item from building funds, fittings and happenings, after the stockpile.
// Recomputed once per state.
let needsState = null , needsCache = { } ;
function fundNeeds ( ) {
if ( needsState === S ) return needsCache ;
const city = S . city , missing = { } , why = { } ;
const add = ( id , short , line ) => { missing [ id ] = ( missing [ id ] || 0 ) + short ; ( why [ id ] || = [ ] ) . push ( line ) ; } ;
for ( const [ key , b ] of Object . entries ( city . buildings ) ) {
const spec = C . buildings [ key ] ;
if ( b . status === "funding" ) for ( const [ id , need ] of Object . entries ( b . target ) ) {
const short = b . missing [ id ] || 0 ;
if ( id !== "gold" && short ) add ( id , short , ` ${ spec . name } · ${ fmt ( need - short ) } / ${ fmt ( need ) } ` ) ;
}
for ( const f of C . fittings ) {
if ( f . building === key && b . level >= f . level && ! b . fittings . includes ( f . id ) ) add ( f . id , 1 , ` ${ spec . name } · 0 / 1 ` ) ;
}
}
for ( const p of S . happenings . problems ) for ( const [ id , need ] of Object . entries ( p . need || { } ) ) {
const short = p . missing [ id ] || 0 ;
if ( short ) add ( id , short , ` ${ p . name } · ${ fmt ( need - short ) } / ${ fmt ( need ) } ` ) ;
}
needsCache = { } ;
for ( const [ id , short ] of Object . entries ( missing ) ) {
const stock = city . stockpile [ id ] || 0 ;
if ( short > stock ) needsCache [ id ] = { qty : short - stock , why : stock ? [ ... why [ id ] , ` Stockpile ${ fmt ( stock ) } ` ] : why [ id ] } ;
}
needsState = S ;
return needsCache ;
}
// How many of an item the city still needs, with a have / need line per reason, or null.
function cityNeed ( id ) {
const it = item ( id ) , fund = fundNeeds ( ) [ id ] ;
let qty = fund ? . qty || 0 ;
const why = [ ... ( fund ? . why || [ ] ) ] ;
for ( const r of S . upcoming ? . requests || [ ] ) {
const short = r . want - r . have ;
const counts = r . any === "food" ? it . food : r . any ? it . slot === r . any : id === r . item ;
if ( short <= 0 || ! counts ) continue ;
qty += r . any === "food" ? Math . ceil ( short / it . food ) : short ;
why . push ( ` Council: ${ r . label } · ${ fmt ( r . have ) } / ${ fmt ( r . want ) } ` ) ;
}
return qty ? { qty , why } : null ;
}
function needTag ( id ) {
const n = cityNeed ( id ) ;
return n ? ` <span class="tag gold" title=" ${ esc ( ` City needs ${ fmt ( n . qty ) } ${ item ( id ) . name } \n ${ n . why . join ( "\n" ) } ` ) } ">🏘️ ${ fmt ( n . qty ) } </span> ` : "" ;
}
2026-10-18 13:48:56 +01:00
// --- skill pages ----------------------------------------------------------
function skillPage ( id ) {
const me = S . me , skill = skillOf ( id ) , level = me . levels [ id ] , xp = me . xp [ id ] ;
const floor = C . xp _table [ level ] , ceil = C . xp _table [ level + 1 ] ;
const pct = ceil ? ( xp - floor ) / ( ceil - floor ) * 100 : 100 ;
const cls = C . classes [ me . cls ] ;
const fast = cls . fast . includes ( id ) ;
let html = `
< div class = "panel" > < div class = "skill-head" >
< div class = "big" > $ { skill . icon } < / d i v >
< div class = "info" >
2026-10-20 20:06:16 +01:00
< h2 > $ { skill . name } < button class = "pin ${me.pinned.includes(id) ? " on " : " "}" data - pin = "${id}" title = "${me.pinned.includes(id) ? " Unpin " : " Pin "}" > 📌 < / b u t t o n > < / h 2 >
2026-10-18 13:48:56 +01:00
< div class = "lv" > Level $ { level } / 99 · $ { fmt ( xp ) } $ { ceil ? ` / ${ fmt ( ceil ) } ` : "" } XP < / d i v >
< div class = "bar xp thick" > < span style = "width:${pct}%" > < / s p a n > < / d i v >
< / d i v >
$ { fast ? ` <span class="tag gold"> ${ cls . icon } ${ cls . name } bonus</span> ` : "" }
< / d i v > < / d i v > ` ;
2026-10-20 15:30:05 +01:00
if ( id === "combat" ) html += combatFrame ( ) ;
2026-10-18 23:09:17 +01:00
if ( id === "prayer" ) html += prayerPanel ( ) ;
2026-10-19 09:24:45 +01:00
if ( id === "construction" ) html += S . city . construction ? siteBanner ( )
2026-10-20 08:01:57 +01:00
: ` <div class="panel muted">Nothing under construction.</div> ` ;
2026-10-19 23:17:23 +01:00
if ( id === "farming" && me . cls === "homesteader" ) html += plotsPanel ( ) ;
2026-10-20 16:04:50 +01:00
html += skillGear ( id ) ;
2026-10-21 12:16:22 +01:00
if ( id === "firekeeping" ) return html + firekeepingPage ( ) ;
html += fireBand ( id ) ;
2026-10-20 08:01:57 +01:00
if ( id === "reconnaissance" ) html += ` <div class="panel small muted">Raises intel on the coming threat. Higher levels open as it nears.</div> ` ;
2026-10-19 09:34:18 +01:00
html += skillHappenings ( id ) ;
2026-10-20 18:01:02 +01:00
const wanted = ( S . orders || [ ] ) . filter ( ( o ) => o . skill === id && canTake ( o ) ) ;
if ( wanted . length ) html += ` <div class="banner good happening-banner"><span class="banner-ico"> ${ ORDER _ICON ( ) } </span>
< div class = "grow" > < b > $ { wanted . length } work order$ { wanted . length > 1 ? "s" : "" } < / b >
< div class = "small" > $ { wanted . map ( ( o ) => esc ( item ( o . item ) . name ) ) . join ( " · " ) } < / d i v > < / d i v >
< button class = "btn" data - nav = "orders" > Work orders < / b u t t o n > < / d i v > ` ;
2026-10-19 09:34:18 +01:00
const live = new Set ( S . happenings . problems . map ( ( p ) => p . kind ) ) ;
2026-10-19 23:23:26 +01:00
const listed = C . actions . filter ( ( a ) => a . skill === id && ( a . special !== "problem" || live . has ( a . problem ) ) ) ;
// Tabs and sections keep their designed order; what's inside each is sorted by the level it needs.
const groups = [ ... new Set ( listed . map ( ( a ) => a . group ) . filter ( Boolean ) ) ] ;
const actions = [ ... listed ] . sort ( ( a , b ) => a . level - b . level ) ;
2026-10-18 22:13:50 +01:00
if ( groups . length > 2 ) {
2026-10-19 23:23:26 +01:00
// Many groups: one tab each.
2026-10-18 22:13:50 +01:00
const open = ( g ) => actions . some ( ( a ) => a . group === g && ! isLocked ( a ) ) ;
let current = tabs [ id ] || remember ( ` tab. ${ id } ` ) ;
2026-10-19 23:23:26 +01:00
// Tier ladders open on the highest tier you can work; categories open on the first.
2026-10-20 14:16:12 +01:00
const ladder = [ "smithing" , "leatherworking" , "tailoring" , "herblore" , "combat" ] . includes ( id ) ;
2026-10-19 23:23:26 +01:00
if ( ! groups . includes ( current ) ) current = ( ladder && [ ... groups ] . reverse ( ) . find ( open ) ) || groups [ 0 ] ;
2026-10-23 22:13:32 +01:00
const needed = ( g ) => actions . some ( ( a ) => a . group === g && ! isLocked ( a ) && Object . keys ( a . outputs ) . some ( cityNeed ) ) ;
const bar = groups . map ( ( g ) => ` <button class="tab ${ g === current ? "on" : "" } ${ open ( g ) ? "" : "dim" } " data-tab=" ${ id } " data-group=" ${ esc ( g ) } "> ${ esc ( g ) } ${ needed ( g ) ? ` <span title="City needs">🏘️</span> ` : "" } </button> ` ) . join ( "" ) ;
2026-10-19 11:41:41 +01:00
// Ungrouped actions (the building site) stay above the tabs.
const loose = actions . filter ( ( a ) => ! a . group ) ;
if ( loose . length ) html += ` <div class="grid" style="margin-bottom:14px"> ${ loose . map ( actionCard ) . join ( "" ) } </div> ` ;
2026-10-18 22:13:50 +01:00
return html + ` <div class="tabs"> ${ bar } </div><div class="grid"> ${ actions . filter ( ( a ) => a . group === current ) . map ( actionCard ) . join ( "" ) } </div> ` ;
}
2026-10-18 23:12:57 +01:00
// A few groups: each under its own heading, in the order they first appear.
let cards = actions . filter ( ( a ) => ! a . group ) . map ( actionCard ) . join ( "" ) ;
for ( const g of groups ) {
cards += ` <div class="section-title grid-head"> ${ esc ( g ) } </div> ` + actions . filter ( ( a ) => a . group === g ) . map ( actionCard ) . join ( "" ) ;
2026-10-18 19:27:56 +01:00
}
return html + ` <div class="grid"> ${ cards } </div> ` ;
}
2026-10-18 22:13:50 +01:00
// Locked: missing a level, class or building, as opposed to just out of materials.
function isLocked ( a ) {
const why = S . me . blocked [ a . id ] ;
return Boolean ( why && ( why . startsWith ( "Needs" ) || why . startsWith ( "Only" ) ) ) ;
}
2026-10-19 23:17:23 +01:00
// A homesteader's two plots: one crop 30% faster, or two crops sharing a 20% faster pace.
function plotsPanel ( ) {
const a = S . me . action , farming = a && C . actions . find ( ( x ) => x . id === a . id ) ? . skill === "farming" ;
const name = ( id ) => esc ( C . actions . find ( ( x ) => x . id === id ) . name ) ;
2026-10-20 08:01:57 +01:00
const status = ! farming ? ` <span class="muted">Start a crop to plant plot 1.</span> `
: a . plot2 ? ` Plot 1: <b> ${ name ( a . id ) } </b> · Plot 2: <b> ${ name ( a . plot2 ) } </b> <span class="muted">· alternating, 20% faster</span> `
: ` Plot 1: <b> ${ name ( a . id ) } </b> · Plot 2: <span class="muted">empty · focused, 30% faster</span> ` ;
2026-10-19 23:17:23 +01:00
return ` <div class="panel tool-panel"><span>🌱 ${ status } </span></div> ` ;
}
2026-10-18 23:12:57 +01:00
// The skill's active potion, or the ones in the bank that could be drunk for it.
2026-10-20 16:04:50 +01:00
// A skill's potion and tool side by side, each a slot with what you could switch to beside it.
function skillGear ( skill ) {
const me = S . me , cards = [ ] ;
2026-10-18 23:12:57 +01:00
const kinds = Object . entries ( C . items ) . filter ( ( [ , it ] ) => it . potion ? . skill === skill ) ;
2026-10-20 16:04:50 +01:00
if ( kinds . length ) {
const active = me . potions [ skill ] , it = active && item ( active . item ) ;
const others = kinds . filter ( ( [ id ] ) => me . bank [ id ] && id !== active ? . item ) . map ( ( [ id , p ] ) =>
` <button class="doll-slot swap" data-potion=" ${ id } " title=" ${ esc ( ` ${ p . name } : ${ p . potion . text } ` ) } "> ${ p . icon } <span class="swap-qty"> ${ fmt ( me . bank [ id ] ) } </span></button> ` ) . join ( "" ) ;
cards . push ( ` <div class="panel gear-card">
< span class = "doll-slot ${it ? " " : " empty "}" title = "${esc(it ? `${it.name}: ${it.potion.text}` : " No potion ")}" > $ { it ? it . icon : glyph ( "round-bottom-flask" , "#98a1b4" ) } < / s p a n >
< div class = "gear-text" > $ { it ? ` <b> ${ esc ( it . name ) } </b><span class="muted small"> ${ esc ( it . potion . text ) } · ${ fmt ( active . charges ) } uses</span> `
: ` <span class="muted">No potion</span> ` } < / d i v >
$ { others ? ` <div class="swap-grid"> ${ others } </div> ` : "" } < / d i v > ` ) ;
}
2026-10-20 21:20:25 +01:00
for ( const [ kind , none ] of [ [ "tool" , "No tool" ] , [ "station" , "No station" ] ] ) {
const slot = ` ${ kind } _ ${ skill } ` ;
if ( ! ( slot in me . equip ) ) continue ;
const id = me . equip [ slot ] , it = id && item ( id ) ;
const others = Object . keys ( me . bank ) . filter ( ( x ) => item ( x ) [ kind ] === skill ) . sort ( ( a , b ) => item ( b ) . speed - item ( a ) . speed ) . map ( ( x ) => {
2026-10-20 16:04:50 +01:00
const [ s , level ] = item ( x ) . req || [ ] , why = s && me . levels [ s ] < level ? ` Needs ${ skillOf ( s ) . name . toLowerCase ( ) } ${ level } ` : "" ;
return ` <button class="doll-slot swap" data-equip=" ${ x } " ${ why ? "disabled" : "" } title=" ${ esc ( [ ` ${ item ( x ) . name } : ${ Math . round ( item ( x ) . speed * 100 ) } % faster ` , why ] . filter ( Boolean ) . join ( " · " ) ) } "> ${ item ( x ) . icon } </button> ` ;
} ) . join ( "" ) ;
cards . push ( ` <div class="panel gear-card">
2026-10-20 21:20:25 +01:00
< span class = "doll-slot ${it ? " " : " empty "}" title = "${esc(it ? it.name : none)}" > $ { it ? it . icon : glyph ( kind === "tool" ? "toolbox" : "anvil" , "#98a1b4" ) } < / s p a n >
< div class = "gear-text" > $ { it ? ` <b> ${ esc ( it . name ) } </b><span class="muted small"> ${ Math . round ( it . speed * 100 ) } % faster</span> ` : ` <span class="muted"> ${ none } </span> ` } < / d i v >
$ { others || it ? ` <div class="swap-grid"> ${ others } ${ it ? ` <button class="doll-slot swap off" data-unequip=" ${ slot } " title="Take it off"> ${ glyph ( "cancel" , "#98a1b4" ) } </button> ` : "" } </div> ` : "" } < / d i v > ` ) ;
2026-10-20 16:04:50 +01:00
}
return cards . length ? ` <div class="gear-row"> ${ cards . join ( "" ) } </div> ` : "" ;
2026-10-18 23:12:57 +01:00
}
2026-10-21 12:16:22 +01:00
// --- fires ----------------------------------------------------------------
2026-10-21 13:14:03 +01:00
// The guild's shared fires. Between polls the client burns them down itself, the same
// way the server does: the burn shared evenly between everyone's piles, each pile's
// hottest fuel first.
2026-10-21 12:16:22 +01:00
const ROMAN = [ "I" , "II" , "III" , "IV" , "V" , "VI" ] ;
// Core to edge of each flame colour, for the animated fires.
const FLAME _RAMPS = [ [ "#fff3cf" , "#ffb54a" , "#ff6a2a" , "#8a210a" ] , [ "#fffbe6" , "#ffe680" , "#f2a81f" , "#7a4a06" ] ,
[ "#ffffff" , "#f6f4ff" , "#c9c4e0" , "#4d4a66" ] , [ "#eef8ff" , "#8fd0ff" , "#3485ea" , "#10347a" ] ,
[ "#ecfff1" , "#8af5ab" , "#22b85a" , "#0a4a25" ] , [ "#f8efff" , "#d6b0ff" , "#9150f0" , "#3a1474" ] ] ;
const STILL = matchMedia ( "(prefers-reduced-motion: reduce)" ) . matches ;
let firewood = Number ( remember ( "firewood" ) ? ? - 1 ) ; // the log tier picked for tending
const flames = { } ; // fire -> its particles, kept across renders
let fireTextAt = 0 ;
const fireOfSkill = ( skill ) => Object . keys ( C . fires ) . find ( ( id ) => C . fires [ id ] . skill === skill ) ;
const flameTint = ( tier ) => tier >= 0 ? C . flames [ tier ] [ 1 ] : "#5d626b" ;
const tendLevel = ( tier ) => C . actions . find ( ( a ) => a . id === ` tend_forge_ ${ C . firewood [ tier ] } ` ) . level ;
function fireNow ( id ) {
const f = S . city . fires [ id ] , fuel = f . fuel . map ( ( c ) => ( { ... c } ) ) ;
2026-10-21 13:14:03 +01:00
const hottest = ( by ) => fuel . filter ( ( c ) => c . by === by ) . reduce ( ( best , c ) => ( c . tier > best . tier ? c : best ) ) ;
2026-10-21 12:16:22 +01:00
let burn = Math . max ( 0 , serverNow ( ) - f . at ) * f . speed ;
2026-10-21 13:14:03 +01:00
while ( burn > 1e-9 && fuel . length ) {
const heads = [ ... new Set ( fuel . map ( ( c ) => c . by ) ) ] . map ( hottest ) ;
const step = Math . min ( burn , Math . min ( ... heads . map ( ( c ) => c . left ) ) * heads . length ) ;
for ( const c of heads ) {
c . left -= step / heads . length ;
if ( c . left <= 1e-6 ) fuel . splice ( fuel . indexOf ( c ) , 1 ) ;
}
burn -= step ;
2026-10-21 12:16:22 +01:00
}
const heat = fuel . reduce ( ( h , c ) => Math . max ( h , c . tier ) , - 1 ) , left = fuel . reduce ( ( sum , c ) => sum + c . left , 0 ) ;
2026-10-21 13:14:03 +01:00
const mine = fuel . filter ( ( c ) => c . by === S . uid ) ;
return { fuel , mine , heat , left , low : heat >= 0 && left < C . fire _low , speed : f . speed } ;
2026-10-21 12:16:22 +01:00
}
function pickFirewood ( ) {
const me = S . me , open = C . firewood . map ( ( _ , t ) => t ) . filter ( ( t ) => me . levels . firekeeping >= tendLevel ( t ) ) ;
if ( open . includes ( firewood ) ) return firewood ;
return [ ... open ] . reverse ( ) . find ( ( t ) => me . bank [ C . firewood [ t ] ] ) ? ? 0 ;
}
function firekeepingPage ( ) {
const me = S . me , fk = me . firekeeping , pick = pickFirewood ( ) , speed = Object . values ( S . city . fires ) [ 0 ] . speed ;
const perLog = ( tier ) => + ( C . tend _xp [ tier ] * fk . minutes ) . toFixed ( 1 ) ;
const logs = C . firewood . map ( ( log , tier ) => {
const level = tendLevel ( tier ) , locked = me . levels . firekeeping < level ;
const tip = ` ${ item ( log ) . name } · ${ C . flames [ tier ] [ 0 ] } · ${ locked ? ` Needs level ${ level } ` : ` ${ perLog ( tier ) } XP a log ` } ` ;
return ` <button class="logbtn ${ tier === pick ? "on" : "" } " style="--c: ${ flameTint ( tier ) } " data-firewood=" ${ tier } " ${ locked ? "disabled" : "" } title=" ${ esc ( tip ) } "><span class="swatch"></span> ${ item ( log ) . icon } ${ fmt ( me . bank [ log ] || 0 ) } </button> ` ;
} ) . join ( "" ) ;
return ` <div class="panel fire-tools"><div class="logs"> ${ logs } </div>
< span class = "tag" title = "Fire and XP per log" > $ { glyph ( "stopwatch" , "#98a1b4" ) } $ { dur ( fk . minutes * 60 / speed ) } · $ { perLog ( pick ) } XP < / s p a n >
< span class = "tag" title = "Most fuel you can bank in a fire" > $ { glyph ( "campfire" , "#98a1b4" ) } $ { dur ( fk . bank * 60 / speed ) } < / s p a n > < / d i v >
< div class = "fire-grid" > $ { Object . keys ( C . fires ) . map ( ( id ) => fireCard ( id , pick ) ) . join ( "" ) } < / d i v > ` ;
}
function fireCard ( id , pick ) {
const me = S . me , spec = C . fires [ id ] , f = S . city . fires [ id ] , n = fireNow ( id ) , skill = skillOf ( spec . skill ) ;
const mine = me . action ? . id . startsWith ( ` tend_ ${ id } _ ` ) ;
const start = ` tend_ ${ id } _ ${ C . firewood [ pick ] } ` , why = mine ? "" : me . blocked [ start ] ;
const crafting = f . users . filter ( ( u ) => ! u . action . startsWith ( "tend_" ) ) ;
const doing = ( u ) => C . actions . find ( ( a ) => a . id === u . action ) ;
2026-10-21 13:14:03 +01:00
const piles = [ ... new Set ( f . fuel . map ( ( c ) => c . by ) ) ] ;
const keepers = piles . length ? ` <span title=" ${ esc ( piles . map ( playerName ) . join ( "\n" ) ) } ">🔥 ${ piles . length } </span> ` : "" ;
2026-10-21 12:16:22 +01:00
const who = crafting . length
? ` <span title=" ${ esc ( crafting . map ( ( u ) => ` ${ u . name } : ${ doing ( u ) . name } ` ) . join ( "\n" ) ) } ">👤 ${ crafting . length } ${ crafting . map ( ( u ) => doing ( u ) . icon ) . join ( "" ) } </span> `
: ` <span class="muted">👤 0</span> ` ;
return ` <div class="fire-card ${ n . heat >= 0 ? "lit" : "out" } ${ mine ? "active" : "" } " style="--c: ${ flameTint ( n . heat ) } ">
< div class = "fire-stage" > < canvas data - flame = "${id}" > < / c a n v a s > < s p a n c l a s s = " f i r e - s t a t e " d a t a - f i r e - s t a t e = " $ { i d } " > < / s p a n > < / d i v >
< div class = "fire-body" >
< div class = "fire-head" > $ { glyph ( spec . glyph , "#e7e9f0" ) } < b > $ { esc ( spec . name ) } < / b > < s p a n c l a s s = " m u t e d s m a l l " > $ { s k i l l . i c o n } $ { e s c ( s k i l l . n a m e ) } < / s p a n > < / d i v >
2026-10-21 13:14:03 +01:00
< div class = "fire-line" > < span data - fire - left = "${id}" title = "Until it's out" > < / s p a n > < s p a n d a t a - f i r e - c o v e r s = " $ { i d } " > < / s p a n > < / d i v >
< div class = "fuel" data - fuel = "${id}" title = "Your pile" > < / d i v >
< div class = "fire-line small" > < span data - fire - mine = "${id}" title = "Your pile / the most you can bank" > < / s p a n > < s p a n t i t l e = " L o g s t h i s f i r e h a s p a i d f o r " > 🪵 $ { f m t ( f . s a v e d | | 0 ) } < / s p a n > < / d i v >
< div class = "fire-line small" > $ { who } $ { keepers } < / d i v >
2026-10-21 12:16:22 +01:00
$ { mine ? ` <div class="bar thick"><span data-progress></span></div> ` : "" }
< div class = "foot" > < span class = "why" > $ { esc ( why || "" ) } < / s p a n > $ { m i n e
? ` <button class="btn danger" data-stop>Stop</button> `
: ` <button class="btn primary" data-start=" ${ start } " ${ why ? "disabled" : "" } >Tend</button> ` } < / d i v >
< / d i v > < / d i v > ` ;
}
// The fire behind a crafting skill, at the top of its page.
function fireBand ( skill ) {
const id = fireOfSkill ( skill ) ;
if ( ! id ) return "" ;
const n = fireNow ( id ) , spec = C . fires [ id ] , lit = n . heat >= 0 ;
const pays = lit && ` Pays the log for tier ${ ROMAN [ n . heat ] } and below ${ id === "forge" ? ", and a coal" : "" } ` ;
return ` <div class="fire-band ${ lit ? "lit" : "" } " style="--c: ${ flameTint ( n . heat ) } ">
$ { glyph ( spec . glyph , lit ? flameTint ( n . heat ) : "#98a1b4" ) }
< div class = "grow" > < b > $ { esc ( spec . name ) } < / b > < s p a n c l a s s = " m u t e d s m a l l " > $ { l i t ? ` $ { C . f l a m e s [ n . h e a t ] [ 0 ] } · < s p a n d a t a - f i r e - t i m e = " $ { i d } " > < / s p a n > ` : " O u t " } < / s p a n > < / d i v >
$ { lit ? ` <span class="tag flame" title=" ${ esc ( pays ) } ">🪵 ≤ ${ ROMAN [ n . heat ] } </span> ` : "" }
< button class = "btn sm" data - nav = "skill:firekeeping" > 🔥 Tend < / b u t t o n >
< / d i v > ` ;
}
function animateFires ( ) {
if ( ! S ? . me || ! S . city ? . fires ) return ;
const text = performance . now ( ) - fireTextAt > 250 ;
if ( text ) fireTextAt = performance . now ( ) ;
for ( const id of Object . keys ( C . fires ) ) {
const canvas = document . querySelector ( ` [data-flame=" ${ id } "] ` ) ;
if ( ! canvas && ! text ) continue ;
const n = fireNow ( id ) ;
if ( canvas ) drawFlame ( canvas , id , n ) ;
if ( ! text ) continue ;
const set = ( sel , html ) => { for ( const el of document . querySelectorAll ( sel ) ) if ( el . dataset . v !== html ) { el . innerHTML = html ; el . dataset . v = html ; } } ;
2026-10-21 13:14:03 +01:00
const bank = S . me . firekeeping . bank * 60 , mine = n . mine . reduce ( ( sum , c ) => sum + c . left , 0 ) ;
const tiers = C . flames . map ( ( _ , t ) => n . mine . filter ( ( c ) => c . tier === t ) . reduce ( ( sum , c ) => sum + c . left , 0 ) ) ;
set ( ` [data-fuel=" ${ id } "] ` , tiers . map ( ( left , t ) => left ? ` <i style="--c: ${ flameTint ( t ) } ;width: ${ ( left / Math . max ( bank , mine ) * 100 ) . toFixed ( 2 ) } %"></i> ` : "" ) . join ( "" ) ) ;
set ( ` [data-fire-left=" ${ id } "] ` , n . heat >= 0 ? ` 🔥 <b> ${ dur ( n . left / n . speed ) } </b> ` : "" ) ;
set ( ` [data-fire-mine=" ${ id } "] ` , ` You ${ dur ( mine / n . speed ) } / ${ dur ( bank / n . speed ) } ` ) ;
2026-10-21 12:16:22 +01:00
set ( ` [data-fire-time=" ${ id } "] ` , dur ( n . left / n . speed ) ) ;
set ( ` [data-fire-covers=" ${ id } "] ` , n . heat >= 0 ? ` <span class="tag flame" style="--c: ${ flameTint ( n . heat ) } " title="Pays the log for tier ${ ROMAN [ n . heat ] } and below">🪵 ≤ ${ ROMAN [ n . heat ] } </span> ` : "" ) ;
set ( ` [data-fire-state=" ${ id } "] ` , n . heat < 0 ? "Out" : n . low ? "Low" : C . flames [ n . heat ] [ 0 ] ) ;
for ( const el of document . querySelectorAll ( ` [data-fire-state=" ${ id } "] ` ) ) el . className = ` fire-state ${ n . heat < 0 ? "out" : n . low ? "low" : "" } ` ;
}
}
function rampAt ( ramp , t ) {
const s = t * ( ramp . length - 1 ) , i = Math . min ( Math . floor ( s ) , ramp . length - 2 ) , f = s - i ;
const rgb = ( hex ) => [ 1 , 3 , 5 ] . map ( ( k ) => parseInt ( hex . slice ( k , k + 2 ) , 16 ) ) ;
const a = rgb ( ramp [ i ] ) , b = rgb ( ramp [ i + 1 ] ) ;
return ` rgb( ${ a . map ( ( v , k ) => Math . round ( v + ( b [ k ] - v ) * f ) ) . join ( "," ) } ) ` ;
}
// A fire on a canvas: rising flame particles in its colour, taller with more fuel;
// smoke when it's low, a grey bed of ash when it's out.
function drawFlame ( cv , id , n ) {
const st = flames [ id ] || = { parts : [ ] , at : performance . now ( ) } ;
const t = performance . now ( ) , dt = Math . min ( 0.05 , ( t - st . at ) / 1000 ) ;
st . at = t ;
const r = cv . getBoundingClientRect ( ) , d = Math . min ( devicePixelRatio || 1 , 2 ) ;
if ( ! r . width ) return ;
if ( cv . width !== Math . round ( r . width * d ) ) { cv . width = Math . round ( r . width * d ) ; cv . height = Math . round ( r . height * d ) ; }
const ctx = cv . getContext ( "2d" ) , W = cv . width , H = cv . height , cx = W / 2 , base = H - 22 * d ;
const lit = n . heat >= 0 , ramp = FLAME _RAMPS [ Math . max ( 0 , n . heat ) ] ;
const power = lit ? Math . min ( 1 , 0.25 + n . left / 14400 ) : 0 ;
ctx . clearRect ( 0 , 0 , W , H ) ;
if ( lit && ! STILL ) {
for ( let i = Math . round ( 90 * power * dt ) + 1 ; i > 0 ; i -- ) st . parts . push ( { k : "f" , x : cx + ( Math . random ( ) - 0.5 ) * 46 * d * ( 0.6 + power * 0.6 ) , y : base ,
vx : ( Math . random ( ) - 0.5 ) * 18 * d , vy : - ( 40 + Math . random ( ) * 60 ) * d * ( 0.55 + power ) , life : 0 ,
max : 0.55 + Math . random ( ) * 0.5 * ( 0.5 + power ) , r : ( 7 + Math . random ( ) * 9 ) * d * ( 0.6 + power * 0.5 ) } ) ;
if ( Math . random ( ) < dt * 6 * power ) st . parts . push ( { k : "e" , x : cx + ( Math . random ( ) - 0.5 ) * 40 * d , y : base - 10 * d ,
vx : ( Math . random ( ) - 0.5 ) * 30 * d , vy : - ( 60 + Math . random ( ) * 60 ) * d , life : 0 , max : 1 + Math . random ( ) , r : 1.4 * d } ) ;
}
if ( ! STILL && ( ! lit || n . low ) && Math . random ( ) < dt * ( lit ? 5 : 3 ) ) st . parts . push ( { k : "s" , x : cx + ( Math . random ( ) - 0.5 ) * 20 * d ,
y : base - ( lit ? 40 * d : 0 ) , vx : ( Math . random ( ) - 0.3 ) * 10 * d , vy : - ( 18 + Math . random ( ) * 14 ) * d , life : 0 , max : 2.5 + Math . random ( ) , r : ( 6 + Math . random ( ) * 5 ) * d } ) ;
ctx . fillStyle = "#3a2a1e" ;
ctx . save ( ) ;
ctx . translate ( cx , base + 8 * d ) ;
for ( const [ angle , w ] of [ [ - 0.18 , 70 ] , [ 0.2 , 64 ] , [ 0 , 50 ] ] ) {
ctx . save ( ) ; ctx . rotate ( angle ) ; ctx . beginPath ( ) ; ctx . roundRect ( - w / 2 * d , - 5 * d , w * d , 10 * d , 5 * d ) ; ctx . fill ( ) ; ctx . restore ( ) ;
}
ctx . restore ( ) ;
if ( lit ) {
const glow = ctx . createRadialGradient ( cx , base + 4 * d , 2 , cx , base + 4 * d , 60 * d ) ;
glow . addColorStop ( 0 , ramp [ 1 ] + "aa" ) ;
glow . addColorStop ( 1 , ramp [ 3 ] + "00" ) ;
ctx . fillStyle = glow ;
ctx . fillRect ( 0 , 0 , W , H ) ;
} else {
ctx . fillStyle = "#4a4a50" ;
ctx . beginPath ( ) ; ctx . ellipse ( cx , base + 12 * d , 38 * d , 5 * d , 0 , 0 , Math . PI * 2 ) ; ctx . fill ( ) ;
}
if ( lit && STILL ) {
const h = 44 * d * ( 0.6 + power * 0.5 ) , g = ctx . createRadialGradient ( cx , base - 18 * d , 2 , cx , base - 18 * d , h ) ;
ramp . forEach ( ( c , i ) => g . addColorStop ( i / 3 , c + ( i === 3 ? "00" : "ff" ) ) ) ;
ctx . fillStyle = g ;
ctx . beginPath ( ) ; ctx . ellipse ( cx , base - 18 * d , 26 * d , h , 0 , 0 , Math . PI * 2 ) ; ctx . fill ( ) ;
}
st . parts = st . parts . filter ( ( p ) => ( p . life += dt ) < p . max ) ;
ctx . globalCompositeOperation = "lighter" ;
for ( const p of st . parts ) {
p . x += p . vx * dt ;
p . y += p . vy * dt ;
const k = p . life / p . max ;
if ( p . k === "f" ) {
p . vx += ( cx - p . x ) * 1.8 * dt ;
ctx . globalAlpha = ( 1 - k ) * 0.55 ;
ctx . fillStyle = rampAt ( ramp , Math . min ( 1 , k * 1.25 ) ) ;
ctx . beginPath ( ) ; ctx . arc ( p . x , p . y , p . r * ( 1 - k * 0.7 ) , 0 , Math . PI * 2 ) ; ctx . fill ( ) ;
} else if ( p . k === "e" ) {
ctx . globalAlpha = 1 - k ;
ctx . fillStyle = ramp [ 1 ] ;
ctx . fillRect ( p . x , p . y , p . r * 2 , p . r * 2 ) ;
}
}
ctx . globalCompositeOperation = "source-over" ;
for ( const p of st . parts ) if ( p . k === "s" ) {
const k = p . life / p . max ;
ctx . globalAlpha = ( 1 - k ) * 0.22 ;
ctx . fillStyle = "#9aa0ae" ;
ctx . beginPath ( ) ; ctx . arc ( p . x , p . y , p . r * ( 1 + k * 2 ) , 0 , Math . PI * 2 ) ; ctx . fill ( ) ;
}
ctx . globalAlpha = 1 ;
}
2026-10-18 23:09:17 +01:00
function prayerPanel ( ) {
const me = S . me , city = S . city , level = me . levels . prayer ;
const cost = C . prayers . filter ( ( p ) => me . prayers . includes ( p . id ) ) . reduce ( ( sum , p ) => sum + p . cost , 0 ) ;
const cards = C . prayers . map ( ( p ) => {
const on = me . prayers . includes ( p . id ) , locked = level < p . level ;
const full = ! on && me . prayers . length >= C . max _prayers ;
return ` <button class="prayer ${ on ? "on" : "" } ${ locked ? "locked" : "" } " data-pray=" ${ p . id } " ${ locked ? "disabled" : "" }
2026-10-20 08:01:57 +01:00
title = "${locked ? `Needs prayer level ${p.level}` : full ? `Max ${C.max_prayers} active` : " "}" >
2026-10-18 23:09:17 +01:00
< span class = "ico" > $ { p . icon } < / s p a n >
< span class = "grow" > < b > $ { esc ( p . name ) } < / b > < s p a n c l a s s = " s m a l l m u t e d " > $ { e s c ( p . t e x t ) } < / s p a n > < / s p a n >
< span class = "small ${locked ? " why " : " muted "}" > $ { locked ? ` Lv ${ p . level } ` : ` ${ p . cost } pt ${ p . cost > 1 ? "s" : "" } /fight ` } < / s p a n >
< / b u t t o n > ` ;
} ) . join ( "" ) ;
const temple = city . buildings . temple . level ;
return ` <div class="cols">
< div class = "panel" > < h2 > Prayers · $ { me . prayers . length } / $ { C . max _prayers } active < / h 2 >
2026-10-20 08:01:57 +01:00
< div class = "prayer-points" > $ { skillOf ( "prayer" ) . icon } < b > $ { fmt ( me . pp ) } < /b> prayer points${cost ? ` · ${cost} per fight (~${fmt(me.pp / cost ) } fights ) ` : ""}</div>
2026-10-18 23:09:17 +01:00
< div class = "prayers" > $ { cards } < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Prayers cost points each fight . Bury bones for more . < / p >
2026-10-18 23:09:17 +01:00
< / d i v >
< div class = "panel" > < h2 > Temple < / h 2 >
2026-10-20 08:01:57 +01:00
$ { temple ? ` <p>Devotion <b> ${ fmt ( city . devotion ) } </b> · ward <b>+ ${ city . ward } </b> defence (max + ${ 15 * temple } )</p>
< p class = "small muted" > Offerings ward the next raid . + $ { temple } prayer point$ { temple > 1 ? "s" : "" } per bone buried . < / p > `
: ` <p class="muted">No temple yet.</p> ` }
2026-10-18 23:09:17 +01:00
< / d i v > < / d i v > ` ;
}
2026-10-20 14:16:12 +01:00
// A damage type's icon: what an enemy attacks with, or (tinted) what it's vulnerable to or resists.
function dmgIcon ( type , how = "" ) {
const t = C . damage _types [ type ] , name = t . name . toLowerCase ( ) ;
const tip = how === "vuln" ? ` Vulnerable to ${ name } (+ ${ Math . round ( ( C . vulnerable - 1 ) * 100 ) } %) `
: how === "resist" ? ` Resists ${ name } (- ${ Math . round ( ( 1 - C . resistant ) * 100 ) } %) `
: how === "attack" ? ` Attacks with ${ name } ` : ` ${ t . name } damage ` ;
return ` <span class="dmg ${ how } " title=" ${ esc ( tip ) } "> ${ glyph ( t . glyph , t . tint ) } </span> ` ;
}
function resistIcons ( v ) {
return v . vulnerable . map ( ( k ) => dmgIcon ( k , "vuln" ) ) . join ( "" ) + v . resists . map ( ( k ) => dmgIcon ( k , "resist" ) ) . join ( "" ) ;
}
// Who turns up in an encounter: each variant, what it attacks with, and how it takes each damage type.
// A variant your weapon is good against is green, one that resists it red.
function variantRows ( e , o ) {
return ` <div class="variants"> ${ e . variants . map ( ( v , i ) => {
const fit = o . variants [ i ] . affinity ;
return ` <div class="variant ${ fit > 1 ? "good" : fit < 1 ? "bad" : "" } ">
< span class = "v-attack" > $ { v . attack . map ( ( k ) => dmgIcon ( k , "attack" ) ) . join ( "" ) } < / s p a n >
2026-10-24 13:48:16 +01:00
< span class = "v-name" > $ { esc ( v . name ) } $ { v . archetype === "elite" && ! e . boss ? ` <span class="muted">· rare</span> ` : "" } < / s p a n >
2026-10-20 14:16:12 +01:00
< span class = "v-hp muted" > $ { fmt ( v . hp ) } HP < / s p a n >
< span class = "v-res" > $ { resistIcons ( v ) } < / s p a n >
< / d i v > ` ;
} ) . join ( "" ) } < / d i v > ` ;
2026-10-19 23:38:34 +01:00
}
2026-10-24 09:49:21 +01:00
// Everything about fighting in one frame. Left: you and whoever's with you, your abilities (on auto),
// your prayers (switch them mid-fight) and your gear (swap it mid-fight). Right: the pack, each with
// what it attacks with and how it takes each damage type (your weapon's outlined), and the fight as
// it plays out.
2026-10-20 15:30:05 +01:00
function combatFrame ( ) {
const me = S . me , a = me . action , f = a ? . fight ;
2026-10-24 09:49:21 +01:00
const encounter = a && C . actions . find ( ( x ) => x . id === a . id ) ;
const waiting = a ? . waiting ;
const others = f ? f . members . filter ( ( m ) => m . id !== S . uid ) : [ ] ;
2026-10-24 12:05:23 +01:00
const unit = ( id , name , foe = false , extra = "" ) => ` <div class="fighter ${ foe ? "foe-unit" : id === S . uid ? "" : "ally" } ${ f ? "aimable" : "" } "
data - u = "${esc(id)}" $ { f ? ` data-aim=" ${ esc ( id ) } " title=" ${ foe ? "Tap to fight this one" : "Tap to aim your heals here" } " ` : "" } >
2026-10-24 14:21:08 +01:00
< div class = "fighter-name" > < b > $ { name } $ { extra } < / b > < s p a n c l a s s = " s m a l l m u t e d " > < s p a n c l a s s = " h i t n u m " d a t a - u - h i t n u m > < / s p a n > < s p a n d a t a - u - s t a t u s > < / s p a n > $ { f o e ? " " : H E A R T } < s p a n d a t a - u - h p > $ { i d = = = S . u i d ? ` $ { f m t ( m e . h p ) } / $ { m e . m a x _ h p } ` : " " } < / s p a n > < / s p a n > < / d i v >
< div class = "bar hp ${foe ? " foe " : " you "}" > < span class = "lag" data - u - lag > < /span><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me . max _hp * 100 } % "` : " " } > < / s p a n > < / d i v >
2026-10-24 09:49:21 +01:00
< div class = "bar swing" > < span data - u - swing > < / s p a n > < / d i v >
< div class = "cast small" data - u - cast > < / d i v >
< / d i v > ` ;
const you = unit ( S . uid , esc ( me . name ) ) + others . map ( ( m ) => unit ( m . id , esc ( m . name ) ) ) . join ( "" ) ;
2026-10-24 12:05:23 +01:00
const run = a ? . run ;
// A dungeon run: where you are in it, and (for the leader, while resting) the button to go in early.
const stage = run ? ` <div class="run-head"><b> ${ esc ( run . dungeon ) } </b>
< span class = "muted small" > $ { run . pull } / $ { run . of } · $ { esc ( run . next ) } $ { run . wipes ? ` · wipes ${ run . wipes } / ${ C . dungeon _wipes } ` : "" } < / s p a n >
$ { f && run . leader === S . uid ? ` <button class="btn sm" data-pull data-pull-btn>Pull now</button> ` : "" } < / d i v > ` : " " ;
let foe = ` <div class="foe-idle muted"> ${ run ? . away ? . length ? ` Waiting for ${ run . away . map ( esc ) . join ( ", " ) } to come back `
: run ? ` Ready: ${ esc ( run . next ) } ` : waiting ? ` 👥 Waiting for a group · 1 / ${ C . group _size } ` : "Not fighting." } < / d i v > ` ;
2026-10-20 15:30:05 +01:00
if ( f ) {
2026-10-24 09:49:21 +01:00
const mine = C . styles [ me . fighter . style || "sword" ] ? . damage || me . fighter . damage ;
const traits = ( v ) => ` <span class="v-attack"> ${ v . attack . map ( ( k ) => dmgIcon ( k , "attack" ) ) . join ( "" ) } </span>
< span class = "v-res" > $ { v . vulnerable . map ( ( k ) => ` <span class=" ${ k === mine ? "yours" : "" } "> ${ dmgIcon ( k , "vuln" ) } </span> ` ) . join ( "" ) } $ { v . resists . map ( ( k ) => ` <span class=" ${ k === mine ? "yours" : "" } "> ${ dmgIcon ( k , "resist" ) } </span> ` ) . join ( "" ) } < / s p a n > ` ;
foe = ` <div class="foe-head"><span class="foe-ico"> ${ encounter ? . icon || "" } </span><b> ${ esc ( f . enemy ) } </b></div>
2026-10-24 13:48:16 +01:00
< div class = "pack" > $ { f . pack . map ( ( v , i ) => unit ( ` e ${ i } ` , ` ${ esc ( v . name ) } ${ v . archetype === "elite" && ! v . boss ? ` <span class="muted">· rare</span> ` : "" } ` , true ,
2026-10-24 09:49:21 +01:00
` <span class="traits-inline"> ${ traits ( v ) } </span> ` ) ) . join ( "" ) } < / d i v >
2026-10-20 15:30:05 +01:00
< ul class = "fight-log" data - f = "log" > < / u l > ` ;
}
return ` <div class="panel fight combat-frame" ${ f ? ` data-fight=" ${ a . start } " ` : "" } >
2026-10-24 09:49:21 +01:00
< div class = "you-side" > $ { you } $ { abilityBar ( me ) } $ { prayerBar ( me ) } $ { paperDoll ( me , true ) } < / d i v >
2026-10-24 12:05:23 +01:00
< div class = "foe-side" > $ { stage } $ { foe } < / d i v >
2026-10-19 23:32:07 +01:00
< / d i v > ` ;
}
2026-10-24 12:05:23 +01:00
// Your abilities, from your weapon and your trade: each lights up as it's used and shows its cooldown.
// On AUTO a rule uses it on a longer cooldown; on YOU you press it (tap, or its number key) on the real
// one, for as long as you're watching. The weapon's ones still to come are dimmed with their level.
2026-10-24 09:49:21 +01:00
function abilityBar ( me ) {
const known = me . fighter . abilities , style = me . fighter . style || "sword" ;
const shield = ! ! me . equip . shield ;
const tile = ( id , on ) => {
2026-10-24 12:05:23 +01:00
const ab = C . abilities [ id ] , manual = on && me . manual . includes ( id ) ;
2026-10-24 09:49:21 +01:00
const why = on ? "" : ab . needs === "shield" && ! shield && me . levels . combat >= ab . level ? "needs a shield" : ` combat ${ ab . level } ` ;
2026-10-24 13:48:16 +01:00
const base = ( ab . cd || 0 ) * ( me . fighter . mods ? . [ id ] ? . cd ? ? 1 ) ;
const cd = base ? ` · ${ + ( manual ? base : base * C . auto _cd ) . toFixed ( 1 ) } s cooldown ` : "" ;
const boosts = ( me . fighter . boosts ? . [ id ] || [ ] ) . map ( ( n ) => ` · ✦ ${ n } ` ) . join ( "" ) ;
const tip = ` ${ ab . name } : ${ ab . tip } ${ cd } ${ boosts } ${ why ? ` · ${ why } ` : "" } ` ;
return ` <span class="ab ${ on ? ( manual ? "manual" : "auto" ) : "locked" } ${ boosts && on ? "boosted" : "" } " data-ab=" ${ id } ">
2026-10-24 12:05:23 +01:00
< button class = "ab-press" data - press = "${id}" title = "${esc(tip)}" $ { on ? "" : "disabled" } > $ { ab . icon } < span class = "ab-cd" data - ab - cd > < / s p a n >
$ { on ? "" : ` <span class="ab-lv"> ${ why === "needs a shield" ? glyph ( "checked-shield" , "#d4dae2" ) : ab . level } </span> ` } < / b u t t o n >
$ { on ? ` <button class="ab-mode" data-mode=" ${ id } " title=" ${ esc ( manual ? ` Yours: press it while you watch. Look away ${ C . watch } s and it's back on auto. Tap for auto. `
: ` Auto: used by itself on a ${ C . auto _cd } × cooldown. Tap to take it yourself.` ) } ">${manual ? " YOU " : " AUTO "}</button>` : " " }
< / s p a n > ` ;
2026-10-24 09:49:21 +01:00
} ;
const kit = C . weapon _kits [ style ] . map ( ( id ) => tile ( id , known . includes ( id ) ) ) ;
const trade = C . trade _ability [ me . cls ] ;
const potion = me . health _potion ;
return ` <div class="ability-bar"> ${ kit . join ( "" ) } ${ trade ? ` <span class="ab-gap"></span> ${ tile ( trade , true ) } ` : "" }
< span class = "ab-gap" > < / s p a n > < s p a n c l a s s = " a b p o t i o n $ { p o t i o n ? " " : " l o c k e d " } " d a t a - a b = " p o t i o n "
2026-10-24 10:01:41 +01:00
title = "${esc(potion ? `${item(potion).name}: +${item(potion).drink} HP, once a fight under ${Math.round(C.potion_below * 100)}% · ${fmt(me.bank[potion])} left` : " No health potion ")}" > $ { item ( potion || "health_potion_1" ) . icon } $ { potion ? ` <span class="swap-qty"> ${ fmt ( me . bank [ potion ] ) } </span> ` : "" } < / s p a n > < / d i v > ` ;
2026-10-24 09:49:21 +01:00
}
2026-10-20 15:30:05 +01:00
// Prayers you can switch on and off between swings, and the points left to pay for them.
function prayerBar ( me ) {
const level = me . levels . prayer ;
const buttons = C . prayers . filter ( ( p ) => p . level <= level ) . map ( ( p ) => {
const on = me . prayers . includes ( p . id ) ;
return ` <button class="pray-btn ${ on ? "on" : "" } " data-pray=" ${ p . id } " title=" ${ esc ( ` ${ p . name } : ${ p . text } · ${ p . cost } pt ${ p . cost > 1 ? "s" : "" } /fight ` ) } "> ${ p . icon } </button> ` ;
} ) . join ( "" ) ;
return ` <div class="prayer-bar"><span class="pp ${ me . prayers . length && ! me . blessing . cost ? "warn" : "" } " title="Prayer points"> ${ skillOf ( "prayer" ) . icon } ${ fmt ( me . pp ) } </span> ${ buttons } </div> ` ;
}
2026-10-24 09:49:21 +01:00
const SWINGS = new Set ( [ "hit" , "crit" , "miss" , "block" , "absorb" ] ) ;
2026-10-24 14:21:08 +01:00
const LAG = 0.6 ; // seconds the "just lost" part of an HP bar trails behind
const STOPPED = 1.2 ; // seconds an interrupted cast stays on its bar
2026-10-24 09:49:21 +01:00
// A line of the fight, terse: who, to whom, how much, with what.
function fightLine ( e , f ) {
const [ , src , tgt , kind , n , , label ] = e ;
const name = ( id ) => id === S . uid ? "You" : id ? . [ 0 ] === "e" ? esc ( f . pack [ + id . slice ( 1 ) ] . name ) : esc ( playerName ( id ) ) ;
const ab = C . abilities [ label ] || C . enemy _abilities [ label ] ;
const what = ab ? ` ${ ab . icon } ${ esc ( ab . name ) } ` : C . damage _types [ label ] ? ` ${ dmgIcon ( label ) } ` : "" ;
const hurtsMe = tgt === S . uid && src ? . [ 0 ] === "e" ;
const tone = hurtsMe ? "bad" : src === S . uid ? "" : "muted" ;
switch ( kind ) {
case "hit" : case "crit" : case "tick" :
return ` <li class=" ${ tone } "> ${ name ( src ) } → ${ name ( tgt ) } <b> ${ n } </b> ${ kind === "crit" ? " crit" : "" } ${ what } </li> ` ;
case "miss" : return ` <li class="muted"> ${ name ( src ) } → ${ name ( tgt ) } miss</li> ` ;
case "block" : return ` <li class=" ${ tgt === S . uid ? "good" : "muted" } "> ${ name ( tgt ) } block ${ tgt === S . uid ? "" : "s" } ${ what } </li> ` ;
2026-10-24 10:01:41 +01:00
case "absorb" : return ` <li class=" ${ tgt === S . uid ? "good" : "muted" } "> ${ C . abilities . ward . icon } ${ name ( tgt ) } soak ${ tgt === S . uid ? "" : "s" } ${ n } </li> ` ;
2026-10-24 09:49:21 +01:00
case "cast" : return ` <li class=" ${ src ? . [ 0 ] === "e" ? "warn" : tone } "> ${ name ( src ) } ${ what } …</li> ` ;
case "use" : return ` <li class=" ${ tone } "> ${ name ( src ) } ${ what } ${ tgt && tgt !== src ? ` → ${ name ( tgt ) } ` : "" } </li> ` ;
case "stop" : return ` <li class="good"> ${ name ( src ) } ✋ ${ name ( tgt ) } ${ what } </li> ` ;
2026-10-24 10:01:41 +01:00
case "heal" : return ` <li class="good"> ${ label === "potion" ? ` ${ name ( tgt ) } ${ item ( "health_potion_1" ) . icon } potion ` : ` ${ name ( src ) } ${ what } → ${ name ( tgt ) } ` } + ${ n } </li> ` ;
2026-10-24 09:49:21 +01:00
case "mend" : return ` <li class="muted"> ${ name ( tgt ) } + ${ n } ${ what } </li> ` ;
case "cc" : return ` <li class="good"> ${ what } ${ name ( tgt ) } held</li> ` ;
case "free" : return ` <li class="muted"> ${ name ( tgt ) } breaks free</li> ` ;
case "die" : return ` <li class="good"> ${ name ( tgt ) } falls</li> ` ;
2026-10-24 12:05:23 +01:00
case "enrage" : return ` <li class="bad"> ${ C . abilities . unstoppable . icon } ${ name ( src ) } ${ label === "hard" ? "<b>loses patience</b>" : "enrages" } </li> ` ;
case "yell" : return ` <li class="warn"> ${ name ( src ) } : <b>“ ${ esc ( label ) } ”</b></li> ` ;
case "join" : return ` <li class="warn"> ${ name ( src ) } joins the fight</li> ` ;
2026-10-24 10:01:41 +01:00
case "wind" : return ` <li class="good"> ${ C . prayers . find ( ( p ) => p . id === "second_wind" ) . icon } Second wind! ${ name ( src ) } </li> ` ;
case "down" : return ` <li class="bad"> ${ C . abilities . concuss . icon } <b> ${ src === S . uid ? "You're knocked out" : ` ${ name ( src ) } is knocked out ` } </b></li> ` ;
2026-10-24 09:49:21 +01:00
case "left" : return ` <li class="muted"> ${ name ( src ) } left</li> ` ;
}
return "" ;
}
2026-10-24 12:05:23 +01:00
// Whether a use of an ability came from a press (within the queue window before it), so it ran on its
// real cooldown.
function pressedNear ( f , uid , ab , time ) {
const m = f . members . find ( ( x ) => x . id === uid ) ;
return ( m . inputs || [ ] ) . some ( ( i ) => i [ 1 ] === "press" && i [ 2 ] === ab && time >= ( f . rest + i [ 0 ] ) / f . pace - 0.01
&& time <= ( f . rest + i [ 0 ] + 1.5 ) / f . pace + 0.01 ) ;
}
2026-10-24 09:49:21 +01:00
// Where everyone in the fight stands at `t` seconds into it: HP, last swing, cast under way, out or not,
// and when each of your abilities was last used.
function fightState ( f , t ) {
const rest = f . rest / f . pace , st = { } ;
2026-10-24 12:05:23 +01:00
for ( const m of f . members ) st [ m . id ] = { hp : m . hp _in , top : m . top , from : m . at / f . pace + rest , every : m . interval , swung : null ,
cast : null , out : null , cds : { } , manualAt : { } , pressed : new Set ( ( m . inputs || [ ] ) . filter ( ( i ) => i [ 1 ] === "press" ) . map ( ( i ) => i [ 2 ] ) ) } ;
f . pack . forEach ( ( foe , i ) => { st [ ` e ${ i } ` ] = { hp : foe . hp , top : foe . hp , from : rest , every : foe . interval / f . pace , swung : null , cast : null , out : null , held : false , enraged : false , hidden : ! ! foe . dormant } ; } ) ;
2026-10-24 09:49:21 +01:00
let shown = - 1 ;
while ( shown + 1 < f . events . length && f . events [ shown + 1 ] [ 0 ] <= t ) {
2026-10-24 14:21:08 +01:00
const [ time , src , tgt , kind , n , hp , label ] = f . events [ ++ shown ] ;
2026-10-24 09:49:21 +01:00
const who = st [ src ] , them = st [ tgt ] ;
if ( them && hp != null ) them . hp = hp ;
if ( who && SWINGS . has ( kind ) && ( label === "" || C . damage _types [ label ] ) ) who . swung = time ;
if ( who && kind === "cast" ) {
const spec = src [ 0 ] === "e" ? C . enemy _abilities [ label ] : C . abilities [ label ] ;
2026-10-24 13:48:16 +01:00
const mod = src [ 0 ] === "e" ? 1 : f . members . find ( ( m ) => m . id === src ) ? . mods ? . [ label ] ? . cast ? ? 1 ;
who . cast = { label , from : time , until : time + spec . cast * mod / f . pace } ;
2026-10-24 09:49:21 +01:00
}
const ab = C . abilities [ label ] ;
2026-10-24 12:05:23 +01:00
if ( who ? . cds && ab && ( ab . cast ? kind === "cast" : kind === "use" || SWINGS . has ( kind ) ) && who . cds [ label ] !== time ) {
who . cds [ label ] = time ;
who . manualAt [ label ] = who . pressed . has ( ` ${ label } ` ) && pressedNear ( f , src , label , time ) ;
}
if ( who ? . cds && tgt ? . [ 0 ] === "e" && ( SWINGS . has ( kind ) || kind === "cast" || kind === "use" ) ) who . aim = tgt ;
2026-10-24 09:49:21 +01:00
if ( who && kind === "heal" && label === "potion" ) who . drank = true ;
2026-10-24 14:21:08 +01:00
if ( them && kind === "stop" && them . cast ) them . stopped = { label : them . cast . label , at : time } ;
2026-10-24 09:49:21 +01:00
if ( them && ( kind === "stop" || kind === "cc" ) ) them . cast = null ;
2026-10-24 14:21:08 +01:00
if ( them && [ "hit" , "crit" , "tick" , "heal" , "mend" ] . includes ( kind ) && n ) them . hit = { at : time , n , kind } ;
2026-10-24 09:49:21 +01:00
if ( them && kind === "cc" ) them . held = true ;
if ( them && ( kind === "free" || kind === "die" ) ) them . held = false ;
if ( them && kind === "die" ) them . out = "dead" ;
if ( who && ( kind === "down" || kind === "left" ) ) who . out = kind ;
if ( who && kind === "enrage" ) who . enraged = true ;
2026-10-24 12:05:23 +01:00
if ( who && kind === "join" ) { who . hidden = false ; who . from = time ; }
2026-10-24 09:49:21 +01:00
}
return { st , shown } ;
2026-10-19 23:32:07 +01:00
}
function animateFight ( now ) {
2026-10-20 07:32:54 +01:00
const a = S . me ? . action , f = a ? . fight ;
if ( ! f ) return ;
2026-10-24 09:49:21 +01:00
const t = now - a . start , rest = f . rest / f . pace ;
2026-10-20 12:54:42 +01:00
const over = t >= f . end ;
2026-10-24 09:49:21 +01:00
const resting = t < rest ;
const { st , shown } = fightState ( f , t ) ;
2026-10-24 14:21:08 +01:00
const before = fightState ( f , t - LAG ) . st ; // the HP a moment ago, trailing the bar
2026-10-21 22:06:49 +01:00
const mine = f . members . find ( ( m ) => m . id === S . uid ) || f . members [ 0 ] ;
2026-10-24 09:49:21 +01:00
const me = st [ mine . id ] ;
// Resting: everyone's HP climbs back to full while they eat.
if ( resting ) for ( const m of f . members ) st [ m . id ] . hp = m . start _hp + ( m . hp _in - m . start _hp ) * Math . min ( 1 , t / rest ) ;
const enemies = f . pack . map ( ( _ , i ) => st [ ` e ${ i } ` ] ) ;
const left = enemies . reduce ( ( sum , e ) => sum + e . hp , 0 ) , total = enemies . reduce ( ( sum , e ) => sum + e . top , 0 ) ;
for ( const el of document . querySelectorAll ( "[data-progress]" ) ) el . style . width = ` ${ ( resting ? 1 : left / total ) * 100 } % ` ;
for ( const el of document . querySelectorAll ( "[data-live-hp]" ) ) el . textContent = fmt ( me . hp ) ;
for ( const el of document . querySelectorAll ( "[data-live-hpbar]" ) ) el . style . width = ` ${ Math . max ( 0 , me . hp / me . top * 100 ) } % ` ;
2026-10-20 12:54:42 +01:00
const panel = $ ( "[data-fight]" ) ;
if ( ! panel ) return ;
2026-10-24 09:49:21 +01:00
panel . classList . toggle ( "waiting" , resting ) ;
2026-10-24 12:05:23 +01:00
const pull = panel . querySelector ( "[data-pull-btn]" ) ;
if ( pull ) pull . hidden = ! resting ;
const pick = [ ... ( mine . inputs || [ ] ) ] . reverse ( ) . find ( ( i ) => i [ 1 ] === "target" && ( f . rest + i [ 0 ] ) / f . pace <= t && i [ 2 ] [ 0 ] === "e" ) ;
2026-10-24 14:21:08 +01:00
const live = ( id ) => id && st [ id ] && ! st [ id ] . out && ! st [ id ] . hidden ;
const tapped = aim . fight === a . start ? aim : { } ;
const aimed = live ( tapped . foe ) ? tapped . foe : live ( pick ? . [ 2 ] ) ? pick [ 2 ] : live ( me . aim ) ? me . aim : null ;
const healing = live ( tapped . ally ) ? tapped . ally : null ;
2026-10-24 09:49:21 +01:00
for ( const el of panel . querySelectorAll ( "[data-u]" ) ) {
const u = st [ el . dataset . u ] ;
if ( ! u ) continue ;
2026-10-24 14:21:08 +01:00
el . classList . toggle ( "aimed" , ( el . dataset . u === aimed || el . dataset . u === healing ) && ! over ) ;
2026-10-24 09:49:21 +01:00
const gone = ! ! u . out || over ;
el . classList . toggle ( "out" , ! ! u . out ) ;
2026-10-24 12:05:23 +01:00
el . hidden = ! ! u . hidden ;
2026-10-24 09:49:21 +01:00
el . querySelector ( "[data-u-bar]" ) . style . width = ` ${ Math . max ( 0 , u . hp / u . top * 100 ) } % ` ;
2026-10-24 14:21:08 +01:00
const was = before [ el . dataset . u ] ;
el . querySelector ( "[data-u-lag]" ) . style . width = ` ${ Math . max ( 0 , Math . max ( u . hp , resting ? 0 : was ? . hp ? ? u . hp ) / u . top * 100 ) } % ` ;
const hit = u . hit && t - u . hit . at < 1 ? u . hit : null , num = el . querySelector ( "[data-u-hitnum]" ) ;
const healed = hit && ( hit . kind === "heal" || hit . kind === "mend" ) ;
num . textContent = hit ? ` ${ healed ? "+" : "− " } ${ hit . n } ` : "" ;
num . className = ` hitnum ${ healed ? "good" : "bad" } ${ hit ? . kind === "crit" ? "crit" : "" } ` ;
num . style . opacity = hit ? ` ${ 1 - ( t - hit . at ) } ` : "0" ;
2026-10-24 09:49:21 +01:00
el . querySelector ( "[data-u-hp]" ) . textContent = u . out === "left" ? "left" : t < u . from && ! resting ? "joining…" : ` ${ fmt ( u . hp ) } / ${ fmt ( u . top ) } ` ;
2026-10-24 10:01:41 +01:00
const status = u . out === "dead" ? "" : u . out === "down" ? "concuss" : u . held ? "thornbind" : u . enraged ? "unstoppable" : "" ;
const statusEl = el . querySelector ( "[data-u-status]" ) ;
if ( statusEl . dataset . key !== status ) {
statusEl . dataset . key = status ;
statusEl . innerHTML = status ? C . abilities [ status ] . icon : "" ;
}
2026-10-24 09:49:21 +01:00
el . querySelector ( "[data-u-swing]" ) . style . width = gone || t < u . from ? "0%" : ` ${ Math . min ( 100 , ( t - ( u . swung ? ? u . from ) ) / u . every * 100 ) } % ` ;
const cast = ! gone && u . cast && t < u . cast . until ? u . cast : null ;
2026-10-24 14:21:08 +01:00
// A cast someone stopped stays up a moment, struck through.
const stopped = ! cast && u . stopped && t - u . stopped . at < STOPPED ? u . stopped : null ;
const table = el . dataset . u [ 0 ] === "e" ? C . enemy _abilities : C . abilities ;
const spec = cast ? table [ cast . label ] : stopped ? table [ stopped . label ] : null ;
2026-10-24 09:49:21 +01:00
const castEl = el . querySelector ( "[data-u-cast]" ) ;
2026-10-24 14:21:08 +01:00
const key = cast ? ` ${ cast . label } : ${ cast . from } ` : stopped ? ` stopped: ${ stopped . label } : ${ stopped . at } ` : "" ;
2026-10-24 09:49:21 +01:00
if ( castEl . dataset . key !== key ) {
castEl . dataset . key = key ;
2026-10-24 14:21:08 +01:00
castEl . classList . toggle ( "stopped" , ! ! stopped ) ;
castEl . innerHTML = cast ? ` <span> ${ spec . icon } ${ esc ( spec . name ) } </span><span class="bar"><span></span></span> `
: stopped ? ` <span>✋ <s> ${ spec . icon } ${ esc ( spec . name ) } </s></span><span class="small">interrupted</span> ` : "" ;
2026-10-24 09:49:21 +01:00
}
if ( cast ) castEl . querySelector ( ".bar > span" ) . style . width = ` ${ Math . min ( 100 , ( t - cast . from ) / ( cast . until - cast . from ) * 100 ) } % ` ;
}
// Your abilities: the shade is the cooldown left, a flash when one's just been used.
for ( const el of panel . querySelectorAll ( "[data-ab]" ) ) {
const id = el . dataset . ab ;
if ( id === "potion" ) { el . classList . toggle ( "spent" , ! ! me . drank ) ; continue ; }
const ab = C . abilities [ id ] , used = me . cds ? . [ id ] ;
2026-10-24 13:48:16 +01:00
const cd = ( ab . cd || 0 ) * ( mine . mods ? . [ id ] ? . cd ? ? 1 ) * ( me . manualAt ? . [ id ] ? 1 : C . auto _cd ) / f . pace ;
2026-10-24 09:49:21 +01:00
const leftCd = used == null || over ? 0 : Math . max ( 0 , used + cd - t ) ;
el . querySelector ( "[data-ab-cd]" ) . style . height = ` ${ cd ? leftCd / cd * 100 : 0 } % ` ;
el . classList . toggle ( "fired" , used != null && t - used < 0.6 ) ;
}
const log = panel . querySelector ( '[data-f="log"]' ) ;
if ( log ) {
const key = ` ${ a . start } : ${ shown } : ${ over } : ${ resting ? Math . ceil ( rest - t ) : "" } ` ;
if ( log . dataset . key !== key ) {
log . dataset . key = key ;
const eaten = Object . entries ( mine . eaten || { } ) ;
const lines = f . events . slice ( Math . max ( 0 , shown - 6 ) , shown + 1 ) . reverse ( ) . map ( ( e ) => fightLine ( e , f ) ) . filter ( Boolean ) ;
if ( over ) lines . unshift ( f . result === "won" ? ` <li class="good"><b>Victory!</b> Next fight coming…</li> `
: ` <li class="bad"><b> ${ f . result === "fled" ? "It dragged on: you fall back." : "Beaten." } </b></li> ` ) ;
log . innerHTML = resting ? ` <li class="muted">Resting · ${ Math . ceil ( rest - t ) } s</li> ${ eaten . length
? ` <li class="muted"> ${ HEART } ${ eaten . map ( ( [ id , q ] ) => ` ${ fmt ( q ) } ${ esc ( item ( id ) . name . toLowerCase ( ) ) } ` ) . join ( ", " ) } </li> ` : "" } `
: lines . join ( "" ) || ` <li class="muted">Squaring up…</li> ` ;
}
}
2026-10-19 23:32:07 +01:00
}
2026-10-20 19:59:45 +01:00
// A piece of jewellery's bonus, e.g. "+8% accuracy".
function jewelText ( it ) {
const st = C . jewel _stats [ it . jewel ] ;
2026-10-20 20:26:54 +01:00
return ` ${ st . sign || "+" } ${ it [ it . jewel ] } ${ st . unit } ${ st . name . toLowerCase ( ) } ` ;
2026-10-20 19:59:45 +01:00
}
2026-10-20 14:16:12 +01:00
// Power, armour and ward, and the armour's weight.
function gearStats ( it ) {
2026-10-20 19:59:45 +01:00
const stats = [ it . power && ` + ${ it . power } power ` , it . armour && ` + ${ it . armour } armour ` , it . ward && ` + ${ it . ward } ward ` ,
it . jewel && ! [ "power" , "ward" ] . includes ( it . jewel ) && jewelText ( it ) ] ;
2026-10-20 14:16:12 +01:00
return stats . filter ( Boolean ) . join ( ", " ) + ( it . weight ? ` ( ${ C . weights [ it . weight ] . name . toLowerCase ( ) } ) ` : "" ) ;
}
2026-10-20 19:59:45 +01:00
const GEAR _SLOTS = [ "weapon" , "shield" , "helmet" , "body" , "legs" , "ring" , "amulet" , "trinket" ] ;
2026-10-20 14:36:05 +01:00
// A weapon's or armour piece's stats in a small grid. Weapons: damage type and power | 1H or 2H | swing
// time; armour: armour | weight | ward. Below: what it needs to wear, and any bonus for you.
2026-10-20 15:03:22 +01:00
function gearChips ( it , who = S . me ) {
2026-10-20 14:36:05 +01:00
const cell = ( icon , text , tip , cls = "" ) => ` <span class="gstat ${ cls } " title=" ${ esc ( tip ) } "> ${ icon } ${ text === "" ? "" : ` <span> ${ text } </span> ` } </span> ` ;
const hands = ( two ) => ` <span class="hands"> ${ two ? "2H" : "1H" } </span> ` ;
let row = [ "" , "" , "" ] ;
if ( it . style ) {
const st = C . styles [ it . style ] , dt = C . damage _types [ st . damage ] ;
row = [ cell ( glyph ( dt . glyph , dt . tint ) , it . power , ` ${ dt . name } damage, + ${ it . power } power ` ) ,
cell ( hands ( it . two _handed ) , "" , it . two _handed ? "Two-handed: no shield" : "One-handed: room for a shield" ) ,
cell ( glyph ( "stopwatch" , "#9fb3c8" ) , ` ${ st . interval } s ` , ` A swing every ${ st . interval } s ` ) ] ;
} else if ( it . slot === "shield" ) {
row = [ cell ( glyph ( "checked-shield" , "#d4dae2" ) , it . armour , ` + ${ it . armour } armour, + ${ it . power } power ` ) ,
cell ( hands ( false ) , "" , "Held in one hand" ) ,
cell ( glyph ( "checked-shield" , "#e0b04a" ) , ` ${ Math . round ( C . block _chance * 100 ) } % ` , "Blocks this share of hits" ) ] ;
} else if ( it . weight ) {
row = [ cell ( glyph ( "checked-shield" , "#d4dae2" ) , it . armour , ` + ${ it . armour } armour: less damage from blows ` ) ,
cell ( "" , C . weights [ it . weight ] . name , ` ${ C . weights [ it . weight ] . name } armour ` , "muted" ) ,
cell ( glyph ( "magic-shield" , "#a77be8" ) , it . ward , ` + ${ it . ward } ward: less damage from magic ` ) ] ;
2026-10-20 19:59:45 +01:00
} else if ( it . jewel ) {
const st = C . jewel _stats [ it . jewel ] ;
2026-10-20 20:26:54 +01:00
row = [ cell ( glyph ( st . glyph , st . tint ) , ` ${ st . sign || "+" } ${ it [ it . jewel ] } ${ st . unit } ` , jewelText ( it ) ) , "<span></span>" , "<span></span>" ] ;
2026-10-20 14:36:05 +01:00
}
let req = "" , bonus = "" ;
if ( it . req ) {
const [ skill , level ] = it . req ;
2026-10-20 15:03:22 +01:00
req = cell ( skillOf ( skill ) . icon , level , ` Needs ${ skillOf ( skill ) . name . toLowerCase ( ) } ${ level } ` , who . levels [ skill ] < level ? "short" : "" ) ;
2026-10-20 14:36:05 +01:00
}
2026-10-20 15:03:22 +01:00
if ( it . style && C . class _style [ who . cls ] === it . style ) {
bonus = cell ( C . classes [ who . cls ] . icon , "+15%" , "The trade's own weapon: +15% damage" , "trade" ) ;
2026-10-20 14:36:05 +01:00
}
2026-10-24 13:48:16 +01:00
return ` <div class="stat-grid"> ${ row . join ( "" ) } ${ req } <span></span> ${ bonus || "<span></span>" } </div>
$ { it . unique ? ` <div class="small unique-text">✦ ${ esc ( it . unique ) } </div> ` : "" } ` ;
}
// A pattern: what it teaches, and the button to learn it (used up).
function patternRow ( id , it ) {
const made = item ( it . pattern ) , skill = skillOf ( it . skill ) , known = ( S . me . patterns || [ ] ) . includes ( id ) ;
const short = S . me . levels [ it . skill ] < it . level ;
return ` <div class="row"><div class="small">Teaches ${ made . icon } <b> ${ esc ( made . name ) } </b> · ${ skill . icon } ${ skill . name } ${ it . level } </div>
$ { known ? ` <span class="tag good">Known</span> ` : ` <button class="btn sm gold" data-learn=" ${ id } " ${ short ? ` disabled title="Needs ${ esc ( skill . name . toLowerCase ( ) ) } ${ it . level } " ` : "" } >Learn</button> ` } < / d i v > ` ;
2026-10-20 14:36:05 +01:00
}
2026-10-18 22:36:00 +01:00
// What an item does once made: healing, defence, tool speed, and who can use it.
function itemStats ( id ) {
const it = item ( id ) ;
const parts = [ ] ;
if ( it . heal ) parts . push ( ` ❤️ Heals ${ it . heal } ` ) ;
2026-10-24 09:49:21 +01:00
if ( it . drink ) parts . push ( ` ❤️ + ${ it . drink } in a fight ` ) ;
2026-10-20 08:01:57 +01:00
if ( it . food ) parts . push ( ` feeds ${ it . food } for 2 days ` ) ;
2026-10-18 22:36:00 +01:00
if ( it . power ) parts . push ( ` + ${ it . power } power ` ) ;
2026-10-19 23:38:34 +01:00
if ( it . style ) {
const st = C . styles [ it . style ] , trade = Object . entries ( C . class _style ) . find ( ( [ , s ] ) => s === it . style ) ? . [ 0 ] ;
2026-10-20 08:01:57 +01:00
parts . push ( ` ${ it . two _handed ? "two-handed " : "" } ${ st . name } , ${ st . interval } s ${ trade ? ` ( ${ C . classes [ trade ] . name . toLowerCase ( ) } ) ` : "" } ` ) ;
2026-10-19 23:38:34 +01:00
}
2026-10-20 14:16:12 +01:00
if ( it . armour || it . ward ) parts . push ( gearStats ( { ... it , power : 0 } ) ) ;
2026-10-20 19:59:45 +01:00
if ( it . reagent ) parts . push ( it . reagent === "jewel" ? "Reagent for jewellery" : ` Reagent for ${ C . weights [ it . reagent ] . name . toLowerCase ( ) } armour ` ) ;
if ( it . jewel && ! [ "power" , "ward" ] . includes ( it . jewel ) ) parts . push ( jewelText ( it ) ) ;
2026-10-20 21:20:25 +01:00
if ( it . tool || it . station ) parts . push ( ` ${ Math . round ( it . speed * 100 ) } % faster ${ skillOf ( it . tool || it . station ) . name . toLowerCase ( ) } ` ) ;
2026-10-20 21:00:31 +01:00
if ( it . slots ) parts . push ( storageText ( it ) ) ;
if ( it . stack ) parts . push ( storageText ( it ) ) ;
2026-10-18 22:36:00 +01:00
if ( it . req && it . req [ 1 ] > 1 ) parts . push ( ` needs ${ skillOf ( it . req [ 0 ] ) . name . toLowerCase ( ) } ${ it . req [ 1 ] } ` ) ;
2026-10-18 23:12:57 +01:00
if ( it . potion ) parts . push ( ` ${ it . potion . text } , ${ C . potion _charges } uses ` ) ;
2026-10-20 08:01:57 +01:00
if ( it . trophy ) parts . push ( ` Trophy for stronger weapons ` ) ;
2026-10-19 11:15:19 +01:00
if ( it . fitting ) {
const f = C . fittings . find ( ( x ) => x . id === id ) , done = S . city . buildings [ f . building ] . fittings . includes ( id ) ;
2026-10-20 08:01:57 +01:00
parts . push ( ` ${ C . buildings [ f . building ] . icon } ${ C . buildings [ f . building ] . name } Lv ${ f . level } : ${ f . effect } ${ done ? " · fitted" : "" } ` ) ;
2026-10-19 11:15:19 +01:00
}
2026-10-18 22:36:00 +01:00
return parts . join ( ", " ) ;
}
2026-10-18 13:48:56 +01:00
function actionCard ( a ) {
const me = S . me ;
const active = me . action ? . id === a . id ;
2026-10-19 23:17:23 +01:00
const second = me . action ? . plot2 === a . id ; // a homesteader's second plot
const growingNow = me . action ? . growing === a . id ;
const canPlot = me . cls === "homesteader" && a . skill === "farming" && ! a . special && ! active && ! second
&& me . action && C . actions . find ( ( x ) => x . id === me . action . id ) ? . skill === "farming" && ! me . action . left ;
2026-10-18 13:48:56 +01:00
const why = me . blocked [ a . id ] ;
2026-10-18 22:13:50 +01:00
const locked = isLocked ( a ) ;
2026-10-18 13:48:56 +01:00
const out = Object . keys ( a . outputs ) [ 0 ] ;
2026-10-19 10:04:38 +01:00
const icon = a . icon ;
2026-10-18 13:48:56 +01:00
const time = me . times [ a . id ] ;
let io = "" ;
if ( Object . keys ( a . inputs ) . length ) {
2026-10-21 12:16:22 +01:00
io += inputChips ( a ) + ( Object . keys ( a . outputs ) . length || a . finds ? ` <span class="arrow">→</span> ` : "" ) ;
2026-10-18 13:48:56 +01:00
}
io += Object . entries ( a . outputs ) . map ( ( [ id , q ] ) => itemChip ( id , q ) ) . join ( "" ) ;
2026-10-20 19:59:45 +01:00
const chance = ( [ id , q , odds ] ) => ` <span class="item-chip" title=" ${ esc ( item ( id ) . name ) } ${ q > 1 ? ` × ${ q } ` : "" } : ${ Math . round ( odds * 100 ) } % chance"> ${ item ( id ) . icon } ${ Math . round ( odds * 100 ) } %</span> ` ;
if ( a . finds ) io += a . finds . map ( chance ) . join ( "" ) ;
2026-10-18 13:48:56 +01:00
let extra = "" ;
2026-10-19 23:32:07 +01:00
if ( a . enemy ) {
2026-10-20 14:21:34 +01:00
const o = me . fight _odds [ a . id ] , e = a . enemy ;
2026-10-24 12:05:23 +01:00
const own = ( a . dungeon ? ` <span class="tag warn" title=" ${ esc ( ` ${ a . pulls . length } pulls: ${ a . pulls . join ( " · " ) } . Trash falls on auto; the bosses take your own hands. ${ C . dungeon _wipes } wipes on a pull and the run's over. ` ) } ">🏰 Dungeon · ${ C . group _size } – ${ C . dungeon _size } </span> `
: a . together ? ` <span class="tag warn" title="Fought by ${ C . group _size } or more: alone, you wait for someone to join">👥 Group</span> ` : "" )
2026-10-24 09:49:21 +01:00
+ ( a . personal _loot ? ` <span class="tag good" title="Everyone in the fight gets their own loot">🎁 Own loot</span> ` : "" ) ;
2026-10-22 14:08:41 +01:00
extra = ` ${ e . boss || own ? ` <div> ${ e . boss ? ` <span class="tag warn">Boss</span> ` : "" } ${ own } </div> ` : "" } ${ variantRows ( e , o ) } ${ fightingHere ( a , why ) } ` ;
2026-10-20 14:21:34 +01:00
if ( a . gold ) io += ` <span class="item-chip" title="Gold: ${ a . gold [ 0 ] } – ${ a . gold [ 1 ] } a kill"> ${ COIN } ${ a . gold [ 0 ] } – ${ a . gold [ 1 ] } </span> ` ;
2026-10-20 19:59:45 +01:00
if ( a . drops ) io += a . drops . map ( chance ) . join ( "" ) ;
2026-10-24 13:48:16 +01:00
if ( a . loot ) {
const l = a . loot , pct = ( x ) => ` ${ Math . round ( x * 100 ) } % ` ;
io += ` <span class="item-chip" title=" ${ esc ( ` ${ item ( l . relic ) . name } : from every boss, to reforge masterworks ` ) } "> ${ item ( l . relic ) . icon } </span> `
+ ` <span class="item-chip" title=" ${ esc ( ` Patterns ( ${ l . patterns . length } ): ${ l . patterns . map ( ( p ) => item ( item ( p ) . pattern ) . name ) . join ( ", " ) } . ${ pct ( l . odds . pattern [ 0 ] ) } from a mini-boss, ${ pct ( l . odds . pattern [ 1 ] ) } from the last boss ` ) } "> ${ item ( l . patterns [ 0 ] ) . icon } ${ l . patterns . length } </span> `
+ ` <span class="item-chip" title=" ${ esc ( ` ${ item ( l . unique ) . name } : ${ item ( l . unique ) . unique } ` ) } "> ${ item ( l . unique ) . icon } ${ pct ( l . odds . unique ) } </span> ` ;
}
2026-10-24 11:43:56 +01:00
if ( a . special === "sabotage" ) extra += ` <div class="small muted">-1% raid strength per win (max 30%)</div> ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-19 15:21:49 +01:00
if ( a . special === "drill" ) {
const s = S . favours . sergeant , boost = s . boost ;
2026-10-20 08:01:57 +01:00
extra = ! s . needed ? ` <div class="small muted">Only while a raid is coming.</div> `
2026-10-20 12:14:26 +01:00
: ` <div class="small makes"> ${ skillOf ( "combat" ) . icon } + ${ + me . drill _per _action . toFixed ( 2 ) } drill · ${ fmt ( s . points ) } / ${ fmt ( s . needed ) } points, worth ${ fmt ( s . defence ) } of ${ fmt ( s . cap ) } defence</div>
$ { boostBar ( s . points , s . needed , boost , "Drill sergeant: this stretch drills twice as fast" ) } ` ;
2026-10-19 15:21:49 +01:00
}
2026-10-19 15:14:00 +01:00
if ( a . special === "recon" ) {
const boost = S . favours . informant . boost ;
2026-10-19 15:39:02 +01:00
const ev = S . upcoming , full = ev && ev . recon >= ev . recon _full ;
2026-10-20 08:01:57 +01:00
extra = full ? ` <div class="small muted">Fully scouted: XP only.</div> `
2026-10-20 12:14:26 +01:00
: ` <div class="small makes"> ${ skillOf ( "reconnaissance" ) . icon } + ${ + me . recon _gain [ a . id ] . toFixed ( 2 ) } recon</div>
$ { ev ? . recon _full ? boostBar ( ev . recon , ev . recon _full , boost , "Informant: this stretch scouts twice as fast" ) : "" } ` ;
2026-10-19 15:14:00 +01:00
}
2026-10-18 23:09:17 +01:00
const temple = S . city . buildings . temple ? . level || 0 ;
2026-10-19 11:01:29 +01:00
if ( a . special === "labour" ) {
2026-10-20 12:14:26 +01:00
extra = ` <div class="small makes"> ${ skillOf ( "construction" ) . icon } + ${ me . labour _per _action } labour per action</div> ` ;
2026-10-19 11:01:29 +01:00
}
2026-10-19 10:04:38 +01:00
if ( a . special === "bury" ) extra = ` <div class="small makes"> ${ skillOf ( "prayer" ) . icon } + ${ a . pp + temple } prayer points</div> ` ;
2026-10-20 08:01:57 +01:00
if ( a . special === "offer" ) extra = ` <div class="small makes"> ${ C . buildings . temple . icon } + ${ + ( a . devotion * ( 1 + 0.25 * temple ) ) . toFixed ( 1 ) } devotion</div> ` ;
2026-10-20 14:36:05 +01:00
const gear = Object . keys ( a . outputs ) . filter ( ( id ) => GEAR _SLOTS . includes ( item ( id ) . slot ) ) ;
const made = Object . keys ( a . outputs ) . filter ( ( id ) => ! gear . includes ( id ) ) . map ( itemStats ) . filter ( Boolean ) ;
extra += gear . map ( ( id ) => gearChips ( item ( id ) ) ) . join ( "" ) ;
2026-10-18 22:36:00 +01:00
if ( made . length ) extra += ` <div class="small makes"> ${ made . join ( " · " ) } </div> ` ;
2026-10-18 13:48:56 +01:00
return `
2026-10-19 23:17:23 +01:00
< div class = "action ${active || second ? " active " : " "} ${locked ? " locked " : " "}" >
2026-10-18 13:48:56 +01:00
< div class = "top" > < div class = "ico" > $ { icon } < / d i v > < d i v >
2026-10-23 22:13:32 +01:00
< div class = "name" > $ { esc ( a . name ) } $ { Object . keys ( a . outputs ) . map ( needTag ) . join ( "" ) } < / d i v >
2026-10-18 13:48:56 +01:00
< div class = "sub" > Level $ { a . level } · $ { dur ( time ) } · $ { a . xp } XP < / d i v >
2026-10-20 21:13:46 +01:00
< / d i v > $ { O b j e c t . k e y s ( a . i n p u t s ) . l e n g t h ? ` < b u t t o n c l a s s = " p i n $ { m e . t r a c k e d = = = a . i d ? " o n " : " " } " d a t a - t r a c k = " $ { a . i d } " t i t l e = " $ { m e . t r a c k e d = = = a . i d ? " U n t r a c k " : " T r a c k " } " > 📌 < / b u t t o n > ` : " " } < / d i v >
2026-10-18 13:48:56 +01:00
$ { io ? ` <div class="io"> ${ io } </div> ` : "" }
$ { extra }
2026-10-19 23:17:23 +01:00
$ { me . action ? . plot2 && ( active || second ) ? ` <div class="small muted">Plot ${ active ? 1 : 2 } ${ growingNow ? " · growing now" : " · next" } </div> ` : "" }
2026-10-20 07:32:54 +01:00
$ { growingNow && ! a . enemy ? ` <div class="bar thick"><span data-progress></span></div> ` : "" }
2026-10-21 22:06:49 +01:00
$ { active && a . enemy ? ` <div><span class="tag gold">⚔️ Fighting ${ me . action . with ? . length ? ` with ${ me . action . with . map ( ( uid ) => esc ( playerName ( uid ) ) ) . join ( ", " ) } ` : "" } </span></div> ` : "" }
2026-10-18 19:35:21 +01:00
$ { active && me . action . left ? ` <div class="small muted">Just one ${ me . action . resume ? ` , then back to ${ esc ( C . actions . find ( ( x ) => x . id === me . action . resume ) . name . toLowerCase ( ) ) } ` : "" } </div> ` : "" }
2026-10-18 13:48:56 +01:00
< div class = "foot" >
2026-10-19 23:17:23 +01:00
< span class = "why" > $ { ! active && ! second && why ? esc ( why ) : "" } < / s p a n >
$ { second ? ` <button class="btn" data-plot="">Clear plot 2</button> `
: active ? ` <button class="btn danger" data-stop>Stop</button> `
2026-10-20 08:01:57 +01:00
: ` <span class="btns"><button class="btn" data-once=" ${ a . id } " ${ why ? "disabled" : "" } title="Once, then resume"> ${ a . enemy ? "Fight once" : Object . keys ( a . outputs ) . length ? "Make one" : "Once" } </button>
$ { canPlot ? ` <button class="btn" data-plot=" ${ a . id } " ${ why ? "disabled" : "" } title="Grow in plot 2">Plot 2</button> ` : "" }
2026-10-18 19:35:21 +01:00
< button class = "btn primary" data - start = "${a.id}" $ { why ? "disabled" : "" } > Start < / b u t t o n > < / s p a n > ` }
2026-10-18 13:48:56 +01:00
< / d i v >
< / d i v > ` ;
}
2026-10-21 22:06:49 +01:00
// Who else is fighting this encounter, with a button to tag in: one per group fighting together.
function fightingHere ( a , why ) {
const mine = S . me . action ? . party ;
const here = S . players . filter ( ( p ) => p . id !== S . uid && p . fight === a . id && ! ( mine && p . party === mine ) ) ;
const groups = [ ... new Map ( here . map ( ( p ) => [ p . party || p . id , here . filter ( ( q ) => ( q . party || q . id ) === ( p . party || p . id ) ) ] ) ) . values ( ) ] ;
return groups . map ( ( group ) => ` <div class="fighting-here"><span class="small">⚔️ ${ group . map ( ( p ) => esc ( p . name ) ) . join ( ", " ) } </span>
< button class = "btn sm" data - join = "${esc(group[0].id)}" $ { why ? ` disabled title=" ${ esc ( why ) } " ` : "" } > Join < / b u t t o n > < / d i v > ` ) . j o i n ( " " ) ;
}
2026-10-18 13:48:56 +01:00
// --- bank -----------------------------------------------------------------
2026-10-20 21:00:31 +01:00
// A strongbox's extra slots or a bag's bigger stack.
function storageText ( it ) {
return it . slots ? ` + ${ it . slots } bank slots ` : ` + ${ fmt ( it . stack ) } per stack ` ;
}
// The strongbox and bag slots, each with what else you could swap in.
function storageGear ( ) {
const me = S . me ;
const card = ( slot , empty , outline ) => {
const id = me . equip [ slot ] , it = id && item ( id ) , size = ( x ) => x . slots || x . stack ;
const others = Object . keys ( me . bank ) . filter ( ( x ) => item ( x ) . slot === slot ) . sort ( ( a , b ) => size ( item ( b ) ) - size ( item ( a ) ) )
. map ( ( x ) => ` <button class="doll-slot swap" data-equip=" ${ x } " title=" ${ esc ( ` ${ item ( x ) . name } : ${ storageText ( item ( x ) ) } ` ) } "> ${ item ( x ) . icon } </button> ` ) . join ( "" ) ;
return ` <div class="panel gear-card">
< span class = "doll-slot ${it ? " " : " empty "}" title = "${esc(it ? it.name : empty)}" > $ { it ? it . icon : glyph ( outline , "#98a1b4" ) } < / s p a n >
< div class = "gear-text" > $ { it ? ` <b> ${ esc ( it . name ) } </b><span class="muted small"> ${ storageText ( it ) } </span> ` : ` <span class="muted"> ${ empty } </span> ` } < / d i v >
$ { others || it ? ` <div class="swap-grid"> ${ others } ${ it ? ` <button class="doll-slot swap off" data-unequip=" ${ slot } " title="Take it off"> ${ glyph ( "cancel" , "#98a1b4" ) } </button> ` : "" } </div> ` : "" } < / d i v > ` ;
} ;
return ` <div class="gear-row"> ${ card ( "storage" , "No strongbox" , "locked-chest" ) } ${ card ( "bag" , "No bag" , "shoulder-bag" ) } </div> ` ;
}
2026-10-18 13:48:56 +01:00
function bank ( ) {
const me = S . me ;
const entries = Object . entries ( me . bank ) . sort ( ( x , y ) => Object . keys ( C . items ) . indexOf ( x [ 0 ] ) - Object . keys ( C . items ) . indexOf ( y [ 0 ] ) ) ;
if ( selected && ! me . bank [ selected ] ) selected = null ;
2026-10-19 10:52:07 +01:00
// An item past one full stack spills into more slots: 2,500 coal shows as 1,000 + 1,000 + 500.
const stacks = entries . flatMap ( ( [ id , q ] ) => {
const parts = [ ] ;
for ( let left = q ; left > 0 ; left -= me . stack ) parts . push ( [ id , Math . min ( left , me . stack ) ] ) ;
return parts ;
} ) ;
let slots = stacks . map ( ( [ id , q ] ) => `
2026-10-19 11:23:26 +01:00
< button class = "slot ${selected === id ? " on " : " "}" data - select = "${id}" title = "${esc(item(id).name)}: ${fmt(me.bank[id])}${me.bank[id] > me.stack ? ` across ${Math.ceil(me.bank[id] / me.stack)} slots` : " "}" >
2026-10-18 13:48:56 +01:00
< span class = "qty ${q >= me.stack ? " full " : " "}" > $ { fmt ( q ) } < / s p a n > $ { i t e m ( i d ) . i c o n } < s p a n c l a s s = " n m " > $ { e s c ( i t e m ( i d ) . n a m e ) } < / s p a n >
< / b u t t o n > ` ) . j o i n ( " " ) ;
2026-10-19 10:52:07 +01:00
for ( let i = stacks . length ; i < me . slots ; i ++ ) slots += ` <div class="slot empty"></div> ` ;
2026-10-18 13:48:56 +01:00
return `
2026-10-20 21:00:31 +01:00
$ { storageGear ( ) }
2026-10-18 13:48:56 +01:00
< div class = "bank-wrap" >
< div >
< div class = "panel" >
2026-10-19 10:52:07 +01:00
< h2 > Bank · $ { me . slots _used } / $ { me . slots } slots · $ { fmt ( me . stack ) } per stack < / h 2 >
2026-10-18 13:48:56 +01:00
< div class = "slots" > $ { slots } < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Bank full :
2026-10-18 13:48:56 +01:00
< button class = "btn sm ${me.overflow === " city " ? " gold " : " "}" data - overflow = "city" > Send it to the city < / b u t t o n >
< button class = "btn sm ${me.overflow === " stop " ? " gold " : " "}" data - overflow = "stop" > Stop working < / b u t t o n >
2026-10-20 08:01:57 +01:00
< br > Overflow earns half favour ; what the city can ' t use is sold . < / p >
2026-10-18 13:48:56 +01:00
< / d i v >
$ { favourPanel ( ) }
2026-10-24 13:34:59 +01:00
$ { strongboxPanel ( ) }
2026-10-18 13:48:56 +01:00
< / d i v >
< div > $ { selected ? itemPanel ( selected ) : goldPanel ( ) } < / d i v >
< / d i v > ` ;
}
function itemPanel ( id ) {
const me = S . me , it = item ( id ) , have = me . bank [ id ] ;
const merchant = S . city . merchant _until > serverNow ( ) ;
const price = it . value * ( merchant ? 1.5 : 1 ) ;
const others = S . players . filter ( ( p ) => p . id !== S . uid ) ;
const qtyBtns = ( attr ) => [ 1 , 10 , 100 ] . filter ( ( q ) => q <= have ) . map ( ( q ) => ` <button class="btn sm" ${ attr } =" ${ id } " data-qty=" ${ q } "> ${ q } </button> ` ) . join ( "" )
+ ` <button class="btn sm" ${ attr } =" ${ id } " data-qty="all">All ( ${ fmt ( have ) } )</button> ` ;
return ` <div class="panel item-panel">
< div class = "ico" > $ { it . icon } < / d i v >
< h3 > $ { esc ( it . name ) } < / h 3 >
< div class = "muted small" > You have $ { fmt ( have ) } · worth $ { price % 1 ? price . toFixed ( 1 ) : price } gold each$ { merchant ? " (merchant in town!)" : "" } < / d i v >
2026-10-20 14:36:05 +01:00
$ { GEAR _SLOTS . includes ( it . slot ) ? gearChips ( it ) : "" }
2026-10-18 22:13:50 +01:00
$ { it . slot ? equipRow ( id , it ) : "" }
2026-10-18 23:12:57 +01:00
$ { it . potion ? ` <div class="row"><div class="small"> ${ esc ( it . potion . text ) } for ${ C . potion _charges } ${ it . potion . skill === "combat" ? "fights" : "actions" } .</div>
< button class = "btn gold" data - potion = "${id}" > Drink < / b u t t o n >
$ { S . me . potions [ it . potion . skill ] && S . me . potions [ it . potion . skill ] . item !== id ? ` <div class="small why">Replaces your ${ esc ( item ( S . me . potions [ it . potion . skill ] . item ) . name . toLowerCase ( ) ) } ( ${ S . me . potions [ it . potion . skill ] . charges } uses left)</div> ` : "" } < / d i v > ` : " " }
2026-10-24 09:49:21 +01:00
$ { it . heal ? ` <div class="row"><div class="small muted">Heals ${ it . heal } resting between fights.</div>
$ { S . me . food === id ? ` <span class="tag good">Your fighting food</span> ` : ` <button class="btn sm" data-food=" ${ id } ">Eat this first</button> ` } < / d i v > ` : " " }
$ { it . drink ? ` <div class="row small muted">+ ${ it . drink } HP in a fight, drunk once a fight under ${ Math . round ( C . potion _below * 100 ) } % HP.</div> ` : "" }
2026-10-24 13:48:16 +01:00
$ { it . pattern ? patternRow ( id , it ) : "" }
$ { it . relic ? ` <div class="row small muted">Reforges masterworks with a pattern from ${ esc ( C . actions . find ( ( a ) => a . id === it . relic ) . name ) } .</div> ` : "" }
2026-10-20 08:01:57 +01:00
$ { it . food ? ` <div class="row small muted">Feeds ${ it . food } townsfolk for 2 days.</div> ` : "" }
2026-10-18 22:40:36 +01:00
$ { C . city _uses [ id ] . length
? ` <div class="row"><div class="k">Donate to the city · + ${ it . value } favour each</div><div class="btns"> ${ qtyBtns ( "data-donate" ) } </div>
< div class = "small muted" > Used for $ { esc ( listing ( C . city _uses [ id ] ) ) } . < / d i v > < / d i v > `
2026-10-20 08:01:57 +01:00
: ` <div class="row small muted">The city has no use for this.</div> ` }
2026-10-18 13:48:56 +01:00
$ { others . length ? ` <div class="row"><div class="k">Give to <select data-keep="give-to"> ${ others . map ( ( p ) => ` <option value=" ${ esc ( p . id ) } "> ${ esc ( p . name ) } </option> ` ) . join ( "" ) } </select></div><div class="btns"> ${ qtyBtns ( "data-give" ) } </div></div> ` : "" }
< div class = "row" > < div class = "k" > Sell < / d i v > < d i v c l a s s = " b t n s " > $ { q t y B t n s ( " d a t a - s e l l " ) } < / d i v > < / d i v >
< div class = "row" > < button class = "btn sm" data - select = "" > Close < / b u t t o n > < / d i v >
< / d i v > $ { g o l d P a n e l ( ) } ` ;
}
2026-10-18 22:40:36 +01:00
function listing ( words ) {
return words . length < 2 ? words . join ( "" ) : ` ${ words . slice ( 0 , - 1 ) . join ( ", " ) } and ${ words . at ( - 1 ) } ` ;
}
2026-10-18 22:13:50 +01:00
function equipRow ( id , it ) {
const [ skill , level ] = it . req || [ ] ;
const short = skill && S . me . levels [ skill ] < level ;
return ` <div class="row"><button class="btn gold" data-equip=" ${ id } " ${ short ? "disabled" : "" } > ${ equipLabel ( it ) } </button>
$ { skill ? ` <div class="small ${ short ? "why" : "muted" } ">Needs ${ esc ( skillOf ( skill ) . name . toLowerCase ( ) ) } level ${ level } </div> ` : "" } < / d i v > ` ;
}
2026-10-18 19:27:56 +01:00
function equipLabel ( it ) {
2026-10-20 21:20:25 +01:00
if ( it . tool || it . station ) return ` Use for ${ skillOf ( it . tool || it . station ) . name . toLowerCase ( ) } ( ${ Math . round ( it . speed * 100 ) } % faster) ` ;
2026-10-20 14:36:05 +01:00
return "Equip" ;
2026-10-18 19:27:56 +01:00
}
2026-10-18 13:48:56 +01:00
function goldPanel ( ) {
return ` <div class="panel"><h2>Gold</h2>
2026-10-19 10:04:38 +01:00
< p class = "gold" > $ { COIN } $ { fmt ( S . me . gold ) } < / p >
2026-10-18 13:48:56 +01:00
< form data - form = "gold" class = "btns" > < input type = "number" min = "1" data - keep = "gold" placeholder = "Amount" style = "width:120px" > < button class = "btn gold" > Donate to treasury < / b u t t o n > < / f o r m >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > 1 favour per gold . < / p > < / d i v > ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-24 13:34:59 +01:00
function strongboxPanel ( ) {
const me = S . me ;
return ` <div class="panel"><h2>Strongbox ${ me . rented ? ` · ${ me . rented } rented ` : "" } </h2>
< div class = "btns" > < button class = "btn gold" data - rent $ { me . gold < me . rent _cost ? "disabled" : "" } > + 1 bank slot ( $ { COIN } $ { fmt ( me . rent _cost ) } ) < / b u t t o n > < / d i v >
< p class = "small muted" > Yours for good . Each costs 35 % more . < / p > < / d i v > ` ;
}
2026-10-18 13:48:56 +01:00
function favourPanel ( ) {
const me = S . me ;
return ` <div class="panel"><h2>Favour with the council</h2>
2026-10-19 10:04:38 +01:00
< p class = "favour" > $ { FAVOUR } $ { fmt ( me . favour ) } favour · $ { fmt ( me . donated ) } given in total < / p >
2026-10-18 13:48:56 +01:00
< div class = "btns" > < button class = "btn" data - perk = "bank_slot" $ { me . favour < me . perk _costs . bank _slot ? "disabled" : "" } > + 1 bank slot ( $ { fmt ( me . perk _costs . bank _slot ) } favour ) < / b u t t o n > < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > More to spend on in < button class = "linkish" data - nav = "council" > Council & amp ; threats < / b u t t o n > . < / p > < / d i v > ` ;
2026-10-18 13:48:56 +01:00
}
// --- town -----------------------------------------------------------------
function town ( ) {
const city = S . city ;
2026-10-18 19:20:52 +01:00
const growth = city . starving ? ` <span class="bad">Hungry: ${ city . growth } a day</span> `
: city . growth > 0 ? ` + ${ city . growth } a day `
: city . growth < 0 ? ` <span class="bad">Overcrowded: ${ city . growth } a day</span> `
2026-10-20 08:01:57 +01:00
: ` <span class="warn">Full: build houses</span> ` ;
const next = city . next _tier ? ` ${ city . next _tier . name } at ${ city . next _tier . pop } people ` : "Largest tier" ;
2026-10-18 13:48:56 +01:00
let html = ` <div class="stats">
2026-10-18 19:20:52 +01:00
< div class = "stat" > < div class = "k" > Settlement < / d i v > < d i v c l a s s = " v " > $ { c i t y . t i e r _ n a m e } < / d i v > < d i v c l a s s = " n o t e " > $ { n e x t } < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Population < /div><div class="v">${city.population} <small>/ $ { city . housing } homes < / s m a l l > < / d i v > < d i v c l a s s = " n o t e " > $ { g r o w t h } < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Food < / d i v > < d i v c l a s s = " v $ { c i t y . f o o d _ d a y s < 1 ? " b a d " : " " } " > $ { c i t y . f o o d _ d a y s ? ? 0 } < s m a l l > d a y s < / s m a l l > < / d i v > < d i v c l a s s = " n o t e " > $ { f m t ( c i t y . f o o d ) } m e a l s i n t h e g r a n a r y < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Treasury < / d i v > < d i v c l a s s = " v g o l d " > $ { f m t ( c i t y . t r e a s u r y ) } < / d i v > < d i v c l a s s = " n o t e " > D a y $ { c i t y . d a y } < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Defence < / d i v > < d i v c l a s s = " v " > $ { f m t ( c i t y . d e f e n c e . t o t a l ) } < / d i v > < d i v c l a s s = " n o t e " > $ { c i t y . r e p e l l e d } r a i d $ { c i t y . r e p e l l e d = = = 1 ? " " : " s " } r e p e l l e d < / d i v > < / d i v >
2026-10-19 09:34:18 +01:00
$ { weatherCard ( ) }
2026-10-18 13:48:56 +01:00
< / d i v > ` ;
2026-10-19 09:34:18 +01:00
html += happeningsPanel ( ) ;
2026-10-24 13:34:59 +01:00
html += caravanPanel ( ) ;
2026-10-20 08:01:57 +01:00
if ( city . starving ) html += ` <div class="banner bad">🍞<div class="grow"><b>Granary empty.</b> People are leaving, defence is down. Donate food.</div></div> ` ;
if ( city . merchant _until > serverNow ( ) ) html += ` <div class="banner">🐪<div class="grow"><b>Merchant in town.</b> Sell prices +50% for ${ countdown ( city . merchant _until ) } .</div></div> ` ;
2026-10-19 09:24:45 +01:00
if ( city . construction ) html += siteBanner ( ) ;
2026-10-18 19:20:52 +01:00
html += ` <div class="section-title">Buildings</div>
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Funds are safe from raiders . Building starts once a fund is full . < / p >
2026-10-18 19:20:52 +01:00
< div class = "grid wide" > $ { Object . entries ( city . buildings ) . map ( ( [ key , b ] ) => buildingCard ( key , b ) ) . join ( "" ) } < / d i v > ` ;
2026-10-24 13:34:59 +01:00
html += monumentsPanel ( ) ;
2026-10-18 19:20:52 +01:00
html += ` <div class="section-title">City stockpile</div><div class="panel"> ` ;
2026-10-18 13:48:56 +01:00
const stock = Object . entries ( city . stockpile ) ;
2026-10-18 19:20:52 +01:00
html += stock . length ? ` <div class="stock"> ${ stock . map ( ( [ id , q ] ) => itemChip ( id , q ) ) . join ( "" ) } </div> ` : ` <p class="muted">Empty.</p> ` ;
2026-10-20 08:01:57 +01:00
html += ` <p class="small muted">Feeds the granary and armoury. Raiders can loot it.</p></div> ` ;
2026-10-18 13:48:56 +01:00
return html ;
}
2026-10-24 13:34:59 +01:00
function lookChip ( id ) {
const c = C . cosmetics [ id ] ;
if ( c . kind === "colour" ) return ` <span class="swatch" style="background: ${ c . value } "></span> ` ;
if ( c . kind === "emblem" ) return glyph ( emblemGlyph ( id ) , "#e7e9f0" ) ;
return ` <span class="ico">📜</span> ` ;
}
const emblemGlyph = ( id ) => C . cosmetics [ id ] . value || C . actions . find ( ( a ) => a . id === C . cosmetics [ id ] . boss ) . glyph ;
function caravanPanel ( ) {
const c = S . me . caravan ;
if ( ! c ) return "" ;
const rows = c . wares . map ( ( w ) => {
const price = w . skill ? ` · ${ COIN } ${ fmt ( w . prices [ w . skill ] ) } ` : "" ;
const picker = w . skill ? "" : ` <select data-keep="tutor-skill"> ${ Object . keys ( w . prices ) . map ( ( s ) =>
` <option value=" ${ s } "> ${ skillOf ( s ) . name } · ${ fmt ( w . prices [ s ] ) } </option> ` ) . join ( "" ) } < / s e l e c t > ` ;
const per = w . charges ? ` next ${ fmt ( w . charges ) } ${ w . per } ` : "against the coming threat" ;
return ` <div class="ware"> ${ glyph ( ... w . glyph ) } <span class="grow"><b> ${ esc ( w . name ) } </b>
< span class = "small" > $ { w . skill ? ` ${ skillOf ( w . skill ) . icon } ` : "" } $ { esc ( w . text ) } · $ { per } < / s p a n > $ { p i c k e r } < / s p a n >
< button class = "btn sm gold" data - buy = "${w.id}" $ { w . bought ? "disabled" : "" } > $ { w . bought ? "Bought" : ` Buy ${ price } ` } < / b u t t o n > < / d i v > ` ;
} ) ;
const look = c . look , have = look . owned || look . bought ;
rows . push ( ` <div class="ware"> ${ lookChip ( look . id ) } <span class="grow"><b> ${ esc ( look . name ) } </b><span class="small muted">For your banner (character sheet)</span></span>
< button class = "btn sm gold" data - buy = "${look.id}" $ { have ? "disabled" : "" } > $ { have ? "Owned" : ` Buy · ${ COIN } ${ fmt ( look . price ) } ` } < / b u t t o n > < / d i v > ` ) ;
return ` <div class="panel"><h2>🐫 Caravan · leaves in ${ countdown ( c . until ) } </h2><div class="wares"> ${ rows . join ( "" ) } </div>
< p class = "small muted" > One of each per visit . Wares boost your own work . Pricier the higher your level . < / p > < / d i v > ` ;
}
function monumentsPanel ( ) {
const m = S . city . monuments , me = S . me ;
const rows = Object . entries ( C . monuments ) . map ( ( [ key , spec ] ) => {
if ( spec . once && m . built . some ( ( b ) => b . kind === key ) ) return "" ;
const head = ` <span class="ico"> ${ glyph ( spec . glyph , spec . tint ) } </span> ` ;
if ( key === "statue" && m . carving ) {
const pick = m . carving . by === S . uid
? ` <span class="btns"><select data-keep="statue-of"> ${ S . players . map ( ( p ) => ` <option value=" ${ esc ( p . id ) } "> ${ esc ( p . name ) } </option> ` ) . join ( "" ) } </select>
< button class = "btn sm gold" data - statue > Carve < / b u t t o n > < / s p a n > `
: ` <span class="small muted"> ${ esc ( playerName ( m . carving . by ) ) } picks who it honours.</span> ` ;
return ` <div class="favour-option"> ${ head } <span class="grow"><b> ${ spec . name } </b><span class="small muted">Funded. Who should it honour?</span></span> ${ pick } </div> ` ;
}
const fund = m . funds [ key ] || { gold : 0 } ;
return ` <div class="favour-option"> ${ head } <span class="grow"><b> ${ spec . name } </b><span class="small muted"> ${ esc ( spec . text ) } </span>
< span class = "small" > $ { COIN } $ { fmt ( fund . gold ) } / $ { fmt ( spec . cost ) } < /span><span class="bar"><span style="width:${fund.gold / spec . cost * 100 } % " > < / s p a n > < / s p a n > < / s p a n >
< form data - form = "monument" data - id = "${key}" class = "btns" > < input type = "number" min = "1" data - keep = "mon-${key}" placeholder = "Gold" style = "width:90px" > < button class = "btn sm gold" $ { me . gold ? "" : "disabled" } > Give < / b u t t o n > < / f o r m > < / d i v > ` ;
} ) . join ( "" ) ;
const built = m . built . map ( ( b ) => {
const spec = C . monuments [ b . kind ] ;
const donors = Object . entries ( b . donors ) . sort ( ( x , y ) => y [ 1 ] - x [ 1 ] ) . map ( ( [ uid ] ) => esc ( playerName ( uid ) ) ) . join ( ", " ) ;
return ` <div class="report win"><b> ${ glyph ( spec . glyph , spec . tint ) } ${ b . of ? ` Statue of ${ esc ( playerName ( b . of ) ) } ` : spec . name } </b> <span class="muted small"> ${ ago ( b . at ) } </span><p class="small">Carved in: ${ donors } </p></div> ` ;
} ) . join ( "" ) ;
return ` <div class="section-title">Monuments</div><div class="panel"> ${ rows } ${ built }
< p class = "small muted" > Looks only . Gold here earns no favour : your name goes in the stone . < / p > < / d i v > ` ;
}
2026-10-20 12:14:26 +01:00
// Progress towards a goal favour can boost. Favour doubles the next `boost` points players put
// in, so the striped stretch behind the bar (twice the boost) is where work counts double.
function boostBar ( done , total , boost , tip ) {
const pct = ( x ) => Math . min ( 100 , x / total * 100 ) ;
return ` <div class="bar gold thick boost-bar" ${ boost ? ` title=" ${ esc ( tip ) } " ` : "" } > ${ boost
? ` <span class="boost" style="width: ${ pct ( done + 2 * boost ) } %"></span> ` : "" } < span style = "width:${pct(done)}%" > < / s p a n > < / d i v > ` ;
}
2026-10-19 09:24:45 +01:00
// The building site: labour so far, who is helping, and ways to speed it up.
function siteBanner ( ) {
const city = S . city , work = city . construction , me = S . me ;
const spec = C . buildings [ work . building ] ;
const queued = city . queue . map ( ( key ) => esc ( C . buildings [ key ] . name ) ) . join ( ", " ) ;
const hire = S . favours . builders ;
const working = me . action ? . id === "labour" ;
const builders = S . players . filter ( ( p ) => p . skill === "construction" && p . doing === "Work on the building site" ) . map ( ( p ) => esc ( p . name ) ) ;
2026-10-19 10:57:55 +01:00
return ` <div class="banner"><span class="banner-ico"> ${ skillOf ( "construction" ) . icon } </span><div class="grow">
2026-10-19 09:24:45 +01:00
< div class = "site-head" > < b > $ { work . repair ? "Repairing" : "Building" } $ { esc ( spec . name ) } < / b >
< span class = "muted small" > $ { fmt ( work . labour ) } / $ { fmt ( work . needed ) } labour < / s p a n >
< span class = "btns" >
2026-10-19 10:57:55 +01:00
$ { working ? ` <span class="btn sm working">Working</span> ` : ` <button class="btn sm primary" data-start="labour">Work on it</button> ` }
2026-10-20 08:01:57 +01:00
$ { hire . why ? "" : ` <button class="btn sm gold" data-favour="builders" ${ me . favour < hire . cost ? "disabled" : "" } title="× 2 labour and XP for the next ${ hire . labour } ">Master builder ${ FAVOUR } ${ fmt ( hire . cost ) } </button> ` }
2026-10-19 09:24:45 +01:00
< / s p a n > < / d i v >
2026-10-20 12:14:26 +01:00
$ { boostBar ( work . labour , work . needed , hire . boost , "Master builder: this stretch builds twice as fast" ) }
2026-10-20 12:13:01 +01:00
< div class = "small muted" style = "margin-top:6px" > $ { builders . length ? ` On site: ${ builders . join ( ", " ) } . ` : "" } Townsfolk + $ { fmt ( city . townsfolk _labour ) } / h . $ { queued ? ` Next: ${ queued } . ` : "" } < / d i v >
2026-10-19 09:24:45 +01:00
< / d i v > < / d i v > ` ;
}
2026-10-19 11:23:26 +01:00
// "Rain: farming 10% faster. Clear skies in 3h 20m."
function weatherTip ( w ) {
2026-10-19 18:33:50 +01:00
return ` ${ w . name } : ${ w . text . charAt ( 0 ) . toLowerCase ( ) } ${ w . text . slice ( 1 ) } . ${ w . next _name } in ${ dur ( w . until - serverNow ( ) ) } . ` ;
2026-10-19 11:23:26 +01:00
}
2026-10-19 09:34:18 +01:00
function weatherCard ( ) {
const h = S . happenings , w = h . weather ;
2026-10-20 08:01:57 +01:00
return ` <div class="stat" title=" ${ esc ( weatherTip ( w ) ) } "><div class="k"> ${ h . season . icon } ${ h . season . name } , day ${ h . season . day } </div>
2026-10-19 09:34:18 +01:00
< div class = "v" > $ { w . icon } < small > $ { esc ( w . name ) } < / s m a l l > < / d i v >
2026-10-20 21:46:37 +01:00
< div class = "note" title = "${esc(forecastTip(w))}" > $ { w . next _icon } $ { esc ( w . next _name ) } in $ { countdown ( w . until ) } < / d i v > < / d i v > ` ;
}
// "Fog in 3h 20m: scouting 30% and hunting 10% slower."
function forecastTip ( w ) {
return ` ${ w . next _name } in ${ dur ( w . until - serverNow ( ) ) } : ${ w . next _text . charAt ( 0 ) . toLowerCase ( ) } ${ w . next _text . slice ( 1 ) } . ` ;
2026-10-19 09:34:18 +01:00
}
// Everything going on in the city right now, and what anyone can do about it.
function happeningsPanel ( ) {
const h = S . happenings , me = S . me , city = S . city ;
const rows = [ ] ;
if ( h . weather . kind !== "clear" ) rows . push ( ` <div class="happening"><span class="ico"> ${ h . weather . icon } </span><span class="grow"><b> ${ esc ( h . weather . name ) } </b><span class="small muted"> ${ esc ( h . weather . text ) } </span></span></div> ` ) ;
2026-10-20 08:01:57 +01:00
if ( h . cold ) rows . push ( ` <div class="happening bad"><span class="ico">🥶</span><span class="grow"><b>Out of firewood</b><span class="small muted">City eats 50% more. Donate logs.</span></span></div> ` ) ;
2026-10-19 09:34:18 +01:00
for ( const e of h . effects ) {
const hire = e . offer ? ` <button class="btn sm gold" data-hire ${ city . treasury < e . cost ? "disabled" : "" } >Hire for ${ fmt ( e . cost ) } gold</button> ` : "" ;
rows . push ( ` <div class="happening"><span class="ico"> ${ e . icon } </span><span class="grow"><b> ${ esc ( e . name ) } </b>
< span class = "small muted" > $ { esc ( e . text ) } · $ { countdown ( e . until ) } left < / s p a n > < / s p a n > $ { h i r e } < / d i v > ` ) ;
}
for ( const p of h . problems ) {
let help = "" ;
if ( p . action ) {
const a = C . actions . find ( ( x ) => x . id === p . action ) ;
help = ` <span class="small muted"> ${ fmt ( p . work ) } / ${ fmt ( p . needed ) } done</span>
< button class = "btn sm primary" data - nav = "skill:${a.skill}" > $ { esc ( a . name ) } < / b u t t o n > ` ;
}
const gifts = Object . entries ( p . need || { } ) . map ( ( [ id , need ] ) => {
const missing = p . missing [ id ] || 0 ;
const mine = Math . min ( missing , me . bank [ id ] || 0 ) , stores = Math . min ( missing , city . stockpile [ id ] || 0 ) ;
return ` <div class="fund-row"> ${ costChip ( item ( id ) . icon , item ( id ) . name , need , need - missing ) } <span class="btns">
$ { mine ? ` <button class="btn sm gold" data-aid=" ${ p . kind } " data-item=" ${ id } " data-source="bank">Give ${ fmt ( mine ) } </button> ` : "" }
$ { stores ? ` <button class="btn sm" data-aid=" ${ p . kind } " data-item=" ${ id } " data-source="city">Stockpile ${ fmt ( stores ) } </button> ` : "" }
< / s p a n > < / d i v > ` ;
} ) . join ( "" ) ;
rows . push ( ` <div class="happening bad"><span class="ico"> ${ p . icon } </span><span class="grow"><b> ${ esc ( p . name ) } </b>
< span class = "small muted" > $ { esc ( p . text ) } $ { p . until ? ` · ${ countdown ( p . until ) } left ` : "" } < / s p a n > $ { g i f t s } < / s p a n > $ { h e l p } < / d i v > ` ) ;
}
if ( ! rows . length ) return "" ;
return ` <div class="panel"><h2>Happenings</h2><div class="happenings"> ${ rows . join ( "" ) } </div></div> ` ;
}
// What's speeding up, slowing down or blocking this skill right now.
function skillHappenings ( skill ) {
2026-10-20 16:04:50 +01:00
const h = S . happenings , banners = [ ] ;
2026-10-19 09:34:18 +01:00
const touches = ( src ) => [ "speed" , "yield" , "xp" ] . some ( ( k ) => src [ k ] && skill in src [ k ] ) || ( src . block && skill in src . block ) ;
2026-10-20 16:04:50 +01:00
const good = ( src ) => ( src . speed ? . [ skill ] ? ? 1 ) < 1 || ( src . yield ? . [ skill ] ? ? 1 ) > 1 || ( src . xp ? . [ skill ] ? ? 1 ) > 1 ;
const banner = ( tone , icon , name , text , button = "" ) => banners . push ( ` <div class="banner ${ tone } happening-banner">
< span class = "banner-ico" > $ { icon } < / s p a n > < d i v c l a s s = " g r o w " > < b > $ { e s c ( n a m e ) } < / b > < d i v c l a s s = " s m a l l " > $ { e s c ( t e x t ) } < / d i v > < / d i v > $ { b u t t o n } < / d i v > ` ) ;
if ( touches ( h . weather ) ) banner ( good ( h . weather ) ? "good" : "bad" , h . weather . icon , h . weather . name , h . weather . text ) ;
for ( const e of h . effects ) if ( touches ( e ) ) banner ( good ( e ) ? "good" : "bad" , e . icon , e . name , e . text ) ;
2026-10-19 09:34:18 +01:00
for ( const p of h . problems ) {
if ( ! C . problems [ p . kind ] || ! touches ( C . problems [ p . kind ] ) ) continue ;
const fix = p . action && C . actions . find ( ( x ) => x . id === p . action ) ;
2026-10-20 16:04:50 +01:00
const button = fix ? ( fix . skill === skill ? ` <button class="btn primary" data-start=" ${ fix . id } "> ${ esc ( fix . name ) } </button> `
: ` <button class="btn" data-nav="skill: ${ fix . skill } "> ${ skillOf ( fix . skill ) . icon } ${ esc ( fix . name ) } </button> ` )
: ` <button class="btn" data-nav="town">Help on the Town square</button> ` ;
banner ( "bad" , p . icon , p . name , p . text , button ) ;
2026-10-19 09:34:18 +01:00
}
2026-10-24 13:34:59 +01:00
for ( const b of S . me . boosts . filter ( ( x ) => x . skill === skill ) ) {
const w = C . wares [ b . ware ] ;
banner ( "good" , glyph ( ... w . glyph ) , w . name , ` ${ w . text } · ${ b . left ? ` ${ fmt ( b . left ) } ${ w . per } left ` : "until the threat lands" } ` ) ;
}
2026-10-20 16:04:50 +01:00
return banners . join ( "" ) ;
2026-10-19 09:34:18 +01:00
}
2026-10-18 19:20:52 +01:00
function buildingCard ( key , b ) {
const spec = C . buildings [ key ] , city = S . city , me = S . me ;
const tags = {
building : ` <span class="tag gold">Under construction</span> ` ,
2026-10-20 08:01:57 +01:00
queued : ` <span class="tag good">Funded · queued</span> ` ,
2026-10-18 19:20:52 +01:00
max : ` <span class="tag good">Fully built</span> ` ,
2026-10-20 08:01:57 +01:00
capped : ` <span class="tag">Needs a bigger settlement</span> ` ,
2026-10-18 19:20:52 +01:00
} ;
let body = tags [ b . status ] ? ` <div> ${ tags [ b . status ] } </div> ` : "" ;
if ( b . status === "funding" || b . status === "queued" ) {
body += ` <div class="fund"> ${ Object . entries ( b . target ) . map ( ( [ id , need ] ) => {
const missing = b . missing [ id ] || 0 ;
const gold = id === "gold" ;
const mine = Math . min ( missing , gold ? me . gold : me . bank [ id ] || 0 ) ;
const stores = Math . min ( missing , gold ? city . treasury : city . stockpile [ id ] || 0 ) ;
2026-10-19 10:04:38 +01:00
const chip = costChip ( gold ? COIN : item ( id ) . icon , gold ? "Gold" : item ( id ) . name , need , need - missing ) ;
2026-10-18 19:20:52 +01:00
return ` <div class="fund-row"> ${ chip } <span class="btns">
$ { mine ? ` <button class="btn sm gold" data-fund=" ${ key } " data-item=" ${ id } " data-source="bank" title="From your ${ gold ? "purse" : "bank" } , earns favour">Give ${ fmt ( mine ) } </button> ` : "" }
$ { stores ? ` <button class="btn sm" data-fund=" ${ key } " data-item=" ${ id } " data-source="city" title="From the city's ${ gold ? "treasury" : "stockpile" } "> ${ gold ? "Treasury" : "Stockpile" } ${ fmt ( stores ) } </button> ` : "" }
< / s p a n > < / d i v > ` ;
} ) . join ( "" ) } < / d i v > ` ;
}
2026-10-19 11:15:19 +01:00
body += fittingRows ( key , b ) ;
2026-10-20 08:01:57 +01:00
const levelText = b . cap < spec . max ? ` Lv ${ b . level } / ${ b . cap } ( ${ city . tier _name . toLowerCase ( ) } cap) ` : ` Lv ${ b . level } / ${ spec . max } ` ;
2026-10-18 19:20:52 +01:00
return ` <div class="action building ${ b . damaged ? "damaged" : "" } ">
< div class = "top" > < div class = "ico" > $ { spec . icon } < / d i v > < d i v >
2026-10-20 08:01:57 +01:00
< div class = "name" > $ { esc ( spec . name ) } < span class = "lv muted" > $ { levelText } < / s p a n > $ { b . d a m a g e d ? ` < s p a n c l a s s = " t a g b a d " > D a m a g e d < / s p a n > ` : " " } < / d i v >
2026-10-18 19:20:52 +01:00
< div class = "sub" > $ { esc ( spec . effect ) } < / d i v >
< / d i v > < / d i v > $ { b o d y } < / d i v > ` ;
}
2026-10-19 11:15:19 +01:00
// Fittings Construction makes for this building: fitted, ready to fit, or waiting on a level.
function fittingRows ( key , b ) {
const rows = C . fittings . filter ( ( f ) => f . building === key ) . map ( ( f ) => {
const it = item ( f . id ) , recipe = C . actions . find ( ( a ) => a . id === ` make_ ${ f . id } ` ) ;
let state ;
if ( b . fittings . includes ( f . id ) ) {
state = b . damaged ? ` <span class="tag bad">Paused</span> ` : ` <span class="tag good">Fitted</span> ` ;
} else if ( b . level < f . level ) {
state = ` <span class="tag">Needs Lv ${ f . level } </span> ` ;
} else {
const mine = S . me . bank [ f . id ] || 0 , stores = S . city . stockpile [ f . id ] || 0 ;
state = mine || stores
? ` ${ mine ? ` <button class="btn sm gold" data-install=" ${ f . id } " data-source="bank" title="From your bank, earns favour">Fit</button> ` : "" }
$ { stores ? ` <button class="btn sm" data-install=" ${ f . id } " data-source="city" title="From the city's stockpile">Fit from stockpile</button> ` : "" } `
: ` <span class="muted small">Construction ${ recipe . level } </span> ` ;
}
return ` <div class="fitting-row"><span class="fit-ico"> ${ it . icon } </span>
< span class = "fitting-text" > < b > $ { esc ( f . name ) } < / b > < s p a n c l a s s = " m u t e d s m a l l " > $ { e s c ( f . e f f e c t ) } < / s p a n > < / s p a n >
< span class = "btns" > $ { state } < / s p a n > < / d i v > ` ;
} ) ;
return rows . length ? ` <div class="fittings"> ${ rows . join ( "" ) } </div> ` : "" ;
}
2026-10-18 13:48:56 +01:00
// --- council --------------------------------------------------------------
function pips ( level ) {
return ` <span class="pips" title="Intel level ${ level } of 3"> ${ [ 1 , 2 , 3 ] . map ( ( i ) => ` <i class=" ${ i <= level ? "on" : "" } "></i> ` ) . join ( "" ) } </span> ` ;
}
2026-10-19 15:09:51 +01:00
function reconText ( ev ) {
const points = ` ${ fmt ( ev . recon ) } / ${ fmt ( ev . recon _full ) } points ` ;
2026-10-20 08:01:57 +01:00
if ( ev . intel >= 3 ) return ` ${ points } · complete ` ;
2026-10-20 12:14:26 +01:00
if ( ev . recon _next <= 0 ) return ` ${ points } · next level opens closer to the threat ` ;
return ` ${ points } · ${ fmt ( ev . recon _next ) } to the next level ` ;
2026-10-19 15:09:51 +01:00
}
2026-10-18 13:48:56 +01:00
function council ( ) {
const ev = S . upcoming , city = S . city , me = S . me ;
let html = "" ;
if ( ! ev ) {
2026-10-20 08:01:57 +01:00
html += ` <div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads.</p></div> ` ;
2026-10-18 13:48:56 +01:00
} else {
2026-10-19 09:34:18 +01:00
const threat = [ "raid" , "dragon" ] . includes ( ev . kind ) ;
html += ` <div class="panel ${ threat ? "threat-card" : "" } ">
< h2 > $ { eventIcon ( ev . kind ) } $ { threat ? "Threat" : "News" } · intel $ { pips ( ev . intel ) } < / h 2 >
2026-10-18 13:48:56 +01:00
< div class = "headline" > $ { esc ( ev . headline ) } < / d i v >
< div class = "kv" >
< span class = "k" > Expected < / s p a n > < s p a n > $ { e v e n t W h e n ( e v ) } < / s p a n >
$ { ev . range ? ` <span class="k">Strength</span><span> ${ ev . strength ? ? ` ${ ev . range [ 0 ] } – ${ ev . range [ 1 ] } ` } </span> ` : "" }
$ { ev . trait ? ` <span class="k">Scouts say</span><span> ${ esc ( ev . trait ) } </span> ` : "" }
2026-10-20 08:01:57 +01:00
$ { ev . camp ? ` <span class="k">Their camp</span><span>Found · sabotage it in Combat ${ ev . sabotage ? ` (- ${ ev . sabotage } % so far) ` : "" } </span> ` : "" }
2026-10-19 22:45:32 +01:00
< span class = "k" > Reconnaissance < / s p a n > < s p a n > $ { r e c o n T e x t ( e v ) } < / s p a n >
2026-10-20 12:14:26 +01:00
< / d i v >
$ { ev . recon _full && ev . intel < 3 ? boostBar ( ev . recon , ev . recon _full , S . favours . informant . boost , "Informant: this stretch scouts twice as fast" ) : "" } ` ;
2026-10-19 09:34:18 +01:00
if ( threat ) {
2026-10-18 13:48:56 +01:00
const strength = ev . strength ? ? ( ev . range ? ` ${ ev . range [ 0 ] } – ${ ev . range [ 1 ] } ` : "?" ) ;
html += ` <div class="vs"><div class="side"><span class="muted small">Our defence</span><b> ${ fmt ( city . defence . total ) } </b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b> ${ strength } </b></div></div> ` ;
}
html += ` </div> ` ;
}
2026-10-18 19:48:22 +01:00
html += favourSpends ( ) ;
2026-10-19 09:34:18 +01:00
if ( ev && [ "raid" , "dragon" ] . includes ( ev . kind ) ) html += raidPrep ( ev ) ;
2026-10-18 13:48:56 +01:00
html += ` <div class="section-title">Past events</div> ` ;
if ( ! S . history . length ) html += ` <p class="muted">Nothing has happened yet.</p> ` ;
for ( const h of S . history ) {
if ( ! h . report ) continue ;
2026-10-19 09:34:18 +01:00
const fight = [ "raid" , "dragon" ] . includes ( h . kind ) ;
const tone = fight ? ( h . report . title . startsWith ( "Victory" ) ? "win" : "loss" )
: { fortune : "win" , hardship : "loss" , minor : "loss" } [ h . track ] || "" ;
html += ` <div class="report ${ tone } ">
< b > $ { h . report . icon || eventIcon ( h . kind ) } $ { esc ( h . report . title ) } < / b > < s p a n c l a s s = " m u t e d s m a l l " > $ { a g o ( h . a t ) } < / s p a n >
$ { fight ? ` <p class="muted">Defence ${ fmt ( h . report . defence ) } vs ${ h . kind === "dragon" ? "the dragon" : "raiders" } ${ fmt ( h . report . attack ) } ${ h . report . garrison . length ? ` · garrison: ${ h . report . garrison . map ( ( g ) => esc ( g . name ) ) . join ( ", " ) } ` : "" } </p> ` : "" }
2026-10-18 13:48:56 +01:00
$ { h . report . lines . map ( ( l ) => ` <p> ${ esc ( l ) } </p> ` ) . join ( "" ) }
< / d i v > ` ;
}
return html ;
}
2026-10-18 19:48:22 +01:00
function favourSpends ( ) {
const f = S . favours , me = S . me ;
const option = ( kind , icon , title , text ) => {
const o = f [ kind ] ;
const short = ! o . why && me . favour < o . cost ;
return ` <div class="favour-option">
< span class = "ico" > $ { icon } < / s p a n >
< span class = "grow" > < b > $ { title } < / b > < s p a n c l a s s = " s m a l l m u t e d " > $ { t e x t } < / s p a n > < / s p a n >
< span class = "btns" > < button class = "btn sm gold" data - favour = "${kind}" $ { o . why || short ? "disabled" : "" }
2026-10-19 10:04:38 +01:00
title = "${esc(o.why || (short ? " Not enough favour " : " "))}" > $ { FAVOUR } $ { fmt ( o . cost ) } < / b u t t o n > < / s p a n >
2026-10-18 19:48:22 +01:00
< / d i v > ` ;
} ;
2026-10-19 10:04:38 +01:00
return ` <div class="panel"><h2>Spend favour on the city · you have ${ FAVOUR } ${ fmt ( me . favour ) } </h2>
2026-10-20 08:01:57 +01:00
$ { option ( "builders" , "🏗️" , "Hire a master builder" , f . builders . why ? esc ( f . builders . why ) + "." : ` × 2 labour and XP for the next ${ f . builders . labour } ${ f . builders . boost ? ` ( ${ fmt ( f . builders . boost ) } left) ` : "" } . Price rises per hire. ` ) }
$ { option ( "informant" , "🕵️" , "Pay an informant" , f . informant . why ? esc ( f . informant . why ) + "." : ` × 2 recon and XP for the next ${ f . informant . recon } ${ f . informant . boost ? ` ( ${ fmt ( f . informant . boost ) } left) ` : "" } . Price rises per hire. ` ) }
$ { option ( "sergeant" , "📯" , "Hire a drill sergeant" , f . sergeant . why ? esc ( f . sergeant . why ) + "." : ` × 2 drill and XP for the next ${ f . sergeant . drill } ${ f . sergeant . boost ? ` ( ${ fmt ( f . sergeant . boost ) } left) ` : "" } . Price rises per hire. ` ) }
2026-10-18 19:48:22 +01:00
< / d i v > ` ;
}
2026-10-18 13:48:56 +01:00
function raidPrep ( ev ) {
const city = S . city , me = S . me , d = city . defence ;
2026-10-24 13:34:59 +01:00
const names = { mercenaries : "Hired sellswords" , ward : "Divine ward (temple)" , rallied : "Rallied militia (favour)" , drilled : "Drilled militia" , townsfolk : "Townsfolk with pitchforks" , walls : "Walls" , militia : "Paid guards (barracks)" , weapons : "Swords the guards hold" , shields : "Shields the guards hold" , arrows : "Arrows" , garrison : "Garrison" } ;
const full = d . posts * d . wage , owed = Math . max ( 0 , full - d . wages ) ;
2026-10-18 13:48:56 +01:00
const reqs = ( ev . requests || [ ] ) . map ( ( r ) => {
const mine = me . bank [ r . item ] || 0 ;
2026-10-18 22:13:50 +01:00
return ` <div class="req"><span> ${ item ( r . item ) . icon } </span><span> ${ esc ( r . label ) } <span class="muted small"> ${ fmt ( r . have ) } / ${ fmt ( r . want ) } </span></span>
2026-10-18 13:48:56 +01:00
$ { mine ? ` <button class="btn sm gold" data-donate=" ${ r . item } " data-qty="all">Give ${ fmt ( mine ) } </button> ` : "<span></span>" }
< div class = "bar" > < span style = "width:${Math.min(100, r.have / r.want * 100)}%" > < / s p a n > < / d i v > < / d i v > ` ;
} ) . join ( "" ) ;
return ` <div class="cols">
< div class = "panel" > < h2 > Council requests < / h 2 > $ { r e q s }
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Stockpile gear arms the defenders , best first . Fights use up arrows and break some gear . < / p > < / d i v >
2026-10-24 13:34:59 +01:00
< div class = "panel" > < h2 > Guards · $ { d . guards } / $ { d . posts } paid < / h 2 >
< p class = "small" > Wage chest $ { COIN } $ { fmt ( d . wages ) } / $ { fmt ( full ) } · $ { d . wage } gold a guard per threat < / p >
< div class = "bar" > < span style = "width:${full ? Math.min(100, d.wages / full * 100) : 0}%" > < / s p a n > < / d i v >
< div class = "btns" style = "margin-top:8px" >
< button class = "btn sm gold" data - wages = "bank" $ { ! owed || ! me . gold ? "disabled" : "" } > Pay $ { COIN } $ { fmt ( Math . min ( owed , me . gold ) ) } < / b u t t o n >
< button class = "btn sm" data - wages = "city" $ { ! owed || city . treasury < 1 ? "disabled" : "" } > From the treasury ( $ { fmt ( city . treasury ) } ) < / b u t t o n > < / d i v >
< p class = "small muted" > Only paid guards stand , and only they hold the armoury ' s swords and shields . Paying earns favour . < / p >
< h2 style = "margin-top:16px" > Defence < / h 2 >
2026-10-19 15:21:49 +01:00
< table class = "parts" > $ { Object . entries ( d . parts ) . filter ( ( [ k , v ] ) => k !== "rallied" || v ) . map ( ( [ k , v ] ) => ` <tr><td> ${ names [ k ] } </td><td> ${ fmt ( v ) } </td></tr> ` ) . join ( "" ) }
2026-10-18 13:48:56 +01:00
$ { d . starving ? ` <tr><td class="bad">Hungry (× 0.8)</td><td></td></tr> ` : "" } < / t a b l e >
< h2 style = "margin-top:16px" > Garrison < / h 2 >
2026-10-20 08:01:57 +01:00
< p class = "small" > $ { d . garrison . length ? d . garrison . map ( ( g ) => ` ${ esc ( g . name ) } ( ${ g . power } ) ` ) . join ( ", " ) : ` <span class="muted">Nobody yet.</span> ` } < / p >
2026-10-18 13:48:56 +01:00
$ { me . garrison ? ` <button class="btn" data-garrison="0">Leave the garrison</button> `
2026-10-19 22:45:32 +01:00
: ` <button class="btn primary" data-garrison="1">Stand guard (+ ${ me . power } )</button> ` }
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Keep working while on guard . Victory earns favour and combat XP . < / p >
2026-10-18 13:48:56 +01:00
< / d i v > < / d i v > ` ;
}
2026-10-20 18:01:02 +01:00
// --- work orders ------------------------------------------------------------
const ORDER _ICON = ( ) => glyph ( "scroll-unfurled" , "#e3c060" ) ;
// An order you could pick up: open, not yours, not claimed by someone else, and your level is high enough.
function canTake ( o ) {
if ( o . status !== "open" || o . by === S . uid ) return false ;
if ( o . kind === "craft" && o . claimed && o . claimed !== S . uid ) return false ;
return S . me . levels [ o . skill ] >= o . level ;
}
// Toasts for new orders you can take, and for things delivered to you. Nothing on the first load.
function orderNews ( state ) {
const orders = state . orders || [ ] , uid = state . uid ;
if ( seenOrders ) {
for ( const o of orders ) {
if ( ! seenOrders . has ( o . id ) && o . by !== uid && state . me && state . me . levels [ o . skill ] >= o . level && o . status === "open" ) {
toast ( ` 📜 ${ item ( o . item ) . name } ${ o . qty > 1 ? ` × ${ fmt ( o . qty ) } ` : "" } · ${ fmt ( o . reward ) } gold ` , "level" ) ;
}
const was = deliveredSeen [ o . id ] ;
if ( o . by === uid && was !== undefined && o . delivered > was ) toast ( ` 📦 ${ item ( o . item ) . name } × ${ fmt ( o . delivered - was ) } ` , "good" ) ;
2026-10-21 13:40:54 +01:00
const got = ( o . earned || { } ) [ uid ] || 0 , had = earnedSeen [ o . id ] ;
if ( had !== undefined && got > had ) toast ( ` 🤝 ${ item ( o . item ) . name } · + ${ fmt ( got - had ) } gold ` , "good" ) ;
2026-10-20 18:01:02 +01:00
}
}
seenOrders = new Set ( orders . map ( ( o ) => o . id ) ) ;
deliveredSeen = Object . fromEntries ( orders . map ( ( o ) => [ o . id , o . delivered ] ) ) ;
2026-10-21 13:40:54 +01:00
earnedSeen = Object . fromEntries ( orders . map ( ( o ) => [ o . id , ( o . earned || { } ) [ uid ] || 0 ] ) ) ;
2026-10-20 18:01:02 +01:00
}
2026-10-21 13:40:54 +01:00
// Recipe slots on a crafting order. Anyone can put reagents in the pool; the crafter brings the rest.
2026-10-20 18:01:02 +01:00
function orderSlots ( o ) {
const a = C . actions . find ( ( x ) => x . id === o . action ) , out = a . outputs [ o . item ] ;
const left = Math . ceil ( ( o . qty - o . delivered ) / out ) , all = Math . ceil ( o . qty / out ) ;
2026-10-21 13:40:54 +01:00
const worth = Object . entries ( a . inputs ) . reduce ( ( sum , [ id , per ] ) => sum + item ( id ) . value * per , 0 ) ;
2026-10-20 18:01:02 +01:00
return Object . entries ( a . inputs ) . map ( ( [ id , per ] ) => {
2026-10-21 13:40:54 +01:00
const name = item ( id ) . name , pool = o . provided [ id ] || 0 , have = S . me . bank [ id ] || 0 ;
if ( o . status !== "open" ) {
return ` <div class="ro-slot open"><span class="doll-slot empty-req" title=" ${ esc ( name ) } "> ${ item ( id ) . icon } </span>
2026-10-24 18:13:44 +01:00
< span class = "ro-have muted" > × $ { fmt ( per * all ) } < / s p a n > < s p a n c l a s s = " r o - n a m e " > $ { e s c ( n a m e ) } < / s p a n > < / d i v > ` ;
2026-10-20 18:01:02 +01:00
}
2026-10-21 13:40:54 +01:00
const need = per * left ;
const who = Object . entries ( o . gave || { } ) . filter ( ( [ , items ] ) => items [ id ] ) . map ( ( [ uid , items ] ) => ` ${ playerName ( uid ) } ${ fmt ( items [ id ] ) } ` ) ;
const tip = [ ` ${ name } : ${ fmt ( pool ) } / ${ fmt ( need ) } ` , ... who ] . join ( " · " ) ;
if ( pool >= need ) {
return ` <div class="ro-slot filled" title=" ${ esc ( tip ) } "><span class="doll-slot"> ${ item ( id ) . icon }
2026-10-24 18:13:44 +01:00
< span class = "swap-qty" > $ { fmt ( need ) } < / s p a n > < s p a n c l a s s = " t i c k " > ✓ < / s p a n > < / s p a n > < s p a n c l a s s = " r o - n a m e " > $ { e s c ( n a m e ) } < / s p a n > < / d i v > ` ;
2026-10-21 13:40:54 +01:00
}
const give = Math . min ( have , need - pool ) ;
// What putting them in pays you once they're crafted: your value's slice of the reagents' share.
const share = o . by === S . uid ? 0 : Math . round ( o . reward / o . qty * out * C . input _share * item ( id ) . value / worth * give ) ;
const fill = give ? ` ${ tip } · + ${ fmt ( give ) } ${ share ? ` → ${ fmt ( share ) } gold ` : "" } ` : ` ${ tip } · 🎒 0 ` ;
return ` <div class="ro-slot open"><button class="doll-slot empty-req" data-order-fill=" ${ o . id } " data-item=" ${ id } " ${ give ? "" : "disabled" } title=" ${ esc ( fill ) } "> ${ item ( id ) . icon } </button>
2026-10-24 18:13:44 +01:00
< span class = "ro-have ${pool + have < need ? " bad " : " good "}" > $ { fmt ( pool ) } / $ { fmt ( need ) } < / s p a n > < s p a n c l a s s = " r o - n a m e " > $ { e s c ( n a m e ) } < / s p a n > < / d i v > ` ;
2026-10-20 18:01:02 +01:00
} ) . join ( "" ) ;
}
function orderCard ( o ) {
const it = item ( o . item ) , mine = o . by === S . uid , skill = skillOf ( o . skill ) ;
const short = S . me . levels [ o . skill ] < o . level , have = S . me . bank [ o . item ] || 0 , left = o . qty - o . delivered ;
const progress = o . qty > 1 || o . kind === "gather"
? ` <div class="small muted"> ${ fmt ( o . delivered ) } / ${ fmt ( o . qty ) } ${ o . kind === "gather" && Object . keys ( o . from ) . length
? ` · ${ Object . entries ( o . from ) . map ( ( [ uid , n ] ) => ` ${ esc ( playerName ( uid ) ) } ${ fmt ( n ) } ` ) . join ( ", " ) } ` : "" } < / d i v >
< div class = "bar" > < span style = "width:${o.delivered / o.qty * 100}%" > < / s p a n > < / d i v > ` : " " ;
let buttons = "" , status = "" ;
if ( o . status !== "open" ) {
status = ` <span class="tag ${ o . status === "done" ? "good" : "" } "> ${ { done : "Done" , cancelled : "Cancelled" , expired : "Expired" } [o.status]}</span> ` ;
2026-10-20 20:11:11 +01:00
if ( mine && ! o . held ) buttons = ` <button class="btn" data-order-remove=" ${ o . id } ">Remove</button> ` ;
2026-10-20 18:01:02 +01:00
} else if ( mine ) {
buttons = ` <button class="btn danger" data-order-cancel=" ${ o . id } ">Cancel</button> ` ;
if ( o . claimed ) status = ` <span class="tag"> ${ esc ( playerName ( o . claimed ) ) } </span> ` ;
} else if ( o . kind === "gather" ) {
buttons = ` <button class="btn primary" data-order-deliver=" ${ o . id } " ${ have ? "" : "disabled" } >Deliver ${ fmt ( Math . min ( have , left ) ) } </button> ` ;
} else if ( o . claimed && o . claimed !== S . uid ) {
status = ` <span class="tag"> ${ esc ( playerName ( o . claimed ) ) } </span> ` ;
} else {
const working = S . me . action ? . order === o . id ;
const why = short ? ` Needs ${ skill . name . toLowerCase ( ) } ${ o . level } ` : S . me . blocked [ o . action ] && ! working
&& ! S . me . blocked [ o . action ] . startsWith ( "Out of" ) ? S . me . blocked [ o . action ] : "" ;
buttons = ` ${ o . claimed === S . uid ? ` <button class="btn" data-order-drop=" ${ o . id } ">Drop</button> ` : "" }
$ { have ? ` <button class="btn" data-order-deliver=" ${ o . id } ">Deliver ${ fmt ( Math . min ( have , left ) ) } </button> ` : "" }
$ { working ? ` <span class="tag gold">Crafting</span> ` : ` <button class="btn primary" data-order-work=" ${ o . id } " ${ why ? ` disabled title=" ${ esc ( why ) } " ` : "" } >Craft it</button> ` } ` ;
}
const reward = o . kind === "gather" ? ` ${ fmt ( Math . round ( o . reward / o . qty ) ) } each ` : fmt ( o . reward ) ;
return ` <div class="action order">
< div class = "top" > < div class = "ico" > $ { it . icon } < / d i v > < d i v >
< div class = "name" > $ { o . kind === "gather" ? "Wanted: " : "" } $ { esc ( it . name ) } $ { o . qty > 1 ? ` × ${ fmt ( o . qty ) } ` : "" } < / d i v >
< div class = "sub" > $ { mine ? "Yours" : esc ( playerName ( o . by ) ) } · $ { ago ( o . at ) } $ { o . held ? ` · ${ fmt ( o . held ) } waiting for bank space ` : "" } < / d i v > < / d i v >
< span class = "ro-req ${short && !mine ? " bad " : " "}" title = "${esc(`${skill.name} ${o.level}`)}" > $ { skill . icon } $ { o . level } < / s p a n > < / d i v >
$ { o . kind === "craft" ? ` <div class="ro-slots"> ${ orderSlots ( o ) } </div> ` : "" }
2026-10-21 13:40:54 +01:00
$ { Object . keys ( o . earned || { } ) . length ? ` <div class="small muted">🤝 ${ Object . entries ( o . earned ) . map ( ( [ uid , n ] ) => ` ${ esc ( playerName ( uid ) ) } ${ fmt ( n ) } ` ) . join ( ", " ) } </div> ` : "" }
2026-10-20 18:01:02 +01:00
$ { progress }
< div class = "foot" > < span class = "item-chip ro-reward" > $ { COIN } $ { reward } < / s p a n > $ { s t a t u s } < s p a n c l a s s = " b t n s " > $ { b u t t o n s } < / s p a n > < / d i v >
< / d i v > ` ;
}
2026-10-20 19:32:27 +01:00
function orderPicker ( list ) {
const d = draft , actions = Object . entries ( list ) . map ( ( [ id , action ] ) => [ id , C . actions . find ( ( x ) => x . id === action ) ] ) ;
const skills = [ ... new Set ( actions . map ( ( [ , a ] ) => a . skill ) ) ] ;
const picked = d . item && actions . find ( ( [ id ] ) => id === d . item ) ? . [ 1 ] ;
d . skill = skills . includes ( d . skill ) ? d . skill : picked ? . skill || skills [ 0 ] ;
const inSkill = actions . filter ( ( [ , a ] ) => a . skill === d . skill ) ;
const groups = [ ... new Set ( inSkill . map ( ( [ , a ] ) => a . group ) . filter ( Boolean ) ) ] ;
if ( ! groups . includes ( d . group ) ) d . group = picked ? . skill === d . skill && picked . group || groups [ 0 ] ;
const q = ( d . search || "" ) . trim ( ) . toLowerCase ( ) ;
const shown = q ? actions . filter ( ( [ id ] ) => item ( id ) . name . toLowerCase ( ) . includes ( q ) )
: inSkill . filter ( ( [ , a ] ) => ! groups . length || a . group === d . group ) ;
const others = S . players . filter ( ( p ) => p . id !== S . uid ) ;
const tiles = shown . sort ( ( x , y ) => x [ 1 ] . level - y [ 1 ] . level ) . map ( ( [ id , a ] ) => {
const makers = others . filter ( ( p ) => p . levels [ a . skill ] >= a . level ) . length ;
const tip = ` ${ item ( id ) . name } · ${ skillOf ( a . skill ) . name } ${ a . level } ${ d . kind === "craft" ? ` · 👥 ${ makers } ` : "" } ` ;
return ` <button class="doll-slot swap pick ${ d . item === id ? "on" : "" } ${ d . kind === "craft" && ! makers ? "nobody" : "" } " data-draft-item=" ${ id } " title=" ${ esc ( tip ) } ">
2026-10-24 18:13:44 +01:00
$ { item ( id ) . icon } < span class = "pick-name" > $ { esc ( item ( id ) . name ) } $ { d . kind === "craft" ? ` <span class="muted">👥 ${ makers } </span> ` : "" } < / s p a n > < s p a n c l a s s = " p i c k - l v l " > $ { a . l e v e l } < / s p a n > < / b u t t o n > ` ;
2026-10-20 19:32:27 +01:00
} ) . join ( "" ) ;
return ` <div class="picker">
< div class = "pick-skills" > $ { skills . map ( ( k ) => ` <button class="pick-skill ${ ! q && k === d . skill ? "on" : "" } " data-draft-skill=" ${ k } " title=" ${ esc ( skillOf ( k ) . name ) } "> ${ skillOf ( k ) . icon } </button> ` ) . join ( "" ) }
< input type = "text" data - keep = "order-search" placeholder = "🔍" value = "${esc(d.search || " ")}" > < / d i v >
$ { groups . length && ! q ? ` <div class="tabs sm"> ${ groups . map ( ( g ) => ` <button class="tab ${ g === d . group ? "on" : "" } " data-draft-group=" ${ esc ( g ) } "> ${ esc ( g ) } </button> ` ) . join ( "" ) } </div> ` : "" }
< div class = "swap-grid pick-grid" > $ { tiles || ` <span class="muted small">—</span> ` } < / d i v > < / d i v > ` ;
}
2026-10-20 18:01:02 +01:00
function orderComposer ( ) {
const d = draft , list = d . kind === "craft" ? C . order _recipes : C . order _raw ;
let slots = "" , maker = "" ;
const a = d . item && C . actions . find ( ( x ) => x . id === list [ d . item ] ) ;
if ( a ) {
const smiths = S . players . filter ( ( p ) => p . id !== S . uid && p . levels [ a . skill ] >= a . level ) . length ;
maker = ` <div class="small muted" title=" ${ esc ( ` ${ skillOf ( a . skill ) . name } ${ a . level } · ${ smiths } can make it ` ) } "> ${ skillOf ( a . skill ) . icon } ${ a . level } · 👥 ${ smiths } </div> ` ;
if ( d . kind === "craft" ) {
const runs = Math . ceil ( d . qty / a . outputs [ d . item ] ) ;
slots = Object . entries ( a . inputs ) . map ( ( [ id , per ] ) => {
const need = per * runs , have = S . me . bank [ id ] || 0 , on = d . provide . includes ( id ) ;
return ` <div class="ro-slot ${ on ? "filled" : "open" } "><button class="doll-slot ${ on ? "on" : "empty-req" } " data-provide=" ${ id } "
$ { ! on && have < need ? ` disabled title=" ${ esc ( ` ${ item ( id ) . name } : ${ fmt ( have ) } / ${ fmt ( need ) } ` ) } " ` : ` title=" ${ esc ( item ( id ) . name ) } " ` } >
2026-10-20 18:19:15 +01:00
$ { item ( id ) . icon } $ { on ? ` <span class="tick">✓</span> ` : "" } < / b u t t o n >
2026-10-24 18:13:44 +01:00
< span class = "ro-have ${have < need ? " bad " : " good "}" > $ { fmt ( have ) } / $ { fmt ( need ) } < / s p a n > < s p a n c l a s s = " r o - n a m e " > $ { e s c ( i t e m ( i d ) . n a m e ) } < / s p a n > < / d i v > ` ;
2026-10-20 18:01:02 +01:00
} ) . join ( "" ) ;
}
}
const gold = d . kind === "craft" ? d . reward : d . reward * d . qty ;
2026-10-20 18:19:15 +01:00
const why = ! a ? "" : d . qty > C . order _max [ d . kind ] ? ` Max ${ fmt ( C . order _max [ d . kind ] ) } `
: gold > S . me . gold ? ` Gold ${ fmt ( S . me . gold ) } / ${ fmt ( gold ) } ` : "" ;
2026-10-20 18:01:02 +01:00
return ` <div class="panel compose">
< div class = "tabs" style = "margin:0 0 14px" > < button class = "tab ${d.kind === " craft " ? " on " : " "}" data - draft - kind = "craft" > Craft < / b u t t o n >
< button class = "tab ${d.kind === " gather " ? " on " : " "}" data - draft - kind = "gather" > Materials < / b u t t o n > < / d i v >
2026-10-20 19:32:27 +01:00
$ { orderPicker ( list ) }
2026-10-20 18:01:02 +01:00
< div class = "compose-grid" >
2026-10-20 19:32:27 +01:00
< div > $ { a ? ` <div class="ro-pick"><span class="doll-slot on"> ${ item ( d . item ) . icon } </span><div><b> ${ esc ( item ( d . item ) . name ) } </b> ${ maker } </div></div> `
: ` <div class="ro-pick"><span class="doll-slot empty-req"></span><span class="muted">—</span></div> ` }
2026-10-20 18:19:15 +01:00
< div class = "stepper" > × < input type = "number" min = "1" max = "${C.order_max[d.kind]}" value = "${d.qty}" data - draft = "qty" >
< span class = "muted small" > max $ { fmt ( C . order _max [ d . kind ] ) } < / s p a n > < / d i v > < / d i v >
2026-10-20 18:01:02 +01:00
< div > $ { slots ? ` <div class="ro-slots big"> ${ slots } </div> ` : "" } < / d i v >
< div > < div class = "reward-input" > $ { COIN } < input type = "number" min = "0" value = "${d.reward}" data - draft = "reward" >
$ { d . kind === "gather" ? ` <span class="muted small">each · ${ fmt ( gold ) } </span> ` : "" } < / d i v >
2026-10-20 18:19:15 +01:00
< div class = "btns" > < button class = "btn primary" data - order - post $ { a && ! why ? "" : "disabled" } > Post < / b u t t o n >
$ { why ? ` <span class="why"> ${ esc ( why ) } </span> ` : "" } < / d i v > < / d i v >
2026-10-20 18:01:02 +01:00
< / d i v > < / d i v > ` ;
}
function orders ( ) {
const all = S . orders || [ ] , open = all . filter ( ( o ) => o . status === "open" ) ;
const takeable = open . filter ( canTake ) , yours = all . filter ( ( o ) => o . by === S . uid ) ;
const lists = { take : takeable , open , yours } ;
if ( ! lists [ orderTab ] ) orderTab = "take" ;
const shown = lists [ orderTab ] ;
2026-10-20 20:11:11 +01:00
const old = orderTab === "yours" && yours . some ( ( o ) => o . status !== "open" && ! o . held ) ;
const tab = ( key , label , n ) => ` <button class="tab ${ orderTab === key ? "on" : "" } " data-otab=" ${ key } "> ${ label } ${ n ? ` <b class="badge"> ${ n } </b> ` : "" } </button> ` ;
2026-10-20 18:01:02 +01:00
return ` <div class="panel"><div class="skill-head"><div class="big"> ${ ORDER _ICON ( ) } </div>
< div class = "info" > < h2 > Work orders < / h 2 > < / d i v >
< button class = "btn ${draft ? " " : " primary "}" data - order - new > $ { draft ? "Close" : "New order" } < / b u t t o n > < / d i v > < / d i v >
$ { draft ? orderComposer ( ) : "" }
2026-10-20 20:11:11 +01:00
< div class = "tabs" > $ { tab ( "take" , "For you" , takeable . length ) } $ { tab ( "open" , "All open" , 0 ) } $ { tab ( "yours" , "Yours" , 0 ) }
$ { old ? ` <button class="btn" data-order-clear>Clear</button> ` : "" } < / d i v >
2026-10-20 18:01:02 +01:00
$ { shown . length ? ` <div class="grid wide"> ${ shown . map ( orderCard ) . join ( "" ) } </div> ` : ` <p class="muted">None.</p> ` } ` ;
}
2026-10-18 13:48:56 +01:00
// --- board & roster -------------------------------------------------------
function board ( ) {
2026-10-24 13:34:59 +01:00
const msgs = S . board . map ( ( m ) => ` <div class="msg"><span class="who"> ${ crest ( S . players . find ( ( p ) => p . id === m . who ) ? . look , 18 ) } ${ esc ( playerName ( m . who ) ) } </span><span class="when"> ${ ago ( m . at ) } </span>
2026-10-19 09:56:11 +01:00
$ { m . who === S . uid && m . id ? ` <button class="msg-delete" data-unpost=" ${ m . id } " title="Delete this message">✕</button> ` : "" }
< div class = "text" > $ { esc ( m . text ) } < / d i v > < / d i v > ` ) . j o i n ( " " ) ;
2026-10-18 13:48:56 +01:00
const ledger = [ ... S . ledger ] . reverse ( ) . map ( ( l ) => ` <li> ${ esc ( l . text ) } <span class="when"> ${ ago ( l . at ) } </span></li> ` ) . join ( "" ) ;
return ` <div class="cols">
< div class = "panel" > < h2 > Town board < / h 2 >
2026-10-20 08:01:57 +01:00
< div class = "msgs" > $ { msgs || ` <p class="muted">No messages yet.</p> ` } < / d i v >
2026-10-18 13:48:56 +01:00
< form data - form = "board" > < textarea data - keep = "board" maxlength = "500" placeholder = "Write to the guild…" > < / t e x t a r e a >
< p > < button class = "btn primary" > Post < / b u t t o n > < / p > < / f o r m > < / d i v >
< div class = "panel" > < h2 > Town ledger < / h 2 > < u l c l a s s = " l e d g e r " > $ { l e d g e r | | ` < l i c l a s s = " m u t e d " > N o t h i n g r e c o r d e d y e t . < / l i > ` } < / u l > < / d i v >
< / d i v > ` ;
}
2026-10-21 22:06:49 +01:00
function joinButton ( p ) {
if ( ! p . fight || p . id === S . uid || ( p . party && p . party === S . me ? . action ? . party ) ) return "" ;
const why = S . me ? . blocked [ p . fight ] ;
return ` <button class="btn sm primary" data-join=" ${ esc ( p . id ) } " ${ why ? ` disabled title=" ${ esc ( why ) } " ` : "" } >⚔️ Join</button> ` ;
}
2026-10-18 13:48:56 +01:00
function roster ( ) {
return ` <div class="grid wide"> ${ S . players . map ( ( p ) => {
const cls = C . classes [ p . cls ] ;
2026-10-19 11:23:26 +01:00
const levels = C . skills . filter ( ( s ) => ! s . class || s . class === p . cls ) . map ( ( s ) => ` <div class="lvl ${ p . levels [ s . id ] <= 1 ? "low" : "" } " title=" ${ s . name } level ${ p . levels [ s . id ] } "><span class="ico"> ${ s . icon } </span><span class="nm"> ${ s . name } </span><b> ${ p . levels [ s . id ] } </b></div> ` ) . join ( "" ) ;
2026-10-18 13:48:56 +01:00
return ` <div class="panel person"><div class="top"><span class="ico"> ${ cls . icon } </span><div>
2026-10-24 13:34:59 +01:00
$ { titled ( p ) } < span class = "muted small" > $ { cls . name } · total level $ { p . total } · $ { esc ( p . look . home ) } < / s p a n >
2026-10-18 13:48:56 +01:00
< div class = "small" > $ { p . doing ? ` ${ skillOf ( p . skill ) . icon } ${ esc ( p . doing ) } ` : ` <span class="muted">Idle</span> ` } $ { p . garrison ? ` · <span class="tag warn">On guard</span> ` : "" } < / d i v >
2026-10-19 10:04:38 +01:00
< div class = "small favour" > $ { FAVOUR } $ { fmt ( p . donated ) } given to the city < / d i v > < / d i v > < / d i v >
2026-10-18 19:35:21 +01:00
< div class = "levels" > $ { levels } < / d i v >
2026-10-21 22:06:49 +01:00
< p > < button class = "btn sm" data - nav = "character:${esc(p.id)}" > Character sheet < / b u t t o n > $ { j o i n B u t t o n ( p ) } < / p > < / d i v > ` ;
2026-10-18 13:48:56 +01:00
} ) . join ( "" ) } < / d i v > ` ;
}
2026-10-21 15:36:16 +01:00
// Patch notes, newest first. The badge stays until this browser has opened the page since the last release.
const NOTE _KINDS = { new : [ "✨" , "good" , "New" ] , balance : [ "⚖️" , "gold" , "Balance" ] , fix : [ "🔧" , "warn" , "Fixed" ] } ;
const notesKey = ( ) => C . patch _notes [ 0 ] ? ` ${ C . patch _notes [ 0 ] . date } ${ C . patch _notes [ 0 ] . title } ` : "" ;
const notesUnread = ( ) => ! ! C . patch _notes [ 0 ] && remember ( "notes" ) !== notesKey ( ) ;
function notes ( ) {
return C . patch _notes . map ( ( release ) => ` <div class="panel"><h2> ${ esc ( release . title ) } <span class="muted small"> ${ esc ( release . date ) } </span></h2>
< ul class = "ledger" > $ { release . notes . map ( ( [ kind , text ] ) => {
const [ icon , tone , label ] = NOTE _KINDS [ kind ] ;
return ` <li><span class="tag ${ tone } " title=" ${ label } "> ${ icon } </span> ${ esc ( text ) } </li> ` ;
} ) . join ( "" ) } < / u l > < / d i v > ` ) . j o i n ( " " ) ;
}
2026-10-18 19:35:21 +01:00
// --- character sheet -----------------------------------------------------
const SLOT _NAMES = { weapon : "Weapon" , shield : "Shield" , helmet : "Helmet" , body : "Body" , legs : "Legs" } ;
function characterSheet ( uid ) {
const mine = uid === S . uid ;
const p = mine ? { ... S . players . find ( ( x ) => x . id === uid ) , ... S . me } : S . players . find ( ( x ) => x . id === uid ) ;
if ( ! p ) return ` <p class="muted">No such adventurer.</p> ` ;
const cls = C . classes [ p . cls ] ;
const skills = C . skills . filter ( ( s ) => ! s . class || s . class === p . cls ) ;
const total = skills . reduce ( ( sum , s ) => sum + p . levels [ s . id ] , 0 ) ;
const days = Math . max ( 1 , Math . ceil ( ( serverNow ( ) - p . joined ) / 86400 ) ) ;
2026-10-19 22:45:32 +01:00
const guard = Math . round ( p . power ) ;
2026-10-18 19:35:21 +01:00
const skillRows = skills . map ( ( s ) => {
const level = p . levels [ s . id ] , xp = p . xp [ s . id ] || 0 ;
const floor = C . xp _table [ level ] , ceil = C . xp _table [ level + 1 ] ;
const pct = ceil ? ( xp - floor ) / ( ceil - floor ) * 100 : 100 ;
2026-10-20 21:20:25 +01:00
const gear = [ p . equip [ ` tool_ ${ s . id } ` ] , p . equip [ ` station_ ${ s . id } ` ] ] . filter ( Boolean ) ;
2026-10-19 11:23:26 +01:00
const marks = ( cls . fast . includes ( s . id ) ? ` <span title=" ${ cls . name } : ${ Math . round ( ( 1 - C . class _speed ) * 100 ) } % faster"> ${ cls . icon } </span> ` : "" )
2026-10-20 21:20:25 +01:00
+ gear . map ( ( id ) => ` <span title=" ${ esc ( ` ${ item ( id ) . name } : ${ Math . round ( item ( id ) . speed * 100 ) } % faster ` ) } "> ${ item ( id ) . icon } </span> ` ) . join ( "" ) ;
2026-10-18 19:35:21 +01:00
return ` < ${ mine ? ` button data-nav="skill: ${ s . id } " ` : "div" } class="sheet-skill">
< span class = "ico" > $ { s . icon } < / s p a n >
< span class = "grow" > < span class = "row1" > < b > $ { s . name } < / b > < s p a n c l a s s = " m a r k s " > $ { m a r k s } < / s p a n > < s p a n c l a s s = " l v " > $ { l e v e l } < / s p a n > < / s p a n >
< span class = "bar xp" > < span style = "width:${pct}%" > < / s p a n > < / s p a n > < / s p a n >
< / $ { m i n e ? " b u t t o n " : " d i v " } > ` ;
} ) . join ( "" ) ;
return `
< div class = "panel sheet-head" >
< div class = "big" > $ { cls . icon } < / d i v >
< div class = "grow" >
< h2 > $ { esc ( p . name ) } < / h 2 >
< div class = "muted" > $ { cls . name } · total level $ { total } · $ { days === 1 ? "first day" : ` ${ days } days ` } in the city < / d i v >
< div class = "small" style = "margin-top:6px" > $ { p . doing ? ` ${ skillOf ( p . skill ) . icon } ${ esc ( p . doing ) } ` : ` <span class="muted">Idle</span> ` } $ { p . garrison ? ` · <span class="tag warn">On guard</span> ` : "" } < / d i v >
< / d i v >
< / d i v >
< div class = "stats" >
< div class = "stat" > < div class = "k" > Health < /div><div class="v">${fmt(p.hp)} <small>/ $ { p . max _hp } < / s m a l l > < / d i v > < d i v c l a s s = " b a r h p " s t y l e = " m a r g i n - t o p : 6 p x " > < s p a n s t y l e = " w i d t h : $ { p . h p / p . m a x _ h p * 1 0 0 } % " > < / s p a n > < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Combat power < / d i v > < d i v c l a s s = " v " > $ { p . p o w e r } < / d i v > < d i v c l a s s = " n o t e " > $ { g u a r d } i n t h e g a r r i s o n < / d i v > < / d i v >
2026-10-18 23:09:17 +01:00
$ { mine ? ` <div class="stat"><div class="k">Prayer points</div><div class="v"> ${ fmt ( p . pp ) } </div><div class="note"> ${ p . prayers . length ? p . prayers . map ( ( id ) => esc ( C . prayers . find ( ( x ) => x . id === id ) . name ) ) . join ( ", " ) : "No prayers active" } </div></div> ` : "" }
2026-10-20 14:16:12 +01:00
< div class = "stat" > < div class = "k" > Armour < / d i v > < d i v c l a s s = " v " > $ { p . a r m o u r } < / d i v > < d i v c l a s s = " n o t e " > $ { M a t h . r o u n d ( p . r e d u c t i o n * 1 0 0 ) } % l e s s p h y s i c a l < / d i v > < / d i v >
< div class = "stat" > < div class = "k" > Ward < / d i v > < d i v c l a s s = " v " > $ { p . w a r d } < / d i v > < d i v c l a s s = " n o t e " > $ { M a t h . r o u n d ( p . w a r d _ r e d u c t i o n * 1 0 0 ) } % l e s s m a g i c < / d i v > < / d i v >
2026-10-18 19:35:21 +01:00
< div class = "stat" > < div class = "k" > Given to the city < / d i v > < d i v c l a s s = " v f a v o u r " > $ { f m t ( p . d o n a t e d ) } < / d i v > < d i v c l a s s = " n o t e " > $ { f m t ( p . f a v o u r ) } f a v o u r t o s p e n d < / d i v > < / d i v >
2026-10-19 10:52:07 +01:00
$ { mine ? ` <div class="stat"><div class="k">Purse</div><div class="v gold"> ${ fmt ( p . gold ) } </div><div class="note"> ${ p . slots _used } / ${ p . slots } bank slots</div></div> ` : "" }
2026-10-18 19:35:21 +01:00
< / d i v >
< div class = "cols" >
< div >
< div class = "panel" > < h2 > Class < / h 2 >
< p > < b > $ { cls . icon } $ { cls . name } < / b > < / p >
< p class = "small" > $ { esc ( cls . bonus ) } . < / p >
2026-10-19 22:45:32 +01:00
< p class = "small" > $ { esc ( cls . masterwork ) } . < / p >
2026-10-18 19:35:21 +01:00
< p class = "small" > $ { esc ( cls . signature ) } . < / p >
2026-10-19 23:38:34 +01:00
< p class = "small" > $ { esc ( cls . combat ) } . < / p >
2026-10-18 19:35:21 +01:00
< / d i v >
2026-10-20 15:09:09 +01:00
$ { equipmentPanel ( p , mine ) }
2026-10-24 13:34:59 +01:00
$ { mine ? lookPanel ( p ) : "" }
2026-10-18 19:35:21 +01:00
< / d i v >
< div class = "panel" > < h2 > Skills < / h 2 > < d i v c l a s s = " s h e e t - s k i l l s " > $ { s k i l l R o w s } < / d i v > < / d i v >
< / d i v > ` ;
}
2026-10-24 13:34:59 +01:00
function lookPanel ( p ) {
const look = p . look , wear = ( kind ) => Object . keys ( C . cosmetics ) . filter ( ( k ) => C . cosmetics [ k ] . kind === kind && ( C . free _looks . includes ( k ) || p . owned . includes ( k ) ) ) ;
const swatches = ( part ) => wear ( "colour" ) . map ( ( k ) => ` <button class="swatch ${ look [ part ] === k ? "on" : "" } " style="background: ${ C . cosmetics [ k ] . value } "
data - look = "${part}" data - value = "${k}" title = "${esc(C.cosmetics[k].name)}" aria - label = "${esc(C.cosmetics[k].name)}" > < / b u t t o n > ` ) . j o i n ( " " ) ;
const emblems = [ ` <button class="btn sm ${ look . emblem ? "" : "gold" } " data-look="emblem" data-value="">None</button> ` ,
... wear ( "emblem" ) . map ( ( k ) => ` <button class="btn sm ${ look . emblem === k ? "gold" : "" } " data-look="emblem" data-value=" ${ k } " title=" ${ esc ( C . cosmetics [ k ] . name ) } " aria-label=" ${ esc ( C . cosmetics [ k ] . name ) } "> ${ glyph ( emblemGlyph ( k ) , "#e7e9f0" ) } </button> ` ) ] . join ( "" ) ;
const home = C . homes . findIndex ( ( [ name ] ) => name === p . shown . home ) , next = C . homes [ home + 1 ] ;
return ` <div class="panel"><h2>Banner & title</h2>
< div class = "look-preview" > $ { crest ( p . shown , 48 ) } < span > < b > $ { esc ( p . name ) } < / b > $ { p . s h o w n . t i t l e ? ` < s p a n c l a s s = " t i t l e " > $ { e s c ( p . s h o w n . t i t l e ) } < / s p a n > ` : " " } < / s p a n > < / d i v >
< p class = "small muted" > Field < / p > < d i v c l a s s = " s w a t c h e s " > $ { s w a t c h e s ( " f i e l d " ) } < / d i v >
< p class = "small muted" > Trim < / p > < d i v c l a s s = " s w a t c h e s " > $ { s w a t c h e s ( " t r i m " ) } < / d i v >
< p class = "small muted" > Emblem < / p > < d i v c l a s s = " b t n s " > $ { e m b l e m s } < / d i v >
< p class = "small muted" > Title < / p > < s e l e c t d a t a - l o o k - t i t l e > < o p t i o n v a l u e = " " > N o n e < / o p t i o n > $ { p . t i t l e s . m a p ( ( t ) = > ` < o p t i o n $ { l o o k . t i t l e = = = t ? " s e l e c t e d " : " " } > $ { e s c ( t ) } < / o p t i o n > ` ) . j o i n ( " " ) } < / s e l e c t >
< p class = "small muted" style = "margin-top:12px" > Home · $ { esc ( p . shown . home ) } < / p >
$ { next ? ` <button class="btn sm gold" data-home ${ p . gold < next [ 1 ] ? "disabled" : "" } > ${ esc ( next [ 0 ] ) } ( ${ COIN } ${ fmt ( next [ 1 ] ) } )</button> ` : ` <p class="small">As grand as it gets.</p> ` }
< p class = "small muted" > Dyes , emblems and titles come with the caravan . Titles are also earned : master a skill ( 50 ) , defend the city , slay a dragon . < / p > < / d i v > ` ;
}
2026-10-20 15:03:22 +01:00
// The character, dressed: a compact grid of slots on a warrior's outline, Melvor-style, with
// room for slots the game doesn't have yet. A two-handed weapon fills the off hand too, faded.
// Picking a slot shows what's in it beside the grid.
const DOLL = [
[ "cape" , "Cape" , "cape" ] , [ "helmet" , "Head" , "visored-helm" ] , [ "amulet" , "Amulet" , "gem-pendant" ] ,
[ "weapon" , "Main hand" , "pointy-sword" ] , [ "body" , "Body" , "breastplate" ] , [ "shield" , "Off hand" , "round-shield" ] ,
[ "gloves" , "Gloves" , "gauntlet" ] , [ "legs" , "Legs" , "leg-armor" ] , [ "ring" , "Ring" , "ring" ] ,
2026-10-20 19:59:45 +01:00
[ "trinket" , "Trinket" , "crystal-ball" ] , [ "boots" , "Boots" , "leather-boot" ] , [ "food" , "Food" , "hot-meal" ] ,
2026-10-20 15:03:22 +01:00
] ;
function dollSlot ( p , slot , label , outline , picked ) {
2026-10-20 15:47:10 +01:00
if ( slot === "food" ) return foodSlot ( p , picked ) ;
2026-10-20 15:03:22 +01:00
const known = slot in p . equip , two = slot === "shield" && p . equip . weapon && item ( p . equip . weapon ) . two _handed ;
const id = two ? p . equip . weapon : p . equip [ slot ] ;
const tip = ! known ? ` ${ label } : not in the game yet ` : id ? ` ${ label } : ${ item ( id ) . name } ` : ` ${ label } : empty ` ;
return ` <button class="doll-slot ${ known ? "" : "future" } ${ id ? "" : "empty" } ${ two ? "ghost" : "" } ${ picked ? "on" : "" } "
$ { known ? ` data-doll=" ${ slot } " ` : "disabled" } title = "${esc(tip)}" >
2026-10-20 15:45:33 +01:00
$ { id ? item ( id ) . icon : glyph ( outline , "#98a1b4" ) } $ { two ? ` <span class="hands">2H</span> ` : "" }
$ { slot === "weapon" && id ? ` <span class="slot-type"> ${ dmgIcon ( C . styles [ item ( id ) . style || "sword" ] . damage ) } </span> ` : "" } < / b u t t o n > ` ;
2026-10-20 15:03:22 +01:00
}
function paperDoll ( p , mine ) {
2026-10-20 15:20:56 +01:00
const pick = dollPick && slot2 ( dollPick , p ) ;
2026-10-20 15:45:33 +01:00
let slot = "" ;
2026-10-20 15:47:10 +01:00
if ( pick === "food" ) slot = foodPanel ( p ) ;
else if ( pick ) {
2026-10-20 15:45:33 +01:00
const label = DOLL . find ( ( [ s ] ) => s === pick ) [ 1 ] ;
2026-10-20 15:20:56 +01:00
const two = pick === "shield" && p . equip . weapon && item ( p . equip . weapon ) . two _handed ;
const id = two ? p . equip . weapon : p . equip [ pick ] , it = id && item ( id ) ;
2026-10-20 15:45:33 +01:00
slot = ` <div class="worn-head"> ${ it ? ` <span class="ico"> ${ it . icon } </span><b> ${ esc ( it . name ) } </b> ` : ` <b> ${ label } </b><span class="muted small">empty</span> ` } </div>
$ { it ? gearChips ( it , p ) : "" } $ { mine ? swapGrid ( p , pick , two ) : "" } ` ;
2026-10-20 15:20:56 +01:00
}
2026-10-20 15:03:22 +01:00
return ` <div class="doll-wrap">
2026-10-20 15:50:54 +01:00
< div class = "doll-col" > < div class = "doll" > < span class = "doll-figure" > $ { glyph ( "swordman" , "#e6e8ee" ) } < / s p a n >
$ { DOLL . map ( ( [ s , name , outline ] ) => dollSlot ( p , s , name , outline , s === pick ) ) . join ( "" ) } < / d i v > $ { c o m b a t S t a t s ( p ) } < / d i v >
< div class = "doll-side" > < div class = "doll-slot-info" > $ { slot } < / d i v > < / d i v >
2026-10-20 15:03:22 +01:00
< / d i v > ` ;
}
2026-10-20 15:45:33 +01:00
// Your fighting numbers, always in the same place under the slot details: icons and numbers,
// with what each means on hover.
function combatStats ( p ) {
const f = p . fighter , pct = ( x ) => ` ${ Math . round ( x * 100 ) } % ` , dt = C . damage _types [ f . damage ] ;
const cell = ( icon , value , tip ) => ` <span class="gstat" title=" ${ esc ( tip ) } "> ${ icon } <span> ${ value } </span></span> ` ;
const cells = [
cell ( glyph ( "biceps" , "#e06a6a" ) , p . power , "Power: from your level and gear" ) ,
cell ( glyph ( "checked-shield" , "#d4dae2" ) , p . armour , ` Armour: ${ pct ( p . reduction ) } less damage from blows ` ) ,
cell ( glyph ( "magic-shield" , "#a77be8" ) , p . ward , ` Ward: ${ pct ( p . ward _reduction ) } less damage from magic ` ) ,
cell ( glyph ( dt . glyph , dt . tint ) , f . max _hit , ` Max hit ( ${ dt . name . toLowerCase ( ) } ) ` ) ,
cell ( glyph ( "striking-diamonds" , "#f0d060" ) , pct ( f . crit ) , "Crit chance" ) ,
cell ( glyph ( "stopwatch" , "#9fb3c8" ) , ` ${ f . interval } s ` , ` A swing every ${ f . interval } s ` ) ,
cell ( glyph ( "archery-target" , "#e0a050" ) , fmt ( f . accuracy ) , "Accuracy" ) ,
cell ( glyph ( "dodging" , "#8fd0e0" ) , fmt ( f . evasion ) , "Evasion" ) ,
cell ( glyph ( "shield-reflect" , "#e0b04a" ) , pct ( f . block ) , "Block chance (with a shield)" ) ,
] ;
2026-10-20 15:50:54 +01:00
return ` <div class="combat-stats stat-grid"> ${ cells . join ( "" ) } </div> ` ;
2026-10-20 15:45:33 +01:00
}
2026-10-20 15:20:56 +01:00
// What else in the bank fits the picked slot, best first: click one to put it on. For weapons the
// corner shows the damage type, so you can pick one for what you're about to fight.
function swapGrid ( p , slot , two ) {
const fits = Object . keys ( p . bank ) . filter ( ( id ) => item ( id ) . slot === slot )
. sort ( ( a , b ) => ( item ( b ) . tier ? ? 0 ) - ( item ( a ) . tier ? ? 0 ) || ( item ( b ) . power ? ? 0 ) - ( item ( a ) . power ? ? 0 ) ) ;
const tiles = fits . map ( ( id ) => {
const it = item ( id ) , [ skill , level ] = it . req || [ ] ;
const why = two ? ` Your ${ item ( p . equip . weapon ) . name . toLowerCase ( ) } takes both hands `
: skill && p . levels [ skill ] < level ? ` Needs ${ skillOf ( skill ) . name . toLowerCase ( ) } ${ level } ` : "" ;
const corner = it . style ? dmgIcon ( C . styles [ it . style ] . damage ) : "" ;
const tip = [ it . name , gearStats ( it ) , it . style ? ( it . two _handed ? "two-handed" : "one-handed" ) : "" , why ] . filter ( Boolean ) . join ( " · " ) ;
return ` <button class="doll-slot swap" data-equip=" ${ id } " ${ why ? "disabled" : "" } title=" ${ esc ( tip ) } "> ${ it . icon }
$ { corner ? ` <span class="swap-type"> ${ corner } </span> ` : "" } $ { it . two _handed ? ` <span class="hands">2H</span> ` : "" } $ { p . bank [ id ] > 1 ? ` <span class="swap-qty"> ${ p . bank [ id ] } </span> ` : "" } < / b u t t o n > ` ;
} ) ;
const off = p . equip [ slot ] && ! two ? ` <button class="doll-slot swap off" data-unequip=" ${ slot } " title="Take it off, back to the bank"> ${ glyph ( "cancel" , "#98a1b4" ) } </button> ` : "" ;
if ( ! tiles . length && ! off ) return ` <div class="muted small">Nothing else in your bank fits here.</div> ` ;
return ` <div class="swap-head muted small">Swap for</div><div class="swap-grid"> ${ tiles . join ( "" ) } ${ off } </div> ` ;
}
2026-10-20 15:03:22 +01:00
// A picked slot only counts if it's one this character has.
function slot2 ( slot , p ) {
2026-10-20 15:47:10 +01:00
return slot in p . equip || ( slot === "food" && "food" in p ) ? slot : null ;
}
2026-10-24 09:49:21 +01:00
// Food for resting between fights: one you picked to eat first, or the cheapest first. Only your own sheet shows it.
2026-10-20 15:47:10 +01:00
function foodSlot ( p , picked ) {
if ( ! ( "food" in p ) ) return ` <span class="doll-slot future" title="Food"></span> ` ;
const id = p . food && p . bank [ p . food ] ? p . food : null ;
2026-10-20 15:50:54 +01:00
const none = ! p . edible . length ;
const tip = none ? "No food in your bank" : id ? ` Food: ${ item ( id ) . name } , heals ${ item ( id ) . heal } ` : "Food: cheapest first" ;
return ` <button class="doll-slot ${ picked ? "on" : "" } ${ none ? "starving" : "" } " data-doll="food" title=" ${ esc ( tip ) } ">
2026-10-21 09:23:19 +01:00
$ { none ? glyph ( "hot-meal" , "#e2645c" ) : id ? item ( id ) . icon : ANY _FOOD } $ { id ? ` <span class="swap-qty"> ${ fmt ( p . bank [ id ] ) } </span> ` : "" } < / b u t t o n > ` ;
2026-10-20 15:47:10 +01:00
}
function foodPanel ( p ) {
const id = p . food && p . bank [ p . food ] ? p . food : null ;
const head = id ? ` <div class="worn-head"><span class="ico"> ${ item ( id ) . icon } </span><b> ${ esc ( item ( id ) . name ) } </b>
< span class = "muted small" > $ { HEART } $ { item ( id ) . heal } · $ { fmt ( p . bank [ id ] ) } left < / s p a n > < / d i v > `
2026-10-21 09:23:19 +01:00
: ` <div class="worn-head"><span class="ico"> ${ ANY _FOOD } </span><b>Cheapest first</b></div> ` ;
2026-10-20 15:47:10 +01:00
const tiles = [ ... p . edible ] . sort ( ( a , b ) => item ( a ) . heal - item ( b ) . heal ) . map ( ( f ) => ` <button class="doll-slot swap ${ f === id ? "on" : "" } " data-food=" ${ f } "
title = "${esc(`${item(f).name}: heals ${item(f).heal}`)}" > $ { item ( f ) . icon } < span class = "swap-qty" > $ { fmt ( p . bank [ f ] ) } < / s p a n > < / b u t t o n > ` ) ;
2026-10-21 09:23:19 +01:00
const cheap = ` <button class="doll-slot swap ${ id ? "" : "on" } " data-food="cheapest" title="Cheapest first"> ${ ANY _FOOD } </button> ` ;
2026-10-24 14:02:24 +01:00
const below = p . eat _below ? ? C . eat _below ;
const label = ( x ) => x === 0 ? "Never" : x === 1 ? "Always" : ` ${ Math . round ( x * 100 ) } % ` ;
const when = ` <div class="swap-head muted small" title="Between fights you eat while under this much of your HP, then rest the rest of the way">Eat below</div>
< div class = "eat-below" > $ { C . eat _choices . map ( ( x ) => ` <button class="btn sm ${ x === below ? "on" : "" } " data-eat=" ${ x } "> ${ label ( x ) } </button> ` ) . join ( "" ) } < / d i v > ` ;
2026-10-20 15:47:10 +01:00
return head + ( p . edible . length ? ` <div class="swap-head muted small">Eat first</div><div class="swap-grid"> ${ cheap } ${ tiles . join ( "" ) } </div> `
2026-10-24 14:02:24 +01:00
: ` <div class="small warn">No food in your bank.</div> ` ) + when ;
2026-10-20 15:03:22 +01:00
}
2026-10-20 15:09:09 +01:00
// The equipment panel, the same on the character sheet and the combat page (which leaves out tools).
function equipmentPanel ( p , mine , tools = true ) {
return ` <div class="panel"><h2>Equipment</h2> ${ paperDoll ( p , mine ) }
$ { tools ? ` <h2 style="margin-top:16px">Tools</h2> ${ toolSlots ( p ) } ` : "" } < / d i v > ` ;
}
2026-10-20 15:03:22 +01:00
// Tools: one small slot per gathering skill.
2026-10-20 21:20:25 +01:00
// Gathering tools in a row; under them each crafting skill's tool, with its station below it.
2026-10-20 15:03:22 +01:00
function toolSlots ( p ) {
2026-10-20 21:20:25 +01:00
const slot = ( kind , s ) => {
const id = p . equip [ ` ${ kind } _ ${ s . id } ` ] , it = id && item ( id ) ;
return ` <span class="doll-slot small ${ id ? "" : "empty" } " title=" ${ esc ( it ? ` ${ it . name } : ${ Math . round ( it . speed * 100 ) } % faster ${ s . name . toLowerCase ( ) } ` : ` ${ s . name } : no ${ kind } ` ) } ">
2026-10-20 15:03:22 +01:00
$ { it ? it . icon : s . icon } < / s p a n > ` ;
2026-10-20 21:20:25 +01:00
} ;
const gathering = C . skills . filter ( ( s ) => ` tool_ ${ s . id } ` in p . equip && ! ( ` station_ ${ s . id } ` in p . equip ) ) ;
const crafting = C . skills . filter ( ( s ) => ` station_ ${ s . id } ` in p . equip ) ;
return ` <div class="tool-slots"> ${ gathering . map ( ( s ) => slot ( "tool" , s ) ) . join ( "" ) } </div>
< div class = "tool-grid" > $ { crafting . map ( ( s ) => slot ( "tool" , s ) + slot ( "station" , s ) ) . join ( "" ) } < / d i v > ` ;
2026-10-20 15:03:22 +01:00
}
2026-10-18 13:48:56 +01:00
// --- registration ---------------------------------------------------------
2026-10-19 22:45:32 +01:00
function classCards ( ) {
return Object . entries ( C . classes ) . map ( ( [ id , c ] ) => `
2026-10-18 13:48:56 +01:00
< button class = "class-card ${pickedClass === id ? " on " : " "}" data - class = "${id}" >
2026-10-19 23:38:34 +01:00
< span class = "ico" > $ { c . icon } < / s p a n > < s p a n > < b > $ { c . n a m e } < / b > < p > $ { e s c ( c . b o n u s ) } . < / p > < p > $ { e s c ( c . m a s t e r w o r k ) } . < / p > < p > $ { e s c ( c . s i g n a t u r e ) } . < / p > < p > $ { e s c ( c . c o m b a t ) } . < / p > < / s p a n >
2026-10-18 13:48:56 +01:00
< / b u t t o n > ` ) . j o i n ( " " ) ;
2026-10-19 22:45:32 +01:00
}
// After the classes became trades, everyone picks once more. Nothing is lost.
function repickPage ( ) {
return ` <div class="welcome">
< h2 > Pick your trade < / h 2 >
2026-10-20 08:01:57 +01:00
< p class = "muted" > Classes are now trades , gathering through crafting . You keep every level and item . Free , once . < / p >
2026-10-19 22:45:32 +01:00
< div class = "classes" > $ { classCards ( ) } < / d i v >
< div class = "btns" > < button class = "btn primary" data - repick $ { pickedClass ? "" : "disabled" } > Take up this trade < / b u t t o n > < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Any class can train any skill . < / p >
2026-10-19 22:45:32 +01:00
< / d i v > ` ;
}
function register ( ) {
const cards = classCards ( ) ;
2026-10-18 13:48:56 +01:00
return ` <div class="welcome">
< h2 > A new city on the frontier < / h 2 >
2026-10-20 08:01:57 +01:00
< p class = "muted" > Settle a frontier city together . Train , build , and hold off the bandits .
You keep working while away , for up to a day . < / p >
2026-10-18 13:48:56 +01:00
< div class = "classes" > $ { cards } < / d i v >
< form data - form = "register" class = "btns" >
< input type = "text" data - keep = "name" maxlength = "20" placeholder = "Your adventurer's name" style = "flex:1;min-width:200px" >
< button class = "btn primary" $ { pickedClass ? "" : "disabled" } > Enter the city < / b u t t o n >
< / f o r m >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Any class can train any skill . < / p >
2026-10-18 13:48:56 +01:00
< / d i v > ` ;
}
// --- animation ------------------------------------------------------------
function animate ( ) {
const now = serverNow ( ) ;
const a = S ? . me ? . action ;
2026-10-19 23:32:07 +01:00
animateFight ( now ) ;
2026-10-24 09:49:21 +01:00
if ( a && ! a . waiting ) {
2026-10-18 13:48:56 +01:00
const pct = Math . min ( 1 , ( now - a . start ) / a . duration ) ;
2026-10-20 07:32:54 +01:00
// In a fight the bar is the enemy's health (set by animateFight), not a timer.
if ( ! a . fight ) for ( const el of document . querySelectorAll ( "[data-progress]" ) ) el . style . width = ( pct * 100 ) + "%" ;
2026-10-20 12:54:42 +01:00
const key = ` ${ a . start } : ${ a . duration } ` ;
if ( pct >= 1 && refreshedFor !== key ) {
refreshedFor = key ;
2026-10-18 13:48:56 +01:00
setTimeout ( refresh , 150 ) ;
2026-10-24 09:49:21 +01:00
} else if ( a . fight && now - a . start > a . duration + 1 && now - retriedAt > 2 ) {
2026-10-20 12:54:42 +01:00
retriedAt = now ; // the next enemy hasn't turned up yet: ask again
refresh ( ) ;
2026-10-18 13:48:56 +01:00
}
}
for ( const el of document . querySelectorAll ( "[data-span]" ) ) {
const [ start , end ] = el . dataset . span . split ( "," ) . map ( Number ) ;
el . style . width = Math . min ( 100 , ( now - start ) / ( end - start ) * 100 ) + "%" ;
}
for ( const el of document . querySelectorAll ( "[data-countdown]" ) ) el . textContent = dur ( Number ( el . dataset . countdown ) - now ) ;
2026-10-21 12:16:22 +01:00
animateFires ( ) ;
2026-10-18 13:48:56 +01:00
}
function loop ( ) {
if ( S ) animate ( ) ;
requestAnimationFrame ( loop ) ;
}
// --- events ---------------------------------------------------------------
document . addEventListener ( "click" , ( e ) => {
2026-10-24 12:05:23 +01:00
const t = e . target . closest ( "button, [data-menu], [data-close], [data-aim]" ) ;
2026-10-18 13:48:56 +01:00
if ( ! t ) return ;
const d = t . dataset ;
2026-10-20 13:12:12 +01:00
if ( t . matches ( "[data-battle-skip]" ) ) return playBattle . finish ? . ( ) ;
if ( d . raidSeen ) {
$ ( "#modal" ) . hidden = true ;
return act ( "/api/seen" , { id : Number ( d . raidSeen ) } ) ;
}
if ( ! $ ( "#modal" ) . hidden && $ ( ".battle" ) ) return ; // the raid report closes with its own button, once watched
if ( t . matches ( "[data-close]" ) || e . target === $ ( "#modal" ) ) { $ ( "#modal" ) . hidden = true ; showThreat ( ) ; return ; }
2026-10-18 14:31:41 +01:00
if ( "menu" in d ) { setMenu ( ! $ ( "#app" ) . classList . contains ( "open" ) ) ; return ; }
2026-10-18 13:48:56 +01:00
if ( d . nav ) {
2026-10-20 21:13:46 +01:00
if ( d . navGroup ) { tabs [ d . nav . slice ( 6 ) ] = d . navGroup ; remember ( ` tab. ${ d . nav . slice ( 6 ) } ` , d . navGroup ) ; }
2026-10-18 13:48:56 +01:00
page = d . nav ; remember ( "page" , page ) ; selected = null ;
history . replaceState ( null , "" , "#" + page ) ;
2026-10-18 14:31:41 +01:00
setMenu ( false ) ;
2026-10-18 13:48:56 +01:00
render ( ) ; window . scrollTo ( 0 , 0 ) ; return ;
}
2026-10-21 12:16:22 +01:00
if ( d . firewood ) { firewood = Number ( d . firewood ) ; remember ( "firewood" , d . firewood ) ; return render ( ) ; }
2026-10-24 12:05:23 +01:00
if ( d . press ) {
if ( ! S . me . manual . includes ( d . press ) ) return toast ( "On auto: tap AUTO to take it yourself" ) ;
t . closest ( ".ab" ) ? . classList . add ( "queued" ) ;
2026-10-24 14:21:08 +01:00
const kind = C . abilities [ d . press ] . target , mine = aim . fight === S . me . action ? . start ;
const target = ! mine ? null : kind === "enemy" ? aim . foe : kind === "ally" ? aim . ally : null ;
return act ( "/api/press" , { id : d . press , ... ( target ? { target } : { } ) } ) ;
2026-10-24 12:05:23 +01:00
}
if ( d . mode ) return act ( "/api/manual" , { id : d . mode , on : ! S . me . manual . includes ( d . mode ) } ) ;
if ( "pull" in d ) return act ( "/api/pull" , { } ) ;
2026-10-24 13:48:16 +01:00
if ( d . learn ) return act ( "/api/learn" , { item : d . learn } ) ;
2026-10-24 14:02:24 +01:00
if ( d . eat !== undefined ) return act ( "/api/eat" , { below : Number ( d . eat ) } ) ;
2026-10-24 14:21:08 +01:00
if ( d . aim ) {
const fight = S . me . action ? . start ;
if ( aim . fight !== fight ) aim = { fight , foe : null , ally : null } ;
if ( d . aim [ 0 ] === "e" ) aim . foe = d . aim ;
else aim . ally = d . aim ;
animateFight ( serverNow ( ) ) ; // the outline moves now; the server hears about it in the background
return d . aim === S . uid ? undefined : act ( "/api/target" , { id : d . aim } ) ;
}
2026-10-18 13:48:56 +01:00
if ( d . start ) return act ( "/api/action" , { id : d . start } ) ;
2026-10-18 19:35:21 +01:00
if ( d . once ) return act ( "/api/action" , { id : d . once , once : true } ) ;
2026-10-18 13:48:56 +01:00
if ( "stop" in d ) return act ( "/api/stop" , { } ) ;
2026-10-21 22:06:49 +01:00
if ( d . join ) { tagging = true ; return act ( "/api/join" , { id : d . join } ) ; }
2026-10-20 18:01:02 +01:00
if ( "orderNew" in d ) {
draft = draft ? null : { kind : "craft" , item : "" , qty : 1 , provide : [ ] , reward : 0 } ;
return render ( ) ;
}
2026-10-20 19:32:27 +01:00
if ( d . draftKind ) { draft = { kind : d . draftKind , item : "" , qty : 1 , provide : [ ] , reward : 0 } ; clearSearch ( ) ; return render ( ) ; }
if ( d . draftSkill ) { draft . skill = d . draftSkill ; draft . search = "" ; clearSearch ( ) ; return render ( ) ; }
if ( d . draftGroup ) { draft . group = d . draftGroup ; return render ( ) ; }
2026-10-24 18:13:44 +01:00
if ( d . draftItem ) {
pickOrderItem ( d . draftItem ) ;
render ( ) ;
// On a phone the named list is long: bring the amount, price and Post into view.
if ( draft . item && matchMedia ( "(max-width: 900px)" ) . matches ) $ ( ".compose-grid" ) ? . scrollIntoView ( { behavior : "smooth" , block : "start" } ) ;
return ;
}
2026-10-20 18:01:02 +01:00
if ( d . provide ) {
draft . provide = draft . provide . includes ( d . provide ) ? draft . provide . filter ( ( x ) => x !== d . provide ) : [ ... draft . provide , d . provide ] ;
return render ( ) ;
}
if ( "orderPost" in d ) {
const body = { kind : draft . kind , item : draft . item , qty : draft . qty , provide : draft . provide , reward : draft . reward } ;
return api ( "/api/order" , body ) . then ( ( state ) => { draft = null ; orderTab = "yours" ; accept ( state ) ; } ) . catch ( ( err ) => toast ( err . message , "bad" ) ) ;
}
if ( d . otab ) { orderTab = d . otab ; return render ( ) ; }
if ( d . orderWork ) return act ( "/api/order/work" , { id : Number ( d . orderWork ) } ) ;
if ( d . orderDrop ) return act ( "/api/order/drop" , { id : Number ( d . orderDrop ) } ) ;
if ( d . orderDeliver ) return act ( "/api/order/deliver" , { id : Number ( d . orderDeliver ) } ) ;
if ( d . orderCancel ) return act ( "/api/order/cancel" , { id : Number ( d . orderCancel ) } ) ;
2026-10-20 20:11:11 +01:00
if ( d . orderRemove ) return act ( "/api/order/remove" , { id : Number ( d . orderRemove ) } ) ;
if ( "orderClear" in d ) return act ( "/api/order/remove" , { } ) ;
2026-10-20 18:46:39 +01:00
if ( d . orderFill ) return act ( "/api/order/provide" , { id : Number ( d . orderFill ) , item : d . item } ) ;
2026-10-20 15:03:22 +01:00
if ( d . doll ) {
2026-10-20 15:20:56 +01:00
dollPick = dollPick === d . doll ? null : d . doll ;
2026-10-20 15:03:22 +01:00
return render ( ) ;
}
2026-10-18 14:31:41 +01:00
if ( "select" in d ) {
selected = d . select || null ;
render ( ) ;
// On a phone the item panel sits under the bank grid: bring it into view.
if ( selected && matchMedia ( "(max-width: 900px)" ) . matches ) $ ( ".item-panel" ) ? . scrollIntoView ( { behavior : "smooth" , block : "start" } ) ;
return ;
}
2026-10-18 13:48:56 +01:00
const qty = d . qty === "all" ? "all" : Number ( d . qty ) ;
if ( d . donate ) return act ( "/api/donate" , { item : d . donate , qty } ) ;
if ( d . sell ) return act ( "/api/sell" , { item : d . sell , qty } ) ;
if ( d . give ) return act ( "/api/give" , { item : d . give , qty , to : $ ( "[data-keep=give-to]" ) . value } ) ;
if ( d . equip ) return act ( "/api/equip" , { item : d . equip } ) ;
if ( d . unequip ) return act ( "/api/equip" , { slot : d . unequip } ) ;
2026-10-18 19:20:52 +01:00
if ( d . fund ) return act ( "/api/fund" , { building : d . fund , item : d . item , source : d . source , qty : "all" } ) ;
2026-10-19 11:15:19 +01:00
if ( d . install ) return act ( "/api/install" , { fitting : d . install , source : d . source } ) ;
2026-10-19 23:17:23 +01:00
if ( "plot" in d ) return act ( "/api/plot" , { id : d . plot || null } ) ;
2026-10-18 13:48:56 +01:00
if ( d . garrison ) return act ( "/api/garrison" , { join : d . garrison === "1" } ) ;
if ( d . perk ) return act ( "/api/perk" , { perk : d . perk } ) ;
2026-10-20 20:06:16 +01:00
if ( d . pin ) return act ( "/api/pin" , { skill : d . pin } ) ;
2026-10-20 21:13:46 +01:00
if ( d . track ) return act ( "/api/track" , { id : d . track } ) ;
2026-10-18 19:48:22 +01:00
if ( d . favour ) return act ( "/api/favour" , { kind : d . favour } ) ;
2026-10-20 15:47:10 +01:00
if ( d . food ) return act ( "/api/food" , { item : d . food === "cheapest" ? null : d . food } ) ;
2026-10-18 23:12:57 +01:00
if ( d . potion ) return act ( "/api/potion" , { item : d . potion } ) ;
2026-10-19 09:56:11 +01:00
if ( d . unpost ) {
if ( ! confirm ( "Delete this message?" ) ) return ;
return act ( "/api/board/delete" , { id : Number ( d . unpost ) } ) ;
}
2026-10-19 09:34:18 +01:00
if ( d . aid ) return act ( "/api/aid" , { kind : d . aid , item : d . item , source : d . source } ) ;
if ( "hire" in d ) return act ( "/api/hire" , { } ) ;
2026-10-24 13:34:59 +01:00
if ( d . wages ) return act ( "/api/wages" , { source : d . wages , qty : "all" } ) ;
if ( "rent" in d ) return act ( "/api/rent" , { } ) ;
if ( d . buy ) return act ( "/api/buy" , { ware : d . buy , skill : $ ( "[data-keep=tutor-skill]" ) ? . value } ) ;
if ( "statue" in d ) return act ( "/api/statue" , { of : $ ( "[data-keep=statue-of]" ) . value } ) ;
if ( "home" in d ) return act ( "/api/home" , { } ) ;
if ( d . look ) return act ( "/api/look" , { look : { ... S . me . look , [ d . look ] : d . value || null } } ) ;
2026-10-19 09:34:18 +01:00
if ( d . devHappen ) return act ( "/api/dev/happen" , { track : d . devHappen } ) ;
2026-10-18 23:09:17 +01:00
if ( d . pray ) {
const on = S . me . prayers . includes ( d . pray ) ;
2026-10-20 08:01:57 +01:00
if ( ! on && S . me . prayers . length >= C . max _prayers ) return toast ( ` Max ${ C . max _prayers } prayers active ` , "bad" ) ;
2026-10-18 23:09:17 +01:00
return act ( "/api/prayers" , { ids : on ? S . me . prayers . filter ( ( x ) => x !== d . pray ) : [ ... S . me . prayers , d . pray ] } ) ;
}
2026-10-18 13:48:56 +01:00
if ( d . overflow ) return act ( "/api/settings" , { overflow : d . overflow } ) ;
if ( d . class ) { pickedClass = d . class ; render ( ) ; return ; }
2026-10-19 22:45:32 +01:00
if ( "repick" in d ) return act ( "/api/class" , { cls : pickedClass } ) ;
2026-10-18 22:13:50 +01:00
if ( d . tab ) { tabs [ d . tab ] = d . group ; remember ( ` tab. ${ d . tab } ` , d . group ) ; render ( ) ; return ; }
2026-10-18 13:48:56 +01:00
if ( d . devAdvance ) return act ( "/api/dev/advance" , { hours : Number ( d . devAdvance ) } ) ;
if ( "devEvent" in d ) return act ( "/api/dev/event" , { } ) ;
} ) ;
2026-10-20 19:32:27 +01:00
function clearSearch ( ) {
const field = $ ( "[data-keep=order-search]" ) ;
if ( field ) field . value = "" ;
}
function pickOrderItem ( id ) {
const list = draft . kind === "craft" ? C . order _recipes : C . order _raw ;
draft . item = list [ id ] ? id : "" ;
draft . provide = [ ] ;
// A starting price: what the item's worth, each.
draft . reward = draft . item ? item ( draft . item ) . value * ( draft . kind === "craft" ? draft . qty : 1 ) : 0 ;
}
document . addEventListener ( "input" , ( e ) => {
if ( e . target . dataset . keep !== "order-search" || ! draft ) return ;
draft . search = e . target . value ;
render ( ) ;
} ) ;
2026-10-18 19:27:56 +01:00
document . addEventListener ( "change" , ( e ) => {
2026-10-24 13:34:59 +01:00
if ( "lookTitle" in e . target . dataset ) return act ( "/api/look" , { look : { ... S . me . look , title : e . target . value || null } } ) ;
2026-10-20 18:01:02 +01:00
const key = e . target . dataset . draft ;
if ( ! key || ! draft ) return ;
if ( key === "item" ) {
2026-10-20 19:32:27 +01:00
pickOrderItem ( e . target . value ) ;
2026-10-20 18:01:02 +01:00
} else {
2026-10-20 18:31:51 +01:00
const before = draft . qty ;
2026-10-20 18:01:02 +01:00
draft [ key ] = Math . max ( key === "qty" ? 1 : 0 , Math . floor ( Number ( e . target . value ) || 0 ) ) ;
2026-10-20 18:19:15 +01:00
if ( key === "qty" ) draft . qty = Math . min ( draft . qty , C . order _max [ draft . kind ] ) ;
2026-10-20 18:31:51 +01:00
// A crafting order's reward is the total: it follows the quantity at the same price each.
if ( key === "qty" && draft . kind === "craft" ) draft . reward = Math . round ( draft . reward / before * draft . qty ) ;
2026-10-20 18:01:02 +01:00
if ( key === "qty" && draft . kind === "craft" && draft . item ) {
// Drop provided reagents the bank can no longer cover at the new quantity.
const a = C . actions . find ( ( x ) => x . id === C . order _recipes [ draft . item ] ) , runs = Math . ceil ( draft . qty / a . outputs [ draft . item ] ) ;
draft . provide = draft . provide . filter ( ( id ) => ( S . me . bank [ id ] || 0 ) >= a . inputs [ id ] * runs ) ;
}
}
render ( ) ;
2026-10-18 19:27:56 +01:00
} ) ;
2026-10-18 13:48:56 +01:00
$ ( "#modal" ) . addEventListener ( "click" , ( e ) => { if ( e . target . id === "modal" ) e . target . hidden = true ; } ) ;
2026-10-24 12:05:23 +01:00
// Number keys press your abilities, in the bar's order (the ones you know).
document . addEventListener ( "keydown" , ( e ) => {
if ( e . target . matches ( "input, textarea, select" ) || e . ctrlKey || e . metaKey || e . altKey ) return ;
const n = Number ( e . key ) ;
if ( ! n ) return ;
const tile = document . querySelectorAll ( ".ability-bar .ab:not(.locked) [data-press]" ) [ n - 1 ] ;
if ( tile ) { e . preventDefault ( ) ; tile . click ( ) ; }
} ) ;
2026-10-18 13:48:56 +01:00
document . addEventListener ( "submit" , async ( e ) => {
const form = e . target . closest ( "[data-form]" ) ;
if ( ! form ) return ;
e . preventDefault ( ) ;
const field = form . querySelector ( "[data-keep]" ) ;
const value = field . value . trim ( ) ;
if ( form . dataset . form === "register" ) {
if ( ! pickedClass ) return toast ( "Pick a class first" , "bad" ) ;
return act ( "/api/register" , { name : value , cls : pickedClass } ) ;
}
if ( form . dataset . form === "board" ) {
if ( ! value ) return ;
field . value = "" ;
return act ( "/api/board" , { text : value } ) ;
}
2026-10-24 13:34:59 +01:00
if ( form . dataset . form === "monument" ) {
field . value = "" ;
return act ( "/api/monument" , { id : form . dataset . id , qty : Number ( value ) } ) ;
}
2026-10-18 13:48:56 +01:00
if ( form . dataset . form === "gold" ) {
field . value = "" ;
return act ( "/api/donate" , { item : "gold" , qty : Number ( value ) } ) ;
}
if ( form . dataset . form === "dev-user" && value ) {
document . cookie = ` gh_user= ${ encodeURIComponent ( value ) } ; path=/; SameSite=Lax ` ;
field . value = "" ;
return refresh ( ) ;
}
} ) ;
document . addEventListener ( "visibilitychange" , ( ) => { if ( ! document . hidden ) refresh ( ) ; } ) ;
2026-10-18 14:42:35 +01:00
// --- swipe the menu open and shut (phones) --------------------------------
// A mostly-sideways swipe drags the drawer with the finger; letting go snaps
// it open or shut by distance or speed. Vertical scrolling is left alone.
// Move and end listeners go on the touched element itself: touch events keep
// going to it even if a re-render detaches it mid-swipe, and then they no
// longer bubble up to the document.
( function swipeMenu ( ) {
const phone = matchMedia ( "(max-width: 900px)" ) ;
let drag = null ;
const reset = ( ) => {
$ ( "#side" ) . style . transform = $ ( "#side" ) . style . transition = "" ;
$ ( "#scrim" ) . style . opacity = "" ;
$ ( "#app" ) . classList . remove ( "dragging" ) ;
} ;
document . addEventListener ( "touchstart" , ( e ) => {
if ( drag ) release ( ) ;
if ( ! phone . matches || e . touches . length !== 1 || ! $ ( "#modal" ) . hidden || e . target . closest ( "input, textarea, select" ) ) return ;
const t = e . touches [ 0 ] ;
drag = { x : t . clientX , y : t . clientY , at : e . timeStamp , open : $ ( "#app" ) . classList . contains ( "open" ) , axis : null , dx : 0 , target : e . target } ;
drag . target . addEventListener ( "touchmove" , move , { passive : false } ) ;
drag . target . addEventListener ( "touchend" , finish ) ;
drag . target . addEventListener ( "touchcancel" , finish ) ;
} , { passive : true } ) ;
function release ( ) {
drag . target . removeEventListener ( "touchmove" , move ) ;
drag . target . removeEventListener ( "touchend" , finish ) ;
drag . target . removeEventListener ( "touchcancel" , finish ) ;
if ( drag . axis === "x" ) reset ( ) ;
drag = null ;
}
function move ( e ) {
if ( ! drag ) return ;
const t = e . touches [ 0 ] ;
const dx = t . clientX - drag . x , dy = t . clientY - drag . y ;
if ( ! drag . axis ) {
if ( Math . hypot ( dx , dy ) < 10 ) return ;
const sideways = Math . abs ( dx ) > Math . abs ( dy ) * 1.3 && ( drag . open ? dx < 0 : dx > 0 ) ;
drag . axis = sideways ? "x" : "y" ;
if ( ! sideways ) return ;
drag . width = $ ( "#side" ) . offsetWidth ;
$ ( "#side" ) . style . transition = "none" ;
$ ( "#app" ) . classList . add ( "dragging" ) ;
}
if ( drag . axis !== "x" ) return ;
e . preventDefault ( ) ;
drag . dx = dx ;
// 0 is fully open, -width fully shut.
const offset = Math . max ( - drag . width , Math . min ( 0 , drag . open ? dx : dx - drag . width ) ) ;
$ ( "#side" ) . style . transform = ` translateX( ${ offset } px) ` ;
$ ( "#scrim" ) . style . opacity = String ( 1 + offset / drag . width ) ;
}
function finish ( e ) {
if ( ! drag ) return ;
const swiped = drag . axis === "x" ;
const speed = drag . dx / Math . max ( 1 , e . timeStamp - drag . at ) ; // px per ms
const open = drag . open
? ! ( drag . dx < - drag . width * 0.35 || speed < - 0.4 )
: drag . dx > drag . width * 0.35 || speed > 0.4 ;
// Hand back to the stylesheet; its transition carries on from here.
release ( ) ;
if ( swiped ) setMenu ( open ) ;
}
} ) ( ) ;
2026-10-18 13:48:56 +01:00
( async function boot ( ) {
try {
2026-10-19 10:04:38 +01:00
C = iconise ( await api ( "/api/content" ) ) ;
2026-10-18 13:48:56 +01:00
await refresh ( ) ;
} catch ( err ) {
$ ( "#main" ) . innerHTML = ` <p class="loading">Couldn't reach the city: ${ esc ( err . message ) } </p> ` ;
return ;
}
2026-10-21 22:06:49 +01:00
setInterval ( ( ) => {
if ( ! document . hidden && Date . now ( ) - polledAt >= ( S ? . me ? . action ? . fight ? FIGHT _POLL _MS : POLL _MS ) ) refresh ( ) ;
} , 500 ) ;
2026-10-18 13:48:56 +01:00
requestAnimationFrame ( loop ) ;
} ) ( ) ;