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 ;
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
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 ;
let refreshedFor = null ; // action start a completion refresh was sent for
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-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> ` ;
const COIN = glyph ( "two-coins" , "#e0b04a" ) , FAVOUR = glyph ( "sparkles" , "#b98ce8" ) , HEART = glyph ( "hearts" , "#e2645c" ) ;
function iconise ( content ) {
const entries = [ ... Object . values ( content . items ) , ... content . skills , ... Object . values ( content . classes ) ,
... Object . values ( content . buildings ) , ... content . prayers , ... content . actions ] ;
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 ;
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 ;
if ( state . version !== version && ! document . querySelector ( "[data-keep]:focus" ) && $ ( "#modal" ) . hidden ) {
location . reload ( ) ; // a new build was deployed
return ;
}
2026-10-18 13:48:56 +01:00
S = state ;
skew = state . now - Date . now ( ) / 1000 ;
if ( state . summary ) showSummary ( state . summary ) ;
render ( ) ;
}
// --- 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 ) {
if ( ! sum . done && ! sum . stopped ) return ;
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-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-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 > ` ) ;
}
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-18 14:31:41 +01:00
setHtml ( $ ( "#side" ) , S . me ? sidebar ( ) : ` <div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div> ${ S . dev ? devbar ( ) : "" } ` ) ;
setHtml ( $ ( "#top" ) , topbar ( ) ) ;
setHtml ( $ ( "#main" ) , S . me ? pageHtml ( ) : register ( ) ) ;
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
}
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" ;
let html = `
< div class = "brand" > < img src = "/static/icon.svg" alt = "" > Guildhall < small > alpha < / s m a l l > < / d i v >
< 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 >
< 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-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-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> ` ;
}
const busy = doing ? . skill === skill . id ? ` <span class="dot"></span> ` : "" ;
html += nav ( "skill:" + skill . id , skill . icon , skill . name , ` ${ me . levels [ skill . id ] } ` , busy ) ;
}
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-18 19:35:21 +01:00
const TITLES = { town : "Town square" , council : "Council & threats" , board : "Town board" , roster : "Guild roster" , 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-18 13:48:56 +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"><span data-progress></span></span></button> ` ;
} else if ( S . me ) {
chips += ` <span class="chip muted">Idle ${ S . me . stopped ? ` · ${ esc ( S . me . stopped ) } ` : "" } </span> ` ;
}
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-19 09:34:18 +01:00
if ( S . upcoming ) chips += ` <button class="chip ${ [ "raid" , "dragon" ] . includes ( S . upcoming . kind ) ? "threat" : "" } " data-nav="council"> ${ eventIcon ( S . upcoming . kind ) } ${ eventWhen ( S . upcoming , true ) } </button> ` ;
2026-10-18 13:48:56 +01:00
return ` <button class="menu-btn" data-menu aria-label="Menu">☰</button><h1> ${ esc ( title ) } </h1> ${ chips } ` ;
}
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-18 13:48:56 +01:00
return ( { town , council , board , roster , bank } [ page ] || town ) ( ) ;
}
function itemChip ( id , qty , have ) {
const short = have !== undefined && have < qty ;
2026-10-18 22:36:00 +01:00
return ` <span class="item-chip ${ short ? "short" : "" } " title=" ${ esc ( [ item ( id ) . name , itemStats ( id ) ] . filter ( Boolean ) . join ( ": " ) ) } "> ${ item ( id ) . icon } ${ fmt ( qty ) } ${ have !== undefined ? ` <span class="have">/ ${ fmt ( have ) } </span> ` : "" } </span> ` ;
2026-10-18 13:48:56 +01:00
}
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-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" >
< h2 > $ { skill . name } < / h 2 >
< 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-19 23:32:07 +01:00
if ( id === "combat" ) html += fightPanel ( ) + combatPanel ( ) ;
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-18 23:12:57 +01:00
html += potionPanel ( id ) ;
2026-10-18 19:27:56 +01:00
if ( ` tool_ ${ id } ` in me . equip ) html += toolPanel ( 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 ) ;
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-19 23:32:07 +01:00
const ladder = [ "smithing" , "leatherworking" , "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-18 22:13:50 +01:00
const bar = groups . map ( ( g ) => ` <button class="tab ${ g === current ? "on" : "" } ${ open ( g ) ? "" : "dim" } " data-tab=" ${ id } " data-group=" ${ esc ( g ) } "> ${ esc ( g ) } </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.
function potionPanel ( skill ) {
const me = S . me ;
const kinds = Object . entries ( C . items ) . filter ( ( [ , it ] ) => it . potion ? . skill === skill ) ;
if ( ! kinds . length ) return "" ;
const active = me . potions [ skill ] ;
const owned = kinds . filter ( ( [ id ] ) => me . bank [ id ] ) ;
const drink = owned . map ( ( [ id , it ] ) => ` <button class="btn sm" data-potion=" ${ id } " title=" ${ esc ( it . potion . text ) } "> ${ it . icon } ${ esc ( it . name ) } ( ${ fmt ( me . bank [ id ] ) } )</button> ` ) . join ( "" ) ;
return ` <div class="panel tool-panel"> ${ active
? ` ${ item ( active . item ) . icon } <b> ${ esc ( item ( active . item ) . name ) } </b> <span class="muted"> ${ esc ( item ( active . item ) . potion . text ) } · ${ fmt ( active . charges ) } uses left ${ me . bank [ active . item ] ? ` , ${ fmt ( me . bank [ active . item ] ) } more in the bank ` : "" } </span> `
: ` <span class="muted">No potion.</span> ` }
$ { ! active && drink ? ` <span class="btns">Drink: ${ drink } </span> ` : "" }
2026-10-20 08:01:57 +01:00
$ { ! active && ! drink ? ` <span class="small muted">Brewed in Herblore.</span> ` : "" }
2026-10-18 23:12:57 +01:00
< / d i v > ` ;
}
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-18 19:27:56 +01:00
function toolPanel ( skill ) {
const tool = S . me . equip [ ` tool_ ${ skill } ` ] ;
return ` <div class="panel tool-panel"> ${ tool
? ` ${ item ( tool ) . icon } <b> ${ esc ( item ( tool ) . name ) } </b> <span class="muted"> ${ Math . round ( item ( tool ) . speed * 100 ) } % faster</span>
< button class = "btn sm" data - unequip = "tool_${skill}" > Unequip < / b u t t o n > `
2026-10-20 08:01:57 +01:00
: ` <span class="muted">No tool.</span> ` } < / d i v > ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-19 23:38:34 +01:00
// What a monster is weak to and shrugs off, and how your weapon fares against it.
function weaknessLine ( kind , odds ) {
const table = C . weakness [ kind ] ;
if ( ! table ) return "" ;
const names = ( pick ) => Object . entries ( table ) . filter ( ( [ , m ] ) => pick ( m ) ) . map ( ( [ s ] ) => ` ${ C . styles [ s ] . name } s ` ) . join ( ", " ) ;
const weak = names ( ( m ) => m > 1 ) , tough = names ( ( m ) => m < 1 ) ;
const yours = odds . weakness > 1 ? ` · <b class="good">your ${ C . styles [ odds . style ] . name } hits it hard</b> `
: odds . weakness < 1 ? ` · <b class="bad">your ${ C . styles [ odds . style ] . name } does half damage</b> ` : "" ;
return ` <div class="small muted"> ${ weak ? ` Weak to ${ weak } ` : "" } ${ weak && tough ? " · " : "" } ${ tough ? ` shrugs off ${ tough } ` : "" } ${ yours } </div> ` ;
}
2026-10-19 23:32:07 +01:00
// The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel ( ) {
const f = S . me . action ? . fight ;
if ( ! f ) return "" ;
const side = ( who , name ) => ` <div class="fighter">
< div class = "fighter-name" > < b > $ { esc ( name ) } < / b > < s p a n c l a s s = " s m a l l m u t e d " d a t a - f = " $ { w h o } - h p " > < / s p a n > < / d i v >
< div class = "bar hp ${who}" > < span data - f = "${who}-bar" > < / s p a n > < / d i v >
< div class = "bar swing" > < span data - f = "${who}-swing" > < / s p a n > < / d i v >
< / d i v > ` ;
return ` <div class="panel fight" data-fight=" ${ S . me . action . start } ">
< div class = "fighters" > $ { side ( "you" , S . me . name ) } < span class = "vs" > ⚔ ️ < / s p a n > $ { s i d e ( " f o e " , f . e n e m y ) } < / d i v >
< ul class = "fight-log" data - f = "log" > < / u l >
< / d i v > ` ;
}
function fightLine ( e , enemy ) {
const [ , who , kind , amount , , , food ] = e ;
const foe = esc ( enemy ) ;
if ( kind === "eat" ) return ` <li class="good">You eat ${ esc ( item ( food ) . name . toLowerCase ( ) ) } (+ ${ amount } )</li> ` ;
if ( kind === "wind" ) return ` <li class="good">Second wind! Back on your feet</li> ` ;
2026-10-19 23:38:34 +01:00
if ( kind === "block" ) return ` <li class="good">You block ${ foe } 's blow</li> ` ;
2026-10-19 23:32:07 +01:00
if ( who === "you" ) return kind === "miss" ? ` <li class="muted">You miss</li> ` : ` <li>You ${ kind === "crit" ? "<b>crit</b>" : "hit" } for <b> ${ amount } </b></li> ` ;
return kind === "miss" ? ` <li class="muted"> ${ foe } misses</li> ` : ` <li class="bad"> ${ foe } ${ kind === "crit" ? "<b>crits</b>" : "hits" } you for <b> ${ amount } </b></li> ` ;
}
function animateFight ( now ) {
2026-10-20 07:32:54 +01:00
const a = S . me ? . action , f = a ? . fight ;
if ( ! f ) return ;
2026-10-19 23:32:07 +01:00
const t = now - a . start ;
2026-10-20 07:32:54 +01:00
{
let i = - 1 ;
while ( i + 1 < f . events . length && f . events [ i + 1 ] [ 0 ] <= t ) i ++ ;
const left = i >= 0 ? f . events [ i ] [ 5 ] : f . enemy _hp ;
for ( const el of document . querySelectorAll ( "[data-progress]" ) ) el . style . width = ` ${ left / f . enemy _hp * 100 } % ` ;
}
const panel = $ ( "[data-fight]" ) ;
if ( ! panel ) return ;
2026-10-20 07:51:16 +01:00
// Between kills: the next enemy is on its way, both swing timers empty.
panel . classList . toggle ( "waiting" , t < 0 ) ;
2026-10-19 23:32:07 +01:00
let shown = - 1 ;
while ( shown + 1 < f . events . length && f . events [ shown + 1 ] [ 0 ] <= t ) shown ++ ;
const last = f . events [ shown ] ;
const hp = last ? last [ 4 ] : f . start _hp , foeHp = last ? last [ 5 ] : f . enemy _hp ;
// Your HP everywhere follows the fight as it plays out.
for ( const el of document . querySelectorAll ( "[data-live-hp]" ) ) el . textContent = fmt ( hp ) ;
for ( const el of document . querySelectorAll ( "[data-live-hpbar]" ) ) el . style . width = ` ${ Math . max ( 0 , hp / f . top * 100 ) } % ` ;
const set = ( key , fn ) => { const el = panel . querySelector ( ` [data-f=" ${ key } "] ` ) ; if ( el ) fn ( el ) ; } ;
set ( "you-bar" , ( el ) => { el . style . width = ` ${ Math . max ( 0 , hp / f . top * 100 ) } % ` ; } ) ;
set ( "foe-bar" , ( el ) => { el . style . width = ` ${ Math . max ( 0 , foeHp / f . enemy _hp * 100 ) } % ` ; } ) ;
set ( "you-hp" , ( el ) => { el . textContent = ` ${ fmt ( hp ) } / ${ fmt ( f . top ) } ` ; } ) ;
set ( "foe-hp" , ( el ) => { el . textContent = ` ${ fmt ( foeHp ) } / ${ fmt ( f . enemy _hp ) } ` ; } ) ;
const over = t >= f . end ;
for ( const [ who , every ] of [ [ "you" , f . interval ] , [ "foe" , f . enemy _interval ] ] ) {
let swung = 0 ;
for ( let i = shown ; i >= 0 ; i -- ) if ( f . events [ i ] [ 1 ] === who && f . events [ i ] [ 2 ] !== "eat" && f . events [ i ] [ 2 ] !== "wind" ) { swung = f . events [ i ] [ 0 ] ; break ; }
2026-10-20 07:51:16 +01:00
set ( ` ${ who } -swing ` , ( el ) => { el . style . width = over || t < 0 ? "0%" : ` ${ Math . min ( 100 , ( t - swung ) / every * 100 ) } % ` ; } ) ;
2026-10-19 23:32:07 +01:00
}
set ( "log" , ( el ) => {
2026-10-20 07:51:16 +01:00
const key = ` ${ a . start } : ${ shown } : ${ over } : ${ t < 0 ? Math . ceil ( - t * 10 ) : "" } ` ;
2026-10-19 23:32:07 +01:00
if ( el . dataset . key === key ) return ;
el . dataset . key = key ;
const lines = f . events . slice ( Math . max ( 0 , shown - 5 ) , shown + 1 ) . reverse ( ) . map ( ( e ) => fightLine ( e , f . enemy ) ) ;
if ( over ) lines . unshift ( f . result === "won" ? ` <li class="good"><b> ${ esc ( f . enemy ) } is beaten.</b> The next one is coming…</li> `
: ` <li class="bad"><b>You fall back.</b></li> ` ) ;
2026-10-20 07:51:16 +01:00
el . innerHTML = t < 0 ? ` <li class="good">Victory! Next enemy in ${ ( - t ) . toFixed ( 1 ) } s</li> `
: lines . join ( "" ) || ` <li class="muted">Squaring up…</li> ` ;
2026-10-19 23:32:07 +01:00
} ) ;
}
2026-10-19 23:38:34 +01:00
// How you fight with what's in your hands, and what your trade fights best with.
function styleNote ( me ) {
const weapon = me . equip . weapon , styleId = ( weapon && item ( weapon ) . style ) || "sword" , st = C . styles [ styleId ] ;
const own = C . class _style [ me . cls ] , traits = {
2026-10-20 08:01:57 +01:00
sword : ` ${ st . interval } s ${ me . equip . shield ? ` , shield blocks ${ Math . round ( C . block _chance * 100 ) } % ` : "" } ` ,
hammer : ` ${ st . interval } s, double crits ` ,
staff : ` ${ st . interval } s, accurate, ignores armour ` ,
bow : ` ${ st . interval } s, shoots first ` ,
2026-10-19 23:38:34 +01:00
} ;
2026-10-20 08:01:57 +01:00
return ` <p class="small"> ${ weapon ? st . name [ 0 ] . toUpperCase ( ) + st . name . slice ( 1 ) : "Fists" } : ${ traits [ styleId ] } .
$ { own === styleId ? ` <b class="good">Trade weapon: +15% damage.</b> ` : ` Trade weapon: ${ esc ( C . styles [ own ] . name ) } (+15%). ` } < / p > ` ;
2026-10-19 23:38:34 +01:00
}
2026-10-18 13:48:56 +01:00
function combatPanel ( ) {
const me = S . me ;
2026-10-18 19:27:56 +01:00
const stat = ( id ) => [ item ( id ) . power && ` + ${ item ( id ) . power } power ` , item ( id ) . armour && ` + ${ item ( id ) . armour } armour ` ] . filter ( Boolean ) . join ( ", " ) ;
2026-10-18 13:48:56 +01:00
const slot = ( name , label ) => {
const id = me . equip [ name ] ;
2026-10-18 19:27:56 +01:00
return ` <div class="gear-slot"> ${ id
? ` <span> ${ item ( id ) . icon } ${ esc ( item ( id ) . name ) } <span class="muted small"> ${ stat ( id ) } </span></span><button class="btn sm" data-unequip=" ${ name } ">Unequip</button> `
: ` <span class="muted">No ${ label } </span> ` } < / d i v > ` ;
2026-10-18 13:48:56 +01:00
} ;
2026-10-18 19:27:56 +01:00
const foods = me . edible . map ( ( id ) => ` <option value=" ${ id } " ${ me . food === id ? "selected" : "" } > ${ esc ( item ( id ) . name ) } (heals ${ item ( id ) . heal } , ${ fmt ( me . bank [ id ] ) } left)</option> ` ) . join ( "" ) ;
const hpPct = me . hp / me . max _hp * 100 ;
return ` <div class="cols">
< div class = "panel" > < h2 > Health < / h 2 >
2026-10-19 23:32:07 +01:00
< div class = "hp-line" > < b > $ { HEART } < span data - live - hp > $ { fmt ( me . hp ) } < /span> / $ { me . max _hp } < / b > < s p a n c l a s s = " m u t e d s m a l l " > r e c o v e r s $ { M a t h . r o u n d ( C . r e g e n _ p e r _ h o u r * 1 0 0 ) } % a n h o u r < / s p a n > < / d i v >
< div class = "bar thick hp" > < span data - live - hpbar style = "width:${hpPct}%" > < / s p a n > < / d i v >
2026-10-18 19:27:56 +01:00
< div class = "row" style = "margin-top:12px" > < label class = "small muted" > Food in fights
< select data - food - pick > $ { ` <option value="">Cheapest first</option> ` + foods } < / s e l e c t > < / l a b e l > < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small" > $ { skillOf ( "prayer" ) . icon } $ { me . prayers . length ? ` ${ me . prayers . map ( ( id ) => esc ( C . prayers . find ( ( p ) => p . id === id ) . name ) ) . join ( " + " ) } · ${ fmt ( me . pp ) } points ${ me . blessing . cost ? "" : ` <b class="warn">(out of points)</b> ` } ` : ` <span class="muted">No prayers active.</span> ` }
2026-10-18 23:09:17 +01:00
< button class = "linkish" data - nav = "skill:prayer" > Prayers < / b u t t o n > < / p >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Eats below half HP , retreats when out of food .
$ { me . edible . length ? "" : ` <b class="warn">No food in your bank.</b> ` } < / p >
2026-10-18 19:27:56 +01:00
< / d i v >
< div class = "panel" > < h2 > Equipment < / h 2 >
< div class = "gear" > $ { slot ( "weapon" , "weapon" ) } $ { slot ( "shield" , "shield" ) } $ { slot ( "helmet" , "helmet" ) } $ { slot ( "body" , "body armour" ) } $ { slot ( "legs" , "leg armour" ) } < / d i v >
2026-10-20 08:01:57 +01:00
< p class = "small muted" > Power $ { me . power } · armour $ { me . armour } ( - $ { Math . round ( me . reduction * 100 ) } % damage ) < / p >
2026-10-19 23:38:34 +01:00
$ { styleNote ( me ) }
2026-10-18 19:27:56 +01:00
< / d i v > < / d i v > ` ;
2026-10-18 13:48:56 +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-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-18 22:36:00 +01:00
if ( it . armour ) parts . push ( ` + ${ it . armour } armour ` ) ;
if ( it . tool ) parts . push ( ` ${ Math . round ( it . speed * 100 ) } % faster ${ skillOf ( it . tool ) . name . toLowerCase ( ) } ` ) ;
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-18 23:09:17 +01:00
io += Object . entries ( a . inputs ) . map ( ( [ id , q ] ) => itemChip ( id , q , me . bank [ id ] || 0 ) ) . join ( "" )
+ ( Object . keys ( a . outputs ) . length ? ` <span class="arrow">→</span> ` : "" ) ;
2026-10-18 13:48:56 +01:00
}
io += Object . entries ( a . outputs ) . map ( ( [ id , q ] ) => itemChip ( id , q ) ) . join ( "" ) ;
let extra = "" ;
2026-10-19 23:32:07 +01:00
if ( a . enemy ) {
const o = me . fight _odds [ a . id ] , e = a . enemy , pct = ( x ) => ` ${ Math . round ( x * 100 ) } % ` ;
extra = ` <div class="small"> ${ HEART } ${ fmt ( e . hp ) } HP · hits up to <b> ${ o . their _max } </b> every ${ e . interval } s ${ e . boss ? ` · <span class="tag warn">Boss</span> ` : "" } </div>
2026-10-20 08:01:57 +01:00
< div class = "small" > You hit < b > $ { pct ( o . hit ) } < / b > ( m a x $ { o . m a x _ h i t } ) · i t h i t s < b > $ { p c t ( o . t h e i r _ h i t ) } < / b > < / d i v >
< div class = "small" > ~ < b > $ { dur ( o . kill _time ) } < / b > a k i l l · < b > $ { M a t h . r o u n d ( o . d a m a g e ) } < / b > d a m a g e t a k e n $ { a . g o l d ? ` · $ { a . g o l d } g o l d ` : " " } < / d i v >
2026-10-19 23:38:34 +01:00
$ { weaknessLine ( e . kind , o ) } ` ;
2026-10-19 09:56:11 +01:00
if ( a . drops ) io += a . drops . map ( ( [ id , q , odds ] ) => ` <span class="item-chip" title=" ${ esc ( item ( id ) . name ) } : ${ Math . round ( odds * 100 ) } % chance"> ${ item ( id ) . icon } ${ q } <span class="have"> ${ Math . round ( odds * 100 ) } %</span></span> ` ) . join ( "" ) ;
2026-10-20 08:01:57 +01:00
if ( a . special === "sabotage" ) extra += ` <div class="small muted">-3% 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-18 22:36:00 +01:00
const made = Object . keys ( a . outputs ) . map ( itemStats ) . filter ( Boolean ) ;
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 >
< div class = "name" > $ { esc ( a . name ) } < / d i v >
< div class = "sub" > Level $ { a . level } · $ { dur ( time ) } · $ { a . xp } XP < / d i v >
< / d i v > < / d i v >
$ { 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> ` : "" }
$ { active && a . enemy ? ` <div><span class="tag gold">⚔️ Fighting</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 > ` ;
}
// --- bank -----------------------------------------------------------------
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 `
< 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 ( ) }
< / 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-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-18 19:27:56 +01:00
$ { it . heal ? ` <div class="row"><div class="small muted">Heals ${ it . heal } in fights.</div>
$ { S . me . food === id ? ` <span class="tag good">Your fighting food</span> ` : ` <button class="btn sm" data-food=" ${ id } ">Eat this first in fights</button> ` } < / d i v > ` : " " }
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 ) {
if ( it . tool ) return ` Use as ${ skillOf ( it . tool ) . name . toLowerCase ( ) } tool ( ${ Math . round ( it . speed * 100 ) } % faster) ` ;
const stats = [ it . power && ` + ${ it . power } power ` , it . armour && ` + ${ it . armour } armour ` ] . filter ( Boolean ) . join ( ", " ) ;
return ` Equip ${ it . slot === "body" ? "body armour" : it . slot === "legs" ? "leg armour" : it . slot } ( ${ stats } ) ` ;
}
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
}
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-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 > ` ;
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-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-19 18:33:50 +01:00
< div class = "note" > $ { w . next _icon } $ { esc ( w . next _name ) } in $ { countdown ( w . until ) } < / d i v > < / d i v > ` ;
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 ) {
const h = S . happenings , lines = [ ] ;
const note = ( icon , name , text ) => lines . push ( ` <span> ${ icon } <b> ${ esc ( name ) } </b> <span class="muted"> ${ esc ( text ) } </span></span> ` ) ;
const touches = ( src ) => [ "speed" , "yield" , "xp" ] . some ( ( k ) => src [ k ] && skill in src [ k ] ) || ( src . block && skill in src . block ) ;
if ( touches ( h . weather ) ) note ( h . weather . icon , h . weather . name , h . weather . text ) ;
for ( const e of h . effects ) if ( touches ( e ) ) note ( e . icon , e . name , e . text ) ;
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 08:01:57 +01:00
note ( p . icon , p . name , ! fix ? p . text : fix . skill === skill ? "Clear it below." : ` ${ skillOf ( fix . skill ) . name } clears it (Town square). ` ) ;
2026-10-19 09:34:18 +01:00
}
return lines . length ? ` <div class="panel tool-panel skill-happenings"> ${ lines . join ( "" ) } </div> ` : "" ;
}
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-19 15:21:49 +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 : "Militia (barracks)" , weapons : "Swords in the armoury" , shields : "Shields in the armoury" , arrows : "Arrows" , garrison : "Garrison" } ;
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-18 13:48:56 +01:00
< div class = "panel" > < h2 > 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 > ` ;
}
// --- board & roster -------------------------------------------------------
function board ( ) {
2026-10-19 09:56:11 +01:00
const msgs = S . board . map ( ( m ) => ` <div class="msg"><span class="who"> ${ esc ( playerName ( m . who ) ) } </span><span class="when"> ${ ago ( m . at ) } </span>
$ { 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 > ` ;
}
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>
< b > $ { esc ( p . name ) } < / b > < s p a n c l a s s = " m u t e d s m a l l " > $ { c l s . n a m e } · t o t a l l e v e l $ { p . t o t a l } < / s p a n >
< 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 >
< p > < button class = "btn sm" data - nav = "character:${esc(p.id)}" > Character sheet < / b u t t o n > < / p > < / d i v > ` ;
2026-10-18 13:48:56 +01:00
} ) . join ( "" ) } < / d i v > ` ;
}
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 gearSlot = ( slot , label , empty ) => {
const id = p . equip [ slot ] ;
const it = id && item ( id ) ;
const stat = ! it ? "" : it . tool ? ` ${ Math . round ( it . speed * 100 ) } % faster `
: [ it . power && ` + ${ it . power } power ` , it . armour && ` + ${ it . armour } armour ` ] . filter ( Boolean ) . join ( ", " ) ;
return ` <div class="sheet-slot ${ id ? "" : "empty" } ">
< div class = "k" > $ { label } < / d i v >
$ { id ? ` <div class="sheet-item"><span class="ico"> ${ it . icon } </span><span><b> ${ esc ( it . name ) } </b><span class="muted small"> ${ stat } </span></span>
$ { mine ? ` <button class="btn sm" data-unequip=" ${ slot } " title="Back to the bank">✕</button> ` : "" } < / d i v > `
: ` <div class="muted small"> ${ empty } </div> ` }
< / d i v > ` ;
} ;
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 ;
const tool = p . equip [ ` tool_ ${ s . id } ` ] ;
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> ` : "" )
+ ( tool ? ` <span title=" ${ esc ( ` ${ item ( tool ) . name } : ${ Math . round ( item ( tool ) . speed * 100 ) } % faster ` ) } "> ${ item ( tool ) . icon } </span> ` : "" ) ;
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-18 19:35:21 +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 d a m a g e < / d i v > < / d i v >
< 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 >
< div class = "panel" > < h2 > Equipment < / h 2 >
< div class = "sheet-gear" > $ { Object . entries ( SLOT _NAMES ) . map ( ( [ slot , label ] ) => gearSlot ( slot , label , "Nothing" ) ) . join ( "" ) } < / d i v >
< h2 style = "margin-top:16px" > Tools < / h 2 >
< div class = "sheet-gear" > $ { C . skills . filter ( ( s ) => ` tool_ ${ s . id } ` in p . equip ) . map ( ( s ) => gearSlot ( ` tool_ ${ s . id } ` , s . name , "No tool" ) ) . join ( "" ) } < / d i v >
2026-10-20 08:01:57 +01:00
$ { mine ? ` <p class="small muted">Equip from the <button class="linkish" data-nav="bank">bank</button>.</p> ` : "" }
2026-10-18 19:35:21 +01:00
< / d i v >
< / 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-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-18 13:48:56 +01:00
if ( a ) {
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-18 13:48:56 +01:00
if ( pct >= 1 && refreshedFor !== a . start ) {
refreshedFor = a . start ;
setTimeout ( refresh , 150 ) ;
}
}
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 ) ;
}
function loop ( ) {
if ( S ) animate ( ) ;
requestAnimationFrame ( loop ) ;
}
// --- events ---------------------------------------------------------------
document . addEventListener ( "click" , ( e ) => {
const t = e . target . closest ( "button, [data-menu], [data-close]" ) ;
if ( ! t ) return ;
const d = t . dataset ;
if ( t . matches ( "[data-close]" ) || e . target === $ ( "#modal" ) ) { $ ( "#modal" ) . hidden = true ; 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 ) {
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 ;
}
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-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-18 19:48:22 +01:00
if ( d . favour ) return act ( "/api/favour" , { kind : d . favour } ) ;
2026-10-18 19:27:56 +01:00
if ( d . food ) return act ( "/api/food" , { item : 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" , { } ) ;
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-18 19:27:56 +01:00
document . addEventListener ( "change" , ( e ) => {
if ( e . target . matches ( "[data-food-pick]" ) ) act ( "/api/food" , { item : e . target . value || null } ) ;
} ) ;
2026-10-18 13:48:56 +01:00
$ ( "#modal" ) . addEventListener ( "click" , ( e ) => { if ( e . target . id === "modal" ) e . target . hidden = true ; } ) ;
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 } ) ;
}
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 ;
}
setInterval ( ( ) => { if ( ! document . hidden ) refresh ( ) ; } , POLL _MS ) ;
requestAnimationFrame ( loop ) ;
} ) ( ) ;