Files
basegame-vcko/static/game/game.js
2026-05-02 23:25:02 -07:00

3096 lines
104 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ── URL params ────────────────────────────────────────────────────────────
const params = new URLSearchParams(location.search);
const GAME_ID = params.get('game_id') || '';
const PLAYER_ID = params.get('player_id') || '';
// ── WebSocket ─────────────────────────────────────────────────────────────
let ws = null;
let reconnectTimer = null;
let concurrentPollTimer = null;
let finalizeRollInFlight = false;
/** Set each render — board card modal reads latest grids / phase */
let latestGameState = null;
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/game/${GAME_ID}?player_id=${PLAYER_ID}`);
ws.onopen = () => {
setConnStatus('ok');
clearTimeout(reconnectTimer);
};
ws.onmessage = evt => {
const msg = JSON.parse(evt.data);
if (msg.type === 'state') render(msg.state);
if (msg.type === 'error') {
setConnStatus('error', msg.message);
ws.close();
}
};
ws.onclose = evt => {
// Code 4004 = game not found; don't retry
if (evt.code === 4004) {
setConnStatus('error', 'Game not found');
return;
}
setConnStatus('off');
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => ws.close();
}
function setConnStatus(s, detail) {
const el = document.getElementById('conn-status');
if (s === 'ok') {
el.textContent = '● connected';
el.className = 'conn-status';
} else if (s === 'error') {
el.textContent = `${detail || 'error'}`;
el.className = 'conn-status disconnected';
} else {
el.textContent = '● disconnected';
el.className = 'conn-status disconnected';
}
}
// ── Seat assignment ───────────────────────────────────────────────────────
// Returns array of 5 player-or-null entries for seats 04.
// Seat 0 = me (bottom), seats 14 = opponents clockwise.
function idsMatch(a, b) {
return String(a ?? '').trim() === String(b ?? '').trim();
}
function seatAssignment(state) {
const all = state.player_list || [];
const myIdx = all.findIndex(p => idsMatch(p.player_id, PLAYER_ID));
const base = myIdx === -1 ? 0 : myIdx;
const n = all.length;
// Spread players evenly around the pentagon.
// Seat 0 = me (bottom flat edge); seats 14 go clockwise.
// Fill opponents symmetrically so they appear "across" the table.
const fillOrders = {
1: [0],
2: [0, 3], // opponent at upper-left (roughly opposite)
3: [0, 2, 4], // spread at 144° — upper-right and lower-left
4: [0, 1, 3, 4], // skip the apex seats, fill flanks first
5: [0, 1, 2, 3, 4],
};
const order = (fillOrders[n] || [0, 1, 2, 3, 4]).slice(0, n);
const seats = [null, null, null, null, null];
order.forEach((seatIdx, i) => {
seats[seatIdx] = all[(base + i) % n];
});
return seats;
}
// ── Tableau sections (domains first … dukes last) ─────────────────────────
function tableauGroupsForPlayer(player) {
const defs = [
['Domains', 'owned_domains'],
['Citizens', 'owned_citizens'],
['Monsters', 'owned_monsters'],
['Starters', 'owned_starters'],
['Dukes', 'owned_dukes'],
];
return defs
.map(([label, key]) => ({ label, cards: player[key] || [] }))
.filter(g => g.cards.length > 0);
}
// ── Main render ───────────────────────────────────────────────────────────
function render(state) {
latestGameState = state;
const seats = seatAssignment(state);
seats.forEach((player, i) => renderSeat(i, player, state));
renderCenter(state);
renderGameOver(state);
scheduleBoardLayout();
syncConcurrentPolling(state);
maybeAutoFinalizeRoll(state);
renderPromptModal(state);
}
function isActiveTurnForPlayer(player, state) {
const ap = state.active_player_id;
if (ap == null || player == null) return false;
return idsMatch(ap, player.player_id);
}
// ── Active-turn tableau animation (rotating highlight around card row) ──
function wrapTableauWithTurnRing(tableauEl, player, state) {
const host = mk('tableau-ring-host');
host.appendChild(mk('tableau-ring-sweep'));
host.appendChild(tableauEl);
if (isActiveTurnForPlayer(player, state)) host.classList.add('is-active-turn');
return host;
}
// ── Seat renderer ─────────────────────────────────────────────────────────
function renderSeat(idx, player, state) {
const el = document.getElementById(`seat-${idx}`);
el.innerHTML = '';
el.style.display = 'block'; // always visible — empty seats show as ghost
if (!player) {
el.classList.add('seat-empty');
const ghost = mk('seat-ghost-label');
ghost.textContent = `Seat ${idx}`;
el.appendChild(ghost);
return;
}
el.classList.remove('seat-empty');
const inner = mk('seat-inner');
inner.appendChild(makeHeader(player, state));
if (idx === 0) {
const tableau = mk('tableau-cards');
const groups = tableauGroupsForPlayer(player);
groups.forEach(g => {
const grp = mk('card-group');
const lbl = mk('card-group-label');
lbl.textContent = g.label;
grp.appendChild(lbl);
const grouped = groupCardsForTableau(g.cards);
if (grouped) {
grouped.forEach(({ card, count }) => grp.appendChild(makeTableauStack(card, count, 'full')));
} else {
g.cards.forEach(c => grp.appendChild(makeTableauStack(c, 1, 'full')));
}
tableau.appendChild(grp);
});
inner.appendChild(wrapTableauWithTurnRing(tableau, player, state));
} else {
const tableau = mk('tableau-cards');
const groups = tableauGroupsForPlayer(player);
groups.forEach(g => {
const grp = mk('card-group');
const lbl = mk('card-group-label');
lbl.textContent = g.label;
grp.appendChild(lbl);
const grouped = groupCardsForTableau(g.cards);
if (grouped) {
grouped.forEach(({ card, count }) => grp.appendChild(makeTableauStack(card, count, 'mini')));
} else {
g.cards.forEach(c => grp.appendChild(makeTableauStack(c, 1, 'mini')));
}
tableau.appendChild(grp);
});
inner.appendChild(wrapTableauWithTurnRing(tableau, player, state));
}
el.appendChild(inner);
}
// ── Center board ──────────────────────────────────────────────────────────
function renderCenter(state) {
const el = document.getElementById('zone-center');
el.innerHTML = '';
const body = mk('center-board-body');
body.appendChild(makeInfoBar(state));
const scrollArea = mk('center-board-scroll');
scrollArea.appendChild(makeGridSection('Monsters', state.monster_grid || [], 'monster', 5, 'board-monsters'));
scrollArea.appendChild(makeCitizenSection(state.citizen_grid || []));
scrollArea.appendChild(makeGridSection('Domains', state.domain_grid || [], 'domain', 5, 'board-domains'));
body.appendChild(scrollArea);
el.appendChild(body);
el.appendChild(makeGameLog(state));
}
const BOARD_TOP_MARGIN_PX = 8;
const BOARD_GAP_ABOVE_TABLEAU_PX = 10;
/** Vertical wheel scrolls opponent tableau rows horizontally (they overflow-x). */
function initOpponentTableauWheelScroll() {
const board = document.getElementById('board');
if (!board || board.dataset.tableauWheelBound) return;
board.dataset.tableauWheelBound = '1';
board.addEventListener(
'wheel',
e => {
const row = e.target.closest('.tableau-cards');
if (!row) return;
const seat = row.closest('.seat');
if (!seat || seat.classList.contains('seat-0') || seat.classList.contains('seat-empty')) return;
if (row.scrollWidth <= row.clientWidth + 1) return;
e.preventDefault();
row.scrollLeft += e.deltaY;
},
{ passive: false },
);
}
const REPULSE_GAP_PX = 12;
const REPULSE_MAX_ITER = 16;
function rectsOverlap(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function inflateRect(r, pad) {
return {
left: r.left - pad,
top: r.top - pad,
right: r.right + pad,
bottom: r.bottom + pad,
};
}
/** Shift opponent seats toward screen edges from pentagon anchors (scales with viewport width). */
function layoutSeatEdgeSpread() {
const boardEl = document.getElementById('board');
if (!boardEl) return;
const vw = window.innerWidth;
const spread = Math.min(220, Math.max(0, (vw - 520) * 0.078));
boardEl.style.setProperty('--seat-edge-spread', `${spread}px`);
}
function layoutCenterBoard() {
const zone = document.getElementById('zone-center');
const seat0 = document.getElementById('seat-0');
if (!zone || !seat0) return;
const seatRect = seat0.getBoundingClientRect();
const vh = window.innerHeight;
const bottomPx = vh - seatRect.top + BOARD_GAP_ABOVE_TABLEAU_PX;
zone.style.bottom = `${Math.max(0, bottomPx)}px`;
zone.style.top = 'auto';
zone.style.left = '50%';
zone.style.right = 'auto';
zone.style.transform = 'translateX(-50%)';
const spaceAbove = seatRect.top - BOARD_GAP_ABOVE_TABLEAU_PX - BOARD_TOP_MARGIN_PX;
const boardViewportCap = vh * 0.75;
const maxH = Math.min(boardViewportCap, Math.max(spaceAbove, 80));
zone.style.maxHeight = `${Math.floor(maxH)}px`;
}
/** Push opponent seats horizontally away from the centered board (priority) and apart from each other. */
function layoutSeatRepulsion() {
const board = document.getElementById('zone-center');
if (!board) return;
const vw = window.innerWidth;
const clamp = x => Math.max(-vw * 0.38, Math.min(vw * 0.38, x));
function occupiedSeat(idx) {
const el = document.getElementById(`seat-${idx}`);
return el && !el.classList.contains('seat-empty') ? el : null;
}
for (let i = 1; i <= 4; i++) {
const el = document.getElementById(`seat-${i}`);
if (!el) continue;
if (el.classList.contains('seat-empty')) {
el.style.setProperty('--seat-nudge-x', '0px');
el.style.setProperty('--seat-nudge-y', '0px');
}
}
const nudge = { 1: { x: 0, y: 0 }, 2: { x: 0, y: 0 }, 3: { x: 0, y: 0 }, 4: { x: 0, y: 0 } };
function applyNudges() {
for (let idx = 1; idx <= 4; idx++) {
const el = document.getElementById(`seat-${idx}`);
if (!el || el.classList.contains('seat-empty')) continue;
nudge[idx].x = clamp(nudge[idx].x);
nudge[idx].y = Math.max(-100, Math.min(100, nudge[idx].y));
el.style.setProperty('--seat-nudge-x', `${nudge[idx].x}px`);
el.style.setProperty('--seat-nudge-y', `${nudge[idx].y}px`);
}
}
function separatePair(idxA, idxB) {
const elA = occupiedSeat(idxA);
const elB = occupiedSeat(idxB);
if (!elA || !elB) return false;
const ra = elA.getBoundingClientRect();
const rb = elB.getBoundingClientRect();
if (!rectsOverlap(ra, rb)) return false;
const ovx = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
const ovy = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
if (ovx <= 0 || ovy <= 0) return false;
const acx = (ra.left + ra.right) / 2;
const bcx = (rb.left + rb.right) / 2;
const push = (ovx + REPULSE_GAP_PX) * 0.48;
if (acx < bcx) {
nudge[idxA].x -= push;
nudge[idxB].x += push;
} else {
nudge[idxA].x += push;
nudge[idxB].x -= push;
}
const acy = (ra.top + ra.bottom) / 2;
const bcy = (rb.top + rb.bottom) / 2;
const ovyPush = (ovy + REPULSE_GAP_PX) * 0.22;
if (Math.abs(ovy) > 8 && ovx < ovy * 1.4) {
if (acy < bcy) {
nudge[idxA].y -= ovyPush;
nudge[idxB].y += ovyPush;
} else {
nudge[idxA].y += ovyPush;
nudge[idxB].y -= ovyPush;
}
}
return true;
}
for (let iter = 0; iter < REPULSE_MAX_ITER; iter++) {
applyNudges();
const boardRect = board.getBoundingClientRect();
const paddedBoard = inflateRect(boardRect, REPULSE_GAP_PX);
let adjusted = false;
[1, 2, 3, 4].forEach(idx => {
const el = occupiedSeat(idx);
if (!el) return;
const r = el.getBoundingClientRect();
if (!rectsOverlap(r, paddedBoard)) return;
const bcx = boardRect.left + boardRect.width / 2;
const scx = r.left + r.width / 2;
const ovx = Math.min(r.right, paddedBoard.right) - Math.max(r.left, paddedBoard.left);
const ovy = Math.min(r.bottom, paddedBoard.bottom) - Math.max(r.top, paddedBoard.top);
if (ovx <= 0 || ovy <= 0) return;
const mag = Math.min(ovx + REPULSE_GAP_PX * 0.5, 140);
const dir = scx < bcx ? -1 : 1;
nudge[idx].x += dir * mag * 0.62;
const scy = r.top + r.height / 2;
const bcy = boardRect.top + boardRect.height / 2;
const vyMag = Math.min(ovy + REPULSE_GAP_PX * 0.35, 90);
const vyDir = scy < bcy ? -1 : 1;
nudge[idx].y += vyDir * vyMag * 0.28;
adjusted = true;
});
if (separatePair(2, 3)) adjusted = true;
if (separatePair(1, 2)) adjusted = true;
if (separatePair(3, 4)) adjusted = true;
if (separatePair(1, 4)) adjusted = true;
if (!adjusted) break;
}
applyNudges();
}
function scheduleBoardLayout() {
layoutSeatEdgeSpread();
layoutCenterBoard();
requestAnimationFrame(() => {
layoutSeatRepulsion();
});
}
function canOfferTakeResourceAction(state) {
if (!PLAYER_ID || !state) return false;
if ((state.phase || '').toString() !== 'action') return false;
const req = state.action_required || {};
if ((req.action || '').toString() !== 'standard_action') return false;
const reqId = req.id || '';
if (!reqId || idsMatch(reqId, state.game_id)) return false;
if (!idsMatch(reqId, PLAYER_ID)) return false;
return Number(state.actions_remaining || 0) > 0;
}
function makeInfoBar(state) {
const bar = mk('info-bar');
const phase = mk('phase-label');
phase.textContent = fmtPhase(state.phase);
bar.appendChild(phase);
const tn = mk('turn-label');
tn.textContent = `Turn ${state.turn_number || 1}`;
bar.appendChild(tn);
const active = (state.player_list || []).find(p => p.player_id === state.active_player_id);
if (active && active.player_id !== PLAYER_ID) {
const who = mk('turn-label');
who.textContent = `${active.name}'s turn`;
bar.appendChild(who);
}
if (state.end_game_triggered) {
const eg = mk('turn-label');
eg.textContent = '⚑ Final round';
eg.style.color = 'var(--gold)';
bar.appendChild(eg);
}
if (canOfferTakeResourceAction(state)) {
const takeWrap = mk('info-bar-take-resource');
const takeLbl = mk('info-bar-take-label');
const nAct = Number(state.actions_remaining || 0);
takeLbl.textContent = `Spend action (${nAct} left)`;
takeWrap.appendChild(takeLbl);
const takeBtns = mk('info-bar-take-buttons');
['gold', 'strength', 'magic'].forEach(r => {
const lab = r === 'gold' ? 'G' : r === 'strength' ? 'S' : 'M';
takeBtns.appendChild(promptButton(`+1 ${lab}`, () => postGameAction({
player_id: PLAYER_ID,
action_type: 'take_resource',
resource: r,
})));
});
takeWrap.appendChild(takeBtns);
bar.appendChild(takeWrap);
}
const dice = mk('dice-display');
if (state.die_one != null) {
dice.appendChild(makeDie(state.die_one));
dice.appendChild(makeDie(state.die_two));
const sum = mk('die-sum');
sum.textContent = `= ${state.die_sum}`;
dice.appendChild(sum);
}
bar.appendChild(dice);
return bar;
}
function makeGridSection(label, grid, _type, _cols, extraClass) {
const sec = mk('center-section' + (extraClass ? ` ${extraClass}` : ''));
const lbl = mk('section-label');
lbl.textContent = label;
sec.appendChild(lbl);
const row = mk('grid-row');
grid.forEach(stack => row.appendChild(makeStack(stack)));
sec.appendChild(row);
return sec;
}
function makeCitizenSection(grid) {
const sec = mk('center-section board-citizens');
const lbl = mk('section-label');
lbl.textContent = 'Citizens';
sec.appendChild(lbl);
const row1 = mk('grid-row citizen-row-first');
const row2 = mk('grid-row citizen-row-second');
grid.slice(0, 5).forEach(s => row1.appendChild(makeStack(s)));
grid.slice(5).forEach(s => row2.appendChild(makeStack(s)));
sec.appendChild(row1);
if (grid.length > 5) sec.appendChild(row2);
return sec;
}
function makeStack(stack) {
if (!stack || stack.length === 0) {
return mk('card-slot-empty');
}
const wrap = mk('grid-stack');
wrap.appendChild(makeCard(stack[stack.length - 1], 'grid'));
if (stack.length > 1) {
const badge = mk('stack-depth');
badge.textContent = `×${stack.length}`;
wrap.appendChild(badge);
}
return wrap;
}
// Group identical owned cards (same logic as dev-client tableau); citizens also key on is_flipped.
function groupCardsForTableau(cards) {
const arr = Array.isArray(cards) ? cards : [];
const map = new Map();
arr.forEach(c => {
if (!c || typeof c !== 'object') return;
const name = (c.name || c.title || '').toString().trim();
const id = c.starter_id || c.citizen_id || c.monster_id || c.domain_id || c.duke_id || c.id || '';
const isCitizenKey = c.citizen_id !== undefined && c.citizen_id !== null;
const flipSeg = isCitizenKey ? `||flip:${c.is_flipped ? 1 : 0}` : '';
const key = `${name}||${id}${flipSeg}`;
const cur = map.get(key);
if (cur) cur.count += 1;
else map.set(key, { card: c, count: 1, sortName: name.toLowerCase(), sortId: String(id) });
});
if (map.size === 0 && arr.length) return null;
return Array.from(map.values()).sort((a, b) => {
if (a.sortName < b.sortName) return -1;
if (a.sortName > b.sortName) return 1;
if (a.sortId < b.sortId) return -1;
if (a.sortId > b.sortId) return 1;
return 0;
});
}
// One card image with optional ×N badge (matches center-board stacks).
function makeTableauStack(card, count, mode) {
const wrap = mk('grid-stack');
wrap.appendChild(makeCard(card, mode));
if (count > 1) {
const badge = mk('stack-depth');
badge.textContent = `×${count}`;
wrap.appendChild(badge);
}
return wrap;
}
function makeGameLog(state) {
const log = mk('game-log');
(state.game_log || []).slice().reverse().forEach(entry => {
const line = mk('log-entry');
line.textContent = entry.msg ?? entry;
log.appendChild(line);
});
return log;
}
function makeDie(val) {
const d = mk('die');
d.textContent = val;
return d;
}
// ── Player header / tableau score strip ──────────────────────────────────
const TABLEAU_RESOURCE_ICONS = {
gold: '/images/gold_icon.jpg',
magic: '/images/magic_icon.png',
strength: '/images/strength_icon.png',
};
function makeResourceScorePill(cls, val, fullName, iconSrc) {
const pill = mk('score-pill ' + cls);
const n = Number(val ?? 0);
const tip = `${n} times ${fullName}`;
pill.title = tip;
pill.setAttribute('aria-label', tip);
pill.appendChild(document.createTextNode(String(n)));
pill.appendChild(document.createTextNode(' \u00D7 '));
const img = document.createElement('img');
img.className = 'score-pill-resource-icon';
img.alt = '';
img.src = iconSrc;
pill.appendChild(img);
return pill;
}
function makeVpScorePill(val) {
const pill = mk('score-pill victory');
const n = Number(val ?? 0);
const tip = `${n} times Victory Points`;
pill.textContent = `${n} VP`;
pill.title = tip;
pill.setAttribute('aria-label', tip);
return pill;
}
/** Inline resource display for card modals (matches tableau icons × coloring). */
function makeModalResourceInline(kind, num, cls, leadingPlus) {
const wrap = document.createElement('span');
wrap.className = cls ? `modal-stat-value ${cls} modal-resource-inline` : 'modal-stat-value modal-resource-inline';
if (leadingPlus) wrap.appendChild(document.createTextNode('+'));
wrap.appendChild(document.createTextNode(String(num)));
wrap.appendChild(document.createTextNode(' \u00D7 '));
const img = document.createElement('img');
img.className = 'modal-resource-icon';
img.alt = '';
img.src = TABLEAU_RESOURCE_ICONS[kind];
const names = { gold: 'Gold', strength: 'Strength', magic: 'Magic' };
const n = Number(num);
const tip = `${n} times ${names[kind]}`;
wrap.title = tip;
wrap.setAttribute('aria-label', tip);
wrap.appendChild(img);
return wrap;
}
function makeModalVpValue(num, cls, leadingPlus) {
const span = document.createElement('span');
span.className = cls ? `modal-stat-value ${cls}` : 'modal-stat-value';
const n = Number(num);
span.textContent = `${leadingPlus ? '+' : ''}${n} VP`;
span.title = `${n} times Victory Points`;
return span;
}
function createModalStatValueEl(row) {
if (row.resource === 'gold' || row.resource === 'strength' || row.resource === 'magic') {
return makeModalResourceInline(row.resource, row.value, row.cls, row.leadingPlus);
}
if (row.resource === 'vp') {
return makeModalVpValue(row.value, row.cls, row.leadingPlus);
}
const v = document.createElement('span');
v.className = row.cls ? `modal-stat-value ${row.cls}` : 'modal-stat-value';
v.textContent = row.value;
return v;
}
function appendCardModalStatRows(infoEl, card) {
buildCardStats(card).forEach(row => {
const r = mk('modal-stat-row');
const l = document.createElement('span');
l.className = 'modal-stat-label';
l.textContent = row.label;
r.appendChild(l);
r.appendChild(createModalStatValueEl(row));
infoEl.appendChild(r);
});
}
function makeHeader(player, state) {
const h = mk('player-header');
const name = mk('player-name');
if (isActiveTurnForPlayer(player, state)) name.classList.add('is-active');
if (player.is_first) {
name.classList.add('is-first');
const star = mk('player-first-star');
star.textContent = '★';
star.title = 'This player went first';
star.setAttribute('aria-label', 'This player went first');
name.appendChild(star);
}
name.appendChild(document.createTextNode(player.name));
h.appendChild(name);
h.appendChild(makeResourceScorePill('gold', player.gold_score, 'Gold', TABLEAU_RESOURCE_ICONS.gold));
h.appendChild(makeResourceScorePill('strength', player.strength_score, 'Strength', TABLEAU_RESOURCE_ICONS.strength));
h.appendChild(makeResourceScorePill('magic', player.magic_score, 'Magic', TABLEAU_RESOURCE_ICONS.magic));
h.appendChild(makeVpScorePill(player.victory_score));
return h;
}
// ── Card factory ──────────────────────────────────────────────────────────
function cardImageUrl(card) {
if (card.monster_id !== undefined) return `/card-image/monster/${card.monster_id}`;
if (card.citizen_id !== undefined) return `/card-image/citizen/${card.citizen_id}`;
if (card.domain_id !== undefined) return `/card-image/domain/${card.domain_id}`;
if (card.duke_id !== undefined) return `/card-image/duke/${card.duke_id}`;
if (card.starter_id !== undefined) return `/card-image/starter/${card.starter_id}`;
return null;
}
function _appendCardText(el, card, mode) {
const name = mk('card-name');
name.textContent = card.name || '?';
el.appendChild(name);
if (mode !== 'compact') {
const sub = cardSub(card);
if (sub) { const s = mk('card-sub'); s.textContent = sub; el.appendChild(s); }
const extra = cardExtra(card);
if (extra) { const e = mk('card-extra'); e.textContent = extra; el.appendChild(e); }
}
}
function makeCard(card, mode) {
const el = mk('card ' + cardClass(card));
el.dataset.card = JSON.stringify(card);
if (card.is_flipped) el.classList.add('flipped');
const imgUrl = mode !== 'compact' ? cardImageUrl(card) : null;
if (imgUrl) {
el.classList.add('card-has-image');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', card.name || 'Card');
const img = document.createElement('img');
img.className = 'card-img';
img.alt = '';
img.onerror = () => {
el.classList.remove('card-has-image');
el.removeAttribute('role');
el.removeAttribute('aria-label');
el.innerHTML = '';
_appendCardText(el, card, mode);
};
img.src = imgUrl; // set src after onerror so handler is registered first
el.appendChild(img);
} else {
_appendCardText(el, card, mode);
}
return el;
}
function cardClass(card) {
if (card.exhausted_id !== undefined) return 'card-exhausted';
if (card.monster_id !== undefined) return 'card-monster';
if (card.citizen_id !== undefined) return 'card-citizen';
if (card.domain_id !== undefined) return 'card-domain';
if (card.duke_id !== undefined) return 'card-duke';
return 'card-starter';
}
function cardSub(card) {
if (card.monster_id !== undefined) {
const parts = [];
if (card.strength_cost) parts.push(`${card.strength_cost} str`);
if (card.magic_cost) parts.push(`${card.magic_cost} mag`);
return parts.length ? `Cost: ${parts.join(' + ')}` : '';
}
if (card.citizen_id !== undefined || card.domain_id !== undefined) {
return card.gold_cost ? `Cost: ${card.gold_cost}g` : '';
}
if (card.starter_id !== undefined) {
const m1 = card.roll_match1, m2 = card.roll_match2;
if (m1 && m2 && m1 !== m2) return `Rolls: ${m1}, ${m2}`;
if (m1) return `Roll: ${m1}`;
}
return '';
}
function cardExtra(card) {
const parts = [];
if (card.vp_reward) parts.push(`${card.vp_reward} VP`);
if (card.gold_reward) parts.push(`+${card.gold_reward}g`);
if (card.strength_reward) parts.push(`+${card.strength_reward} str`);
if (card.magic_reward) parts.push(`+${card.magic_reward} mag`);
// Domain text (short)
if (!parts.length && card.text) {
const t = card.text.slice(0, 40);
return t.length < card.text.length ? t + '…' : t;
}
return parts.join(' ');
}
// ── Game over overlay ─────────────────────────────────────────────────────
function renderGameOver(state) {
const existing = document.getElementById('game-over-overlay');
if (state.phase !== 'game_over' || !state.final_scores) {
if (existing) existing.remove();
return;
}
if (existing) return;
const overlay = mk('game-over-overlay');
overlay.id = 'game-over-overlay';
const panel = mk('game-over-panel');
const title = mk('game-over-title');
title.textContent = 'Game Over';
panel.appendChild(title);
(state.final_scores || []).forEach(s => {
const row = mk('score-row');
const rank = mk('rank');
rank.textContent = `#${s.rank}`;
row.appendChild(rank);
const name = mk('sname');
name.textContent = s.name;
row.appendChild(name);
const total = mk('total');
total.textContent = `${s.total_vp} VP`;
row.appendChild(total);
const bd = mk('breakdown');
bd.textContent = `${s.base_vp} base + ${s.duke_vp} Duke`;
row.appendChild(bd);
panel.appendChild(row);
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
}
// ── Helpers ───────────────────────────────────────────────────────────────
function mk(classes) {
const el = document.createElement('div');
el.className = classes || '';
return el;
}
function fmtPhase(phase) {
return {
roll: 'Roll Phase',
harvest: 'Harvest Phase',
action: 'Action Phase',
cleanup: 'Cleanup',
game_over: 'Game Over',
setup: 'Setup',
}[phase] || (phase || '');
}
// ── Card hover preview ────────────────────────────────────────────────────
const _previewEl = document.createElement('img');
_previewEl.className = 'card-preview';
document.body.appendChild(_previewEl);
let _hoverCard = null;
let _hoverTimer = null;
let _pendingRect = null;
let _previewPlacement = 'auto';
function previewPlacementForCard(cardEl) {
if (!cardEl.closest('.center-board')) return 'auto';
if (cardEl.closest('.citizen-row-second')) return 'above';
if (cardEl.closest('.board-domains')) return 'above';
if (cardEl.closest('.board-monsters')) return 'below';
if (cardEl.closest('.citizen-row-first')) return 'below';
return 'auto';
}
_previewEl.onload = () => {
if (_pendingRect) {
positionPreview(_pendingRect, _previewEl.naturalWidth, _previewEl.naturalHeight, _previewPlacement);
}
};
function positionPreview(rect, w, h, placement) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const mode = placement != null ? placement : _previewPlacement;
let top;
if (mode === 'below') {
top = rect.bottom + 8;
if (top + h > vh - 8) top = rect.top - h - 8;
} else if (mode === 'above') {
top = rect.top - h - 8;
if (top < 8) top = rect.bottom + 8;
} else {
top = rect.top - h - 8;
if (top < 8) top = rect.bottom + 8;
}
let left = rect.left + rect.width / 2 - w / 2;
left = Math.max(8, Math.min(left, vw - w - 8));
_previewEl.style.top = top + 'px';
_previewEl.style.left = left + 'px';
}
document.addEventListener('mouseover', e => {
const cardEl = e.target.closest('.card[data-card]');
if (cardEl === _hoverCard) return;
clearTimeout(_hoverTimer);
_hoverCard = cardEl;
if (!cardEl) { _previewEl.style.display = 'none'; return; }
const card = JSON.parse(cardEl.dataset.card);
const url = cardImageUrl(card);
if (!url) return;
_previewPlacement = previewPlacementForCard(cardEl);
_hoverTimer = setTimeout(() => {
const rect = cardEl.getBoundingClientRect();
_pendingRect = rect;
_previewEl.style.display = 'block';
if (_previewEl.src.endsWith(url) && _previewEl.complete && _previewEl.naturalWidth) {
positionPreview(rect, _previewEl.naturalWidth, _previewEl.naturalHeight, _previewPlacement);
} else {
_previewEl.src = url;
}
}, 120);
});
document.addEventListener('mouseout', e => {
const cardEl = e.target.closest('.card[data-card]');
if (!cardEl || cardEl.contains(e.relatedTarget)) return;
clearTimeout(_hoverTimer);
_hoverCard = null;
_previewEl.style.display = 'none';
});
// ── Board market actions (hire / build / slay) ─────────────────────────────
function topOfStack(stack) {
if (!Array.isArray(stack) || stack.length === 0) return null;
return stack[stack.length - 1];
}
function canAffordCost(player, cost) {
const G = Number(player?.gold_score || 0);
const S = Number(player?.strength_score || 0);
const M = Number(player?.magic_score || 0);
const goldCost = Number(cost?.gold || 0);
const strengthCost = Number(cost?.strength || 0);
const magicMin = Number(cost?.magicMin || 0);
const remainingMagic = M - magicMin;
if (remainingMagic < 0) return { ok: false };
const deficitGold = Math.max(0, goldCost - G);
const deficitStrength = Math.max(0, strengthCost - S);
if (goldCost > 0 && deficitGold > 0 && G <= 0) return { ok: false };
if (strengthCost > 0 && deficitStrength > 0 && S <= 0) return { ok: false };
const ok = (deficitGold + deficitStrength) <= remainingMagic;
const payGold = Math.min(G, goldCost);
const payStrength = Math.min(S, strengthCost);
const payMagic = magicMin + deficitGold + deficitStrength;
return { ok, payGold, payStrength, payMagic, deficitGold, deficitStrength, remainingMagic };
}
function ownedNameCount(player, name) {
const target = (name ?? '').toString();
if (!target) return 0;
const starters = Array.isArray(player?.owned_starters) ? player.owned_starters : [];
const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : [];
let n = 0;
starters.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; });
citizens.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; });
return n;
}
function citizenRoleCounts(card) {
const r = card && card.roles;
if (r && typeof r === 'object') {
return {
sn: Number(r.shadow) || 0,
hn: Number(r.holy) || 0,
son: Number(r.soldier) || 0,
wn: Number(r.worker) || 0,
};
}
return {
sn: Number(card.shadow_count) || 0,
hn: Number(card.holy_count) || 0,
son: Number(card.soldier_count) || 0,
wn: Number(card.worker_count) || 0,
};
}
function formatHarvestGSM(card, onTurn) {
const g = onTurn ? 'gold_payout_on_turn' : 'gold_payout_off_turn';
const s = onTurn ? 'strength_payout_on_turn' : 'strength_payout_off_turn';
const m = onTurn ? 'magic_payout_on_turn' : 'magic_payout_off_turn';
const gv = Number(card[g]) || 0;
const sv = Number(card[s]) || 0;
const mv = Number(card[m]) || 0;
return `G ${gv}, S ${sv}, M ${mv}`;
}
function pushHarvestHints(hints, card) {
const hasOn = card.gold_payout_on_turn !== undefined || card.strength_payout_on_turn !== undefined || card.magic_payout_on_turn !== undefined;
const hasOff = card.gold_payout_off_turn !== undefined || card.strength_payout_off_turn !== undefined || card.magic_payout_off_turn !== undefined;
if (!hasOn && !hasOff) return;
const onStr = formatHarvestGSM(card, true);
const offStr = formatHarvestGSM(card, false);
if (onStr === offStr) {
hints.push(`Harvest: ${onStr} (on & off turn)`);
} else {
hints.push(`Harvest (on turn): ${onStr}`);
hints.push(`Harvest (off turn): ${offStr}`);
}
}
function cardDetailedRules(card) {
if (!card || typeof card !== 'object') return '';
const rawText = (card.text ?? '').toString().trim();
if (rawText) return rawText;
const parts = [];
pushHarvestHints(parts, card);
if (card.monster_id !== undefined && card.monster_id !== null) {
const vp = Number(card.vp_reward || 0);
const gr = Number(card.gold_reward || 0);
const sr = Number(card.strength_reward || 0);
const mr = Number(card.magic_reward || 0);
parts.push(`Reward: VP ${vp} · G ${gr} · S ${sr} · M ${mr}`);
}
const passive = (card.passive_effect ?? '').toString().trim();
const activation = (card.activation_effect ?? '').toString().trim();
if (passive) parts.push(`Passive: ${passive}`);
if (activation) parts.push(`Activation: ${activation}`);
const spOn = (card.special_payout_on_turn ?? '').toString().trim();
const spOff = (card.special_payout_off_turn ?? '').toString().trim();
if (spOn) parts.push(`Special (on turn): ${spOn}`);
if (spOff) parts.push(`Special (off turn): ${spOff}`);
const specialReward = (card.special_reward ?? '').toString().trim();
const specialCost = (card.special_cost ?? '').toString().trim();
if (specialReward) parts.push(`Special reward: ${specialReward}`);
if (specialCost) parts.push(`Special cost: ${specialCost}`);
return parts.join('\n').trim();
}
function normalizedPassiveEffects(player, turnNumber) {
const out = [];
const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : [];
domains.forEach(d => {
if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return;
const name = (d?.name ?? '').toString().trim().toLowerCase();
const text = (d?.text ?? '').toString().trim().toLowerCase();
const raw = (d?.passive_effect ?? '').toString().trim().toLowerCase();
if (raw) {
out.push(raw);
const nrm = raw.replace(/effect:add/g, 'effect.add').replace(/action:/g, 'action.');
if (nrm.startsWith('effect.add ')) {
out.push(nrm.slice('effect.add '.length).trim());
}
}
if (name.includes('emerald stronghold') || (text.includes("ignore '+'") && text.includes('buying citizens'))) {
out.push('action.emeraldstronghold');
}
if (name.includes('pratchett') || (text.includes('1gp less') && text.includes('domain'))) {
out.push('action.pratchettsplateau');
}
});
return out;
}
function hasActionEffectFlag(player, flag, turnNumber) {
const target = (flag ?? '').toString().trim().toLowerCase();
if (!target) return false;
const effects = normalizedPassiveEffects(player, turnNumber);
return effects.includes(target);
}
function findMarketStack(card, state) {
let grid = null;
let idKey = null;
if (card.monster_id != null) { grid = state?.monster_grid; idKey = 'monster_id'; }
else if (card.citizen_id != null) { grid = state?.citizen_grid; idKey = 'citizen_id'; }
else if (card.domain_id != null) { grid = state?.domain_grid; idKey = 'domain_id'; }
else return null;
const stacks = Array.isArray(grid) ? grid : [];
const cid = card[idKey];
for (let i = 0; i < stacks.length; i++) {
const top = topOfStack(stacks[i]);
if (!top || top[idKey] !== cid) continue;
return { stack: stacks[i], stackIndex: i, top };
}
return null;
}
function evaluateMarketCardContext(card, state) {
const phase = (state?.phase || '').toString();
const req = state?.action_required || {};
const reqAction = (req?.action || '').toString();
const reqId = req?.id || '';
const standardActionPhase =
phase === 'action' && reqAction === 'standard_action' && reqId && reqId !== state?.game_id;
const isYourTurn = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const actionsRemaining = Number(state?.actions_remaining || 0);
const players = state?.player_list || [];
const actingPlayer = players.find(p => idsMatch(p.player_id, reqId)) || null;
const tn = Number(state?.turn_number);
const emeraldActive = actingPlayer ? hasActionEffectFlag(actingPlayer, 'action.emeraldstronghold', tn) : false;
const pratchettActive = actingPlayer ? hasActionEffectFlag(actingPlayer, 'action.pratchettsplateau', tn) : false;
const loc = state ? findMarketStack(card, state) : null;
let blockReason = '';
let top = loc ? loc.top : null;
let stackSize = loc ? loc.stack.length : 0;
if (!state) {
blockReason = 'Game state not loaded.';
} else if (!loc) {
blockReason = 'This card is not on the market (stacks may have changed).';
} else if (card.monster_id != null && !top.is_accessible) {
blockReason = 'This monster stack is blocked.';
} else if (card.citizen_id != null && !top.is_accessible) {
blockReason = 'This citizen stack is blocked.';
} else if (card.domain_id != null && (!top.is_visible || !top.is_accessible)) {
blockReason = 'This domain cannot be built right now.';
}
let evalRes = { ok: false, payGold: 0, payStrength: 0, payMagic: 0 };
let scaledCost = 0;
let baseCost = 0;
let surcharge = 0;
let effectiveGold = 0;
let pratchettHint = '';
let dupHint = '';
let emeraldHint = '';
if (actingPlayer && loc && top && !blockReason) {
if (card.citizen_id != null) {
baseCost = Number(top.gold_cost || 0);
surcharge = emeraldActive ? 0 : ownedNameCount(actingPlayer, top.name);
scaledCost = baseCost + surcharge;
evalRes = canAffordCost(actingPlayer, { gold: scaledCost, strength: 0, magicMin: 0 });
dupHint = surcharge ? `base ${baseCost}g + ${surcharge} duplicate(s)` : '';
emeraldHint = (!surcharge && emeraldActive) ? 'Emerald Stronghold: no duplicate surcharge.' : '';
} else if (card.domain_id != null) {
baseCost = Number(top.gold_cost || 0);
effectiveGold = Math.max(0, baseCost - (pratchettActive ? 1 : 0));
evalRes = canAffordCost(actingPlayer, { gold: effectiveGold, strength: 0, magicMin: 0 });
pratchettHint = pratchettActive && baseCost !== effectiveGold ? `base ${baseCost}g 1 (Pratchett's Plateau)` : '';
} else if (card.monster_id != null) {
evalRes = canAffordCost(actingPlayer, {
gold: 0,
strength: Number(top.strength_cost || 0),
magicMin: Number(top.magic_cost || 0),
});
}
}
const canActThisCard =
standardActionPhase &&
isYourTurn &&
actionsRemaining > 0 &&
loc &&
!blockReason;
return {
phase,
standardActionPhase,
isYourTurn,
actionsRemaining,
actingPlayer,
reqId,
emeraldActive,
pratchettActive,
loc,
top,
stackSize,
blockReason,
evalRes,
scaledCost,
baseCost,
surcharge,
effectiveGold,
pratchettHint,
dupHint,
emeraldHint,
canActThisCard,
};
}
function clampPayInt(value, minV, maxV) {
let n = Math.floor(Number(value));
if (!Number.isFinite(n)) n = 0;
const lo = Math.floor(Number(minV) || 0);
const hiRaw = maxV === '' || maxV === undefined || maxV === null ? null : Number(maxV);
const hi = hiRaw === null || !Number.isFinite(hiRaw) ? null : Math.floor(hiRaw);
n = Math.max(lo, n);
if (hi !== null) n = Math.min(hi, n);
return n;
}
function readMarketPayRow(row) {
const gEl = row.querySelector('.pay-g');
const sEl = row.querySelector('.pay-s');
const mEl = row.querySelector('.pay-m');
const g = (!gEl || gEl.disabled) ? 0 : clampPayInt(gEl.value, gEl.min, gEl.max);
const s = (!sEl || sEl.disabled) ? 0 : clampPayInt(sEl.value, sEl.min, sEl.max);
const m = (!mEl || mEl.disabled) ? 0 : clampPayInt(mEl.value, mEl.min, mEl.max);
return { gold: g, strength: s, magic: m };
}
function mkPayField(label, cls, minV, maxV, value, disabled, title, resourceIconKey) {
const lab = document.createElement('label');
lab.className = 'market-pay-field';
if (title) lab.title = title;
const span = document.createElement('span');
span.className = 'market-pay-field-label';
if (resourceIconKey && TABLEAU_RESOURCE_ICONS[resourceIconKey]) {
span.classList.add(`market-pay-field-label--${resourceIconKey}`);
const img = document.createElement('img');
img.className = 'market-pay-label-icon';
img.src = TABLEAU_RESOURCE_ICONS[resourceIconKey];
img.alt = '';
span.appendChild(img);
if (label) span.appendChild(document.createTextNode(` ${label}`));
} else {
span.textContent = label;
}
lab.appendChild(span);
const inp = document.createElement('input');
inp.type = 'number';
inp.className = `market-pay-input ${cls}`;
inp.min = String(minV);
inp.max = maxV === null || maxV === undefined ? '' : String(maxV);
inp.value = String(value);
inp.disabled = !!disabled;
lab.appendChild(inp);
return lab;
}
function fillMarketCostSummary(costEl, card, ctx, pay) {
const stackTail = ` · stack ×${ctx.stackSize}`;
if (card.citizen_id != null) {
costEl.appendChild(document.createTextNode('Cost: '));
costEl.appendChild(makeModalResourceInline('gold', ctx.scaledCost, 'modal-gold', false));
if (ctx.dupHint) costEl.appendChild(document.createTextNode(` (${ctx.dupHint})`));
if (ctx.emeraldHint) costEl.appendChild(document.createTextNode(` · ${ctx.emeraldHint}`));
costEl.appendChild(document.createTextNode(' · suggested pay '));
costEl.appendChild(makeModalResourceInline('gold', pay.payGold ?? 0, 'modal-gold', false));
if (pay.payMagic) {
costEl.appendChild(document.createTextNode(', '));
costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false));
}
costEl.appendChild(document.createTextNode(stackTail));
return;
}
if (card.domain_id != null) {
costEl.appendChild(document.createTextNode('Cost: '));
costEl.appendChild(makeModalResourceInline('gold', ctx.effectiveGold, 'modal-gold', false));
if (ctx.pratchettHint) costEl.appendChild(document.createTextNode(` (${ctx.pratchettHint})`));
costEl.appendChild(document.createTextNode(' · suggested pay '));
costEl.appendChild(makeModalResourceInline('gold', pay.payGold ?? 0, 'modal-gold', false));
if (pay.payMagic) {
costEl.appendChild(document.createTextNode(', '));
costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false));
}
costEl.appendChild(document.createTextNode(stackTail));
return;
}
if (card.monster_id != null) {
const sc = Number(ctx.top?.strength_cost || 0);
const mm = Number(ctx.top?.magic_cost || 0);
costEl.appendChild(document.createTextNode('Cost: '));
costEl.appendChild(makeModalResourceInline('strength', sc, 'modal-str', false));
costEl.appendChild(document.createTextNode(' + '));
costEl.appendChild(makeModalResourceInline('magic', mm, 'modal-mag', false));
costEl.appendChild(document.createTextNode(' minimum · suggested pay '));
costEl.appendChild(makeModalResourceInline('strength', pay.payStrength ?? 0, 'modal-str', false));
costEl.appendChild(document.createTextNode(', '));
costEl.appendChild(makeModalResourceInline('magic', pay.payMagic ?? 0, 'modal-mag', false));
costEl.appendChild(document.createTextNode(stackTail));
}
}
function appendMarketActionUI(infoEl, card, ctx) {
const panel = mk('market-action-panel');
const actName = ctx.actingPlayer?.name || ctx.reqId || 'Active player';
const hdr = mk('market-action-heading');
if (ctx.standardActionPhase) {
hdr.textContent = ctx.isYourTurn
? `Your action (${ctx.actionsRemaining} remaining)`
: `${actName}'s turn — ${ctx.actionsRemaining} action(s) remaining`;
} else {
hdr.textContent = `Phase: ${fmtPhase(ctx.phase)}`;
}
panel.appendChild(hdr);
if (ctx.actingPlayer) {
const p = ctx.actingPlayer;
const resRow = mk('market-resources-row market-resources-row--strip');
const intro = document.createElement('span');
intro.className = 'market-resources-intro';
intro.textContent = `Resources (${actName}):`;
resRow.appendChild(intro);
resRow.appendChild(makeResourceScorePill('gold', p.gold_score, 'Gold', TABLEAU_RESOURCE_ICONS.gold));
resRow.appendChild(makeResourceScorePill('strength', p.strength_score, 'Strength', TABLEAU_RESOURCE_ICONS.strength));
resRow.appendChild(makeResourceScorePill('magic', p.magic_score, 'Magic', TABLEAU_RESOURCE_ICONS.magic));
resRow.appendChild(makeVpScorePill(p.victory_score));
panel.appendChild(resRow);
}
const fx = [];
if (ctx.emeraldActive) fx.push('Emerald Stronghold: ignore citizen duplicate surcharge');
if (ctx.pratchettActive) fx.push("Pratchett's Plateau: domains cost 1 less gold");
if (fx.length) {
const fb = mk('market-effects-banner');
fb.textContent = `Active: ${fx.join(' · ')}`;
panel.appendChild(fb);
}
if (ctx.blockReason) {
const br = mk('market-block-note');
br.textContent = ctx.blockReason;
panel.appendChild(br);
}
const payWrap = mk('market-pay-row');
const Gmax = Number(ctx.actingPlayer?.gold_score || 0);
const Smax = Number(ctx.actingPlayer?.strength_score || 0);
const Mmax = Number(ctx.actingPlayer?.magic_score || 0);
const pay = ctx.evalRes;
const inputsDisabled = !ctx.standardActionPhase;
let primaryLabel = '';
if (card.citizen_id != null) {
primaryLabel = 'Hire citizen';
payWrap.dataset.citizenId = String(card.citizen_id);
payWrap.appendChild(mkPayField('', 'pay-g', 0, Gmax, pay.payGold ?? 0, inputsDisabled, 'Gold payment', 'gold'));
payWrap.appendChild(mkPayField('', 'pay-s', 0, 0, 0, true, 'Citizens use gold and magic', 'strength'));
payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic'));
} else if (card.domain_id != null) {
primaryLabel = 'Build domain';
payWrap.dataset.domainId = String(card.domain_id);
payWrap.appendChild(mkPayField('', 'pay-g', 0, Gmax, pay.payGold ?? 0, inputsDisabled, 'Gold payment', 'gold'));
payWrap.appendChild(mkPayField('', 'pay-s', 0, 0, 0, true, 'Domains use gold and magic', 'strength'));
payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic'));
} else if (card.monster_id != null) {
primaryLabel = 'Slay monster';
payWrap.dataset.monsterId = String(card.monster_id);
payWrap.appendChild(mkPayField('', 'pay-g', 0, 0, 0, true, 'Monsters use strength and magic', 'gold'));
payWrap.appendChild(mkPayField('', 'pay-s', 0, Smax, pay.payStrength ?? 0, inputsDisabled, 'Strength payment', 'strength'));
payWrap.appendChild(mkPayField('', 'pay-m', 0, Mmax, pay.payMagic ?? 0, inputsDisabled, 'Magic payment', 'magic'));
}
const costEl = mk('market-cost-summary');
fillMarketCostSummary(costEl, card, ctx, pay);
panel.appendChild(costEl);
const affordEl = mk(ctx.evalRes.ok ? 'market-afford-ok' : 'market-afford-bad');
affordEl.textContent = ctx.evalRes.ok
? 'Suggested payment fits current resources.'
: 'Suggested payment exceeds resources — adjust G/S/M or magic coverage.';
panel.appendChild(affordEl);
const fieldsRow = mk('market-pay-fields');
fieldsRow.appendChild(payWrap);
panel.appendChild(fieldsRow);
const btnRow = mk('market-primary-actions');
function attachPrimary(btnEl, disabled) {
if (disabled) btnEl.disabled = true;
btnRow.appendChild(btnEl);
}
const hireDisabled = !(card.citizen_id != null && ctx.canActThisCard);
const buildDisabled = !(card.domain_id != null && ctx.canActThisCard);
const slayDisabled = !(card.monster_id != null && ctx.canActThisCard);
if (card.citizen_id != null) {
attachPrimary(promptButton('Hire', () => {
const p = readMarketPayRow(payWrap);
postGameAction({
player_id: PLAYER_ID,
action_type: 'hire_citizen',
citizen_id: Number(card.citizen_id),
payment: { gold: p.gold, strength: p.strength, magic: p.magic },
});
document.getElementById('card-modal-overlay')?.remove();
}), hireDisabled);
} else if (card.domain_id != null) {
attachPrimary(promptButton('Build', () => {
const p = readMarketPayRow(payWrap);
postGameAction({
player_id: PLAYER_ID,
action_type: 'build_domain',
domain_id: Number(card.domain_id),
payment: { gold: p.gold, strength: p.strength, magic: p.magic },
});
document.getElementById('card-modal-overlay')?.remove();
}), buildDisabled);
} else if (card.monster_id != null) {
attachPrimary(promptButton('Slay', () => {
const p = readMarketPayRow(payWrap);
postGameAction({
player_id: PLAYER_ID,
action_type: 'slay_monster',
monster_id: Number(card.monster_id),
payment: { gold: p.gold, strength: p.strength, magic: p.magic },
});
document.getElementById('card-modal-overlay')?.remove();
}), slayDisabled);
}
panel.appendChild(btnRow);
const help = mk('market-action-help');
help.textContent =
primaryLabel
? `${primaryLabel}: adjust gold, strength, and magic payment (magic covers shortages after you spend required minimum magic on monsters).`
: '';
if (help.textContent) panel.appendChild(help);
infoEl.appendChild(panel);
}
function openMarketCardModal(card) {
if (document.getElementById('game-prompt-overlay')) return;
if (document.getElementById('card-modal-overlay')) return;
const state = latestGameState;
const ctx = evaluateMarketCardContext(card, state);
const overlay = document.createElement('div');
overlay.id = 'card-modal-overlay';
overlay.className = 'card-modal-overlay';
const modal = mk('card-modal card-modal--market');
modal.addEventListener('click', e => e.stopPropagation());
const url = cardImageUrl(card);
if (url) {
const img = document.createElement('img');
img.className = 'card-modal-img';
img.src = url;
modal.appendChild(img);
}
const info = mk('card-modal-info');
const heading = document.createElement('h2');
heading.className = 'modal-card-name';
heading.textContent = card.name || '?';
info.appendChild(heading);
appendCardModalStatRows(info, card);
const rc = citizenRoleCounts(card);
const rp = [];
if (rc.sn > 0) rp.push(`Shadow +${rc.sn}`);
if (rc.hn > 0) rp.push(`Holy +${rc.hn}`);
if (rc.son > 0) rp.push(`Soldier +${rc.son}`);
if (rc.wn > 0) rp.push(`Worker +${rc.wn}`);
if (rp.length && (card.citizen_id != null || card.domain_id != null)) {
const row = mk('modal-stat-row');
const l = document.createElement('span');
l.className = 'modal-stat-label';
l.textContent = 'Roles';
const v = document.createElement('span');
v.className = 'modal-stat-value';
v.textContent = rp.join(' · ');
row.appendChild(l);
row.appendChild(v);
info.appendChild(row);
}
if (card.text) {
const t = document.createElement('p');
t.className = 'modal-card-text';
t.textContent = card.text;
info.appendChild(t);
}
const rules = cardDetailedRules(card);
if (rules && rules !== (card.text || '').toString().trim()) {
const t2 = document.createElement('p');
t2.className = 'modal-card-text market-rules-extra';
t2.textContent = rules;
info.appendChild(t2);
}
appendMarketActionUI(info, card, ctx);
modal.appendChild(info);
overlay.appendChild(modal);
overlay.addEventListener('click', () => overlay.remove());
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); }
});
document.body.appendChild(overlay);
}
function isBoardMarketCard(card, cardEl) {
if (!cardEl || !cardEl.closest('.center-board')) return false;
return card.monster_id != null || card.citizen_id != null || card.domain_id != null;
}
// ── Card click modal ──────────────────────────────────────────────────────
document.addEventListener('click', e => {
const cardEl = e.target.closest('.card[data-card]');
if (!cardEl) return;
_previewEl.style.display = 'none';
const card = JSON.parse(cardEl.dataset.card);
if (isBoardMarketCard(card, cardEl)) {
openMarketCardModal(card);
return;
}
openCardModal(card);
});
function openCardModal(card) {
if (document.getElementById('game-prompt-overlay')) return;
if (document.getElementById('card-modal-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'card-modal-overlay';
overlay.className = 'card-modal-overlay';
const modal = mk('card-modal');
modal.addEventListener('click', e => e.stopPropagation());
const url = cardImageUrl(card);
if (url) {
const img = document.createElement('img');
img.className = 'card-modal-img';
img.src = url;
modal.appendChild(img);
}
const info = mk('card-modal-info');
const heading = document.createElement('h2');
heading.className = 'modal-card-name';
heading.textContent = card.name || '?';
info.appendChild(heading);
appendCardModalStatRows(info, card);
if (card.text) {
const t = document.createElement('p');
t.className = 'modal-card-text';
t.textContent = card.text;
info.appendChild(t);
}
modal.appendChild(info);
overlay.appendChild(modal);
overlay.addEventListener('click', () => overlay.remove());
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', esc); }
});
document.body.appendChild(overlay);
}
function buildCardStats(card) {
const rows = [];
const push = (label, value, cls, resource, leadingPlus) => {
if (value != null && value !== 0 && value !== '') {
rows.push({
label,
value,
cls: cls || '',
resource: resource || null,
leadingPlus: !!leadingPlus,
});
}
};
if (card.monster_id != null) push('Type', 'Monster', null, null, false);
else if (card.citizen_id != null) push('Type', 'Citizen', null, null, false);
else if (card.domain_id != null) push('Type', 'Domain', null, null, false);
else if (card.duke_id != null) push('Type', 'Duke', null, null, false);
else if (card.starter_id != null) push('Type', 'Starter', null, null, false);
if (card.gold_cost) push('Gold cost', card.gold_cost, 'modal-gold', 'gold', false);
if (card.strength_cost) push('Str cost', card.strength_cost, 'modal-str', 'strength', false);
if (card.magic_cost) push('Mag cost', card.magic_cost, 'modal-mag', 'magic', false);
if (card.vp_reward) push('VP reward', card.vp_reward, 'modal-vp', 'vp', false);
if (card.gold_reward) push('Gold reward', card.gold_reward, 'modal-gold', 'gold', true);
if (card.strength_reward) push('Str reward', card.strength_reward, 'modal-str', 'strength', true);
if (card.magic_reward) push('Mag reward', card.magic_reward, 'modal-mag', 'magic', true);
if (card.domain_id != null) {
const req = [];
if (card.shadow_count) req.push(`${card.shadow_count} Shadow`);
if (card.holy_count) req.push(`${card.holy_count} Holy`);
if (card.soldier_count) req.push(`${card.soldier_count} Soldier`);
if (card.worker_count) req.push(`${card.worker_count} Worker`);
if (req.length) push('Requires', req.join(', '));
}
if (card.starter_id != null) {
const m1 = card.roll_match1, m2 = card.roll_match2;
if (m1 && m2 && m1 !== m2) push('Rolls', `${m1}, ${m2}`);
else if (m1) push('Roll', String(m1));
}
if (card.is_flipped) push('Status', 'Flipped');
return rows;
}
// ── Prompt modal (required choices, concurrent setup) ─────────────────────
function clampDie(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 1;
return Math.max(1, Math.min(6, Math.trunc(x)));
}
function syncConcurrentPolling(state) {
const ca = state?.concurrent_action;
const pend = ca && Array.isArray(ca.pending) ? ca.pending : [];
const should = pend.length > 0;
if (should && !concurrentPollTimer) {
concurrentPollTimer = setInterval(() => {
fetchGameStateFromApi();
}, 1500);
} else if (!should && concurrentPollTimer) {
clearInterval(concurrentPollTimer);
concurrentPollTimer = null;
}
}
async function fetchGameStateFromApi() {
if (!GAME_ID || !PLAYER_ID) return;
try {
const res = await fetch(`/api/game/${encodeURIComponent(GAME_ID)}/state?player_id=${encodeURIComponent(PLAYER_ID)}`);
if (!res.ok) return;
const data = await res.json();
render(data);
} catch (e) {
console.error(e);
}
}
async function postGameAction(body) {
const res = await fetch(`/api/game/${encodeURIComponent(GAME_ID)}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = payload?.detail || res.statusText || 'Request failed';
window.alert(detail);
return false;
}
if (payload?.game_state) render(payload.game_state);
else fetchGameStateFromApi();
return true;
}
function removePromptOverlay() {
const el = document.getElementById('game-prompt-overlay');
if (el && el._promptEscHandler) {
document.removeEventListener('keydown', el._promptEscHandler);
el._promptEscHandler = null;
}
el?.remove();
}
function openPromptOverlayShell(opts) {
removePromptOverlay();
const { title, subtitle, dismissible, bodyEl, footerEl } = opts;
const overlay = document.createElement('div');
overlay.id = 'game-prompt-overlay';
overlay.className = 'card-modal-overlay game-prompt-overlay';
const modal = mk('card-modal card-modal--prompt');
modal.addEventListener('click', e => e.stopPropagation());
const head = mk('prompt-modal-head');
const h = document.createElement('h2');
h.className = 'modal-card-name prompt-modal-title';
h.textContent = title;
head.appendChild(h);
if (subtitle) {
const sub = mk('prompt-modal-subtitle');
sub.textContent = subtitle;
head.appendChild(sub);
}
modal.appendChild(head);
if (bodyEl) modal.appendChild(bodyEl);
if (footerEl) {
const ft = mk('prompt-modal-footer');
ft.appendChild(footerEl);
modal.appendChild(ft);
}
overlay.appendChild(modal);
function dismiss() {
removePromptOverlay();
}
function onKey(e) {
if (e.key === 'Escape' && dismissible) dismiss();
}
if (dismissible) {
overlay.addEventListener('click', dismiss);
overlay._promptEscHandler = onKey;
document.addEventListener('keydown', onKey);
}
document.body.appendChild(overlay);
}
function promptButton(label, onClick, secondary) {
const b = document.createElement('button');
b.type = 'button';
b.className = secondary ? 'prompt-btn prompt-btn-secondary' : 'prompt-btn';
b.textContent = label;
b.addEventListener('click', onClick);
return b;
}
function promptActionsRow(buttons) {
const row = mk('prompt-modal-actions');
buttons.forEach(b => row.appendChild(b));
return row;
}
function harvestTurnChip(state, forPlayerId) {
const pid = (forPlayerId || '').toString();
const ap = state?.active_player_id;
if (!pid || ap == null) return null;
const onTurn = idsMatch(pid, ap);
const el = mk('prompt-turn-chip');
el.textContent = onTurn ? 'On-turn harvest' : 'Off-turn harvest';
if (onTurn) el.classList.add('is-on-turn');
return el;
}
function playerById(state, pid) {
const list = state?.player_list || [];
return list.find(p => idsMatch(p.player_id, pid)) || null;
}
function pendingPlayerLabels(state, pending) {
const players = state?.player_list || [];
return (pending || []).map(pid => {
const p = players.find(x => idsMatch(x.player_id, pid));
return p?.name ? p.name : pid;
});
}
function ownedCitizenRoleSelectorCount(player, roleSelector) {
const role = (roleSelector || '').toString().trim().toLowerCase();
if (!role) return 0;
const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : [];
const keyByRole = {
holy_citizen: 'holy_count',
shadow_citizen: 'shadow_count',
soldier_citizen: 'soldier_count',
worker_citizen: 'worker_count',
};
const key = keyByRole[role];
if (!key) return 0;
let n = 0;
citizens.forEach(c => {
if (Number(c?.[key] || 0) > 0) n += 1;
});
return n;
}
function domainPassiveOnBuildTurnCooldown(domain, turnNumber) {
const acq = domain?.acquired_turn_number;
if (acq === undefined || acq === null) return false;
const t = Number(turnNumber);
if (!Number.isFinite(t)) return false;
return Number(acq) === t;
}
function parseRollSetOneDieEffects(player, turnNumber) {
const out = [];
const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : [];
domains.forEach(d => {
if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return;
const raw = (d?.passive_effect ?? '').toString().trim();
if (!raw) return;
const parts = raw.split(/\s+/);
const head0 = (parts[0] || '').toLowerCase().replace(/:/g, '.');
if (!parts.length || head0 !== 'roll.set_one_die') return;
const kv = {};
for (let i = 1; i < parts.length; i += 1) {
const p = parts[i];
const eq = p.indexOf('=');
if (eq < 0) continue;
const k = p.slice(0, eq).trim().toLowerCase();
const v = p.slice(eq + 1).trim();
kv[k] = v;
}
const target = Number(kv.target);
const costSpec = (kv.cost || '').toString().trim().toLowerCase();
if (!Number.isFinite(target) || target < 1 || target > 6 || !costSpec) return;
out.push({ domainName: (d?.name || 'Domain').toString(), target, costSpec });
});
return out;
}
function rollEffectCostGold(player, costSpec) {
const spec = (costSpec || '').toString().trim().toLowerCase();
if (spec.startsWith('g:')) {
const n = Number(spec.slice(2));
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
if (spec.startsWith('g_per_owned_role:')) {
const role = spec.slice('g_per_owned_role:'.length);
return ownedCitizenRoleSelectorCount(player, role);
}
if (spec === 'g:per_owned_holy_citizen' || spec === 'per_owned_holy_citizen') {
return ownedCitizenRoleSelectorCount(player, 'holy_citizen');
}
return null;
}
function listRollSetOneDieOptions(player, rolled1, rolled2, turnNumber) {
const effects = parseRollSetOneDieEffects(player, turnNumber);
const gold = Number(player?.gold_score || 0);
const options = [];
effects.forEach(e => {
const costGold = rollEffectCostGold(player, e.costSpec);
if (costGold === null || gold < costGold) return;
if (Number(rolled1) !== Number(e.target)) {
options.push({ die: 1, target: Number(e.target), costGold, domainName: e.domainName });
}
if (Number(rolled2) !== Number(e.target)) {
options.push({ die: 2, target: Number(e.target), costGold, domainName: e.domainName });
}
});
return options;
}
async function sendFinalizeRollChoice(d1, d2) {
if (!GAME_ID || !PLAYER_ID || finalizeRollInFlight) return;
finalizeRollInFlight = true;
try {
await postGameAction({
player_id: PLAYER_ID,
action_type: 'finalize_roll',
die_one: clampDie(d1),
die_two: clampDie(d2),
});
} finally {
finalizeRollInFlight = false;
}
}
/** Affordable roll.set_one_die choices for the player who must finalize (may be empty). */
function finalizeRollModifierOptions(state) {
const req = state?.action_required || {};
if ((req.action || '').toString() !== 'finalize_roll') return [];
const reqId = (req.id || '').toString();
const actingPlayer = playerById(state, reqId);
if (!actingPlayer) return [];
const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1);
const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1);
return listRollSetOneDieOptions(actingPlayer, rolled1, rolled2, state.turn_number);
}
/** No prompt when there are zero modifiers — finalize immediately (matches dev-client behavior). */
function maybeAutoFinalizeRoll(state) {
if (!GAME_ID || !PLAYER_ID || finalizeRollInFlight) return;
if ((state?.phase || '').toString() !== 'roll_pending') return;
const req = state?.action_required || {};
if ((req.action || '').toString() !== 'finalize_roll') return;
if (!idsMatch(req.id, PLAYER_ID)) return;
if (finalizeRollModifierOptions(state).length > 0) return;
const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1);
const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1);
sendFinalizeRollChoice(rolled1, rolled2);
}
function labelForChoiceToken(tok) {
const t = (tok || '').toString().trim().toLowerCase();
if (t === 'g') return 'Gold';
if (t === 's') return 'Strength';
if (t === 'm') return 'Magic';
if (t === 'v') return 'Victory';
if (t.startsWith('citizens.')) {
const name = t.split('.', 2)[1] || '';
return name ? `${name} citizen` : 'Citizen';
}
return tok;
}
function parseChooseCommand(cmd) {
const parts = (cmd || '').toString().trim().split(/\s+/);
if (!parts.length || parts[0] !== 'choose') return [];
const options = [];
for (let i = 1; i + 1 < parts.length; i += 2) {
const token = parts[i];
const amount = parts[i + 1];
const tl = (token || '').toString().trim().toLowerCase();
if (!(tl === 'g' || tl === 's' || tl === 'm' || tl === 'v' || tl.startsWith('citizens.'))) continue;
options.push({ token, amount });
if (options.length >= 3) break;
}
return options;
}
function resourceSpecLabel(spec) {
const raw = (spec || '').toString().trim().toLowerCase();
const m = /^(g|s|m|v|vp)\s*:\s*(\d+)$/.exec(raw);
if (!m) return raw || '';
const n = Number(m[2]);
const k = m[1] === 'vp' ? 'v' : m[1];
const word = k === 'g' ? 'gold' : k === 's' ? 'strength' : k === 'm' ? 'magic' : 'VP';
const unit = k === 'v' ? '' : ' ';
return k === 'v' ? `${n} VP` : `${n}${unit}${word}`;
}
function domainEffectGainIsVp(kv) {
const g = (kv?.gain ?? '').toString().trim().toLowerCase();
return g.startsWith('v:') || g.startsWith('vp:');
}
function domainManipulateExplain(prc) {
const item = prc?.item || {};
const mode = (item.mode || '').toString().trim().toLowerCase();
const kv = item.kv || {};
if (mode === 'pay_to_player') {
const pay = resourceSpecLabel(kv.pay);
const gain = resourceSpecLabel(kv.gain);
const gainLine = gain ? ` Gain ${gain} from the bank (not from that player).` : '';
let decline = '';
if (prc?.allow_skip && domainEffectGainIsVp(kv)) {
decline = ' You may decline: no payment and no VP.';
} else if (prc?.allow_skip) {
decline = ' You may skip this optional effect.';
}
return `Pay ${pay || '(see rules)'} to the player you choose.${gainLine}${decline}`;
}
if (mode === 'take_from_player') {
const take = resourceSpecLabel(kv.take);
return `Take ${take || '(see rules)'} from the player you choose.`;
}
return 'Choose another player.';
}
function selfConvertExplain(kv) {
const pay = resourceSpecLabel(kv?.pay);
const gain = resourceSpecLabel(kv?.gain);
return `Trade ${pay || '?'} from your supply for ${gain || '?'} (bank).`;
}
function dukePromptBlurb(card) {
if (!card || typeof card !== 'object') return '';
const rawText = (card.text ?? '').toString().trim();
if (rawText) return rawText;
const passive = (card.passive_effect ?? '').toString().trim();
const activation = (card.activation_effect ?? '').toString().trim();
const bits = [];
if (passive) bits.push(`Passive: ${passive}`);
if (activation) bits.push(`Activation: ${activation}`);
return bits.join('\n');
}
/** Matches dev-client cardFullText duke multiplier display (resources use ×1/N). */
function dukeScalingLine(card) {
if (!card || typeof card !== 'object') return '';
if (card.duke_id == null) return '';
const mults = [];
const add = (label, val) => {
if (val === undefined || val === null) return;
const n = Number(val);
if (!Number.isFinite(n) || n === 0) return;
mults.push(`${label}×${n}`);
};
const addResource = (label, val) => {
if (val === undefined || val === null) return;
const n = Number(val);
if (!Number.isFinite(n) || n === 0) return;
mults.push(`${label}×1/${n}`);
};
addResource('Gold', card.gold_multiplier);
addResource('Strength', card.strength_multiplier);
addResource('Magic', card.magic_multiplier);
add('Shadow', card.shadow_multiplier);
add('Holy', card.holy_multiplier);
add('Soldier', card.soldier_multiplier);
add('Worker', card.worker_multiplier);
add('Monster', card.monster_multiplier);
add('Citizen', card.citizen_multiplier);
add('Domain', card.domain_multiplier);
add('Boss', card.boss_multiplier);
add('Minion', card.minion_multiplier);
add('Beast', card.beast_multiplier);
add('Titan', card.titan_multiplier);
return mults.join(' · ');
}
function renderConcurrentChooseDuke(state, concurrent) {
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
const completed = Array.isArray(concurrent.completed) ? concurrent.completed : [];
const isPending = !!(PLAYER_ID && pending.some(pid => idsMatch(pid, PLAYER_ID)));
const totalParticipants = pending.length + completed.length;
const players = state?.player_list || [];
const you = players.find(p => idsMatch(p.player_id, PLAYER_ID)) || null;
const waitingLabels = pendingPlayerLabels(state, pending);
const body = mk('prompt-modal-body');
const status = mk('prompt-modal-note');
status.textContent =
`Starting setup: ${completed.length}/${totalParticipants} duke choice(s) submitted.` +
(pending.length ? ` Waiting on: ${waitingLabels.join(', ')}.` : '');
body.appendChild(status);
if (!isPending) {
const youDone = !!(PLAYER_ID && completed.some(pid => idsMatch(pid, PLAYER_ID)));
const line = mk('prompt-modal-note');
line.textContent = youDone
? 'You have already chosen your duke. Waiting on the other player(s).'
: 'Starting setup is in progress.';
body.appendChild(line);
openPromptOverlayShell({
title: 'Choose your Duke',
subtitle: null,
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const dukes = Array.isArray(you?.owned_dukes) ? you.owned_dukes : [];
if (!dukes.length) {
body.appendChild(document.createTextNode('No dukes found to choose from.'));
openPromptOverlayShell({
title: 'Choose your Duke',
dismissible: false,
bodyEl: body,
footerEl: null,
});
return;
}
const list = mk('prompt-choice-list');
dukes.forEach(d => {
const id = d?.duke_id;
const name = d?.name || `Duke #${id}`;
const cardEl = mk('prompt-choice-card');
const inner = mk('prompt-choice-card-inner');
const url = cardImageUrl(d);
if (url) {
const wrap = mk('prompt-choice-card-img-wrap');
const img = document.createElement('img');
img.className = 'prompt-choice-card-img';
img.alt = '';
img.loading = 'lazy';
img.src = url;
img.onerror = () => wrap.remove();
wrap.appendChild(img);
inner.appendChild(wrap);
}
const main = mk('prompt-choice-card-main');
const nm = mk('prompt-choice-card-title');
nm.textContent = `${name} (#${id})`;
main.appendChild(nm);
const scalingLine = dukeScalingLine(d);
if (scalingLine) {
const sc = mk('prompt-choice-card-scaling');
sc.textContent = scalingLine;
main.appendChild(sc);
}
const blurb = dukePromptBlurb(d);
if (blurb) {
const tx = mk('prompt-choice-card-text');
tx.textContent = blurb;
main.appendChild(tx);
}
const row = mk('prompt-choice-card-actions');
row.appendChild(promptButton('Keep this duke', () => {
postGameAction({
player_id: PLAYER_ID,
action_type: 'submit_concurrent_action',
kind: 'choose_duke',
response: String(id),
});
}));
main.appendChild(row);
inner.appendChild(main);
cardEl.appendChild(inner);
list.appendChild(cardEl);
});
body.appendChild(list);
openPromptOverlayShell({
title: 'Choose 1 Duke to keep',
subtitle: null,
dismissible: false,
bodyEl: body,
footerEl: null,
});
}
function renderConcurrentFlipCitizen(state, concurrent) {
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
const completed = Array.isArray(concurrent.completed) ? concurrent.completed : [];
const isPending = !!(PLAYER_ID && pending.some(pid => idsMatch(pid, PLAYER_ID)));
const totalParticipants = pending.length + completed.length;
const data = concurrent.data || {};
const buyerId = (data.buyer_id || '').toString();
const buyer = playerById(state, buyerId);
const buyerTag = buyer?.name || buyerId || '';
const waitingLabels = pendingPlayerLabels(state, pending);
const body = mk('prompt-modal-body');
const status = mk('prompt-modal-note');
status.textContent =
`Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted.` +
(pending.length ? ` Waiting on: ${waitingLabels.join(', ')}.` : '') +
(buyerTag ? ` Triggered by ${buyerTag}.` : '');
body.appendChild(status);
if (!isPending) {
const youDone = !!(PLAYER_ID && completed.some(pid => idsMatch(pid, PLAYER_ID)));
const line = mk('prompt-modal-note');
line.textContent = youDone
? 'You already chose a citizen to flip. Waiting on other players.'
: 'You have no pending flip choice (no eligible citizens, or not in this prompt).';
body.appendChild(line);
openPromptOverlayShell({
title: 'Flip a citizen',
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const you = playerById(state, PLAYER_ID);
const citizens = Array.isArray(you?.owned_citizens) ? you.owned_citizens : [];
const choices = [];
citizens.forEach((c, idx) => {
if (!c || c.is_flipped) return;
choices.push({ idx, card: c, nm: (c.name || `Citizen #${idx}`).toString() });
});
if (!choices.length) {
body.appendChild(document.createTextNode('No face-up citizens on your tableau — contact host if this seems wrong.'));
openPromptOverlayShell({
title: 'Flip a citizen',
dismissible: false,
bodyEl: body,
footerEl: null,
});
return;
}
const list = mk('prompt-choice-list');
choices.forEach(({ idx, card, nm }) => {
const cardEl = mk('prompt-choice-card');
const titleEl = mk('prompt-choice-card-title');
titleEl.textContent = `${nm} (slot #${idx})`;
cardEl.appendChild(titleEl);
const metaParts = [];
if (card.roll_match1 !== undefined || card.roll_match2 !== undefined) {
metaParts.push(`Roll ${card.roll_match1 ?? ''}/${card.roll_match2 ?? ''}`);
}
if (card.gold_cost !== undefined) metaParts.push(`${card.gold_cost}g`);
if (metaParts.length) {
const meta = mk('prompt-choice-card-meta');
meta.textContent = metaParts.join(' · ');
cardEl.appendChild(meta);
}
const row = mk('prompt-choice-card-actions');
row.appendChild(promptButton('Flip this citizen face-down', () => {
postGameAction({
player_id: PLAYER_ID,
action_type: 'submit_concurrent_action',
kind: 'flip_one_citizen',
response: String(idx),
});
}));
cardEl.appendChild(row);
list.appendChild(cardEl);
});
body.appendChild(list);
openPromptOverlayShell({
title: 'Choose 1 citizen to flip face-down',
dismissible: false,
bodyEl: body,
footerEl: null,
});
}
function renderConcurrentPanel(state, concurrent) {
const kind = concurrent?.kind || '';
if (kind === 'choose_duke') return renderConcurrentChooseDuke(state, concurrent);
if (kind === 'flip_one_citizen') return renderConcurrentFlipCitizen(state, concurrent);
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
const body = mk('prompt-modal-body');
const note = mk('prompt-modal-note');
note.textContent =
`Waiting on concurrent action "${kind}" (${pending.length} player(s) still need to respond).`;
body.appendChild(note);
openPromptOverlayShell({
title: 'Waiting',
dismissible: true,
bodyEl: body,
footerEl: null,
});
}
function renderFinalizeRollPrompt(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const rolled1 = clampDie(state?.rolled_die_one ?? state?.die_one ?? 1);
const rolled2 = clampDie(state?.rolled_die_two ?? state?.die_two ?? 1);
const body = mk('prompt-modal-body');
if (!isYou) {
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId} to finalize the roll.`;
body.appendChild(note);
openPromptOverlayShell({
title: 'Finalize roll',
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const you = playerById(state, PLAYER_ID);
const options = listRollSetOneDieOptions(you, rolled1, rolled2, state.turn_number);
const diceLine = mk('prompt-modal-dice-line');
diceLine.appendChild(makeDie(rolled1));
diceLine.appendChild(document.createTextNode(' + '));
diceLine.appendChild(makeDie(rolled2));
diceLine.appendChild(document.createTextNode(` = ${rolled1 + rolled2}`));
body.appendChild(diceLine);
const foot = mk('prompt-modal-actions prompt-modal-actions--wrap');
foot.appendChild(promptButton(`Keep ${rolled1} + ${rolled2}`, () => sendFinalizeRollChoice(rolled1, rolled2)));
options.forEach(o => {
const fromVal = o.die === 1 ? rolled1 : rolled2;
const d1 = o.die === 1 ? o.target : rolled1;
const d2 = o.die === 2 ? o.target : rolled2;
foot.appendChild(promptButton(
`Die ${o.die}: ${fromVal}${o.target} (${o.costGold}g · ${o.domainName})`,
() => sendFinalizeRollChoice(d1, d2),
));
});
const hint = mk('prompt-modal-note');
hint.textContent = 'Choose a roll modifier or keep the rolled dice.';
body.appendChild(hint);
openPromptOverlayShell({
title: 'Finalize roll',
subtitle: null,
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderDomainSelfConvertPrompt(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const prc = state?.pending_required_choice || null;
const dn = (prc?.domain_name || 'Domain').toString();
const kv = prc?.kv || {};
const explain = selfConvertExplain(kv);
const body = mk('prompt-modal-body');
if (!isYou) {
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId}${dn} optional trade.`;
body.appendChild(note);
openPromptOverlayShell({
title: `${dn}: trade`,
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const sub = mk('prompt-modal-note');
sub.textContent = explain;
body.appendChild(sub);
const foot = promptActionsRow([
promptButton('Confirm trade', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'confirm_self_convert',
})),
promptButton('Decline', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'skip',
}), true),
]);
openPromptOverlayShell({
title: `${dn}: optional trade`,
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function harvestExchangeExplain(command) {
const parts = (command || '').trim().split(/\s+/);
if (parts.length < 5 || parts[0].toLowerCase() !== 'exchange') return (command || '').trim() || 'Optional harvest exchange.';
const pay = parts[1].toLowerCase();
const payN = parts[2];
const gain = parts[3].toLowerCase();
const gainN = parts[4];
const labels = { g: 'gold', s: 'strength', m: 'magic', v: 'victory points' };
return `Pay ${payN} ${labels[pay] || pay}, gain ${gainN} ${labels[gain] || gain}.`;
}
function renderHarvestOptionalExchangePrompt(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const prc = state?.pending_required_choice || null;
const cmd = (prc?.command || '').toString();
const explain = harvestExchangeExplain(cmd);
const body = mk('prompt-modal-body');
if (!isYou) {
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId} — optional citizen harvest exchange.`;
body.appendChild(note);
openPromptOverlayShell({
title: 'Harvest exchange',
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const sub = mk('prompt-modal-note');
sub.textContent = explain;
body.appendChild(sub);
const foot = promptActionsRow([
promptButton('Take exchange', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'confirm_harvest_exchange',
})),
promptButton('Skip (keep resources)', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'skip_harvest_exchange',
}), true),
]);
openPromptOverlayShell({
title: 'Harvest: optional exchange',
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderDomainChoosePlayer(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const prc = state?.pending_required_choice || null;
const opts = Array.isArray(prc?.options) ? prc.options : [];
const dn = (prc?.item?.domain_name || 'Domain').toString();
const explain = prc?.kind === 'domain_manipulate_player'
? domainManipulateExplain(prc)
: 'Choose another player.';
const body = mk('prompt-modal-body');
if (!isYou) {
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId} to choose a player for ${dn}.`;
body.appendChild(note);
openPromptOverlayShell({
title: `${dn}`,
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const sub = mk('prompt-modal-note');
sub.textContent = explain;
body.appendChild(sub);
const foot = mk('prompt-modal-actions prompt-modal-actions--wrap');
opts.forEach((o, idx) => {
const nm = (o?.name || o?.player_id || '?').toString();
foot.appendChild(promptButton(nm, () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: `choose_player ${idx + 1}`,
})));
});
const kv = prc?.item?.kv || {};
const skipLabel = prc?.allow_skip && domainEffectGainIsVp(kv)
? 'Decline (no pay, no VP)'
: 'Skip (optional)';
if (prc?.allow_skip) {
foot.appendChild(promptButton(skipLabel, () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'skip',
}), true));
}
openPromptOverlayShell({
title: `${dn}: choose another player`,
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderDomainChooseMonster(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const prc = state?.pending_required_choice || null;
const opts = Array.isArray(prc?.options) ? prc.options : [];
const dn = (prc?.domain_name || 'Domain').toString();
const delta = Number(prc?.delta) || 0;
const body = mk('prompt-modal-body');
if (!isYou) {
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId}${dn} (monster +${delta} strength cost).`;
body.appendChild(note);
openPromptOverlayShell({
title: dn,
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const foot = mk('prompt-modal-actions prompt-modal-actions--wrap');
opts.forEach((o, idx) => {
const nm = (o?.name || '?').toString();
foot.appendChild(promptButton(nm, () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: `choose_monster ${idx + 1}`,
})));
});
openPromptOverlayShell({
title: `${dn}: strengthen a center monster`,
subtitle: `Add +${delta} to strength cost`,
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function chooseOptionButtonLabel(opt, idx) {
const token = (opt?.token || '').toString();
const label = labelForChoiceToken(token);
const amt = Number(opt.amount);
const prettyAmt = Number.isFinite(amt) ? amt : opt.amount;
const tl = token.trim().toLowerCase();
if (tl === 'count_area') {
const area = (opt?.area ?? '').toString();
const res = (opt?.resource ?? '').toString().toLowerCase();
const mult = Number(opt?.mult);
const rLabel = labelForChoiceToken(res);
const mText = Number.isFinite(mult) ? mult : opt?.mult;
return `+(${mText} × ${area}) ${rLabel}`;
}
if (tl.startsWith('citizens.')) {
const name = (opt?.name ?? '').toString().trim();
const extras = Array.isArray(opt?.extras) ? opt.extras : [];
const extraText = extras.map(e => {
const et = (e?.token ?? '').toString().toLowerCase();
const ea = Number(e?.amount);
const el = labelForChoiceToken(et);
const an = Number.isFinite(ea) ? ea : e?.amount;
return `+${an} ${el}`;
}).join(' + ');
const extraSuffix = extraText ? ` + ${extraText}` : '';
const who = name ? `${name} citizen` : label;
return `Gain ${prettyAmt} ${who}${extraSuffix}`;
}
return `+${prettyAmt} ${label}`;
}
function renderChoosePrompt(state, chooseCmd) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const pendingChoice = state?.pending_required_choice || null;
let options = parseChooseCommand(chooseCmd);
if (
pendingChoice &&
pendingChoice.kind === 'special_payout_choose' &&
Array.isArray(pendingChoice.options) &&
pendingChoice.options.length
) {
options = pendingChoice.options;
}
const body = mk('prompt-modal-body');
if (!options.length || !isYou) {
const note = mk('prompt-modal-note');
note.textContent = !options.length
? `Waiting on required choice: ${chooseCmd}`
: `Waiting on ${reqId}${chooseCmd}`;
body.appendChild(note);
openPromptOverlayShell({
title: 'Choose one',
dismissible: !isYou || !options.length,
bodyEl: body,
footerEl: null,
});
return;
}
const foot = mk('prompt-modal-actions prompt-modal-actions--wrap');
options.forEach((opt, idx) => {
foot.appendChild(promptButton(chooseOptionButtonLabel(opt, idx), () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: `choose ${idx + 1}`,
})));
});
openPromptOverlayShell({
title: 'Choose one',
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderManualHarvestPrompt(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const slots = Array.isArray(state?.harvest_prompt_slots) ? state.harvest_prompt_slots : [];
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const chip = harvestTurnChip(state, reqId);
const body = mk('prompt-modal-body');
const headRow = mk('prompt-modal-inline');
const ht = mk('prompt-modal-note');
ht.textContent = isYou ? 'Harvest — choose order' : `Harvest in progress for ${reqId}`;
headRow.appendChild(ht);
if (chip) headRow.appendChild(chip);
body.appendChild(headRow);
if (!isYou || !slots.length) {
const note = mk('prompt-modal-note');
note.textContent = !isYou
? `${slots.length} card(s) remaining for this harvest.`
: !slots.length
? 'No harvest slots (try reconnecting).'
: '';
if (note.textContent) body.appendChild(note);
openPromptOverlayShell({
title: 'Harvest',
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
if (slots.some(s => s.kind === 'citizen' && s.is_thief)) {
const thief = mk('prompt-modal-note');
thief.textContent = 'If you have the Thief, harvest that citizen before other citizens.';
body.appendChild(thief);
}
const foot = mk('prompt-modal-actions prompt-modal-actions--wrap');
slots.forEach(s => {
const ai = Number(s.activation_index);
const dup = Number.isFinite(ai) && ai > 0 ? ` · #${ai + 1}` : '';
const ci = Number(s.card_idx);
const copy = Number.isFinite(ci) ? ` · copy ${ci + 1}` : '';
const label = `${s.name || ''} (${s.kind} #${s.card_id}${copy}${dup})`;
const sk = (s.slot_key || '').toString();
foot.appendChild(promptButton(`Harvest: ${label}`, () => postGameAction({
player_id: PLAYER_ID,
action_type: 'harvest_card',
harvest_slot_key: sk,
})));
});
openPromptOverlayShell({
title: 'Harvest',
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderBonusResourcePrompt(state) {
const req = state?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = !!(PLAYER_ID && idsMatch(reqId, PLAYER_ID));
const chip = harvestTurnChip(state, reqId);
const body = mk('prompt-modal-body');
const headRow = mk('prompt-modal-inline');
const ht = mk('prompt-modal-note');
ht.textContent = isYou ? 'Harvest bonus — choose +1 resource' : `Harvest bonus pending for ${reqId}`;
headRow.appendChild(ht);
if (chip) headRow.appendChild(chip);
body.appendChild(headRow);
if (!isYou) {
openPromptOverlayShell({
title: 'Harvest bonus',
dismissible: true,
bodyEl: body,
footerEl: null,
});
return;
}
const foot = promptActionsRow([
promptButton('+1 Gold', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'gold',
})),
promptButton('+1 Strength', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'strength',
})),
promptButton('+1 Magic', () => postGameAction({
player_id: PLAYER_ID,
action_type: 'act_on_required_action',
action: 'magic',
})),
]);
openPromptOverlayShell({
title: 'Harvest bonus',
dismissible: false,
bodyEl: body,
footerEl: foot,
});
}
function renderUnknownRequired(state, reqAction, reqId) {
const body = mk('prompt-modal-body');
const note = mk('prompt-modal-note');
note.textContent = `Waiting on ${reqId}: ${reqAction}`;
body.appendChild(note);
openPromptOverlayShell({
title: 'Waiting',
dismissible: true,
bodyEl: body,
footerEl: null,
});
}
function renderPromptModal(state) {
if (!GAME_ID || !PLAYER_ID) return;
const concurrent = state?.concurrent_action || null;
const concurrentPending = concurrent && Array.isArray(concurrent.pending) ? concurrent.pending : [];
if (concurrentPending.length > 0) {
renderConcurrentPanel(state, concurrent);
return;
}
const req = state?.action_required || {};
const reqId = req?.id || '';
const reqAction = (req?.action || '').toString();
if (!reqId || reqId === state?.game_id) {
removePromptOverlay();
return;
}
if (reqAction === 'standard_action') {
removePromptOverlay();
return;
}
if (reqAction === 'finalize_roll') {
if (finalizeRollModifierOptions(state).length === 0) {
removePromptOverlay();
return;
}
renderFinalizeRollPrompt(state);
return;
}
if (reqAction === 'domain_self_convert') {
renderDomainSelfConvertPrompt(state);
return;
}
if (reqAction === 'choose_player') {
renderDomainChoosePlayer(state);
return;
}
if (reqAction === 'choose_monster_strength') {
renderDomainChooseMonster(state);
return;
}
if (typeof reqAction === 'string' && reqAction.trim().startsWith('choose ')) {
renderChoosePrompt(state, reqAction);
return;
}
if (reqAction === 'harvest_optional_exchange') {
renderHarvestOptionalExchangePrompt(state);
return;
}
if (reqAction === 'manual_harvest') {
renderManualHarvestPrompt(state);
return;
}
if (reqAction !== 'bonus_resource_choice') {
renderUnknownRequired(state, reqAction, reqId);
return;
}
renderBonusResourcePrompt(state);
}
// ── Lobby modal when visiting without game_id / player_id ────────────────
function initLobbyModal() {
const overlay = document.getElementById('lobby-overlay');
const connEl = document.getElementById('conn-status');
const errEl = document.getElementById('lobby-error');
const stepJoin = document.getElementById('lobby-step-join');
const stepWait = document.getElementById('lobby-step-wait');
const nameInput = document.getElementById('lobby-display-name');
const joinBtn = document.getElementById('lobby-join-btn');
const readyBtn = document.getElementById('lobby-ready-btn');
const leaveBtn = document.getElementById('lobby-leave-btn');
const playerList = document.getElementById('lobby-player-list');
const metaEl = document.getElementById('lobby-meta');
if (!overlay || !stepJoin || !stepWait || !joinBtn || !readyBtn || !leaveBtn || !playerList || !nameInput) {
if (connEl) connEl.textContent = 'Missing game_id or player_id in URL';
return;
}
let lobbyPlayerId = '';
let lobbyWs = null;
let lobbyWsReconnectTimer = null;
let lastLobbySnapshot = null;
function shutdownLobbySocket() {
if (lobbyWs) {
lobbyWs.onopen = null;
lobbyWs.onmessage = null;
lobbyWs.onclose = null;
lobbyWs.onerror = null;
try {
lobbyWs.close();
} catch (_) {
/* ignore */
}
lobbyWs = null;
}
}
function tearDownLobbyConnection() {
if (lobbyWsReconnectTimer) {
clearTimeout(lobbyWsReconnectTimer);
lobbyWsReconnectTimer = null;
}
shutdownLobbySocket();
}
function setLobbyLiveStatus(mode) {
const liveEl = document.getElementById('lobby-live');
if (!liveEl) return;
liveEl.classList.remove('lobby-live--ok', 'lobby-live--warn', 'lobby-live--off');
if (mode === 'ok') {
liveEl.textContent = 'Live';
liveEl.classList.add('lobby-live--ok');
} else if (mode === 'warn') {
liveEl.textContent = 'Connecting…';
liveEl.classList.add('lobby-live--warn');
} else {
liveEl.textContent = 'Offline';
liveEl.classList.add('lobby-live--off');
}
}
function lobbyWsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/ws/lobby`;
}
function sendLobbyIdentify() {
if (!lobbyWs || lobbyWs.readyState !== WebSocket.OPEN) return;
const pid = lobbyPlayerId || localStorage.getItem('playerId') || '';
lobbyWs.send(JSON.stringify({ type: 'identify', player_id: pid || null }));
}
function connectLobbyWs() {
if (lobbyWsReconnectTimer) {
clearTimeout(lobbyWsReconnectTimer);
lobbyWsReconnectTimer = null;
}
shutdownLobbySocket();
setLobbyLiveStatus('warn');
lobbyWs = new WebSocket(lobbyWsUrl());
lobbyWs.onopen = () => {
setLobbyLiveStatus('ok');
sendLobbyIdentify();
};
lobbyWs.onmessage = evt => {
let msg;
try {
msg = JSON.parse(evt.data);
} catch (_) {
return;
}
if (msg.type === 'lobby_status') applyLobbyStatusPayload(msg);
else if (msg.type === 'game_started') handleGameStarted(msg);
};
lobbyWs.onclose = () => {
lobbyWs = null;
setLobbyLiveStatus('warn');
lobbyWsReconnectTimer = setTimeout(connectLobbyWs, 2200);
};
lobbyWs.onerror = () => {
try {
lobbyWs.close();
} catch (_) {
/* ignore */
}
};
}
function openOverlay() {
overlay.classList.add('lobby-overlay--open');
overlay.setAttribute('aria-hidden', 'false');
if (connEl) connEl.textContent = '● lobby';
}
function showLobbyError(msg) {
if (!errEl) return;
if (!msg) {
errEl.textContent = '';
errEl.classList.add('lobby-hidden');
return;
}
errEl.textContent = msg;
errEl.classList.remove('lobby-hidden');
}
function enterGameFromLobby(gameId, playerId) {
tearDownLobbyConnection();
localStorage.setItem('playerId', playerId);
localStorage.setItem('gameId', gameId);
const q = new URLSearchParams({ game_id: gameId, player_id: playerId });
location.replace(`${location.pathname}?${q}`);
}
function handleGameStarted(msg) {
const pid = lobbyPlayerId || localStorage.getItem('playerId');
const gid = msg.game_id;
const ids = msg.player_ids || [];
if (!pid || !gid) return;
if (!ids.some(x => idsMatch(x, pid))) return;
enterGameFromLobby(gid, pid);
}
function applyLobbyStatusPayload(data) {
lastLobbySnapshot = data;
if (data.in_game && data.game_id) {
const pid = lobbyPlayerId || localStorage.getItem('playerId');
if (pid) enterGameFromLobby(data.game_id, pid);
return;
}
const selfId = lobbyPlayerId || localStorage.getItem('playerId') || '';
const inList = selfId && (data.lobby || []).some(x => idsMatch(x.player_id, selfId));
if (selfId && stepWait && !stepWait.classList.contains('lobby-hidden') && !inList) {
showLobbyError('You are no longer in this lobby. Join again.');
lobbyPlayerId = '';
localStorage.removeItem('playerId');
tearDownLobbyConnection();
connectLobbyWs();
stepWait.classList.add('lobby-hidden');
stepJoin.classList.remove('lobby-hidden');
return;
}
if (metaEl) {
metaEl.textContent =
typeof data.game_count === 'number'
? `${data.game_count} active game${data.game_count === 1 ? '' : 's'} on this server`
: '';
}
renderLobbyRows(data.lobby || [], selfId);
const self = (data.lobby || []).find(x => idsMatch(x.player_id, selfId));
if (self) {
const ready = !!self.is_ready;
readyBtn.textContent = ready ? 'Cancel ready' : 'Ready';
readyBtn.classList.toggle('is-cancel', ready);
}
}
async function fetchLobbyPayload() {
const pid = lobbyPlayerId || localStorage.getItem('playerId') || '';
const url = pid
? `/api/lobby/status?player_id=${encodeURIComponent(pid)}`
: '/api/lobby/status';
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail != null ? String(data.detail) : res.statusText;
throw new Error(detail || 'Lobby request failed');
}
return data;
}
function renderLobbyRows(lobby, selfId) {
playerList.innerHTML = '';
lobby.forEach(p => {
const li = document.createElement('li');
li.className = 'lobby-player-row' + (idsMatch(p.player_id, selfId) ? ' is-self' : '');
const nameSpan = document.createElement('span');
nameSpan.className = 'lobby-p-name';
nameSpan.textContent = p.name || 'Player';
const stSpan = document.createElement('span');
stSpan.className = 'lobby-p-status' + (p.is_ready ? ' is-ready' : '');
stSpan.textContent = p.is_ready ? 'Ready' : 'Waiting';
li.appendChild(nameSpan);
li.appendChild(stSpan);
playerList.appendChild(li);
});
}
function showWaitUi() {
stepJoin.classList.add('lobby-hidden');
stepWait.classList.remove('lobby-hidden');
openOverlay();
sendLobbyIdentify();
}
async function tryResumeStoredPlayer() {
const saved = localStorage.getItem('playerId');
if (!saved) return;
try {
const res = await fetch(`/api/lobby/status?player_id=${encodeURIComponent(saved)}`);
const data = await res.json();
if (!res.ok) return;
if (data.in_game && data.game_id) {
enterGameFromLobby(data.game_id, saved);
return;
}
const stillThere = (data.lobby || []).some(p => idsMatch(p.player_id, saved));
if (stillThere) {
lobbyPlayerId = saved;
showWaitUi();
}
} catch (_) {
/* ignore */
}
}
joinBtn.addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) {
showLobbyError('Enter a display name.');
return;
}
showLobbyError('');
joinBtn.disabled = true;
try {
const res = await fetch('/api/lobby/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail != null ? String(data.detail) : res.statusText || 'Join failed');
}
lobbyPlayerId = data.player_id || '';
localStorage.setItem('playerId', lobbyPlayerId);
showWaitUi();
} catch (e) {
showLobbyError(e.message || 'Could not join lobby.');
} finally {
joinBtn.disabled = false;
}
});
nameInput.addEventListener('keydown', ev => {
if (ev.key === 'Enter') joinBtn.click();
});
readyBtn.addEventListener('click', async () => {
const pid = lobbyPlayerId || localStorage.getItem('playerId');
if (!pid) return;
readyBtn.disabled = true;
try {
let st = lastLobbySnapshot;
if (!st) {
try {
st = await fetchLobbyPayload();
} catch (e) {
showLobbyError(e.message || 'Could not reach lobby.');
return;
}
}
const self = (st.lobby || []).find(x => idsMatch(x.player_id, pid));
const endpoint = self && self.is_ready ? '/api/lobby/unready' : '/api/lobby/ready';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ player_id: pid, debug_starting_resources: false }),
});
const out = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(out.detail != null ? String(out.detail) : res.statusText || 'Ready failed');
}
if (out.game_id) {
enterGameFromLobby(out.game_id, pid);
return;
}
} catch (e) {
showLobbyError(e.message || 'Ready toggle failed.');
} finally {
readyBtn.disabled = false;
}
});
leaveBtn.addEventListener('click', async () => {
const pid = lobbyPlayerId || localStorage.getItem('playerId');
if (!pid) return;
leaveBtn.disabled = true;
try {
await fetch(`/api/lobby/leave?player_id=${encodeURIComponent(pid)}`, { method: 'POST' });
} catch (_) {
/* still reset UI */
}
lobbyPlayerId = '';
localStorage.removeItem('playerId');
showLobbyError('');
sendLobbyIdentify();
stepWait.classList.add('lobby-hidden');
stepJoin.classList.remove('lobby-hidden');
leaveBtn.disabled = false;
});
openOverlay();
connectLobbyWs();
tryResumeStoredPlayer();
}
// ── Boot ──────────────────────────────────────────────────────────────────
if (!GAME_ID || !PLAYER_ID) {
initLobbyModal();
} else {
connect();
initOpponentTableauWheelScroll();
window.addEventListener('resize', () => scheduleBoardLayout());
const seat0 = document.getElementById('seat-0');
const zoneCenter = document.getElementById('zone-center');
if (typeof ResizeObserver !== 'undefined') {
if (seat0) new ResizeObserver(() => scheduleBoardLayout()).observe(seat0);
if (zoneCenter) new ResizeObserver(() => scheduleBoardLayout()).observe(zoneCenter);
}
}