From 0e89f37283ddafabbd721ae048f87d67a07100ea Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 28 Oct 2026 18:33:59 +0000 Subject: [PATCH] Guild roster: cards / table views Co-Authored-By: Claude Opus 5.5 --- game.py | 1 + static/app.js | 70 ++++++++++++++++++++++++++++++++++++++++------- static/styles.css | 49 +++++++++++++++++++++++++++------ 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/game.py b/game.py index edef4d5..1b1471b 100644 --- a/game.py +++ b/game.py @@ -6753,6 +6753,7 @@ ORDER_RECIPES, ORDER_RAW = _order_sources() # line here (new / balance / fix), and a new release a new entry on top. PATCH_NOTES = [ {"date": "2026-10-28", "title": "Queue", "notes": [ + ("new", "Updated guild roster layout"), ("new", "Firekeeping: +Q next to 🔥 Tend all queues tending until your piles are full, then the queue moves on."), ("new", "City work orders: 🏘️ City when posting, or 🔨 on anything a building needs (goes straight into its fund). Anyone can craft them, several at once. Reagents earn their worth in favour, the maker the rest."), ("new", "Work orders: no size cap (was 500 crafted / 2,000 materials)."), diff --git a/static/app.js b/static/app.js index fafe8b8..62c566b 100644 --- a/static/app.js +++ b/static/app.js @@ -15,6 +15,7 @@ let selected = null; // bank item let dollPick = null; // equipment slot shown on the character sheet let achPick = null; // achievement chip spelled out: "section.card.entry" let orderTab = "take"; // work orders: for you, all open, yours +let rosterView = remember("roster") || "cards"; // guild roster: cards or table let draft = null; // a work order being written let seenOrders = null; // order ids already known, so only new ones toast let deliveredSeen = {}; // how much of each order had arrived @@ -2664,18 +2665,66 @@ function rosterNav() { function roster() { const ordered = [...S.players].sort((a, b) => online(b) - online(a)); // stable: by donations within each - return `
${ordered.map((p) => { - const cls = C.classes[p.cls]; - const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `
${s.icon}${s.name}${p.levels[s.id]}
`).join(""); - return `
${cls.icon}
- ${presence(p)}${titled(p)} ${cls.name} · total level ${p.total} · ${esc(p.look.home)}${lastSeen(p)} -
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
-
${FAVOUR} ${fmt(p.donated)} given to the city
-
${levels}
-

${joinButton(p)}

`; + const view = (id, label) => ``; + return `
${view("cards", "▦ Cards")}${view("table", "☰ Table")}
` + + (rosterView === "table" ? rosterTable(ordered) : rosterCards(ordered)); +} + +// Skills by group, in content order, leaving out other classes' own skills. +function skillGroups(cls) { + const groups = new Map(); + for (const s of C.skills.filter((s) => !s.class || s.class === cls)) groups.set(s.group, [...(groups.get(s.group) || []), s]); + return [...groups]; +} + +const rosterSub = (p) => `${p.look?.title ? `${esc(p.look.title)} · ` : ""}${C.classes[p.cls].icon} ${C.classes[p.cls].name}`; +const rosterDoing = (p) => p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`; + +function rosterCards(players) { + return `
${players.map((p) => { + const groups = skillGroups(p.cls); + const top = Object.values(p.levels).sort((a, b) => b - a)[2]; // their best three, ties included + const chip = (s) => { + const lvl = p.levels[s.id]; + return `
= top ? "top" : ""}" title="${s.name} ${lvl}">${s.icon}${lvl}
`; + }; + // Each group spans one column per skill; dense packing sets the short groups side by side. + const cols = Math.max(...groups.map(([, list]) => list.length)); + const skills = groups.map(([name, list]) => `
${esc(name)}
+
${list.map(chip).join("")}
`).join(""); + return `
+
${crest(p.look, 44)}${presence(p)} +
+
${rosterSub(p)} · ${esc(p.look.home)}${lastSeen(p)}
+
${p.total}total
+
${rosterDoing(p)}${p.garrison ? `On guard` : ""}${joinButton(p)} + ${FAVOUR} ${fmt(p.donated)}
+
${skills}
`; }).join("")}
`; } +// Everyone side by side: the best in each skill in gold. +function rosterTable(players) { + const groups = skillGroups(S.me.cls), skills = groups.flatMap(([, list]) => list); + const best = Object.fromEntries(skills.map((s) => [s.id, Math.max(...players.map((p) => p.levels[s.id]))])); + const firsts = new Set(groups.map(([, list]) => list[0].id)); + const sep = (s) => firsts.has(s.id) ? "sep" : ""; + const rows = players.map((p) => ` +
${crest(p.look, 30)}${presence(p)} +
${rosterSub(p)}${lastSeen(p)}
+ ${p.total} +
${rosterDoing(p)}${p.garrison ? `🛡️` : ""}${joinButton(p)}
+ ${skills.map((s) => { + const lvl = p.levels[s.id]; + return `${lvl}`; + }).join("")} + ${fmt(p.donated)}`).join(""); + return `
+ ${groups.map(([name, list]) => ``).join("")} + ${skills.map((s) => ``).join("")} + ${rows}
${esc(name)}
PlayerTotalDoing${s.icon}${FAVOUR}
`; +} + // Patch notes, newest first. The badge stays until this browser has opened the page since the last release. const NOTE_KINDS = { new: ["✨", "good", "New"], balance: ["⚖️", "gold", "Balance"], fix: ["🔧", "warn", "Fixed"] }; const notesKey = () => C.patch_notes[0] ? `${C.patch_notes[0].date} ${C.patch_notes[0].title}` : ""; @@ -3026,7 +3075,7 @@ function loop() { document.addEventListener("click", (e) => { if (queueOpen && !e.target.closest(".queue-panel, [data-queue-menu]")) { queueOpen = false; render(); } - const t = e.target.closest("button, [data-menu], [data-close], [data-aim], [data-goto]"); + const t = e.target.closest("button, [data-nav], [data-menu], [data-close], [data-aim], [data-goto]"); if (!t) return; const d = t.dataset; if (t.matches("[data-battle-skip]")) return playBattle.skip?.(); @@ -3054,6 +3103,7 @@ document.addEventListener("click", (e) => { card.classList.add("flash"); return; } + if (d.rosterView) { rosterView = d.rosterView; remember("roster", d.rosterView); return render(); } if (d.firewood) { firewood = Number(d.firewood); remember("firewood", d.firewood); return render(); } if (d.press) { if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself"); diff --git a/static/styles.css b/static/styles.css index 9c9a877..9f655ee 100644 --- a/static/styles.css +++ b/static/styles.css @@ -318,15 +318,48 @@ table.parts td:last-child { text-align: right; font-variant-numeric: tabular-num .ledger li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; } .ledger .when { color: var(--muted); font-size: 12px; display: block; } .ledger .got { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; } +.roster-views { margin-bottom: 14px; } +.avatar { position: relative; flex: none; line-height: 0; } +.avatar .online { position: absolute; top: -2px; right: -4px; margin: 0; border: 2px solid var(--panel); box-sizing: content-box; } +button.who { background: none; border: 0; padding: 0; font-weight: 700; text-align: left; } +.person { display: flex; flex-direction: column; gap: 12px; margin-bottom: 0; cursor: pointer; } +.person:hover { border-color: var(--muted); } +.roster-table tbody tr:hover td { background: var(--panel-2); } .person .top { display: flex; gap: 12px; align-items: center; } -.person .ico { font-size: 30px; } -.levels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; font-size: 13px; margin-top: 12px; } -.levels .lvl { display: flex; align-items: center; gap: 7px; padding: 4px 0; border-bottom: 1px solid var(--line); } -.levels .lvl .ico { width: 18px; text-align: center; flex: none; font-size: 14px; line-height: 1; } -.levels .lvl .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.levels .lvl b { font-variant-numeric: tabular-nums; font-weight: 650; } -.levels .lvl.low { color: var(--muted); } -.levels .lvl.low b { font-weight: 400; } +.person .grow { flex: 1; min-width: 0; } +.person button.who { font-size: 17px; } +.person .sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.person .total { text-align: right; line-height: 1.1; font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.person .total b { display: block; font-size: 22px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; } +.person .status { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--side); border-radius: 8px; } +.person .status .grow { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.person .status .favour { font-variant-numeric: tabular-nums; white-space: nowrap; } +.sk-grid { display: grid; grid-auto-flow: row dense; gap: 8px 4px; } +.sk-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 2px 3px; } +.sk-row { display: grid; gap: 4px; } +.sk { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 5px 0 4px; border-radius: 6px; background: var(--panel-2); font-size: 16px; line-height: 1; } +.sk b { font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } +.sk.top { box-shadow: inset 0 0 0 1px rgba(224, 176, 74, .45); } +.sk.top b { color: var(--gold); } +.sk.low { background: none; opacity: .45; } +.sk.low b { font-weight: 400; } +.roster-table { overflow-x: auto; padding: 8px 14px 4px; } +.roster-table table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; } +.roster-table th { color: var(--muted); font-weight: 600; padding: 6px 4px; font-size: 12px; } +.roster-table th .gi { width: 15px; height: 15px; } +/* The player column stays put while the skills scroll on a phone. */ +.roster-table tbody td:first-child, .roster-table thead tr:last-child th:first-child { position: sticky; left: 0; z-index: 1; background: var(--panel); } +.roster-table th.grp { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; border-bottom: 1px solid var(--line); } +.roster-table td { padding: 9px 4px; border-top: 1px solid var(--line); text-align: center; white-space: nowrap; } +.roster-table tbody tr { cursor: pointer; } +.roster-table .left { text-align: left; } +.roster-table .sep { border-left: 1px solid var(--line); } +.roster-table td.total { font-weight: 700; font-size: 15px; } +.roster-table td.low { color: var(--muted); opacity: .55; } +.roster-table td.best { color: var(--gold); font-weight: 700; } +.roster-table td.favour, .roster-table th.favour { text-align: right; } +.who-cell, .roster-table .doing { display: flex; align-items: center; gap: 8px; } +.who-cell { gap: 10px; min-width: 170px; } /* --- character sheet -------------------------------------------------- */