Guild roster: cards / table views

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-28 18:33:59 +00:00
parent 30be596a3f
commit 0e89f37283
3 changed files with 102 additions and 18 deletions

View file

@ -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 `<div class="grid wide">${ordered.map((p) => {
const cls = C.classes[p.cls];
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("");
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
${presence(p)}${titled(p)} <span class="muted small">${cls.name} · total level ${p.total} · ${esc(p.look.home)}${lastSeen(p)}</span>
<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>` : ""}</div>
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div>
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button>${joinButton(p)}</p></div>`;
const view = (id, label) => `<button class="tab ${rosterView === id ? "on" : ""}" data-roster-view="${id}">${label}</button>`;
return `<div class="tabs sm roster-views">${view("cards", "▦ Cards")}${view("table", "☰ Table")}</div>`
+ (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 ? `<span class="title">${esc(p.look.title)}</span> · ` : ""}${C.classes[p.cls].icon} ${C.classes[p.cls].name}`;
const rosterDoing = (p) => p.doing ? `${skillOf(p.skill).icon} <span class="grow">${esc(p.doing)}</span>` : `<span class="grow muted">Idle</span>`;
function rosterCards(players) {
return `<div class="grid wide">${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 `<div class="sk ${lvl <= 1 ? "low" : lvl >= top ? "top" : ""}" title="${s.name} ${lvl}">${s.icon}<b>${lvl}</b></div>`;
};
// 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]) => `<div class="sk-group" style="grid-column: span ${list.length}"><div class="sk-label">${esc(name)}</div>
<div class="sk-row" style="grid-template-columns: repeat(${list.length}, minmax(0, 1fr))">${list.map(chip).join("")}</div></div>`).join("");
return `<div class="panel person" data-nav="character:${esc(p.id)}">
<div class="top"><span class="avatar">${crest(p.look, 44)}${presence(p)}</span>
<div class="grow"><button class="who" data-nav="character:${esc(p.id)}" title="Character sheet">${esc(p.name)}</button>
<div class="small muted sub">${rosterSub(p)} · ${esc(p.look.home)}${lastSeen(p)}</div></div>
<div class="total" title="Total level"><b>${p.total}</b>total</div></div>
<div class="status small">${rosterDoing(p)}${p.garrison ? `<span class="tag warn">On guard</span>` : ""}${joinButton(p)}
<span class="favour" title="Given to the city">${FAVOUR} ${fmt(p.donated)}</span></div>
<div class="sk-grid" style="grid-template-columns: repeat(${cols}, minmax(0, 1fr))">${skills}</div></div>`;
}).join("")}</div>`;
}
// 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) => `<tr data-nav="character:${esc(p.id)}">
<td class="left"><div class="who-cell"><span class="avatar">${crest(p.look, 30)}${presence(p)}</span>
<div><button class="who" data-nav="character:${esc(p.id)}" title="Character sheet">${esc(p.name)}</button><div class="small muted">${rosterSub(p)}${lastSeen(p)}</div></div></div></td>
<td class="total">${p.total}</td>
<td class="left"><div class="doing small">${rosterDoing(p)}${p.garrison ? `<span class="tag warn" title="On guard">🛡️</span>` : ""}${joinButton(p)}</div></td>
${skills.map((s) => {
const lvl = p.levels[s.id];
return `<td class="${sep(s)} ${lvl <= 1 ? "low" : lvl === best[s.id] ? "best" : ""}" title="${s.name} ${lvl}">${lvl}</td>`;
}).join("")}
<td class="favour">${fmt(p.donated)}</td></tr>`).join("");
return `<div class="panel roster-table"><table>
<thead><tr><th colspan="3"></th>${groups.map(([name, list]) => `<th class="grp sep" colspan="${list.length}">${esc(name)}</th>`).join("")}<th></th></tr>
<tr><th class="left">Player</th><th>Total</th><th class="left">Doing</th>${skills.map((s) => `<th class="${sep(s)}" title="${s.name}">${s.icon}</th>`).join("")}<th class="favour" title="Given to the city">${FAVOUR}</th></tr></thead>
<tbody>${rows}</tbody></table></div>`;
}
// 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");

View file

@ -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 -------------------------------------------------- */