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-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-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-19 11:23:26 +01:00
< div class = "purse" > < span class = "gold" title = "Gold: from fights and selling. Fund buildings with it or give it to the treasury" > $ { 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 g i v i n g t o t h e c i t y . S p e n d i t o n t h e C o u n c i l p a g e , o r o n e x t r a b a n k s l o t s " > $ { 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 ( )
: ` <div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</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-19 15:09:51 +01:00
if ( id === "reconnaissance" ) html += ` <div class="panel small muted">Reconnaissance on a coming threat raises its intel. Each level takes more than the last, a bigger band takes longer to read, and the sharpest news (the exact day and their camp) only comes in the last hours before they strike. The spymaster's guild adds to every point and gathers some on its own. The council & threats page shows the progress.</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 ) ;
const status = ! farming ? ` <span class="muted">Start a crop to plant your first plot.</span> `
: a . plot2 ? ` Plot 1: <b> ${ name ( a . id ) } </b> · Plot 2: <b> ${ name ( a . plot2 ) } </b> <span class="muted">· harvests alternate, together 20% faster than one crop</span> `
: ` Plot 1: <b> ${ name ( a . id ) } </b> · Plot 2: <span class="muted">empty, so you're focused: 30% faster. Use "Plot 2" on another crop to grow both.</span> ` ;
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> ` : "" }
$ { ! active && ! drink ? ` <span class="small muted">Herblore can brew ${ kinds . length === 1 ? "one" : "some" } for ${ skill === "combat" ? "fighting" : esc ( skillOf ( skill ) . name . toLowerCase ( ) ) } .</span> ` : "" }
< / 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" : "" }
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 >
2026-10-19 10:04:38 +01:00
< div class = "prayer-points" > $ { skillOf ( "prayer" ) . icon } < b > $ { fmt ( me . pp ) } < /b> prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost ) } fights ` : ""}</div>
2026-10-18 23:09:17 +01:00
< 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
}
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 ) {
const panel = $ ( "[data-fight]" ) , a = S . me ? . action , f = a ? . fight ;
if ( ! panel || ! f ) return ;
const t = now - a . start ;
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 ; }
set ( ` ${ who } -swing ` , ( el ) => { el . style . width = over ? "0%" : ` ${ Math . min ( 100 , ( t - swung ) / every * 100 ) } % ` ; } ) ;
}
set ( "log" , ( el ) => {
const key = ` ${ a . start } : ${ shown } : ${ over } ` ;
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> ` ) ;
el . innerHTML = lines . join ( "" ) || ` <li class="muted">Squaring up…</li> ` ;
} ) ;
}
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 = {
sword : ` a swing every ${ st . interval } s ${ me . equip . shield ? ` , and your shield blocks ${ Math . round ( C . block _chance * 100 ) } % of hits ` : "" } ` ,
hammer : ` slow ( ${ st . interval } s) heavy blows, twice the crits ` ,
staff : ` a cast every ${ st . interval } s that rarely misses and goes through armour ` ,
bow : ` fast ( ${ st . interval } s), and you shoot first ` ,
} ;
return ` <p class="small">Fighting with ${ weapon ? ` a ${ esc ( st . name ) } ` : "your fists" } : ${ traits [ styleId ] } .
$ { own === styleId ? ` <b class="good">Your trade's weapon: +15% damage.</b> ` : ` As a ${ esc ( C . classes [ me . cls ] . name . toLowerCase ( ) ) } you hit 15% harder with a ${ esc ( C . styles [ own ] . name ) } . ` } < / p > ` ;
}
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-19 10:04:38 +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">(not enough points: prayers are off)</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-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 >
2026-10-19 22:45:32 +01:00
< 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 . Equip gear from the bank . < / 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 } ` ) ;
if ( it . food ) parts . push ( ` feeds ${ it . food } ${ it . food === 1 ? "person" : "people" } for two days ` ) ;
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 ] ;
parts . push ( ` ${ it . two _handed ? "two-handed " : "" } ${ st . name } , a swing every ${ st . interval } s ${ trade ? ` (the ${ C . classes [ trade ] . name . toLowerCase ( ) } 's weapon) ` : "" } ` ) ;
}
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-19 23:32:07 +01:00
if ( it . trophy ) parts . push ( ` An expedition trophy: stronger weapons will need these ` ) ;
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 ) ;
parts . push ( ` ${ C . buildings [ f . building ] . icon } For the ${ C . buildings [ f . building ] . name . toLowerCase ( ) } (Lv ${ f . level } ): ${ f . effect } ${ done ? " · already fitted" : "" } ` ) ;
}
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>
< div class = "small" > You land < b > $ { pct ( o . hit ) } < / b > o f s w i n g s ( u p t o $ { o . m a x _ h i t } ) · i t l a n d s < b > $ { p c t ( o . t h e i r _ h i t ) } < / b > < / d i v >
2026-10-19 23:38:34 +01:00
< div class = "small" > About < 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 >
$ { 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-18 13:48:56 +01:00
if ( a . special === "sabotage" ) extra += ` <div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div> ` ;
}
2026-10-19 15:21:49 +01:00
if ( a . special === "drill" ) {
const s = S . favours . sergeant , boost = s . boost ;
2026-10-19 15:35:35 +01:00
extra = ! s . needed ? ` <div class="small muted">Fighters drill the townsfolk while a raid is coming. A fully drilled militia adds one defence per townsperson.</div> `
: ` <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 ${ boost ? ` <b class="boosted">× 2 drill and XP for the next ${ fmt ( boost ) } </b> ` : "" } </div>
< div class = "small muted" > A bigger band needs more drilling . A fully drilled militia adds one defence per townsperson . < / d i v > ` ;
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 ;
extra = full ? ` <div class="small muted">The coming threat is fully scouted: this only trains the skill for now.</div> `
: ` <div class="small makes"> ${ skillOf ( "reconnaissance" ) . icon } + ${ + me . recon _gain [ a . id ] . toFixed ( 2 ) } recon on the coming threat ${ boost ? ` <b class="boosted">× 2 recon and XP for the next ${ fmt ( boost ) } </b> ` : "" } </div> ` ;
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" ) {
const boost = S . favours . builders . boost ;
extra = ` <div class="small makes"> ${ skillOf ( "construction" ) . icon } + ${ me . labour _per _action } labour per action ${ boost ? ` <b class="boosted">× 2 labour and XP for the next ${ fmt ( boost ) } </b> ` : "" } </div> ` ;
}
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-19 10:57:55 +01:00
if ( a . special === "offer" ) extra = ` <div class="small makes"> ${ C . buildings . temple . icon } + ${ + ( 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 `
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> ` : "" }
$ { growingNow ? ` <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" >
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-19 23:32:07 +01:00
: ` <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 . enemy ? "Fight once" : Object . keys ( a . outputs ) . length ? "Make one" : "Once" } </button>
2026-10-19 23:17:23 +01:00
$ { canPlot ? ` <button class="btn" data-plot=" ${ a . id } " ${ why ? "disabled" : "" } title="Grow this in your second plot as well">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 >
< 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 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-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>
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 >
< 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>
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-19 15:21:49 +01:00
< p class = "small muted" > Favour can also hire master builders , informants and drill sergeants : 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-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-18 13:48:56 +01:00
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-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>
< 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-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-19 11:01:29 +01:00
$ { hire . why ? "" : ` <button class="btn sm gold" data-favour="builders" ${ me . favour < hire . cost ? "disabled" : "" } title="The next ${ hire . labour } labour on the site counts double, with double XP">Master builder ${ FAVOUR } ${ fmt ( hire . cost ) } </button> ` }
2026-10-19 09:24:45 +01:00
< / s p a n > < / d i v >
< div class = "bar gold" style = "margin-top:6px" > < span style = "width:${Math.min(100, work.labour / work.needed * 100)}%" > < / s p a n > < / d i v >
2026-10-19 11:01:29 +01:00
< div class = "small muted" style = "margin-top:6px" > $ { builders . length ? ` On site: ${ builders . join ( ", " ) } . ` : "" } $ { hire . boost ? ` <b class="boosted">Master builder on site: the next ${ fmt ( hire . boost ) } labour counts double, with double XP.</b> ` : "" } The townsfolk add about $ { fmt ( city . townsfolk _labour ) } labour an hour . $ { 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-19 11:23:26 +01:00
return ` <div class="stat" title=" ${ esc ( ` ${ weatherTip ( w ) } Weather changes every few hours; each season lasts a week. ` ) } "><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> ` ) ;
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">People are cold and eating half again as much. Donate logs.</span></span></div> ` ) ;
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 ) ;
note ( p . icon , p . name , ! fix ? p . text : fix . skill === skill ? "Help clear it below." : ` ${ skillOf ( fix . skill ) . name } can clear it: see the Town square. ` ) ;
}
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> ` ,
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 ) ;
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-18 19:20:52 +01:00
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-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 ` ;
if ( ev . intel >= 3 ) return ` ${ points } · nothing more to learn ` ;
2026-10-19 15:14:00 +01:00
const boost = S . favours . informant . boost ? ` · <b class="boosted">informant's leads: × 2 for the next ${ fmt ( S . favours . informant . boost ) } </b> ` : "" ;
if ( ev . recon _next <= 0 ) return ` ${ points } · enough for the next level, but they're too far off to read yet ${ boost } ` ;
return ` ${ points } · ${ fmt ( ev . recon _next ) } more for the next intel level ${ boost } ` ;
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 ) {
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 {
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> ` : "" }
$ { 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> ` : "" }
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-18 13:48:56 +01:00
< / d i v >
2026-10-19 22:45:32 +01:00
$ { ev . intel < 3 ? ` <p class="small muted">Better intel comes from the spymaster's guild and anyone's scouting (rangers are best at it), and the sharpest only as the threat draws near.</p> ` : "" } ` ;
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-19 11:01:29 +01:00
$ { option ( "builders" , "🏗️" , "Hire a master builder" , f . builders . why ? esc ( f . builders . why ) + "." : ` The next ${ f . builders . labour } labour anyone puts into the site counts double, with double Construction XP ${ f . builders . boost ? ` ( ${ fmt ( f . builders . boost ) } still to use) ` : "" } . Costs more each time on the same site. ` ) }
2026-10-19 22:45:32 +01:00
$ { option ( "informant" , "🕵️" , "Pay an informant" , f . informant . why ? esc ( f . informant . why ) + "." : ` Leads to follow up: the next ${ f . informant . recon } reconnaissance on the coming threat counts double, with double XP ${ f . informant . boost ? ` ( ${ fmt ( f . informant . boost ) } still to use) ` : "" } . Costs more each time for the same threat. ` ) }
2026-10-19 15:35:35 +01:00
$ { option ( "sergeant" , "📯" , "Hire a drill sergeant" , f . sergeant . why ? esc ( f . sergeant . why ) + "." : ` The next ${ f . sergeant . drill } drill points fighters put into the militia count double, with double combat XP ${ f . sergeant . boost ? ` ( ${ fmt ( f . sergeant . boost ) } still to use) ` : "" } . Costs more each time for the same threat. ` ) }
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-19 14:51:05 +01:00
< p class = "small muted" > Arrows , swords and shields in the stockpile arm the defenders : one sword and shield each , best first . Every fight uses up arrows and breaks some of the gear , more when it ' s close . Food keeps them fighting . < / 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 >
< 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> `
2026-10-19 22:45:32 +01:00
: ` <button class="btn primary" data-garrison="1">Stand guard (+ ${ me . power } )</button> ` }
2026-10-18 13:48:56 +01:00
< 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 ( ) {
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 >
< 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-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 >
$ { 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 ---------------------------------------------------------
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 >
< p class = "muted" > Classes are now whole trades , from gathering through to the finished goods , so you can play
the quiet gathering side and the busier crafting side yourself . You keep every level and item . This choice
is free , once . < / p >
< 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 >
< p class = "small muted" > Every class can still train every skill , scout and drill . A trade makes its skills faster and better . < / p >
< / 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 >
< 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 >
2026-10-19 22:45:32 +01:00
< p class = "small muted" > Every class can train every skill . Your class is a trade : its skills are faster , its crafts sometimes make double , and it has one bonus of its own . < / 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 ) ;
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-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 ) ;
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-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 ) ;
} ) ( ) ;