'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 0–4. // Seat 0 = me (bottom), seats 1–4 = 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 1–4 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); } }