SOURCE / PINNED RELEASE
Made of little things.
Squad Table
- Release
- d36201f8ed4f…
- Author-recorded commit
- 7b9bb681d6ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d
Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import './napplet-settings.js';
import { identity, IDENTITY_DOMAIN, storage, type Subscription } from '@napplet/sdk';
import { Board } from './board.js';
import { NATIONS, templatesFor, type NationId } from './catalog.js';
import { counterIcon, esc, NATION_STYLE } from './counters.js';
import { runtimeHasDomain } from './domain-availability.js';
import {
counterName,
locationName,
mapOf,
newId,
phaseActors,
sideName,
sideNation,
templateOf,
undoCandidate,
type Action,
type Entry,
type Owner,
type SideId,
type TableState,
} from './model.js';
import {
createLocal,
createOnline,
forgetTable,
listTables,
onlineAvailable,
parseShareCode,
rememberTable,
shareCode,
TableSession,
type SavedTable,
} from './net.js';
import { settings, onSettings } from './napplet-settings.js';
import { Effects } from './effects.js';
import { endButtonState, renderMenu } from './phase-menu.js';
import { setSoundEnabled, soundEnabled } from './sound.js';
import { RULE_NOTES } from './rules/data.js';
import { planFire, type FirePlan } from './rules/fire.js';
import { lineOfSight } from './rules/los.js';
import { checkPath, fmt, phaseKind, phaseName, reachable, type Reach } from './rules/movement.js';
import { SCENARIO_BY_ID, SCENARIOS, TERRAIN_NAMES } from './scenarios.js';
import './styles.css';
// Keep app-owned colors: the map, counters and HUD share one wartime-table
// palette. Host theme data is not applied. See docs/napplet-visual-design.md.
const FOLLOW_HOST_THEME = false;
void FOLLOW_HOST_THEME;
function $<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
const el = {
lobby: $<HTMLElement>('#lobby'),
table: $<HTMLElement>('#table'),
newScenario: $<HTMLSelectElement>('#newScenario'),
newBrief: $<HTMLParagraphElement>('#newBrief'),
newSide: $<HTMLDivElement>('#newSide'),
createOnline: $<HTMLButtonElement>('#createOnline'),
createLocal: $<HTMLButtonElement>('#createLocal'),
onlineNote: $<HTMLParagraphElement>('#onlineNote'),
joinCode: $<HTMLInputElement>('#joinCode'),
joinButton: $<HTMLButtonElement>('#joinButton'),
joinNote: $<HTMLParagraphElement>('#joinNote'),
myTables: $<HTMLUListElement>('#myTables'),
myTablesEmpty: $<HTMLParagraphElement>('#myTablesEmpty'),
identityNote: $<HTMLParagraphElement>('#identityNote'),
back: $<HTMLButtonElement>('#backButton'),
endPhase: $<HTMLButtonElement>('#endPhase'),
actionsPanel: $<HTMLDivElement>('#actionsPanel'),
turnLabel: $<HTMLSpanElement>('#turnLabel'),
activeChip: $<HTMLSpanElement>('#activeChip'),
phaseLabel: $<HTMLSpanElement>('#phaseLabel'),
rollDR: $<HTMLButtonElement>('#rollDR'),
rolldr: $<HTMLButtonElement>('#rolldr'),
los: $<HTMLButtonElement>('#losButton'),
undo: $<HTMLButtonElement>('#undoButton'),
seatChip: $<HTMLSpanElement>('#seatChip'),
syncDot: $<HTMLSpanElement>('#syncDot'),
panelButton: $<HTMLButtonElement>('#panelButton'),
panelClose: $<HTMLButtonElement>('#panelClose'),
panel: $<HTMLElement>('#panel'),
boardHost: $<HTMLDivElement>('#boardHost'),
zoomIn: $<HTMLButtonElement>('#zoomIn'),
zoomOut: $<HTMLButtonElement>('#zoomOut'),
zoomFit: $<HTMLButtonElement>('#zoomFit'),
modeBar: $<HTMLDivElement>('#modeBar'),
modeText: $<HTMLSpanElement>('#modeText'),
modeActions: $<HTMLDivElement>('#modeActions'),
toast: $<HTMLDivElement>('#toast'),
diceTray: $<HTMLDivElement>('#diceTray'),
strip: $<HTMLDivElement>('#strip'),
stripHex: $<HTMLDivElement>('#stripHex'),
stripCounters: $<HTMLDivElement>('#stripCounters'),
stripMove: $<HTMLButtonElement>('#stripMove'),
stripMore: $<HTMLButtonElement>('#stripMore'),
stackPanel: $<HTMLDivElement>('#stackPanel'),
trayPanel: $<HTMLDivElement>('#trayPanel'),
infoPanel: $<HTMLDivElement>('#infoPanel'),
logList: $<HTMLOListElement>('#logList'),
logBadge: $<HTMLSpanElement>('#logBadge'),
chatSend: $<HTMLButtonElement>('#chatSend'),
chatInput: $<HTMLInputElement>('#chatInput'),
rollWhy: $<HTMLInputElement>('#rollWhy'),
tooSmallState: $<HTMLParagraphElement>('#tooSmallState'),
};
type Tab = 'actions' | 'stack' | 'tray' | 'log' | 'info';
type Mode =
| { kind: 'none' }
| { kind: 'fire'; from: string; ids: string[]; to: string | null }
| { kind: 'move'; ids: string[]; from: string; dt: boolean; override: string | null }
| { kind: 'los'; from: string | null; to: string | null };
const ui = {
me: 'guest',
pubkey: '',
session: null as TableSession | null,
selected: null as string | null, // hex id or off-board box
picked: new Set<string>(), // counters chosen within the selected location
mode: { kind: 'none' } as Mode,
tab: 'actions' as Tab,
overrideTools: false, // reveal manual edit tools after setup (logged as overrides)
endArmed: false,
panelOpen: true,
traySide: 'a' as SideId | 'n',
newSide: 'a' as SideId,
logSeen: 0,
shownLos: null as { from: string; to: string } | null,
};
const board = new Board(el.boardHost, {
tap: onBoardTap,
dragMove: (from, to) => {
const ids = ui.selected === from && ui.picked.size ? orderedPicked(from) : [...(stateOrNull()?.stacks.get(from) ?? [])];
moveCounters(ids, to);
},
hover: (hex) => {
if (settings.sharePointer) ui.session?.cursor(hex);
if (ui.mode.kind === 'los' && ui.mode.from && !ui.mode.to) renderBoard();
},
});
const effects = new Effects(board);
/** Play shooting/rally/melee effects for newly resolved entries (the last few only). */
function playEffects(fresh: Entry[]): void {
const state = stateOrNull();
if (!state) return;
for (const entry of fresh.filter((e) => e.d?.length && e.a.t !== 'roll').slice(-2)) {
const line = state.lines.find((l) => l.i === entry.i);
if (!line || line.kind !== 'fire') continue;
const a = entry.a;
if (a.t === 'fire') {
const firers = a.ids.map((id) => state.counters.get(id)).filter(Boolean);
const from = firers[0]?.at;
if (from) effects.fire(entry, line, from, firers.some((c) => /-(lmg|mmg|hmg)$/.test(c!.k)));
} else if (a.t === 'cc') {
effects.closeCombat(entry, line);
} else if (a.t === 'rally' || a.t === 'repair') {
const c = state.counters.get(a.id) ?? state.counters.get(`${a.id}h`);
if (c && /^[A-Z]/.test(c.at)) effects.rally(line, c.at);
}
}
}
function stateOrNull(): TableState | null {
return ui.session?.state ?? null;
}
function narrow(): boolean {
return window.innerWidth < 760;
}
// ---------- identity ----------
let identitySub: Subscription | null = null;
async function loadIdentity(): Promise<void> {
if (!runtimeHasDomain(IDENTITY_DOMAIN)) {
setIdentity('');
return;
}
try {
setIdentity(await identity.getPublicKey());
} catch {
setIdentity('');
}
try {
identitySub = identity.onChanged((pubkey) => setIdentity(pubkey));
} catch {
// Identity changes are an enhancement.
}
}
function setIdentity(pubkey: string): void {
const changed = pubkey !== ui.pubkey;
ui.pubkey = pubkey;
ui.me = pubkey ? `nostr:${pubkey}` : 'guest';
if (ui.session && changed && ui.session.online) {
ui.session.me = ui.me;
ui.session.onChange('status');
}
renderLobbyNotes();
}
function shortKey(principal: string): string {
const key = principal.replace(/^(nostr|guest):/, '');
return key ? `${key.slice(0, 6)}…${key.slice(-4)}` : '';
}
// ---------- lobby ----------
function renderScenarioPicker(): void {
el.newScenario.innerHTML = SCENARIOS.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
updateNewScenario();
}
function updateNewScenario(): void {
const s = SCENARIO_BY_ID.get(el.newScenario.value) ?? SCENARIOS[0];
el.newBrief.textContent = s.brief;
el.newSide.innerHTML = (['a', 'b'] as const)
.map((side) => {
const nat = s.sides[side].nat;
const st = NATION_STYLE[nat];
return `<button type="button" role="radio" class="seg-btn" data-side="${side}" aria-checked="${ui.newSide === side}">
<span class="swatch" style="background:${st.face};border-color:${st.edge}"></span>${esc(s.sides[side].name)}
</button>`;
})
.join('');
}
function renderLobbyNotes(): void {
const online = onlineAvailable();
el.createOnline.disabled = !online;
el.joinButton.disabled = !online;
if (!online) {
el.onlineNote.textContent = 'Online tables need a host with shared-backend support. Hotseat games still work here.';
} else if (!ui.pubkey) {
el.onlineNote.textContent = 'Sign in with your Nostr account to create or sit at an online table. Anyone can watch.';
} else {
el.onlineNote.textContent = 'Online tables keep the log and roll the dice on the shared server. Share the code with your opponent.';
}
el.identityNote.textContent = ui.pubkey ? `Signed in as ${shortKey(ui.pubkey)}` : '';
if (!runtimeHasDomain('storage')) {
el.myTablesEmpty.textContent = 'This host has no app storage, so tables are not remembered and hotseat games are not saved.';
}
}
async function renderMyTables(): Promise<void> {
const tables = await listTables();
el.myTablesEmpty.hidden = tables.length > 0;
el.myTables.innerHTML = tables
.map((t, i) => {
const s = SCENARIO_BY_ID.get(t.scenario);
const nat = t.side === 'n' || !s ? null : s.sides[t.side].nat;
const when = new Date(t.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return `<li class="table-item">
<button type="button" class="table-open" data-index="${i}">
<span class="swatch" style="background:${nat ? NATION_STYLE[nat].face : '#555'}"></span>
<span class="table-name">${esc(t.title || s?.name || 'Table')}</span>
<span class="table-meta">${t.kind === 'online' ? 'Online' : 'Hotseat'} · ${nat ? esc(NATIONS[nat].name) : 'Watching'} · ${when}</span>
</button>
<button type="button" class="tool slim table-forget" data-index="${i}" aria-label="Remove from list" title="Remove from list">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m6 6 8 8M14 6l-8 8" /></svg>
</button>
</li>`;
})
.join('');
el.myTables.querySelectorAll<HTMLButtonElement>('.table-open').forEach((b) =>
b.addEventListener('click', () => void openTable(tables[Number(b.dataset.index)])),
);
el.myTables.querySelectorAll<HTMLButtonElement>('.table-forget').forEach((b) =>
b.addEventListener('click', async () => {
const t = tables[Number(b.dataset.index)];
// Hotseat games exist only here, so ask twice (dialogs are blocked in the sandbox).
if (t.kind === 'local' && b.dataset.armed !== '1') {
b.dataset.armed = '1';
b.classList.add('armed');
b.title = 'Tap again to delete this hotseat game';
b.setAttribute('aria-label', b.title);
toast('Tap × again to delete this hotseat game from this device.');
return;
}
await forgetTable(t);
await renderMyTables();
}),
);
}
el.newScenario.addEventListener('change', updateNewScenario);
el.newSide.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-side]');
if (!b) return;
ui.newSide = b.dataset.side as SideId;
updateNewScenario();
});
// Sandboxed frames do not allow form submission, so buttons and Enter keys
// call the handlers directly.
function onEnter(input: HTMLInputElement, action: () => void): void {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
action();
}
});
}
el.createOnline.addEventListener('click', async () => {
if (!ui.pubkey) {
el.onlineNote.textContent = 'Sign in with your Nostr account first: your seat belongs to that account.';
return;
}
el.createOnline.disabled = true;
el.onlineNote.textContent = 'Setting up the table… your host may ask to confirm your account.';
try {
const s = SCENARIO_BY_ID.get(el.newScenario.value)!;
const saved = await createOnline(s.id, s.name, ui.newSide);
await openTable(saved);
ui.tab = 'info';
renderPanel();
} catch (error) {
el.onlineNote.textContent = error instanceof Error ? error.message : 'Could not create the table.';
} finally {
el.createOnline.disabled = !onlineAvailable();
}
});
el.createLocal.addEventListener('click', async () => {
const s = SCENARIO_BY_ID.get(el.newScenario.value)!;
const saved = createLocal(s.id, s.name);
saved.side = ui.newSide;
await rememberTable(saved);
await openTable(saved);
});
async function joinWithCode(): Promise<void> {
const code = parseShareCode(el.joinCode.value);
if (!code) {
el.joinNote.textContent = 'That does not look like a table code. It starts with ASL1.';
return;
}
el.joinNote.textContent = 'Opening…';
const saved: SavedTable = { kind: 'online', id: code.instance, release: code.release, scenario: '', title: '', side: 'n', updated: Date.now() };
try {
await openTable(saved);
el.joinNote.textContent = '';
el.joinCode.value = '';
ui.tab = 'info';
renderPanel();
} catch (error) {
el.joinNote.textContent = error instanceof Error ? error.message : 'Could not open that table.';
}
}
el.joinButton.addEventListener('click', () => void joinWithCode());
onEnter(el.joinCode, () => void joinWithCode());
// ---------- table lifecycle ----------
async function openTable(saved: SavedTable): Promise<void> {
closeTable();
const session = new TableSession({ ...saved }, saved.kind === 'local' ? 'local' : ui.me);
if (saved.kind === 'online') {
await session.open();
// A joined table learns its scenario from the server.
const scenario = session.meta?.scenario ?? saved.scenario;
(session.saved as SavedTable).scenario = scenario;
(session.saved as SavedTable).title = session.meta?.title || SCENARIO_BY_ID.get(scenario)?.name || 'Table';
await session.refresh();
await rememberTable({ ...session.saved, side: session.mySide(), updated: Date.now() });
} else {
await session.open();
}
ui.session = session;
ui.selected = null;
ui.picked.clear();
ui.mode = { kind: 'none' };
ui.shownLos = null;
ui.logSeen = session.state.lines.length;
ui.traySide = session.mySide() === 'b' ? 'b' : 'a';
session.onChange = onSessionChange;
el.lobby.hidden = true;
el.table.hidden = false;
ui.panelOpen = !narrow();
board.setMap(mapOf(session.state.scenario));
renderAll();
requestAnimationFrame(() => (narrow() ? board.fitPlayable() : board.fit()));
if (session.liveAvailable() && settings.autoLive) void session.startLive();
}
function closeTable(): void {
ui.session?.close();
ui.session = null;
}
el.back.addEventListener('click', async () => {
closeTable();
el.table.hidden = true;
el.lobby.hidden = false;
await renderMyTables();
});
function onSessionChange(reason: 'state' | 'status' | 'presence', fresh?: Entry[]): void {
const session = ui.session;
if (!session) return;
if (reason === 'state') {
// Drop picks for counters that no longer exist or moved away.
const state = session.state;
for (const id of [...ui.picked]) {
if (state.counters.get(id)?.at !== ui.selected) ui.picked.delete(id);
}
for (const entry of fresh ?? []) {
if (entry.d?.length) showDice(entry);
}
requestAnimationFrame(() => playEffects(fresh ?? []));
if (ui.mode.kind === 'fire' && ui.mode.ids.some((id) => state.counters.get(id)?.at !== (ui.mode as { from: string }).from || state.counters.get(id)?.fired)) {
ui.mode = { kind: 'none' };
}
renderAll();
} else if (reason === 'presence') {
renderBoard();
} else {
renderHud();
if (ui.tab === 'info') renderInfo();
}
}
// ---------- actions ----------
function canAct(silent = false): boolean {
const s = ui.session;
if (!s) return false;
if (!s.online) return true;
if (s.mySide() !== 'n') return true;
if (!silent) toast(ui.pubkey ? 'Take a seat in the Table tab to move counters and roll.' : 'Sign in and take a seat to play. You are watching.');
return false;
}
function submit(actions: Action[]): void {
if (!ui.session || !actions.length) return;
ui.session.submit(actions);
}
/** After setup, manual edits are rules overrides and are flagged in the log. */
function overrideTag(): { ovr?: string } {
const s = stateOrNull();
return s && s.turn > 0 ? { ovr: 'manual edit' } : {};
}
function actor(): Owner {
return ui.session?.mySide() ?? 'n';
}
/** Legal destinations for the group being moved, or null outside move mode. */
function currentReach(): Reach | null {
const s = stateOrNull();
const m = ui.mode;
if (!s || m.kind !== 'move' || m.override) return null;
return reachable(s, m.ids, actor(), m.dt);
}
function explainUnreachable(state: TableState, ids: string[], to: string, dt: boolean, reach: Reach): string {
const check = checkPath(state, ids, [to], dt, actor());
if (!check.ok && !/not next to/.test(check.reasons[0])) return check.reasons[0];
if (reach.kind === 'advance') return 'Advancing units move one hex only.';
return `${to} is out of reach: ${fmt(reach.left)} MF left.`;
}
/** Submit a rules-checked move. Returns false (with a toast) when it is not legal. */
function ruleMove(ids: string[], to: string, dt: boolean): boolean {
const s = stateOrNull();
if (!s || !canAct() || !ids.length) return false;
const reach = reachable(s, ids, actor(), dt);
if (!reach.ok) {
toast(`${reach.reasons[0]} Use Move → Override to do it anyway.`, 4500);
return false;
}
const target = reach.hexes.get(to);
if (!target) {
toast(explainUnreachable(s, ids, to, dt, reach), 4000);
return false;
}
submit([{ t: 'mv', ids, path: target.path, ...(dt && reach.kind === 'move' ? { dt: true } : {}) }]);
ui.selected = to;
ui.picked = new Set(ids);
board.reveal(to);
return true;
}
function moveCounters(ids: string[], to: string): void {
ruleMove(ids, to, false);
ui.mode = { kind: 'none' };
renderAll();
}
function orderedPicked(at: string): string[] {
const stack = stateOrNull()?.stacks.get(at) ?? [];
return stack.filter((id) => ui.picked.has(id));
}
function selectLocation(at: string | null): void {
ui.selected = at;
ui.picked = new Set(at ? (stateOrNull()?.stacks.get(at) ?? []) : []);
renderAll();
}
function onBoardTap(hex: string | null): void {
const mode = ui.mode;
if (mode.kind === 'fire') {
const s = stateOrNull();
if (!hex || !s) return;
const plan = planFire(s, mode.ids, hex, actor());
if (plan.ok) {
ui.mode = { ...mode, to: hex };
fireError = null;
ui.tab = 'actions';
if (!ui.panelOpen) setPanel(true);
} else {
fireError = `${hex}: ${plan.reasons[0]}`;
toast(fireError, 4000);
}
renderAll();
return;
}
if (mode.kind === 'move') {
if (!hex) {
ui.mode = { kind: 'none' };
} else if (mode.override) {
submit([{ t: 'move', ids: mode.ids, to: hex, ovr: mode.override }]);
ui.selected = hex;
ui.picked = new Set(mode.ids);
ui.mode = { kind: 'none' };
} else if (hex !== mode.from && ruleMove(mode.ids, hex, mode.dt)) {
// Keep moving the same group while it has MF and somewhere to go.
ui.mode = { ...mode, from: hex, dt: false };
const next = currentReach();
if (!next || !next.ok || !next.hexes.size || next.kind === 'setup' || next.kind === 'advance') ui.mode = { kind: 'none' };
}
renderAll();
return;
}
if (mode.kind === 'los') {
if (!hex) return;
if (!mode.from || mode.to) ui.mode = { kind: 'los', from: hex, to: null };
else if (hex !== mode.from) ui.mode = { kind: 'los', from: mode.from, to: hex };
renderAll();
if (ui.mode.kind === 'los' && ui.mode.to) {
ui.tab = 'stack';
renderPanel();
}
return;
}
ui.shownLos = null;
selectLocation(hex === ui.selected ? null : hex);
if (hex && ui.panelOpen && ui.tab !== 'tray' && ui.tab !== 'actions') {
ui.tab = 'stack';
renderPanel();
}
}
function startMove(): void {
if (!ui.selected || !canAct()) return;
const ids = orderedPicked(ui.selected);
if (!ids.length) {
toast('Pick at least one counter to move.');
return;
}
ui.mode = { kind: 'move', ids, from: ui.selected, dt: false, override: null };
if (narrow()) setPanel(false);
renderAll();
}
function roll(n: 1 | 2): void {
if (!canAct()) return;
const why = el.rollWhy.value.trim().slice(0, 60);
el.rollWhy.value = '';
submit([{ t: 'roll', n, why }]);
}
function addCounter(k: string, side: Owner): void {
if (!canAct()) return;
const s = stateOrNull();
if (!s) return;
const target = ui.selected ?? (side === 'n' ? null : `pool-${side}`);
if (!target) {
toast('Select a hex first, then add the marker.');
return;
}
if (s.turn > 0 && side !== 'n' && !ui.overrideTools) {
toast('After setup, adding units is an override. Turn on Override tools in the Stack tab first.', 4500);
return;
}
const id = newId();
submit([{ t: 'add', id, k, side, to: target, ...(side === 'n' ? {} : overrideTag()) }]);
if (!ui.selected) ui.selected = target;
ui.picked.add(id);
toast(`Added to ${locationName(target)}${target.includes('-') ? '' : ` ${target}`}`);
}
// ---------- rendering ----------
function renderAll(): void {
renderHud();
renderBoard();
renderPanel();
renderStrip();
renderModeBar();
}
function renderBoard(): void {
const s = ui.session;
if (!s) return;
const presence = [...s.presence.values()].filter((p) => Date.now() - p.at < 60000);
const los = ui.mode.kind === 'los' && ui.mode.from ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
board.setDragSelection(ui.selected ? orderedPicked(ui.selected) : []);
const reach = currentReach();
const m = ui.mode;
board.render(s.state, {
targets: fireTargets(),
fireLine: m.kind === 'fire' && m.to ? { from: m.from, to: m.to } : null,
selected: ui.selected,
targeting: ui.mode.kind === 'move',
reach: reach?.ok && reach.kind !== 'setup' ? new Map([...reach.hexes].map(([id, v]) => [id, v.cost])) : null,
los,
presence,
showCoords: settings.showCoords,
});
}
function natChip(state: TableState, side: Owner): string {
const nat = sideNation(state.scenario, side);
const st = NATION_STYLE[nat];
return `<span class="chip-swatch" style="background:${st.face};border-color:${st.edge}"></span>${esc(NATIONS[nat].short)}`;
}
function renderHud(): void {
const s = ui.session;
if (!s) return;
const state = s.state;
el.turnLabel.textContent = state.turn === 0 ? 'Setup' : `T${state.turn}/${state.scenario.turns}`;
el.activeChip.innerHTML = natChip(state, state.active);
el.activeChip.title = `${sideName(state.scenario, state.active)} player turn`;
el.phaseLabel.textContent = state.turn === 0 ? '' : phaseName(state);
const side = s.mySide();
const acting = canAct(true);
for (const b of [el.rollDR, el.rolldr]) b.disabled = !acting;
const end = endButtonState(state, endAs());
el.endPhase.disabled = !end.enabled;
el.endPhase.textContent = ui.endArmed && end.enabled ? 'Confirm?' : end.enabled ? 'End' : state.over ? 'Over' : 'Wait';
el.endPhase.title = end.label;
el.endPhase.classList.toggle('armed', ui.endArmed && end.enabled);
el.undo.disabled = !acting || !undoCandidate(state, s.me);
el.los.setAttribute('aria-pressed', String(ui.mode.kind === 'los'));
if (!s.online) {
el.seatChip.innerHTML = `<button type="button" class="seat-toggle" title="Switch which side you are acting for">${natChip(state, side)}<span class="seat-label">acting</span></button>`;
el.seatChip.querySelector('button')!.addEventListener('click', () => {
s.setLocalSide(side === 'a' ? 'b' : 'a');
ui.traySide = s.mySide() as SideId;
renderAll();
});
} else {
el.seatChip.innerHTML = side === 'n' ? '<span class="seat-label">watching</span>' : `${natChip(state, side)}<span class="seat-label">you</span>`;
}
const st = s.status;
const live = st.live === 'connected' ? ` · live with ${st.peers}` : st.live === 'waiting' ? ' · live link open' : '';
const text = !s.online
? 'Hotseat game on this device'
: st.sync === 'saving'
? `Saving${live}`
: st.sync === 'offline'
? 'Offline: retrying'
: st.sync === 'error'
? 'Not saved'
: `Synced${live}`;
el.syncDot.dataset.state = !s.online ? 'local' : st.sync === 'idle' ? (st.live === 'connected' ? 'live' : 'ok') : st.sync;
el.syncDot.title = st.message || text;
el.syncDot.setAttribute('aria-label', st.message || text);
if (st.message && (st.sync === 'error' || st.sync === 'offline')) toast(st.message, 5000);
el.panelButton.setAttribute('aria-expanded', String(ui.panelOpen));
el.table.classList.toggle('panel-open', ui.panelOpen);
const roll = state.lastRoll;
el.tooSmallState.textContent = `Turn ${state.turn} · ${NATIONS[sideNation(state.scenario, state.active)].short} · ${phaseName(state)}${
roll ? ` · last roll ${roll.dice.reduce((a, b) => a + b, 0)} (${NATIONS[sideNation(state.scenario, roll.side)].short})` : ''
}`;
}
function renderModeBar(): void {
const m = ui.mode;
el.modeBar.hidden = m.kind === 'none';
const actions: string[] = [];
if (m.kind === 'move') {
const reach = currentReach();
const n = `${m.ids.length} counter${m.ids.length === 1 ? '' : 's'}`;
if (m.override) {
el.modeText.textContent = `Override: tap where to put ${n}`;
} else if (!reach || !reach.ok) {
el.modeText.textContent = `Can't move: ${reach?.reasons[0] ?? 'nothing selected'}`;
actions.push(`<button type="button" class="btn small danger" data-mode="override">Override</button>`);
} else if (reach.kind === 'setup') {
el.modeText.textContent = `Setup: tap a hex for ${n}`;
} else if (reach.kind === 'advance') {
el.modeText.textContent = `Advance ${n}: tap an adjacent hex`;
actions.push(`<button type="button" class="btn small" data-mode="override">Override</button>`);
} else {
el.modeText.textContent = `${reach.kind === 'rout' ? 'Rout' : 'Move'} ${n} · ${fmt(reach.left)} MF left`;
if (reach.canDoubleTime)
actions.push(`<button type="button" class="btn small" data-mode="dt" aria-pressed="${m.dt}" title="+1 MF; the units become CX">Double Time</button>`);
actions.push(`<button type="button" class="btn small" data-mode="override">Override</button>`);
}
} else if (m.kind === 'fire') {
el.modeText.textContent = m.to ? `Fire ${m.from} → ${m.to}: roll in the Actions panel` : `Fire from ${m.from}: tap a target`;
} else if (m.kind === 'los') {
el.modeText.textContent = !m.from ? 'LOS: tap the firing hex' : !m.to ? 'LOS: tap the target hex' : `LOS ${m.from} → ${m.to}`;
}
const moving = m.kind === 'move' && !m.override && Boolean(currentReach()?.ok);
actions.push(`<button type="button" class="btn small" data-mode="cancel">${moving ? 'Done' : 'Cancel'}</button>`);
el.modeActions.innerHTML = actions.join('');
}
function setPanel(open: boolean): void {
ui.panelOpen = open;
el.table.classList.toggle('panel-open', open);
el.panelButton.setAttribute('aria-expanded', String(open));
renderStrip();
requestAnimationFrame(() => renderBoard());
}
function renderPanel(): void {
el.panel.querySelectorAll<HTMLButtonElement>('.tab').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === ui.tab)));
el.panel.querySelectorAll<HTMLElement>('.tab-body').forEach((b) => (b.hidden = b.dataset.body !== ui.tab));
if (ui.tab === 'actions') renderActions();
else if (ui.tab === 'stack') renderStack();
else if (ui.tab === 'tray') renderTray();
else if (ui.tab === 'log') renderLog();
else renderInfo();
const unseen = (stateOrNull()?.lines.length ?? 0) > ui.logSeen && ui.tab !== 'log';
el.logBadge.hidden = !unseen;
}
function terrainSummary(state: TableState, hex: string): string {
const cell = mapOf(state.scenario).cells.get(hex);
if (!cell) return '';
return `${TERRAIN_NAMES[cell.terrain]}${cell.road ? ' with road' : ''}${cell.level ? ` · level ${cell.level}` : ''}`;
}
// ---------- Actions menu and fire ----------
function currentFirePlan(): FirePlan | null {
const s = stateOrNull();
const m = ui.mode;
if (!s || m.kind !== 'fire' || !m.to) return null;
const plan = planFire(s, m.ids, m.to, actor());
return plan.ok ? plan : null;
}
let fireError: string | null = null;
/** Whose End button this is: your seat online; in hotseat, whichever side still has to end. */
function endAs(): Owner {
const s = ui.session;
if (!s) return 'n';
if (s.online) return s.mySide();
return phaseActors(s.state).find((side) => !s.state.ready.has(side)) ?? s.mySide();
}
function renderActions(): void {
const s = ui.session;
if (!s) return;
const m = ui.mode;
el.actionsPanel.innerHTML = renderMenu({
state: s.state,
me: s.mySide(),
endAs: endAs(),
online: s.online,
selected: ui.selected,
picked: ui.picked,
fire: m.kind === 'fire' ? { from: m.from, ids: m.ids, to: m.to } : null,
firePlan: currentFirePlan(),
fireError,
});
}
/** Enemy hexes this fire group could attack, with a short FP/DRM label. */
function fireTargets(): Map<string, string> | null {
const s = stateOrNull();
const m = ui.mode;
if (!s || m.kind !== 'fire') return null;
const out = new Map<string, string>();
const enemy = actor() === 'a' ? 'b' : 'a';
for (const [hex, ids] of s.stacks) {
if (!/^[A-Z]/.test(hex)) continue;
if (!ids.some((id) => s.counters.get(id)?.side === enemy)) continue;
const plan = planFire(s, m.ids, hex, actor());
if (plan.ok) out.set(hex, `${plan.column}${plan.drm ? (plan.drm > 0 ? `+${plan.drm}` : `${plan.drm}`) : ''}`);
}
return out;
}
function endPhase(): void {
const s = ui.session;
if (!s || !canAct()) return;
const end = endButtonState(s.state, endAs());
if (!end.enabled) return;
if (!ui.endArmed) {
ui.endArmed = true;
renderHud();
window.setTimeout(() => {
ui.endArmed = false;
renderHud();
}, 3500);
toast(`Tap again to end the ${end.label.replace(/^End /, '')}. You can't go back.`, 3500);
return;
}
ui.endArmed = false;
ui.mode = { kind: 'none' };
el.toast.hidden = true;
if (s.online) {
submit([{ t: 'end' }]);
return;
}
// Hotseat: one person plays both sides, so finish the phase for every side
// that acts in it, then hand control to whoever acts next.
for (const side of phaseActors(s.state)) {
if (s.state.ready.has(side)) continue;
s.setLocalSide(side);
submit([{ t: 'end' }]);
}
const next = phaseActors(s.state);
s.setLocalSide(next.length === 1 ? next[0] : s.state.active);
ui.traySide = s.mySide() as SideId;
renderAll();
}
el.actionsPanel.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
const s = ui.session;
if (!b || !s) return;
const d = b.dataset;
if (d.act === 'end-phase') {
ui.endArmed = true; // the panel button is already explicit
endPhase();
} else if (d.goto) {
selectLocation(d.goto);
if (/^[A-Z]/.test(d.goto)) board.reveal(d.goto);
ui.tab = 'actions';
renderPanel();
} else if (d.pick) {
if (ui.picked.has(d.pick)) ui.picked.delete(d.pick);
else ui.picked.add(d.pick);
renderActions();
renderBoard();
} else if (d.act === 'fire-start' && ui.selected) {
const ids = orderedPicked(ui.selected).filter((id) => !s.state.counters.get(id)?.fired);
ui.mode = { kind: 'fire', from: ui.selected, ids, to: null };
fireError = null;
if (!fireTargets()?.size) fireError = 'No enemy unit is in range and line of sight of this group.';
if (narrow()) setPanel(false);
renderAll();
} else if (d.act === 'fire-roll') {
const m = ui.mode;
const plan = currentFirePlan();
if (m.kind !== 'fire' || !m.to || !plan || !canAct()) return;
submit([{ t: 'fire', ids: m.ids, to: m.to }]);
ui.mode = { kind: 'none' };
ui.picked.clear();
renderAll();
} else if (d.act === 'fire-cancel') {
ui.mode = { kind: 'none' };
renderAll();
} else if (d.rally && canAct()) submit([{ t: 'rally', id: d.rally }]);
else if (d.repair && canAct()) submit([{ t: 'repair', id: d.repair }]);
else if (d.cc && canAct()) submit([{ t: 'cc', to: d.cc }]);
});
el.endPhase.addEventListener('click', endPhase);
function renderLosReport(state: TableState, from: string, to: string): string {
const map = mapOf(state.scenario);
if (!map.cells.has(from) || !map.cells.has(to)) return '';
const los = lineOfSight(state, from, to);
const rows = los.hexes
.map((hex) => {
const cell = map.cells.get(hex)!;
const hindrance = los.hindrances.find((h) => h.hex === hex);
const blocked = los.blockedBy === hex;
return `<li class="${blocked ? 'los-block' : hindrance ? 'los-hinder' : ''}"><b>${hex}</b> ${esc(TERRAIN_NAMES[cell.terrain])}${cell.level ? ` · L${cell.level}` : ''}${
blocked ? ' · <b>blocks</b>' : hindrance ? ` · hindrance +${hindrance.value}` : ''
}</li>`;
})
.join('');
const verdict = los.clear
? `<p class="los-verdict ok">Clear${los.hindrance ? `, hindrance +${los.hindrance}` : ''}</p>`
: `<p class="los-verdict bad">${esc(los.reason ?? 'Blocked')}</p>`;
return `<section class="los-report">
<h3>Line of sight ${from} → ${to}</h3>
${verdict}
<ol>${rows}</ol>
<p class="hint">Elevation is approximated with a straight sight line; blind hexes aren't modelled exactly.</p>
<div class="row">
<button type="button" class="btn small" data-act="los-share">Post to log</button>
<button type="button" class="btn small" data-act="los-clear">Clear</button>
</div>
</section>`;
}
function phaseBanner(state: TableState): string {
const side = state.scenario.sides[state.active].name;
const kind = phaseKind(state);
const text =
kind === 'setup'
? 'Setup: place units anywhere, then press End in the top bar (or the Actions tab) to start turn 1.'
: kind === 'move'
? `Movement Phase: ${side} units move. Pick a stack, tap Move, then tap a highlighted hex.`
: kind === 'rout'
? 'Rout Phase: broken units of both sides move.'
: kind === 'advance'
? `Advance Phase: ${side} units may advance one hex.`
: `${phaseName(state)} Phase: no movement.`;
return `<p class="phase-banner">${esc(text)}</p>`;
}
function unitStatus(state: TableState, id: string): string {
const c = state.counters.get(id);
if (!c) return '';
const bits: string[] = [];
const spent = state.move.spent.get(id) ?? 0;
if (state.move.done.has(id)) bits.push('finished moving');
else if (spent > 0) bits.push(`${fmt(spent)} MF used`);
if (c.cx) bits.push('CX');
if (c.pin) bits.push('pinned');
if (c.fired) bits.push(c.fired === 'prep' ? 'prep fired' : c.fired === 'final' ? 'final fired' : 'advancing fired');
return bits.length ? ` · ${bits.join(' · ')}` : '';
}
function renderStack(): void {
const s = ui.session;
if (!s) return;
const state = s.state;
const los = ui.mode.kind === 'los' && ui.mode.from && ui.mode.to ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
const parts: string[] = [];
parts.push(phaseBanner(state));
if (los) parts.push(renderLosReport(state, los.from, los.to));
const at = ui.selected;
if (!at) {
parts.push(`<p class="empty">Tap a hex to see its stack. ${
narrow() ? '' : 'Drag a stack with the mouse to move it; '
}use Move to send picked counters elsewhere.</p>`);
} else {
const ids = [...(state.stacks.get(at) ?? [])].reverse(); // top first
const isHex = !at.includes('-');
parts.push(`<div class="stack-head">
<div><b class="stack-hex">${esc(isHex ? at : `${sideName(state.scenario, at.endsWith('a') ? 'a' : 'b')} ${locationName(at)}`)}</b>
<span class="hint">${isHex ? esc(terrainSummary(state, at)) : ''}</span></div>
<span class="hint">${ids.length} counter${ids.length === 1 ? '' : 's'}</span>
</div>`);
const edits = state.turn === 0 || ui.overrideTools;
if (ids.length) {
parts.push(`<div class="row stack-tools">
<button type="button" class="btn small primary" data-act="move" ${ui.picked.size ? '' : 'disabled'}>Move ${ui.picked.size || ''}</button>
<button type="button" class="btn small" data-act="pick-all">${ui.picked.size === ids.length ? 'Pick none' : 'Pick all'}</button>
${isHex && edits ? `<button type="button" class="btn small${state.turn ? ' danger' : ''}" data-act="to-pool" ${ui.picked.size ? '' : 'disabled'}>Off-board</button>
<button type="button" class="btn small danger" data-act="to-elim" ${ui.picked.size ? '' : 'disabled'}>Eliminate</button>` : ''}
</div>`);
parts.push('<ul class="stack-list">');
for (const id of ids) {
const c = state.counters.get(id);
if (!c) continue;
const t = templateOf(c);
const picked = ui.picked.has(id);
const rotates = t.type === 'vehicle' || t.type === 'gun';
parts.push(`<li class="stack-item${picked ? ' picked' : ''}">
<button type="button" class="pick" data-pick="${esc(id)}" aria-pressed="${picked}" title="${picked ? 'Unpick' : 'Pick'} ${esc(counterName(c))}">
${counterIcon(c, 44)}
<span class="pick-text"><span class="pick-name">${esc(counterName(c))}</span>
<span class="pick-sub">${esc(NATIONS[t.nat].name)} ${esc(t.type === 'fort' ? 'terrain' : t.type)}${esc(unitStatus(state, id))}</span></span>
</button>
<div class="item-tools">
${edits ? `<button type="button" class="tool slim" data-flip="${esc(id)}" title="Flip${state.turn ? ' (override)' : ''}" aria-label="Flip ${esc(counterName(c))}">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7h10l-3-3M16 13H6l3 3" /></svg></button>` : ''}
${rotates ? `<button type="button" class="tool slim" data-rot="${esc(id)}" title="Turn facing" aria-label="Turn ${esc(counterName(c))}">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M15 10a5 5 0 1 1-2-4M13 2v4h4" /></svg></button>` : ''}
<button type="button" class="tool slim" data-top="${esc(id)}" title="Put on top" aria-label="Put ${esc(counterName(c))} on top">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15V5M5 9l5-5 5 5" /></svg></button>
<button type="button" class="tool slim" data-label="${esc(id)}" title="Edit label" aria-label="Edit label of ${esc(counterName(c))}">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 16h3l8-8-3-3-8 8z" /></svg></button>
${edits ? `<button type="button" class="tool slim" data-del="${esc(id)}" title="Remove from table${state.turn ? ' (override)' : ''}" aria-label="Remove ${esc(counterName(c))}">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10" /></svg></button>` : ''}
</div>
</li>`);
}
parts.push('</ul>');
} else {
parts.push('<p class="empty">Empty. Add counters from the Counters tab.</p>');
}
}
parts.push(offboardBoxes(state));
if (state.turn > 0) {
parts.push(`<div class="override-row"><button type="button" class="btn small${ui.overrideTools ? ' danger' : ''}" data-act="override-tools" aria-pressed="${ui.overrideTools}">${
ui.overrideTools ? 'Hide override tools' : 'Override tools…'
}</button><span class="hint">Flip, remove, eliminate or add units by hand. Every use is flagged in the log.</span></div>`);
}
el.stackPanel.innerHTML = parts.join('');
}
function offboardBoxes(state: TableState): string {
const box = (at: string, side: SideId, label: string) => {
const n = state.stacks.get(at)?.length ?? 0;
return `<button type="button" class="box${ui.selected === at ? ' active' : ''}" data-box="${at}">${natChip(state, side)} ${label} <b>${n}</b></button>`;
};
return `<div class="boxes"><span class="hint">Off-board</span>
${box('pool-a', 'a', 'ready')}${box('pool-b', 'b', 'ready')}${box('elim-a', 'a', 'lost')}${box('elim-b', 'b', 'lost')}
</div>`;
}
el.stackPanel.addEventListener('click', (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>('button');
const s = ui.session;
if (!target || !s) return;
const d = target.dataset;
if (d.pick) {
if (ui.picked.has(d.pick)) ui.picked.delete(d.pick);
else ui.picked.add(d.pick);
renderStack();
renderStrip();
renderBoard();
} else if (d.flip && canAct()) submit([{ t: 'flip', id: d.flip, ...overrideTag() }]);
else if (d.rot && canAct()) submit([{ t: 'rot', id: d.rot, r: ((s.state.counters.get(d.rot)?.rot ?? 0) + 1) % 6 }]);
else if (d.top && canAct()) submit([{ t: 'top', id: d.top }]);
else if (d.del && canAct()) {
const c = s.state.counters.get(d.del);
if (c) {
submit([{ t: 'del', id: d.del, ...overrideTag() }]);
toast(`Removed ${counterName(c)}. Undo brings it back.`);
}
} else if (d.label && canAct()) {
editLabel(d.label);
} else if (d.box) {
selectLocation(ui.selected === d.box ? null : d.box);
} else if (d.act === 'move') startMove();
else if (d.act === 'pick-all') {
const ids = s.state.stacks.get(ui.selected ?? '') ?? [];
ui.picked = ui.picked.size === ids.length ? new Set() : new Set(ids);
renderAll();
} else if ((d.act === 'to-pool' || d.act === 'to-elim') && ui.selected && canAct()) {
const ids = orderedPicked(ui.selected);
const bySide = { a: [] as string[], b: [] as string[] };
for (const id of ids) {
const c = s.state.counters.get(id);
if (c) bySide[c.side === 'b' ? 'b' : 'a'].push(id);
}
const kind = d.act === 'to-pool' ? 'pool' : 'elim';
const actions: Action[] = [];
if (bySide.a.length) actions.push({ t: 'move', ids: bySide.a, to: `${kind}-a`, ...overrideTag() });
if (bySide.b.length) actions.push({ t: 'move', ids: bySide.b, to: `${kind}-b`, ...overrideTag() });
submit(actions);
ui.picked.clear();
} else if (d.act === 'override-tools') {
ui.overrideTools = !ui.overrideTools;
renderStack();
} else if (d.act === 'los-share' && canAct()) {
const los = ui.mode.kind === 'los' && ui.mode.from && ui.mode.to ? { from: ui.mode.from, to: ui.mode.to } : ui.shownLos;
if (los) submit([{ t: 'los', from: los.from, to: los.to }]);
toast('LOS posted to the log');
} else if (d.act === 'los-clear') {
ui.mode = { kind: 'none' };
ui.shownLos = null;
renderAll();
}
});
/** Inline label editor in the stack list. */
function editLabel(id: string): void {
const c = stateOrNull()?.counters.get(id);
const row = el.stackPanel.querySelector<HTMLElement>(`[data-pick="${CSS.escape(id)}"] .pick-name`);
if (!c || !row) return;
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 24;
input.className = 'label-input';
input.value = c.label ?? templateOf(c).label;
input.setAttribute('aria-label', 'Counter label. Leave empty to restore the printed one.');
row.replaceWith(input);
input.focus();
input.select();
let done = false;
const commit = (save: boolean) => {
if (done) return;
done = true;
const text = input.value.trim().slice(0, 24);
const current = c.label ?? templateOf(c).label;
if (save && text !== current) submit([{ t: 'label', id, text }]);
else renderStack();
};
input.addEventListener('click', (e) => e.stopPropagation());
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') commit(true);
else if (e.key === 'Escape') commit(false);
e.stopPropagation();
});
input.addEventListener('blur', () => commit(true));
}
function renderTray(): void {
const s = ui.session;
if (!s) return;
const state = s.state;
const sides: (SideId | 'n')[] = ['a', 'b', 'n'];
const tabs = sides
.map((side) => {
const nat: NationId = side === 'n' ? 'mk' : state.scenario.sides[side].nat;
return `<button type="button" role="radio" class="seg-btn" data-tray="${side}" aria-checked="${ui.traySide === side}">
<span class="swatch" style="background:${NATION_STYLE[nat].face};border-color:${NATION_STYLE[nat].edge}"></span>${esc(NATIONS[nat].name)}</button>`;
})
.join('');
const nat: NationId = ui.traySide === 'n' ? 'mk' : state.scenario.sides[ui.traySide].nat;
const where = ui.selected ? (ui.selected.includes('-') ? locationName(ui.selected) : `hex ${ui.selected}`) : ui.traySide === 'n' ? 'a selected hex' : 'the off-board box';
const items = templatesFor(nat)
.map((t) => {
const sample = { id: 'tray', k: t.k, side: ui.traySide, face: 0 as const, rot: 0, at: '' };
return `<button type="button" class="tray-item" data-add="${t.k}" title="Add ${esc(t.label)}">${counterIcon(sample, 46)}<span>${esc(t.tag ? `${t.label} ${t.tag}` : t.label)}</span></button>`;
})
.join('');
el.trayPanel.innerHTML = `<div class="seg" role="radiogroup" aria-label="Counter set">${tabs}</div>
<p class="hint">Tap a counter to add it to ${esc(where)}. Edit its label afterwards to match your unit tables.</p>
<div class="tray">${items}</div>`;
}
el.trayPanel.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
if (!b) return;
if (b.dataset.tray) {
ui.traySide = b.dataset.tray as SideId | 'n';
renderTray();
} else if (b.dataset.add) {
addCounter(b.dataset.add, ui.traySide);
}
});
const DIE_PIPS: Record<number, [number, number][]> = {
1: [[0, 0]],
2: [[-1, -1], [1, 1]],
3: [[-1, -1], [0, 0], [1, 1]],
4: [[-1, -1], [1, -1], [-1, 1], [1, 1]],
5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]],
6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]],
};
function dieSvg(value: number, colored: boolean, size = 34): string {
const pips = (DIE_PIPS[value] ?? [])
.map(([x, y]) => `<circle cx="${12 + x * 6}" cy="${12 + y * 6}" r="2.3" fill="${colored ? '#fff4dc' : '#1b1a14'}"/>`)
.join('');
return `<svg class="die" viewBox="0 0 24 24" width="${size}" height="${size}" role="img" aria-label="${value}"><rect x="1" y="1" width="22" height="22" rx="4.5" fill="${
colored ? '#a3321f' : '#f3ecd8'
}" stroke="#1b1a14" stroke-width="1.2"/>${pips}</svg>`;
}
function renderLog(): void {
const s = ui.session;
if (!s) return;
const state = s.state;
ui.logSeen = state.lines.length;
el.logBadge.hidden = true;
const pendingFrom = s.confirmed.length;
el.logList.innerHTML = state.lines
.slice(-300)
.map((line) => {
const nat = sideNation(state.scenario, line.side);
const st = NATION_STYLE[nat];
const time = new Date(line.at * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const dice = line.dice ? `<span class="log-dice">${line.dice.map((d, i) => dieSvg(d, i === 0, 18)).join('')}</span>` : '';
const pending = line.i >= pendingFrom ? ' pending' : '';
const text =
line.kind === 'chat'
? `<span class="chat-text">${esc(line.text)}</span>`
: line.kind === 'los'
? `<button type="button" class="link" data-los="${line.los!.from}:${line.los!.to}">${esc(line.text)}</button>`
: esc(line.text);
return `<li class="log-line kind-${line.kind}${line.undone ? ' undone' : ''}${pending}">
<span class="log-side" style="background:${st.face};border-color:${st.edge}" title="${esc(sideName(state.scenario, line.side))}"></span>
<span class="log-body">${dice}${text}${
line.detail?.length ? `<ul class="log-detail">${line.detail.map((d) => `<li>${esc(d)}</li>`).join('')}</ul>` : ''
}</span><time class="log-time">${time}</time></li>`;
})
.join('');
el.logList.scrollTop = el.logList.scrollHeight;
}
el.logList.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-los]');
if (!b) return;
const [from, to] = b.dataset.los!.split(':');
ui.shownLos = { from, to };
ui.mode = { kind: 'none' };
board.reveal(to);
ui.tab = 'stack';
renderAll();
});
function sendChat(): void {
const text = el.chatInput.value.trim();
if (!text || !ui.session) return;
if (ui.session.online && !ui.pubkey) {
toast('Sign in to chat at an online table.');
return;
}
submit([{ t: 'chat', text: text.slice(0, 280) }]);
el.chatInput.value = '';
}
el.chatSend.addEventListener('click', sendChat);
onEnter(el.chatInput, sendChat);
onEnter(el.rollWhy, () => roll(2));
function renderInfo(): void {
const s = ui.session;
if (!s) return;
const state = s.state;
const sc = state.scenario;
const meta = s.meta;
const mine = s.mySide();
const seat = (side: SideId) => {
const holder = side === 'a' ? meta?.seatA : meta?.seatB;
const nat = sc.sides[side].nat;
const who = !s.online ? 'This device' : holder ? (holder === s.me ? 'You' : shortKey(holder)) : 'Open seat';
const join =
s.online && !holder && mine === 'n'
? `<button type="button" class="btn small primary" data-join="${side}" ${ui.pubkey ? '' : 'disabled'}>Sit here</button>`
: '';
return `<li class="seat-row"><span class="swatch" style="background:${NATION_STYLE[nat].face};border-color:${NATION_STYLE[nat].edge}"></span>
<span><b>${esc(sc.sides[side].name)}</b>${sc.first === side ? ' · moves first' : ''}<br><span class="hint">${esc(who)}</span></span>${join}</li>`;
};
const code = s.online ? shareCode(s.saved.release!, s.saved.id) : '';
const live = s.status.live;
const liveText =
live === 'connected'
? `Live: connected to ${s.status.peers} other player${s.status.peers === 1 ? '' : 's'}. Moves appear instantly.`
: live === 'waiting'
? 'Live link open. Waiting for your opponent to open this table.'
: live === 'connecting'
? 'Opening live link…'
: 'Live link off. Moves still sync every few seconds.';
el.infoPanel.innerHTML = `
<section class="info-block">
<h3>${esc(sc.name)}</h3>
<p>${esc(sc.brief)}</p>
<p><b>Length:</b> ${sc.turns} game turns. <b>Victory:</b> ${esc(sc.victory)}</p>
<ul class="setup">
<li><b>${esc(sc.sides.a.name)}:</b> ${esc(sc.sides.a.setup)}</li>
<li><b>${esc(sc.sides.b.name)}:</b> ${esc(sc.sides.b.setup)}</li>
</ul>
</section>
<section class="info-block">
<h3>Seats</h3>
<ul class="seats">${seat('a')}${seat('b')}</ul>
${s.online && mine === 'n' && !ui.pubkey ? '<p class="hint">Sign in with your Nostr account to take a seat. You can watch without one.</p>' : ''}
${!s.online ? '<p class="hint">Hotseat: use the side chip in the top bar to switch who is acting. Dice are rolled on this device.</p>' : ''}
</section>
${
s.online
? `<section class="info-block">
<h3>Invite</h3>
<p class="hint">Send this code to your opponent. They paste it under “Join with a code”. Anyone with it can watch.</p>
<div class="code-row"><input id="shareCode" type="text" readonly value="${esc(code)}" aria-label="Table code" data-napplet-select="text" /><button type="button" class="btn small" data-act="copy">Copy</button></div>
</section>
<section class="info-block">
<h3>Live play</h3>
<p class="hint">${esc(liveText)}</p>
${
s.liveAvailable()
? `<button type="button" class="btn small" data-act="live">${live === 'off' ? 'Go live' : 'Stop live link'}</button>`
: '<p class="hint">This host has no peer connections; the table still syncs through the server.</p>'
}
</section>`
: ''
}
<section class="info-block">
<h3>Rules</h3>
<ul class="setup">${RULE_NOTES.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
<p class="hint">Blocked moves show the reason. Override moves anyway; overrides are flagged in red in the log.</p>
</section>
<section class="info-block">
<h3>Map key</h3>
<ul class="key">
<li><span class="key-sw" style="background:#46612f"></span>Woods</li>
<li><span class="key-sw" style="background:#7f9152"></span>Brush</li>
<li><span class="key-sw" style="background:#dcc376"></span>Grain</li>
<li><span class="key-sw" style="background:#6f8c45;border-radius:50%"></span>Orchard</li>
<li><span class="key-sw" style="background:#a5724a"></span>Wooden building</li>
<li><span class="key-sw" style="background:#8f8b83"></span>Stone building</li>
<li><span class="key-sw" style="background:#86a9b6"></span>Water</li>
<li><span class="key-sw" style="background:#4f7a72"></span>Marsh</li>
<li><span class="key-sw" style="background:#d5c296"></span>Hill level 1</li>
<li><span class="key-sw" style="background:#c3a874"></span>Hill level 2</li>
<li><span class="key-line" style="background:#3a5527"></span>Hedge</li>
<li><span class="key-line" style="background:#5e5a54"></span>Wall</li>
<li><span class="key-line" style="background:#ddd3b4;outline:1px solid #8f8466"></span>Road</li>
</ul>
</section>
<div class="row"><button type="button" class="btn small" data-act="leave">Back to your tables</button></div>
<p class="hint">${s.online ? `Table ${esc(shortKey(s.saved.id))} · dice are rolled by the table server and logged for both players.` : ''}</p>`;
}
el.infoPanel.addEventListener('click', async (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('button');
const s = ui.session;
if (!b || !s) return;
if (b.dataset.join) {
b.disabled = true;
try {
await s.join(b.dataset.join as SideId);
ui.traySide = s.mySide() === 'b' ? 'b' : 'a';
toast(`You are playing ${sideName(s.state.scenario, s.mySide())}`);
renderAll();
} catch (error) {
toast(error instanceof Error ? error.message : 'Could not take that seat.');
b.disabled = false;
}
} else if (b.dataset.act === 'copy') {
const input = el.infoPanel.querySelector<HTMLInputElement>('#shareCode');
if (!input) return;
input.select();
try {
await navigator.clipboard.writeText(input.value);
toast('Code copied');
} catch {
toast('Copy the selected code with your keyboard or long-press.');
}
} else if (b.dataset.act === 'leave') {
el.back.click();
} else if (b.dataset.act === 'live') {
if (s.status.live === 'off') await s.startLive();
else await s.stopLive();
renderInfo();
}
});
function renderStrip(): void {
const s = ui.session;
const show = Boolean(s && ui.selected && !ui.panelOpen && ui.mode.kind === 'none');
el.strip.hidden = !show;
if (!show || !s) return;
const at = ui.selected!;
const ids = [...(s.state.stacks.get(at) ?? [])].reverse();
el.stripHex.innerHTML = `<b>${esc(at.includes('-') ? locationName(at) : at)}</b><span>${esc(at.includes('-') ? '' : terrainSummary(s.state, at))}</span>`;
el.stripCounters.innerHTML = ids.length
? ids
.map((id) => {
const c = s.state.counters.get(id)!;
return `<button type="button" class="strip-ctr" data-pick="${esc(id)}" aria-pressed="${ui.picked.has(id)}" title="${esc(counterName(c))}">${counterIcon(c, 38)}</button>`;
})
.join('')
: '<span class="hint">Empty hex</span>';
el.stripMove.disabled = !ui.picked.size;
}
el.stripCounters.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-pick]');
if (!b) return;
const id = b.dataset.pick!;
if (ui.picked.has(id)) ui.picked.delete(id);
else ui.picked.add(id);
renderStrip();
renderBoard();
});
el.stripMove.addEventListener('click', startMove);
el.stripMore.addEventListener('click', () => {
ui.tab = 'stack';
setPanel(true);
renderPanel();
});
// ---------- toasts and dice ----------
let toastTimer = 0;
let lastToast = '';
function toast(text: string, ms = 2600): void {
if (text === lastToast && !el.toast.hidden) return;
lastToast = text;
el.toast.textContent = text;
el.toast.hidden = false;
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => {
el.toast.hidden = true;
lastToast = '';
}, ms);
}
let diceTimer = 0;
function showDice(entry: Entry): void {
const state = stateOrNull();
if (!state || !entry.d) return;
// Rules rolls carry a batch of dice; the first one or two are the headline roll.
const shown = entry.a.t === 'roll' ? entry.d : entry.d.slice(0, entry.a.t === 'repair' ? 1 : 2);
const total = shown.reduce((a, b) => a + b, 0);
const nat = sideNation(state.scenario, entry.side);
const line = state.lines.find((l) => l.i === entry.i);
const why =
entry.a.t === 'roll'
? entry.a.why
: line?.text.replace(/^[A-Z]{2} /, '').replace(/^.*?→ /, '') ?? entry.a.t;
el.diceTray.innerHTML = `<div class="dice-faces">${shown.map((d, i) => dieSvg(d, i === 0 && shown.length === 2)).join('')}</div>
<div class="dice-text"><b>${shown.length === 2 ? 'DR' : 'dr'} ${total}</b><span>${esc(NATIONS[nat].short)}${why ? ` · ${esc(why)}` : ''}</span></div>`;
el.diceTray.hidden = false;
el.diceTray.classList.remove('pop');
void el.diceTray.offsetWidth;
el.diceTray.classList.add('pop');
window.clearTimeout(diceTimer);
diceTimer = window.setTimeout(() => (el.diceTray.hidden = true), 8000);
}
el.diceTray.addEventListener('click', () => (el.diceTray.hidden = true));
// ---------- controls ----------
el.rollDR.addEventListener('click', () => roll(2));
el.rolldr.addEventListener('click', () => roll(1));
el.los.addEventListener('click', () => {
ui.mode = ui.mode.kind === 'los' ? { kind: 'none' } : { kind: 'los', from: ui.selected && !ui.selected.includes('-') ? ui.selected : null, to: null };
ui.shownLos = null;
if (ui.mode.kind === 'los' && narrow()) setPanel(false);
renderAll();
});
el.undo.addEventListener('click', () => {
const s = ui.session;
if (!s || !canAct()) return;
const line = undoCandidate(s.state, s.me);
if (line) submit([{ t: 'undo', ref: line.i }]);
});
el.modeActions.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-mode]');
const m = ui.mode;
if (!b) return;
if (b.dataset.mode === 'cancel') ui.mode = { kind: 'none' };
else if (b.dataset.mode === 'dt' && m.kind === 'move') ui.mode = { ...m, dt: !m.dt };
else if (b.dataset.mode === 'override' && m.kind === 'move') {
const reach = currentReach();
const reason = reach && !reach.ok ? reach.reasons[0] : 'moved outside the movement rules';
ui.mode = { ...m, override: reason.slice(0, 120) };
toast('Override: this move skips the rules and is flagged in the log.', 4000);
}
renderAll();
});
el.panelButton.addEventListener('click', () => {
setPanel(!ui.panelOpen);
if (ui.panelOpen) renderPanel();
});
el.panelClose.addEventListener('click', () => setPanel(false));
el.panel.querySelectorAll<HTMLButtonElement>('.tab').forEach((tab) =>
tab.addEventListener('click', () => {
ui.tab = tab.dataset.tab as Tab;
renderPanel();
}),
);
const soundButton = $<HTMLButtonElement>('#soundButton');
function renderSound(): void {
soundButton.setAttribute('aria-pressed', String(!soundEnabled()));
soundButton.title = soundEnabled() ? 'Mute battle sounds' : 'Turn battle sounds on';
soundButton.querySelector('.tool-label')!.textContent = soundEnabled() ? 'Sound' : 'Muted';
}
soundButton.addEventListener('click', () => {
setSoundEnabled(!soundEnabled());
renderSound();
void savePrefs();
});
async function savePrefs(): Promise<void> {
if (!runtimeHasDomain('storage')) return;
try {
await storage.setItem('prefs', JSON.stringify({ sound: soundEnabled() }));
} catch {
// Preference is a nicety; keep the in-memory value.
}
}
async function loadPrefs(): Promise<void> {
if (!runtimeHasDomain('storage')) return;
try {
const prefs = JSON.parse((await storage.getItem('prefs')) ?? '{}') as { sound?: boolean };
if (typeof prefs.sound === 'boolean') setSoundEnabled(prefs.sound);
} catch {
// Defaults apply.
}
renderSound();
}
void loadPrefs();
renderSound();
el.zoomIn.addEventListener('click', () => board.zoomBy(1.25));
el.zoomOut.addEventListener('click', () => board.zoomBy(0.8));
el.zoomFit.addEventListener('click', () => board.fit());
document.addEventListener('keydown', (e) => {
if (!ui.session || el.table.hidden) return;
const typing = (e.target as HTMLElement).closest('input, textarea, select');
if (typing) return;
if (e.key === 'Escape') {
if (ui.mode.kind !== 'none') ui.mode = { kind: 'none' };
else if (ui.shownLos) ui.shownLos = null;
else selectLocation(null);
renderAll();
} else if (e.key === 'm' && ui.selected) startMove();
else if (e.key === 'l') el.los.click();
else if (e.key === 'r') roll(2);
else if (e.key === 'd') roll(1);
else if ((e.key === 'z' && (e.ctrlKey || e.metaKey)) || e.key === 'u') el.undo.click();
else if (e.key === 'f' && ui.selected && canAct()) {
const ids = orderedPicked(ui.selected);
if (ids.length) submit(ids.map((id) => ({ t: 'flip', id }) as Action));
} else if (e.key === '+' || e.key === '=') board.zoomBy(1.25);
else if (e.key === '-') board.zoomBy(0.8);
else return;
e.preventDefault();
});
let wasNarrow = narrow();
window.addEventListener('resize', () => {
const now = narrow();
if (now !== wasNarrow && ui.session) setPanel(!now);
wasNarrow = now;
});
onSettings(() => {
if (ui.session) renderBoard();
});
window.addEventListener('pagehide', () => {
closeTable();
identitySub?.close();
});
renderScenarioPicker();
renderLobbyNotes();
void loadIdentity().then(renderMyTables);
void renderMyTables();
