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 ) ;
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 ) ;
const got = Object . entries ( sum . items ) . map ( ( [ id , q ] ) => ` + ${ fmt ( q ) } ${ item ( id ) . icon } ${ item ( id ) . name } ` ) ;
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:09:17 +01:00
if ( sum . prayer _out ) toast ( "🙏 Out of prayer points: your prayers have gone quiet" , "bad" ) ;
if ( sum . second _wind ) toast ( "🌬️ Second Wind! Back on your feet at half health" , "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-18 22:40:36 +01:00
$ { sold ? ` <p class="muted small">Your bank was full, so this was sold for ${ fmt ( sum . sold _gold ) } gold:</p><ul> ${ sold } </ul> ` : "" }
2026-10-18 19:54:08 +01:00
$ { spilled ? ` <p class="muted small">Your bank was full, so this went to the city stockpile (for half the favour of giving it yourself):</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-18 13:48:56 +01:00
function nav ( id , icon , label , meta = "" , extra = "" ) {
return ` <button class="nav ${ page === id ? "on" : "" } " data-nav=" ${ id } "><span class="ic"> ${ icon } </span><span class="label"> ${ label } </span> ${ extra } <span class="meta"> ${ meta } </span></button> ` ;
}
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-18 19:27:56 +01:00
< div class = "cls" > $ { cls . icon } $ { cls . name } · ❤ ️ $ { fmt ( me . hp ) } / $ { me . max _hp } < / d i v >
2026-10-18 13:48:56 +01:00
< div class = "purse" > < span class = "gold" title = "Gold" > 🪙 $ { 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 " > ✨ $ { f m t ( m e . f a v o u r ) } < / s p a n > < / d i v >
< / d i v >
< div class = "navhead" > City < / d i v >
$ { nav ( "town" , "🏘️" , "Town square" , ` ${ S . city . population } ` ) }
$ { nav ( "council" , "📜" , "Council & threats" , "" , raid ? ` <span class="alert"></span> ` : "" ) }
$ { nav ( "board" , "💬" , "Town board" ) }
$ { nav ( "roster" , "👥" , "Guild roster" , ` ${ S . players . length } ` ) }
< div class = "navhead" > You < / d i v >
2026-10-18 19:35:21 +01:00
$ { nav ( "character" , cls . icon , "Character" , ` ⚔️ ${ me . levels . combat } ` ) }
2026-10-18 13:48:56 +01:00
$ { nav ( "bank" , "🏦" , "Bank" , ` ${ Object . keys ( me . bank ) . length } / ${ me . slots } ` ) } ` ;
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 ) ;
}
return html + ( S . dev ? devbar ( ) : "" ) ;
}
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 >
< button class = "btn sm" data - dev - event > Event now < / b u t t o n >
< / 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 ) {
const action = C . actions . find ( ( a ) => a . id === S . me . action . id ) ;
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> ` ;
}
if ( S . upcoming ) chips += ` <button class="chip ${ S . upcoming . kind === "raid" ? "threat" : "" } " data-nav="council"> ${ eventIcon ( S . upcoming . kind ) } ${ eventWhen ( S . upcoming , true ) } </button> ` ;
return ` <button class="menu-btn" data-menu aria-label="Menu">☰</button><h1> ${ esc ( title ) } </h1> ${ chips } ` ;
}
function eventIcon ( kind ) {
return { raid : "⚔️" , merchant : "🐪" , refugees : "🧳" } [ kind ] || "❗" ;
}
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 ( ) {
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 ) {
return ` <span class="item-chip ${ have < need ? "short" : "" } " title=" ${ esc ( name ) } "> ${ icon } ${ fmt ( have ) } <span class="have">/ ${ fmt ( need ) } </span></span> ` ;
}
2026-10-18 13:48:56 +01:00
// --- skill pages ----------------------------------------------------------
function skillPage ( id ) {
const me = S . me , skill = skillOf ( id ) , level = me . levels [ id ] , xp = me . xp [ id ] ;
const floor = C . xp _table [ level ] , ceil = C . xp _table [ level + 1 ] ;
const pct = ceil ? ( xp - floor ) / ( ceil - floor ) * 100 : 100 ;
const cls = C . classes [ me . cls ] ;
const fast = cls . fast . includes ( id ) ;
let html = `
< div class = "panel" > < div class = "skill-head" >
< div class = "big" > $ { skill . icon } < / d i v >
< div class = "info" >
< 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 > ` ;
if ( id === "combat" ) html += combatPanel ( ) ;
2026-10-18 23:09:17 +01:00
if ( id === "prayer" ) html += prayerPanel ( ) ;
2026-10-18 19:27:56 +01:00
if ( ` tool_ ${ id } ` in me . equip ) html += toolPanel ( id ) ;
2026-10-18 13:48:56 +01:00
if ( id === "reconnaissance" ) html += ` <div class="panel small muted">Every ${ 8 } reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.</div> ` ;
2026-10-18 22:13:50 +01:00
const actions = C . actions . filter ( ( a ) => a . skill === id ) ;
const groups = [ ... new Set ( actions . map ( ( a ) => a . group ) . filter ( Boolean ) ) ] ;
if ( groups . length > 2 ) {
// Many tiers: one tab per tier, opening on the highest one you can work.
const open = ( g ) => actions . some ( ( a ) => a . group === g && ! isLocked ( a ) ) ;
let current = tabs [ id ] || remember ( ` tab. ${ id } ` ) ;
if ( ! groups . includes ( current ) ) current = [ ... groups ] . reverse ( ) . find ( open ) || groups [ 0 ] ;
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 ( "" ) ;
return html + ` <div class="tabs"> ${ bar } </div><div class="grid"> ${ actions . filter ( ( a ) => a . group === current ) . map ( actionCard ) . join ( "" ) } </div> ` ;
}
2026-10-18 19:27:56 +01:00
let group = null , cards = "" ;
2026-10-18 22:13:50 +01:00
for ( const a of actions ) {
2026-10-18 19:27:56 +01:00
if ( a . group && a . group !== group ) {
group = a . group ;
cards += ` <div class="section-title grid-head"> ${ esc ( group ) } </div> ` ;
}
cards += actionCard ( a ) ;
}
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-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" : "" }
title = "${locked ? `Needs prayer level ${p.level}` : full ? `Only ${C.max_prayers} at once: turn one off first` : " "}" >
< 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 >
< div class = "prayer-points" > 🙏 < b > $ { fmt ( me . pp ) } < /b> prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost ) } fights ` : ""}</div>
< div class = "prayers" > $ { cards } < / d i v >
< p class = "small muted" > Active prayers work in every fight while you have the points to pay for them , and each point spent trains Prayer . Bury bones for more points . < / p >
< / d i v >
< div class = "panel" > < h2 > Temple < / h 2 >
$ { temple ? ` <p>Devotion <b> ${ fmt ( city . devotion ) } </b> · divine ward <b>+ ${ city . ward } </b> defence for the next raid (up to + ${ 15 * temple } at temple level ${ temple } ).</p>
< p class = "small muted" > Offering bones builds devotion ; it wards the city through the next raid , then starts again . The temple also adds $ { temple } prayer point$ { temple > 1 ? "s" : "" } to every bone you bury . < / p > `
: ` <p class="muted">No temple yet. Build one on the Town square to offer bones for a divine ward in raids, and to get more prayer points from bones.</p> ` }
< / 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 > `
: ` <span class="muted">No tool. A smith can make you one that speeds this up.</span> ` } < / d i v > ` ;
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 >
< div class = "hp-line" > < b > ❤ ️ $ { fmt ( me . hp ) } / $ { 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 style = "width:${hpPct}%" > < / s p a n > < / d i v >
< 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-18 23:09:17 +01:00
< p class = "small" > 🙏 $ { 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">(not enough points: prayers are off)</b> ` } ` : ` <span class="muted">No prayers active.</span> ` }
< button class = "linkish" data - nav = "skill:prayer" > Prayers < / b u t t o n > < / p >
2026-10-18 19:27:56 +01:00
< p class = "small muted" > Below half health you eat from your bank automatically .
$ { me . edible . length ? "" : ` <b class="warn">You have no food in your bank.</b> ` }
Out of food and badly hurt , you retreat : the fight is lost , but nothing else is . < / p >
< / 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 >
< p class = "small muted" > Power $ { me . power } ( level + weapon + shield + half your armour ) · armour $ { me . armour } takes $ { Math . round ( me . reduction * 100 ) } % off the damage you take$ { me . cls === "warrior" ? " · as a warrior you count × 1.5 in the garrison" : "" } . Equip gear from the bank . < / p >
< / 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 } ` ) ;
if ( it . food ) parts . push ( ` feeds ${ it . food } ${ it . food === 1 ? "person" : "people" } for two days ` ) ;
if ( it . power ) parts . push ( ` + ${ it . power } power ` ) ;
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 ] } ` ) ;
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 ;
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 ] ;
const icon = out ? item ( out ) . icon : skillOf ( a . skill ) . icon ;
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 = "" ;
if ( a . skill === "combat" ) {
const chance = Math . min ( 0.97 , Math . max ( 0.15 , 0.5 + ( me . power - a . difficulty ) * 0.04 ) ) ;
2026-10-18 19:27:56 +01:00
extra = ` <div class="small">Win chance <b> ${ Math . round ( chance * 100 ) } %</b> · about <b> ${ me . fight _damage [ a . id ] } </b> damage a fight ${ a . gold ? ` · ${ a . gold } gold ` : "" } </div> ` ;
2026-10-18 13:48:56 +01:00
if ( a . drops ) io += a . drops . map ( ( [ id , q , odds ] ) => ` <span class="item-chip" title=" ${ Math . round ( odds * 100 ) } % chance"> ${ item ( id ) . icon } ${ q } <span class="have"> ${ Math . round ( odds * 100 ) } %</span></span> ` ) . join ( "" ) ;
if ( a . special === "sabotage" ) extra += ` <div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div> ` ;
}
if ( a . special === "recon" ) extra = ` <div class="small muted">+ ${ a . recon } recon on the next event</div> ` ;
2026-10-18 23:09:17 +01:00
const temple = S . city . buildings . temple ? . level || 0 ;
if ( a . special === "bury" ) extra = ` <div class="small makes">🙏 + ${ a . pp + temple } prayer points</div> ` ;
if ( a . special === "offer" ) extra = ` <div class="small makes">⛪ + ${ + ( a . devotion * ( 1 + 0.25 * temple ) ) . toFixed ( 1 ) } devotion for the city's ward</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 `
< div class = "action ${active ? " active " : " "} ${locked ? " locked " : " "}" >
< 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 }
$ { active ? ` <div class="bar thick"><span data-progress></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" >
< span class = "why" > $ { ! active && why ? esc ( why ) : "" } < / s p a n >
2026-10-18 19:35:21 +01:00
$ { active ? ` <button class="btn danger" data-stop>Stop</button> `
: ` <span class="btns"><button class="btn" data-once=" ${ a . id } " ${ why ? "disabled" : "" } title="Do this once, then go back to what you were doing"> ${ a . skill === "combat" ? "Fight once" : Object . keys ( a . outputs ) . length ? "Make one" : "Once" } </button>
< 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 ;
let slots = entries . map ( ( [ id , q ] ) => `
< button class = "slot ${selected === id ? " on " : " "}" data - select = "${id}" title = "${esc(item(id).name)}" >
< 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 ( " " ) ;
for ( let i = entries . length ; i < me . slots ; i ++ ) slots += ` <div class="slot empty"></div> ` ;
return `
< div class = "bank-wrap" >
< div >
< div class = "panel" >
< h2 > Bank · $ { entries . length } / $ { me . slots } slots · up to $ { fmt ( me . stack ) } each < / h 2 >
< div class = "slots" > $ { slots } < / d i v >
< p class = "small muted" > When an item doesn ' t fit :
< 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-18 22:40:36 +01:00
< br > Raids never touch your bank . Overflow sent to the city earns half the favour of giving it yourself ;
anything the city has no use for is sold instead . < / 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 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-18 13:48:56 +01:00
$ { it . food ? ` <div class="row small muted">Feeds ${ it . food } townsfolk for two days in the city granary.</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 > `
: ` <div class="row small muted">The city has no use for this. Sell it, or give it to someone who can use it.</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>
< p class = "gold" > 🪙 $ { fmt ( S . me . gold ) } < / p >
< 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 >
< p class = "small muted" > The treasury pays the gold part of new buildings . 1 favour per gold . < / p > < / d i v > ` ;
}
function favourPanel ( ) {
const me = S . me ;
return ` <div class="panel"><h2>Favour with the council</h2>
< p class = "favour" > ✨ $ { fmt ( me . favour ) } favour · $ { fmt ( me . donated ) } given in total < / p >
< 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-18 19:48:22 +01:00
< p class = "small muted" > Favour can also hire builders , pay informants and rally the militia : see < 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> `
: ` <span class="warn">Full. Build more houses to grow</span> ` ;
const next = city . next _tier ? ` ${ city . next _tier . name } at ${ city . next _tier . pop } people ` : "The largest a settlement can be" ;
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-18 13:48:56 +01:00
< / d i v > ` ;
if ( city . starving ) html += ` <div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div> ` ;
2026-10-18 14:31:41 +01:00
if ( city . merchant _until > serverNow ( ) ) html += ` <div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${ countdown ( city . merchant _until ) } .</div></div> ` ;
2026-10-18 13:48:56 +01:00
const work = city . construction ;
if ( work ) {
const spec = C . buildings [ work . building ] ;
2026-10-18 19:20:52 +01:00
const queued = city . queue . map ( ( key ) => esc ( C . buildings [ key ] . name ) ) . join ( ", " ) ;
2026-10-18 19:48:22 +01:00
const hire = S . favours . builders ;
2026-10-18 14:31:41 +01:00
html += ` <div class="banner">🏗️<div class="grow"><b> ${ work . repair ? "Repairing" : "Building" } ${ esc ( spec . name ) } </b> · done in ${ countdown ( work . done _at ) }
2026-10-18 19:48:22 +01:00
$ { hire . why ? "" : ` <button class="btn sm gold" data-favour="builders" ${ S . me . favour < hire . cost ? "disabled" : "" } title="Halve the time left">Hire builders ✨ ${ fmt ( hire . cost ) } </button> ` }
2026-10-18 19:20:52 +01:00
< div class = "bar gold" style = "margin-top:6px" > < span data - span = "${work.start},${work.done_at}" > < / s p a n > < / d i v >
$ { queued ? ` <div class="small muted" style="margin-top:6px">Next: ${ queued } </div> ` : "" } < / d i v > < / d i v > ` ;
2026-10-18 13:48:56 +01:00
}
2026-10-18 19:20:52 +01:00
html += ` <div class="section-title">Buildings</div>
< p class = "small muted" > Each building collects its own fund . Materials in a fund are safe from raiders , and building starts on its own once the fund is complete . < / p >
< 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> ` ;
html += ` <p class="small muted">Food, weapons and anything not yet in a fund. The granary and armoury draw from here, and so do raiders.</p></div> ` ;
2026-10-18 13:48:56 +01:00
return html ;
}
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> ` ,
queued : ` <span class="tag good">Funded · waiting for builders</span> ` ,
max : ` <span class="tag good">Fully built</span> ` ,
capped : ` <span class="tag">Next level when the settlement grows</span> ` ,
} ;
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 ) ;
const chip = costChip ( gold ? "🪙" : item ( id ) . icon , gold ? "Gold" : item ( id ) . name , need , need - missing ) ;
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 > ` ;
}
const levelText = b . cap < spec . max ? ` Lv ${ b . level } · max ${ b . cap } for a ${ city . tier _name . toLowerCase ( ) } ` : ` Lv ${ b . level } / ${ spec . max } ` ;
return ` <div class="action building ${ b . damaged ? "damaged" : "" } ">
< div class = "top" > < div class = "ico" > $ { spec . icon } < / d i v > < d i v >
< 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 : r e p a i r s n e e d e d < / s p a n > ` : " " } < / d i v >
< div class = "sub" > $ { esc ( spec . effect ) } < / d i v >
< / d i v > < / d i v > $ { b o d y } < / d i v > ` ;
}
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> ` ;
}
function council ( ) {
const ev = S . upcoming , city = S . city , me = S . me ;
let html = "" ;
if ( ! ev ) {
html += ` <div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads. The spymasters will report as soon as something stirs.</p></div> ` ;
} else {
html += ` <div class="panel ${ ev . kind === "raid" ? "threat-card" : "" } ">
< h2 > $ { eventIcon ( ev . kind ) } $ { ev . kind === "raid" ? "Threat" : "News" } · intel $ { pips ( ev . intel ) } < / h 2 >
< 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> ` : "" }
$ { ev . camp ? ` <span class="k">Their camp</span><span>Located. Fighters can sabotage it from the combat page ${ ev . sabotage ? ` (weakened ${ ev . sabotage } % so far) ` : "" } .</span> ` : "" }
$ { me . cls === "scout" || ev . recon ? ` <span class="k">Reconnaissance</span><span> ${ ev . intel < 3 ? ` ${ ev . recon } points · ${ ev . recon _next } more for the next intel level ` : ` ${ ev . recon } points · nothing more to learn ` } </span> ` : "" }
< / d i v >
$ { ev . intel < 3 ? ` <p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p> ` : "" } ` ;
if ( ev . kind === "raid" ) {
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 ( ) ;
if ( ev ? . kind === "raid" ) 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 ;
const win = h . kind !== "raid" || h . report . title . startsWith ( "Victory" ) ;
html += ` <div class="report ${ h . kind === "raid" ? ( win ? "win" : "loss" ) : "" } ">
< b > $ { 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 >
$ { h . kind === "raid" ? ` <p class="muted">Defence ${ fmt ( h . report . defence ) } vs raiders ${ fmt ( h . report . attack ) } ${ h . report . garrison . length ? ` · garrison: ${ h . report . garrison . map ( ( g ) => esc ( g . name ) ) . join ( ", " ) } ` : "" } </p> ` : "" }
$ { 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" : "" }
title = "${esc(o.why || (short ? " Not enough favour " : " "))}" > ✨ $ { fmt ( o . cost ) } < / b u t t o n > < / s p a n >
< / d i v > ` ;
} ;
return ` <div class="panel"><h2>Spend favour on the city · you have ✨ ${ fmt ( me . favour ) } </h2>
$ { option ( "builders" , "🏗️" , "Hire extra builders" , f . builders . why ? esc ( f . builders . why ) + "." : ` Halves the time left on the current building (saves about ${ dur ( Math . round ( f . builders . saves * 60 ) * 60 ) } ). ` ) }
$ { option ( "informant" , "🕵️" , "Pay an informant" , f . informant . why ? esc ( f . informant . why ) + "." : "One more level of intel on what's coming." ) }
$ { option ( "militia" , "📯" , "Rally the militia" , f . militia . why ? esc ( f . militia . why ) + "." : ` +10 defence for the coming raid ${ f . militia . rallied ? ` (+ ${ f . militia . rallied } so far) ` : "" } . Costs more each time. ` ) }
< / 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-18 23:09:17 +01:00
const names = { ward : "Divine ward (temple)" , rallied : "Rallied militia (favour)" , 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 }
< p class = "small muted" > Arrows , swords and shields in the stockpile arm the defenders . Food keeps them fighting . < / p > < / d i v >
< div class = "panel" > < h2 > Defence < / h 2 >
< table class = "parts" > $ { Object . entries ( d . parts ) . map ( ( [ k , v ] ) => ` <tr><td> ${ names [ k ] } </td><td> ${ fmt ( v ) } </td></tr> ` ) . join ( "" ) }
$ { 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 >
< p class = "small" > $ { d . garrison . length ? d . garrison . map ( ( g ) => ` ${ esc ( g . name ) } ( ${ g . power } ) ` ) . join ( ", " ) : ` <span class="muted">Nobody has signed up yet.</span> ` } < / p >
$ { me . garrison ? ` <button class="btn" data-garrison="0">Leave the garrison</button> `
: ` <button class="btn primary" data-garrison="1">Stand guard (+ ${ me . cls === "warrior" ? me . power * 1.5 : me . power } )</button> ` }
< p class = "small muted" > You can keep working while you ' re signed up . Victory earns the garrison favour and combat XP . < / p >
< / d i v > < / d i v > ` ;
}
// --- board & roster -------------------------------------------------------
function board ( ) {
const msgs = S . board . map ( ( m ) => ` <div class="msg"><span class="who"> ${ esc ( playerName ( m . who ) ) } </span><span class="when"> ${ ago ( m . at ) } </span><div class="text"> ${ esc ( m . text ) } </div></div> ` ) . join ( "" ) ;
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 >
< div class = "msgs" > $ { msgs || ` <p class="muted">No messages yet. Say hello, or ask for that iron you need.</p> ` } < / d i v >
< 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-18 22:24:32 +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 } "><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 >
< div class = "small 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 ) ) ;
const guard = Math . round ( p . power * ( p . cls === "warrior" ? 1.5 : 1 ) ) ;
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 } ` ] ;
const marks = ( cls . fast . includes ( s . id ) ? ` <span title=" ${ cls . name } bonus"> ${ cls . icon } </span> ` : "" )
+ ( tool ? ` <span title=" ${ esc ( item ( tool ) . name ) } "> ${ item ( tool ) . icon } </span> ` : "" ) ;
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 >
$ { mine ? ` <div class="stat"><div class="k">Purse</div><div class="v gold"> ${ fmt ( p . gold ) } </div><div class="note"> ${ Object . keys ( p . bank ) . length } / ${ p . slots } bank slots</div></div> ` : "" }
< / 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 >
< p class = "small" > $ { esc ( cls . signature ) } . < / p >
< / 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 >
$ { mine ? ` <p class="small muted">Equip gear and tools from the <button class="linkish" data-nav="bank">bank</button>.</p> ` : "" }
< / 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 ---------------------------------------------------------
function register ( ) {
const cards = Object . entries ( C . classes ) . map ( ( [ id , c ] ) => `
< button class = "class-card ${pickedClass === id ? " on " : " "}" data - class = "${id}" >
< 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 . s i g n a t u r e ) } . < / p > < / s p a n >
< / b u t t o n > ` ) . j o i n ( " " ) ;
return ` <div class="welcome">
< h2 > A new city on the frontier < / h 2 >
< p class = "muted" > You ' re one of a handful of adventurers settling a young city at the edge of the kingdom .
Train your skills , supply the council , and keep the place standing when the bandits come .
Your adventurer keeps working while you ' re away , for up to a day . < / p >
< 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 >
< p class = "small muted" > Every class can train every skill . Your class makes some things faster and gives you one thing only you can do . < / p >
< / d i v > ` ;
}
// --- animation ------------------------------------------------------------
function animate ( ) {
const now = serverNow ( ) ;
const a = S ? . me ? . action ;
if ( a ) {
const pct = Math . min ( 1 , ( now - a . start ) / a . duration ) ;
for ( const el of document . querySelectorAll ( "[data-progress]" ) ) el . style . width = ( pct * 100 ) + "%" ;
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-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:09:17 +01:00
if ( d . pray ) {
const on = S . me . prayers . includes ( d . pray ) ;
if ( ! on && S . me . prayers . length >= C . max _prayers ) return toast ( ` Only ${ C . max _prayers } prayers at once: turn one off first ` , "bad" ) ;
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-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 {
C = await api ( "/api/content" ) ;
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 ) ;
} ) ( ) ;