diff --git a/server.py b/server.py index fdb6754..895f7f0 100644 --- a/server.py +++ b/server.py @@ -6,17 +6,21 @@ Simple REST API to replace the socket-based protocol import time import uuid +from pathlib import Path from typing import Dict, List, Optional from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse +from fastapi.responses import FileResponse from pydantic import BaseModel import shortuuid from game import Game, LobbyMember, GameMember, load_game_data, GameObjectEncoder import json +_REPO_ROOT = Path(__file__).resolve().parent +_DEV_CLIENT_INDEX = _REPO_ROOT / "static" / "dev-client" / "index.html" + app = FastAPI(title="VCK Online API", description="Development server for Valeria Card Kingdoms Online") # CORS middleware for web client @@ -449,2768 +453,10 @@ except: pass # static directory might not exist -@app.get("/", response_class=HTMLResponse) +@app.get("/") async def root(): """Simple HTML client for testing""" - return """ - - - - VCK Online - Dev Client - - - -

VCK Online - Development Client

- -
-

Lobby

-
- - - -
-
- -
-
-
-
- -
-

Game

-
-
-
-
-
-
-
-
-
-
-
-
- -
- - -
-
-
-
-
-
-
- -
-
-
-
-

Game log

-
-
- -
-
Tableau seats: buttons are arranged in turn order around the Board.
-
-
-
- Game state JSON -

-            
-
- - - - - - - """ + return FileResponse(_DEV_CLIENT_INDEX, media_type="text/html") if __name__ == "__main__": diff --git a/static/dev-client/dev-client.css b/static/dev-client/dev-client.css new file mode 100644 index 0000000..45f13be --- /dev/null +++ b/static/dev-client/dev-client.css @@ -0,0 +1,213 @@ +body { font-family: Arial, sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; } + .section { border: 1px solid #ccc; padding: 15px; margin: 10px 0; } + button { padding: 8px 15px; margin: 5px; cursor: pointer; } + input { padding: 5px; margin: 5px; } + .lobby-player { padding: 5px; margin: 2px; background: #f0f0f0; } + .ready { background: #90EE90; } + pre { background: #f5f5f5; padding: 10px; overflow-x: auto; } + details { margin-top: 10px; } + details > summary { cursor: pointer; font-weight: 700; user-select: none; } + .dice-row { margin: 10px 0; } + .dice { display: flex; gap: 10px; align-items: center; } + .dice-panel { + margin-top: 8px; + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 10px; + background: #fff; + } + .dice-panel-layout { + display: grid; + grid-template-columns: auto minmax(260px, 1fr) auto; + gap: 14px; + align-items: start; + } + .dice-panel-col { min-width: 0; } + .dice-panel label { font-size: 13px; } + .dice-panel-fields { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; align-items: center; } + .dice-panel-fields input[type="number"] { width: 70px; } + .dice-panel-hint { margin-top: 6px; font-size: 12px; color: #444; } + .roll-effects { margin-top: 8px; font-size: 12px; color: #333; } + .roll-effects ul { margin: 4px 0 0 18px; padding: 0; } + @media (max-width: 980px) { + .dice-panel-layout { grid-template-columns: 1fr; } + } + .die { + width: 44px; height: 44px; + border: 2px solid #222; + border-radius: 10px; + background: #fff; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + padding: 6px; + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + } + .die.increase { border-color: #0a6; color: #0a6; } + .die.decrease { border-color: #b00; color: #b00; } + .pip { width: 8px; height: 8px; border-radius: 50%; background: #111; justify-self: center; align-self: center; } + .die.increase .pip { background: currentColor; } + .die.decrease .pip { background: currentColor; } + .pip.off { opacity: 0; } + .dice-meta { color: #333; font-size: 14px; } + .delta-wrap { display: flex; flex-wrap: wrap; gap: 10px; } + .delta-card { + border: 1px solid #ddd; + background: #fafafa; + border-radius: 8px; + padding: 6px 10px; + font-size: 13px; + color: #222; + } + .delta-grid { + display: grid; + grid-template-columns: minmax(110px, 1fr) repeat(4, 64px); + column-gap: 10px; + row-gap: 2px; + align-items: baseline; + } + .delta-name { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .delta-cell { display: grid; grid-template-columns: auto 1fr; column-gap: 6px; } + .delta-label { color: #666; font-weight: 700; } + .delta-value { text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; } + .delta-pos { color: #0a6; font-weight: 700; } + .delta-neg { color: #b00; font-weight: 700; } + .delta-zero { color: #666; font-weight: 700; } + .delta-totals { color: #111; font-weight: 700; } + .delta-muted { color: #666; font-weight: 600; } + + /* Tableau modal (dev UI) */ + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + display: none; + z-index: 9999; + padding: 30px; + } + .modal-backdrop.open { display: block; } + .modal-panel { + background: #fff; + border-radius: 12px; + max-width: 980px; + margin: 0 auto; + max-height: calc(100vh - 60px); + overflow: auto; + border: 1px solid rgba(0,0,0,0.15); + box-shadow: 0 18px 60px rgba(0,0,0,0.35); + } + .modal-header { + position: sticky; + top: 0; + background: #fff; + border-bottom: 1px solid #eee; + padding: 12px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .modal-title { font-weight: 800; } + .modal-close { + border: 1px solid #ddd; + background: #fafafa; + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; + font-weight: 700; + } + .modal-body { padding: 14px; } + .kv { display: flex; gap: 8px; flex-wrap: wrap; margin: 6px 0 12px; } + .pill { + border: 1px solid #e2e2e2; + background: #f8f8f8; + border-radius: 999px; + padding: 4px 10px; + font-size: 13px; + color: #222; + } + .tableau-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + @media (max-width: 860px) { .tableau-grid { grid-template-columns: 1fr; } } + .tableau-card { + border: 1px solid #e6e6e6; + border-radius: 10px; + background: #fff; + padding: 10px; + } + .tableau-card h3 { margin: 0 0 8px 0; font-size: 16px; } + .mini { font-size: 13px; color: #444; } + .list { display: flex; flex-direction: column; gap: 6px; } + .item { + border: 1px solid #eee; + background: #fafafa; + border-radius: 8px; + padding: 8px 10px; + } + .item-title { font-weight: 800; } + .item-sub { color: #555; font-size: 13px; margin-top: 3px; } + + /* Tableau seat buttons (around Board) */ + .tableau-actions { margin-top: 10px; } + .tableau-seat-layout { + position: relative; + width: 100%; + max-width: 760px; + height: 220px; + margin-top: 8px; + border: 1px solid #e6e6e6; + border-radius: 12px; + background: #fff; + } + @media (max-width: 560px) { .tableau-seat-layout { height: 260px; } } + .tableau-seat-btn { + position: absolute; + transform: translate(-50%, -50%); + white-space: nowrap; + border: 1px solid #ddd; + background: #fafafa; + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; + font-weight: 700; + } + .tableau-seat-btn.first-seat { + border-color: #b7a200; + background: #fff8cc; + } + .tableau-seat-btn.board-seat { + background: #111; + border-color: #111; + color: #fff; + } + + /* Payment editor (hire / build / slay) */ + .pay-row { display: flex; align-items: flex-start; gap: 8px; flex-wrap: wrap; } + .cost-line { flex: 1; min-width: 200px; } + .pay-controls { display: block; margin-top: 4px; } + .pay-controls input[type="number"] { width: 58px; } + + .game-log-wrap { + margin-top: 14px; + border: 1px solid #ccc; + border-radius: 8px; + background: #fafafa; + overflow: hidden; + } + .game-log-wrap h3 { + margin: 0; + padding: 8px 10px; + font-size: 14px; + background: #eee; + border-bottom: 1px solid #ddd; + } + #gameLog { + max-height: 220px; + overflow-y: auto; + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; + font-family: ui-monospace, Menlo, Monaco, "Courier New", monospace; + color: #222; + } + .game-log-line { margin: 2px 0; } + .game-log-tick { color: #666; margin-right: 6px; user-select: none; } diff --git a/static/dev-client/dev-client.js b/static/dev-client/dev-client.js new file mode 100644 index 0000000..49c3ec1 --- /dev/null +++ b/static/dev-client/dev-client.js @@ -0,0 +1,2448 @@ +let playerId = localStorage.getItem('playerId') || ''; + let currentGameId = localStorage.getItem('gameId') || ''; + let lastGameState = null; + let lastRenderedGameLogKey = null; + let finalizeRollInFlight = false; + let autoHarvestInFlight = false; + + function getDebugStartingResourcesEnabled() { + return localStorage.getItem('debugStartingResourcesEnabled') === 'true'; + } + + function getAutoHarvestEnabled() { + const v = localStorage.getItem('autoHarvestEnabled'); + if (v === null) return true; + return v === 'true'; + } + + function syncDebugStartingResourcesUiFromStorage() { + const el = document.getElementById('debugStartingResourcesEnabled'); + if (!el) return; + el.checked = getDebugStartingResourcesEnabled(); + } + + function wireDebugStartingResourcesUi() { + const el = document.getElementById('debugStartingResourcesEnabled'); + if (!el) return; + el.addEventListener('change', () => { + localStorage.setItem('debugStartingResourcesEnabled', String(!!el.checked)); + }); + syncDebugStartingResourcesUiFromStorage(); + } + + function syncAutoHarvestUiFromStorage() { + const el = document.getElementById('autoHarvestEnabled'); + if (!el) return; + el.checked = getAutoHarvestEnabled(); + } + + function wireAutoHarvestUi() { + const el = document.getElementById('autoHarvestEnabled'); + if (!el) return; + el.addEventListener('change', () => { + localStorage.setItem('autoHarvestEnabled', String(!!el.checked)); + }); + syncAutoHarvestUiFromStorage(); + } + + function clampDie(n) { + const x = Number(n); + if (!Number.isFinite(x)) return 1; + return Math.max(1, Math.min(6, Math.trunc(x))); + } + + function getDiceRigSettings() { + const enabled = localStorage.getItem('diceRigEnabled') === 'true'; + const d1 = clampDie(localStorage.getItem('diceRigDie1') || 1); + const d2 = clampDie(localStorage.getItem('diceRigDie2') || 1); + return { enabled, d1, d2 }; + } + + function setDiceRigSettings(next) { + localStorage.setItem('diceRigEnabled', String(!!next.enabled)); + localStorage.setItem('diceRigDie1', String(clampDie(next.d1))); + localStorage.setItem('diceRigDie2', String(clampDie(next.d2))); + } + + function syncDiceRigUiFromStorage() { + const enabledEl = document.getElementById('diceOverrideEnabled'); + const d1El = document.getElementById('diceOverrideDie1'); + const d2El = document.getElementById('diceOverrideDie2'); + if (!enabledEl || !d1El || !d2El) return; + const s = getDiceRigSettings(); + enabledEl.checked = !!s.enabled; + d1El.value = String(s.d1); + d2El.value = String(s.d2); + d1El.disabled = !s.enabled; + d2El.disabled = !s.enabled; + } + + function wireDiceRigUi() { + const enabledEl = document.getElementById('diceOverrideEnabled'); + const d1El = document.getElementById('diceOverrideDie1'); + const d2El = document.getElementById('diceOverrideDie2'); + if (!enabledEl || !d1El || !d2El) return; + + const onChange = () => { + setDiceRigSettings({ + enabled: enabledEl.checked, + d1: d1El.value, + d2: d2El.value, + }); + syncDiceRigUiFromStorage(); + // If we're currently waiting on a pending roll, apply immediately. + if (lastGameState) maybeFinalizePendingRoll(lastGameState); + }; + + enabledEl.addEventListener('change', onChange); + d1El.addEventListener('change', onChange); + d2El.addEventListener('change', onChange); + d1El.addEventListener('input', onChange); + d2El.addEventListener('input', onChange); + + syncDiceRigUiFromStorage(); + } + // Poll handle used while a concurrent (non-ordered) prompt is active so + // every browser session sees other players' progress in near-real-time. + // Intentionally NOT an unconditional global poll: the standard-action panel + // rebuilds payment inputs from gameState, and polling would wipe in-progress edits. + // We do poll when waiting on others (passive) or during concurrent_action (below). + let concurrentPollHandle = null; + let passiveGamePollHandle = null; + if (playerId) { + document.getElementById('playerId').textContent = 'Player ID: ' + playerId; + } + wireDiceRigUi(); + wireDebugStartingResourcesUi(); + wireAutoHarvestUi(); + + async function joinLobby() { + const name = document.getElementById('playerName').value; + if (!name) { + alert('Please enter a name'); + return; + } + try { + const response = await fetch('/api/lobby/join', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: name}) + }); + const data = await response.json(); + playerId = data.player_id; + localStorage.setItem('playerId', playerId); + document.getElementById('playerId').textContent = 'Player ID: ' + playerId; + getLobbyStatus(); + } catch (error) { + alert('Error: ' + error.message); + } + } + + async function getLobbyStatus() { + if (!playerId) return; + try { + const response = await fetch(`/api/lobby/status?player_id=${playerId}`); + const data = await response.json(); + + let html = '

Players in Lobby:

'; + data.lobby.forEach(p => { + const debugTag = p.debug_starting_resources ? ' (debug 100/100/100)' : ''; + html += `
+ ${p.name}${debugTag} - ${p.is_ready ? 'Ready' : 'Not Ready'} + ${p.player_id === playerId ? '' : ''} +
`; + }); + html += `

Active games: ${data.game_count}

`; + if (data.in_game) { + html += `

You are in game: ${data.game_id}

`; + if (data.game_id && data.game_id !== currentGameId) { + currentGameId = data.game_id; + localStorage.setItem('gameId', currentGameId); + // Fetch immediately when we first learn the game id + getGameState(false); + } + } else { + // If server says we're not in a game, clear any stale id + if (currentGameId) { + currentGameId = ''; + localStorage.removeItem('gameId'); + stopGamePollingIntervals(); + } + } + document.getElementById('lobbyStatus').innerHTML = html; + } catch (error) { + console.error('Error:', error); + } + } + + async function toggleReady() { + if (!playerId) return; + try { + const response = await fetch('/api/lobby/ready', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + debug_starting_resources: getDebugStartingResourcesEnabled(), + }) + }); + const data = await response.json(); + if (data.game_id) { + currentGameId = data.game_id; + localStorage.setItem('gameId', currentGameId); + alert('Game started! Game ID: ' + data.game_id); + // Immediately fetch state so the Game section fills in + getGameState(false); + } + getLobbyStatus(); + } catch (error) { + console.error('Error:', error); + } + } + + function applyGameStateClientUpdate(data) { + lastGameState = data; + renderDice(data); + syncDiceRigUiFromStorage(); + maybeFinalizePendingRoll(data); + const pre = document.getElementById('gameState'); + if (pre) pre.textContent = JSON.stringify(data, null, 2); + updateConcurrentPolling(data); + updatePassiveGamePolling(data); + refreshTableauActionButtons(data); + maybeAutoHarvest(data); + } + + function maybeAutoHarvest(gameState) { + if (!playerId || !currentGameId) return; + if (!gameState || autoHarvestInFlight) return; + if (!getAutoHarvestEnabled()) return; + const concurrent = gameState?.concurrent_action || null; + if (concurrent && Array.isArray(concurrent.pending) && concurrent.pending.length > 0) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const reqAction = (req?.action || '').toString(); + if (reqAction !== 'manual_harvest') return; + if (!reqId || reqId !== playerId) return; + const slots = Array.isArray(gameState?.harvest_prompt_slots) ? gameState.harvest_prompt_slots : []; + if (slots.length !== 1) return; + const slotKey = (slots[0]?.slot_key || '').toString().trim(); + if (!slotKey) return; + autoHarvestInFlight = true; + sendHarvestCard(slotKey, { suppressAlert: true }) + .finally(() => { autoHarvestInFlight = false; }); + } + + async function maybeFinalizePendingRoll(gameState) { + if (!playerId || !currentGameId) return; + if (!gameState) return; + if (finalizeRollInFlight) return; + const phase = (gameState.phase || '').toString(); + if (phase !== 'roll_pending') return; + + const req = gameState.action_required || {}; + const reqId = (req.id || '').toString(); + const reqAction = (req.action || '').toString(); + if (reqAction !== 'finalize_roll') return; + if (reqId !== playerId) return; + + const rolled1 = clampDie(gameState.rolled_die_one ?? gameState.die_one ?? 1); + const rolled2 = clampDie(gameState.rolled_die_two ?? gameState.die_two ?? 1); + const s = getDiceRigSettings(); + if (!s.enabled) { + const player = Array.isArray(gameState?.player_list) + ? gameState.player_list.find(p => (p?.player_id || '') === playerId) + : null; + const opts = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number); + if (opts.length > 0) { + // Do not auto-finalize when roll modifiers are available. + return; + } + } + const final1 = s.enabled ? clampDie(s.d1) : rolled1; + const final2 = s.enabled ? clampDie(s.d2) : rolled2; + + finalizeRollInFlight = true; + try { + const res = await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'finalize_roll', + die_one: final1, + die_two: final2 + }) + }); + const payload = await res.json(); + if (!res.ok) { + console.error(payload); + return; + } + if (payload && payload.game_state) { + applyGameStateClientUpdate(payload.game_state); + } else { + getGameState(false); + } + } catch (e) { + console.error(e); + } finally { + finalizeRollInFlight = false; + } + } + + 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 (!playerId || !currentGameId) return; + if (finalizeRollInFlight) return; + finalizeRollInFlight = true; + try { + const res = await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'finalize_roll', + die_one: clampDie(d1), + die_two: clampDie(d2) + }) + }); + const payload = await res.json(); + if (!res.ok) { + alert(payload?.detail || 'Finalize roll failed'); + return; + } + if (payload && payload.game_state) applyGameStateClientUpdate(payload.game_state); + else getGameState(false); + } catch (e) { + console.error(e); + } finally { + finalizeRollInFlight = false; + } + } + + function refreshTableauActionButtons(gameState) { + const wrap = document.getElementById('tableauSeatLayout'); + if (!wrap) return; + wrap.innerHTML = ''; + const players = gameState && Array.isArray(gameState.player_list) ? gameState.player_list : []; + const possessive = (name) => { + const s = (name ?? '').toString().trim(); + if (!s) return 'Player'; + const lower = s.toLowerCase(); + if (lower.endsWith('s')) return `${s}'`; + return `${s}'s`; + }; + + // Board button (center) + const boardBtn = document.createElement('button'); + boardBtn.type = 'button'; + boardBtn.className = 'tableau-seat-btn board-seat'; + boardBtn.textContent = 'Board'; + boardBtn.style.left = '50%'; + boardBtn.style.top = '50%'; + boardBtn.onclick = () => { openBoardTableau(); }; + wrap.appendChild(boardBtn); + + const cleanPlayers = players.filter(p => p && p.player_id); + const n = cleanPlayers.length; + if (!n) return; + + const seatAnglesDeg = (count) => { + if (count === 1) return [-90]; + if (count === 2) return [180, 0]; // left / right + if (count === 3) return [-90, 150, 30]; // triangle around board + if (count === 4) return [-90, 0, 90, 180]; // top / right / bottom / left + // 5+ evenly spaced circle, starting at top, clockwise + const out = []; + for (let i = 0; i < count; i++) out.push(-90 + (360 * i) / count); + return out; + }; + + const angles = seatAnglesDeg(n); + const w = wrap.clientWidth || 760; + const h = wrap.clientHeight || 220; + const radius = Math.max(70, Math.min(w, h) * 0.42); + const firstPid = cleanPlayers[0]?.player_id || ''; + + cleanPlayers.forEach((p, idx) => { + const pid = p.player_id; + const nm = ((p.name ?? '').toString().trim() || pid); + const isSelf = pid === playerId; + const isFirst = pid === firstPid; + const label = isSelf ? 'My Tableau' : `${possessive(nm)} Tableau`; + + const deg = angles[idx % angles.length]; + const rad = (deg * Math.PI) / 180; + const x = (w / 2) + radius * Math.cos(rad); + const y = (h / 2) + radius * Math.sin(rad); + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tableau-seat-btn' + (isFirst ? ' first-seat' : ''); + btn.textContent = isFirst ? `${label} (First)` : label; + btn.style.left = `${x}px`; + btn.style.top = `${y}px`; + btn.onclick = () => { openSeatTableau(pid); }; + wrap.appendChild(btn); + }); + } + + async function getGameState(forcePrompt = true) { + let gameId = currentGameId; + if (!gameId && forcePrompt) { + gameId = prompt('Enter game ID:'); + } + if (!gameId) return; + currentGameId = gameId; + localStorage.setItem('gameId', currentGameId); + try { + const qs = playerId ? `?player_id=${encodeURIComponent(playerId)}` : ''; + const response = await fetch(`/api/game/${gameId}/state${qs}`); + const data = await response.json(); + applyGameStateClientUpdate(data); + } catch (error) { + alert('Error: ' + error.message); + } + } + + async function ensureGameStateForTableau() { + if (!currentGameId) { + alert('No game id yet. Start a game or refresh lobby status first.'); + return false; + } + if (lastGameState && lastGameState.game_id === currentGameId) return true; + try { + const qs = playerId ? `?player_id=${encodeURIComponent(playerId)}` : ''; + const response = await fetch(`/api/game/${currentGameId}/state${qs}`); + const data = await response.json(); + applyGameStateClientUpdate(data); + return true; + } catch (e) { + alert('Error: ' + e.message); + return false; + } + } + + function stopGamePollingIntervals() { + if (concurrentPollHandle) { + clearInterval(concurrentPollHandle); + concurrentPollHandle = null; + } + if (passiveGamePollHandle) { + clearInterval(passiveGamePollHandle); + passiveGamePollHandle = null; + } + } + + function updateConcurrentPolling(gameState) { + // Only poll while a concurrent action is active. This keeps + // non-pending players' UI honest as others submit, without + // disturbing the rest of the in-game UI (which rebuilds inputs + // on every render). + const ca = gameState?.concurrent_action || null; + const pend = ca && Array.isArray(ca.pending) ? ca.pending : []; + const shouldPoll = pend.length > 0; + if (shouldPoll && !concurrentPollHandle) { + concurrentPollHandle = setInterval(() => { + if (!currentGameId) return; + getGameState(false); + }, 1500); + } else if (!shouldPoll && concurrentPollHandle) { + clearInterval(concurrentPollHandle); + concurrentPollHandle = null; + } + } + + function localGameUiIsFragile(gameState) { + if (!playerId || !gameState) return false; + const ca = gameState.concurrent_action || null; + const pend = ca && Array.isArray(ca.pending) ? ca.pending : []; + if (pend.length && pend.includes(playerId)) return true; + const req = gameState.action_required || {}; + const reqId = req.id || ''; + const reqAction = (req.action || '').toString(); + if (!reqId || reqId === gameState.game_id) return false; + if (reqId !== playerId) return false; + if (reqAction === 'manual_harvest') return true; + if (reqAction === 'bonus_resource_choice') return true; + const trimmed = reqAction.trim(); + if (trimmed.startsWith('choose ')) return true; + if (trimmed === 'choose_player' || trimmed === 'choose_monster_strength' || trimmed === 'domain_self_convert') return true; + if (reqAction === 'standard_action' && (gameState.phase || '') === 'action') return true; + return false; + } + + function updatePassiveGamePolling(gameState) { + if (!currentGameId) { + if (passiveGamePollHandle) { + clearInterval(passiveGamePollHandle); + passiveGamePollHandle = null; + } + return; + } + const ca = gameState?.concurrent_action || null; + const pend = ca && Array.isArray(ca.pending) ? ca.pending : []; + const concurrentBlocking = pend.length > 0; + const fragile = localGameUiIsFragile(gameState); + const shouldPoll = !concurrentBlocking && !fragile; + + if (shouldPoll && !passiveGamePollHandle) { + passiveGamePollHandle = setInterval(() => { + if (!currentGameId) { + clearInterval(passiveGamePollHandle); + passiveGamePollHandle = null; + return; + } + getGameState(false); + }, 2000); + } else if (!shouldPoll && passiveGamePollHandle) { + clearInterval(passiveGamePollHandle); + passiveGamePollHandle = null; + } + } + + function escapeHtml(s) { + return (s ?? '').toString() + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function openModal() { + const m = document.getElementById('tableauModal'); + if (m) m.classList.add('open'); + } + + function closeTableau() { + const m = document.getElementById('tableauModal'); + if (m) m.classList.remove('open'); + } + + function onTableauBackdropClick(e) { + // Click-out closes (panel stops propagation) + closeTableau(); + } + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeTableau(); + }); + + function pill(label, value) { + return `${escapeHtml(label)}: ${escapeHtml(value)}`; + } + + 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 renderCardItem(card, count = 1) { + if (!card || typeof card !== 'object') { + return `
${escapeHtml(String(card))}
`; + } + const name = card.name || card.title || '(unnamed)'; + const id = card.starter_id || card.citizen_id || card.monster_id || card.domain_id || card.duke_id || card.id || ''; + const isCitizen = card.citizen_id !== undefined && card.citizen_id !== null; + + const hints = []; + if (card.roll_match1 !== undefined || card.roll_match2 !== undefined) { + const rm1 = card.roll_match1 ?? ''; + const rm2 = card.roll_match2 ?? ''; + hints.push(`Roll: ${rm1}${rm2 !== '' ? '/' + rm2 : ''}`); + } + if (card.gold_cost !== undefined) hints.push(`Gold cost: ${card.gold_cost}`); + if (card.strength_cost !== undefined) hints.push(`Strength cost: ${card.strength_cost}`); + if (card.magic_cost !== undefined) hints.push(`Magic cost: ${card.magic_cost}`); + pushHarvestHints(hints, card); + if (isCitizen && card.is_flipped) hints.push('Flipped — no harvest payout / roll spend counts'); + + const { sn, hn, son, wn } = citizenRoleCounts(card); + const roleParts = []; + if (sn > 0) roleParts.push(`Shadow +${sn}`); + if (hn > 0) roleParts.push(`Holy +${hn}`); + if (son > 0) roleParts.push(`Soldier +${son}`); + if (wn > 0) roleParts.push(`Worker +${wn}`); + const isDomain = card.domain_id !== undefined && card.domain_id !== null; + const showRoleRow = (isCitizen || isDomain) && roleParts.length; + const roleBlock = showRoleRow + ? `
Roles: ${escapeHtml(roleParts.join(' · '))}
` + : ''; + + const subtitle = hints.length ? `
${escapeHtml(hints.join(' · '))}
` : ''; + const fullText = cardFullText(card); + const rulesText = fullText + ? `
${escapeHtml(fullText)}
` + : ''; + const idText = id !== '' ? ` (#${escapeHtml(id)})` : ''; + const qty = Number(count) || 1; + const qtyText = qty > 1 ? ` x${qty}` : ''; + return `
${escapeHtml(name)}${qtyText}${idText}
${subtitle}${roleBlock}${rulesText}
`; + } + + 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 we saw non-objects in the list, just fall back to rendering raw items. + 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; + }); + } + + function renderCardList(title, cards) { + const arr = Array.isArray(cards) ? cards : []; + if (!arr.length) { + return `

${escapeHtml(title)}

none
`; + } + const grouped = groupCardsForTableau(arr); + // If grouping failed (unexpected contents), keep the original behavior. + if (!grouped) { + return `
+

${escapeHtml(title)} (${arr.length})

+
${arr.map(renderCardItem).join('')}
+
`; + } + return `
+

${escapeHtml(title)} (${arr.length} total, ${grouped.length} types)

+
${grouped.map(x => renderCardItem(x.card, x.count)).join('')}
+
`; + } + + function cardFullText(card) { + if (!card || typeof card !== 'object') return ''; + + // Prefer an explicit "text" field (Domains have this). + const rawText = (card.text ?? '').toString().trim(); + if (rawText) return rawText; + + // Otherwise synthesize from other special/effect fields we already serialize. + const parts = []; + // Include baseline harvest payouts so market rows show core card behavior, + // not just special/passive text. + pushHarvestHints(parts, card); + // Monsters use reward fields instead of harvest payout fields. + 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}`); + + // Dukes don't currently have rules text in data, so show their multipliers as the "text". + if (card.duke_id !== undefined) { + 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; + // Duke resource scaling is "per N resources" (reciprocal display). + 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); + if (mults.length) parts.unshift(mults.join(' · ')); + } + + // Newlines separate rule lines when shown in pre-wrap text. + return parts.join('\n').trim(); + } + + function renderPlayerTableau(gameState, targetPlayerId, isSelfView) { + const titleEl = document.getElementById('tableauTitle'); + const bodyEl = document.getElementById('tableauBody'); + if (!bodyEl) return; + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const subject = players.find(p => p?.player_id === targetPlayerId) || null; + + if (isSelfView) { + if (!playerId) { + if (titleEl) titleEl.textContent = 'My Tableau'; + bodyEl.innerHTML = `

Not joined

Join the lobby first so we know your player id.
`; + return; + } + if (!subject) { + if (titleEl) titleEl.textContent = 'My Tableau'; + bodyEl.innerHTML = `

Player not in game

No player with id ${escapeHtml(playerId)} found in this game state.
`; + return; + } + } else if (!subject) { + if (titleEl) titleEl.textContent = 'Tableau'; + bodyEl.innerHTML = `

Player not in game

No player with id ${escapeHtml(targetPlayerId)} in this game state.
`; + return; + } + + const displayName = ((subject.name ?? '').toString().trim() || subject.player_id || 'Player'); + if (titleEl) { + const possessive = (name) => { + const s = (name ?? '').toString().trim(); + if (!s) return 'Player'; + const lower = s.toLowerCase(); + if (lower.endsWith('s')) return `${s}'`; + return `${s}'s`; + }; + titleEl.textContent = isSelfView ? 'My Tableau' : `${possessive(displayName)} Tableau`; + } + + const resourceRow = ` +
+ ${pill('Gold', subject.gold_score ?? 0)} + ${pill('Strength', subject.strength_score ?? 0)} + ${pill('Magic', subject.magic_score ?? 0)} + ${pill('Victory', subject.victory_score ?? 0)} + ${pill('Shadow', subject.shadow_count ?? 0)} + ${pill('Holy', subject.holy_count ?? 0)} + ${pill('Soldier', subject.soldier_count ?? 0)} + ${pill('Worker', subject.worker_count ?? 0)} +
+ `; + + const dukes = Array.isArray(subject.owned_dukes) ? subject.owned_dukes : []; + const duke = dukes.length ? dukes[0] : null; + const dukeName = duke ? (duke?.name || 'Duke') : 'None'; + const dukeText = duke ? cardFullText(duke) : ''; + const dukeLine = `
+ Duke: ${escapeHtml(dukeName)} + ${dukeText ? `
${escapeHtml(dukeText)}
` : ''} +
`; + + bodyEl.innerHTML = ` + ${resourceRow} + ${dukeLine} +
+ ${renderCardList('Starters', subject.owned_starters)} + ${renderCardList('Citizens', subject.owned_citizens)} + ${renderCardList('Monsters', subject.owned_monsters)} + ${renderCardList('Domains', subject.owned_domains)} +
+ `; + } + + function boardStackMeta(kind, top, depth) { + const bits = []; + bits.push(depth + ' card' + (depth === 1 ? '' : 's')); + if (kind === 'citizen' || kind === 'monster') { + bits.push(top.is_accessible ? 'top accessible' : 'top not accessible'); + } + if (kind === 'domain') { + bits.push(top.is_visible ? 'top visible' : 'top hidden'); + bits.push(top.is_accessible ? 'top accessible' : 'top not accessible'); + } + return bits.join(' · '); + } + + const expandedMonsterStacks = new Set(); + + function isMonsterStackExpanded(stackIndex) { + return expandedMonsterStacks.has(Number(stackIndex)); + } + + function toggleMonsterStackExpand(stackIndex) { + const idx = Number(stackIndex); + if (Number.isNaN(idx)) return; + if (expandedMonsterStacks.has(idx)) expandedMonsterStacks.delete(idx); + else expandedMonsterStacks.add(idx); + if (lastGameState) renderBoardTableau(lastGameState); + } + window.toggleMonsterStackExpand = toggleMonsterStackExpand; + + function renderMonsterStackCards(stack, stackIndex) { + const expanded = isMonsterStackExpanded(stackIndex); + const top = topOfStack(stack); + if (!top) return ''; + if (!expanded) { + return renderCardItem(top); + } + const cards = [...stack].reverse(); + return `
${cards.map((card, i) => { + const role = i === 0 ? 'Top (slayable)' : 'Buried'; + return `
${role}
${renderCardItem(card)}
`; + }).join('')}
`; + } + + function renderBoardStackSection(title, grid, kind) { + const g = Array.isArray(grid) ? grid : []; + const blocks = g.map((stack, idx) => { + const depth = Array.isArray(stack) ? stack.length : 0; + if (!depth) { + return `
Stack ${idx + 1}
empty
`; + } + const top = topOfStack(stack); + if (!top) { + return `
Stack ${idx + 1}
empty
`; + } + const meta = boardStackMeta(kind, top, depth); + const expandControl = kind === 'monster' + ? `` + : ''; + const cardHtml = kind === 'monster' + ? renderMonsterStackCards(stack, idx) + : renderCardItem(top); + return `
+
Stack ${idx + 1}
+ ${expandControl ? `
${expandControl}
` : ''} +
${escapeHtml(meta)}
+ ${cardHtml} +
`; + }); + return `

${escapeHtml(title)}

${blocks.join('')}
`; + } + + function renderBoardTableau(gameState) { + const titleEl = document.getElementById('tableauTitle'); + const bodyEl = document.getElementById('tableauBody'); + if (!bodyEl) return; + if (titleEl) titleEl.textContent = 'Board (stacks)'; + const citizenGrid = Array.isArray(gameState?.citizen_grid) ? gameState.citizen_grid : []; + const domainGrid = Array.isArray(gameState?.domain_grid) ? gameState.domain_grid : []; + const monsterGrid = Array.isArray(gameState?.monster_grid) ? gameState.monster_grid : []; + bodyEl.innerHTML = ` +
Top of each stack is the play surface. Monster stacks can be expanded to view buried cards, but only the top monster is slayable.
+
+ ${renderBoardStackSection('Citizens (market)', citizenGrid, 'citizen')} + ${renderBoardStackSection('Domains', domainGrid, 'domain')} + ${renderBoardStackSection('Monsters', monsterGrid, 'monster')} +
+ `; + } + + async function openMyTableau() { + if (!(await ensureGameStateForTableau())) return; + renderPlayerTableau(lastGameState, playerId, true); + openModal(); + } + + async function openPlayerTableau(targetPlayerId) { + if (!targetPlayerId) return; + if (!(await ensureGameStateForTableau())) return; + renderPlayerTableau(lastGameState, targetPlayerId, false); + openModal(); + } + + async function openSeatTableau(targetPlayerId) { + if (!targetPlayerId) return; + if (!(await ensureGameStateForTableau())) return; + const isSelf = targetPlayerId === playerId; + renderPlayerTableau(lastGameState, targetPlayerId, isSelf); + openModal(); + } + + async function openBoardTableau() { + if (!(await ensureGameStateForTableau())) return; + renderBoardTableau(lastGameState); + openModal(); + } + + function diePipMask(value) { + // grid indices: 0 1 2 / 3 4 5 / 6 7 8 + // positions: TL, TC, TR, ML, MC, MR, BL, BC, BR + const masks = { + 1: [4], + 2: [0, 8], + 3: [0, 4, 8], + 4: [0, 2, 6, 8], + 5: [0, 2, 4, 6, 8], + 6: [0, 2, 3, 5, 6, 8] + }; + return masks[value] || []; + } + + function buildDie(value, status, title) { + const die = document.createElement('div'); + die.className = 'die' + (status ? ` ${status}` : ''); + const on = new Set(diePipMask(value)); + for (let i = 0; i < 9; i++) { + const pip = document.createElement('div'); + pip.className = 'pip' + (on.has(i) ? '' : ' off'); + die.appendChild(pip); + } + die.title = title || `d${value || 0}`; + return die; + } + + function renderGameLog(gameState) { + const el = document.getElementById('gameLog'); + if (!el) return; + const entries = Array.isArray(gameState?.game_log) ? gameState.game_log : []; + const logKey = JSON.stringify([ + gameState?.game_id || currentGameId || '', + entries.map(e => [ + e && e.tick !== undefined && e.tick !== null ? e.tick : '', + (e && (e.msg || e.message)) || '' + ]) + ]); + if (logKey === lastRenderedGameLogKey) return; + + const wasAtBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 8; + const previousScrollTop = el.scrollTop; + const shouldAutoScroll = lastRenderedGameLogKey === null || wasAtBottom; + lastRenderedGameLogKey = logKey; + + if (!entries.length) { + el.textContent = '(No events yet.)'; + return; + } + el.innerHTML = entries.map(e => { + const tick = e && e.tick !== undefined && e.tick !== null ? e.tick : ''; + const msg = escapeHtml(String((e && e.msg) || (e && e.message) || '')); + return `
[${tick}]${msg}
`; + }).join(''); + el.scrollTop = shouldAutoScroll ? el.scrollHeight : previousScrollTop; + } + + function renderDice(gameState) { + const rolled1 = Number(gameState?.rolled_die_one ?? gameState?.die_one ?? 0); + const rolled2 = Number(gameState?.rolled_die_two ?? gameState?.die_two ?? 0); + const rolledSum = Number(gameState?.rolled_die_sum ?? ((rolled1 || 0) + (rolled2 || 0)) ?? 0); + const final1 = Number(gameState?.die_one || 0); + const final2 = Number(gameState?.die_two || 0); + const finalSum = Number(gameState?.die_sum || 0); + + const diceEl = document.getElementById('dice'); + const metaEl = document.getElementById('diceMeta'); + const effectsEl = document.getElementById('rollEffects'); + const deltaEl = document.getElementById('harvestDeltas'); + if (!diceEl || !metaEl) return; + + const die1Changed = rolled1 && final1 && rolled1 !== final1; + const die2Changed = rolled2 && final2 && rolled2 !== final2; + const die1Status = die1Changed ? (final1 > rolled1 ? 'increase' : 'decrease') : ''; + const die2Status = die2Changed ? (final2 > rolled2 ? 'increase' : 'decrease') : ''; + const die1Display = die1Changed ? final1 : rolled1; + const die2Display = die2Changed ? final2 : rolled2; + + diceEl.innerHTML = ''; + diceEl.appendChild(buildDie(die1Display, die1Status, die1Changed ? `d${final1} (rolled ${rolled1})` : `d${die1Display}`)); + diceEl.appendChild(buildDie(die2Display, die2Status, die2Changed ? `d${final2} (rolled ${rolled2})` : `d${die2Display}`)); + + const turn = gameState?.turn_number; + const phase = gameState?.phase; + const active = gameState?.active_player_id; + const actionsRemaining = gameState?.actions_remaining; + + const parts = []; + if (rolled1 && rolled2) parts.push(`${rolled1} + ${rolled2} = ${rolledSum}`); + else parts.push(`Dice: not rolled`); + if (rolled1 && rolled2 && final1 && final2 && (rolled1 !== final1 || rolled2 !== final2)) { + parts.push(`Final ${final1} + ${final2} = ${finalSum}`); + } + if ((gameState?.phase || '') === 'roll_pending') { + parts.push(`Awaiting finalize`); + } + if ((gameState?.phase || '') === 'action_end_pending') { + parts.push(`Action-end domains`); + } + if (turn !== undefined) parts.push(`Turn ${turn}`); + if (phase) parts.push(`Phase ${phase}`); + if (actionsRemaining !== undefined) parts.push(`Actions remaining ${actionsRemaining}`); + if (active) parts.push(`Active ${active}`); + metaEl.innerHTML = parts.join(' · '); + + renderGameLog(gameState); + + // Update rig hint text. + const hintEl = document.getElementById('dicePanelHint'); + if (hintEl) { + const s = getDiceRigSettings(); + const msg = s.enabled + ? `Enabled: will finalize as ${clampDie(s.d1)} + ${clampDie(s.d2)} (graphic still shows the rolled dice).` + : `Disabled: roll finalizes as the rolled dice.`; + hintEl.textContent = msg; + } + + if (effectsEl) { + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const activePlayerId = (gameState?.active_player_id || '').toString(); + const activePlayer = players.find(p => (p?.player_id || '').toString() === activePlayerId) || null; + const effects = parseRollSetOneDieEffects(activePlayer, gameState.turn_number); + if (!effects.length) { + effectsEl.innerHTML = 'Roll phase effects: none'; + } else { + const rows = effects.map((e) => { + return `
  • ${escapeHtml(e.domainName)}: set one die to ${escapeHtml(String(e.target))} (cost: ${escapeHtml(e.costSpec)})
  • `; + }).join(''); + effectsEl.innerHTML = `Roll phase effects (active player):`; + } + } + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + if (deltaEl) { + deltaEl.innerHTML = ''; + players.forEach(p => { + const d = p?.harvest_delta || {}; + const g = Number(d.gold || 0); + const s = Number(d.strength || 0); + const m = Number(d.magic || 0); + const v = Number(d.victory || 0); + const G = Number(p?.gold_score || 0); + const S = Number(p?.strength_score || 0); + const M = Number(p?.magic_score || 0); + const V = Number(p?.victory_score || 0); + + const card = document.createElement('div'); + card.className = 'delta-card'; + const name = p?.name || (p?.player_id ? p.player_id.slice(0, 6) : 'Player'); + + const fmt = (n) => (n > 0 ? `+${n}` : `${n}`); + const cls = (n) => (n > 0 ? 'delta-pos' : (n < 0 ? 'delta-neg' : 'delta-zero')); + + card.innerHTML = ` +
    + ${name} + ΔG${fmt(g)} + ΔS${fmt(s)} + ΔM${fmt(m)} + ΔVP${fmt(v)} + + Totals + G${G} + S${S} + M${M} + VP${V} +
    + `; + deltaEl.appendChild(card); + }); + } + + renderChoicePanel(gameState); + } + + function renderChoicePanel(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + // Concurrent (non-ordered) prompts always take precedence over + // turn-based action_required: while one is active the engine + // will not advance and no per-player turn prompts are valid. + const concurrent = gameState?.concurrent_action || null; + const concurrentPending = concurrent && Array.isArray(concurrent.pending) ? concurrent.pending : []; + if (concurrentPending.length > 0) { + return renderConcurrentActionPanel(gameState, concurrent); + } + + const req = gameState?.action_required || {}; + const reqId = req?.id || ''; + const reqAction = req?.action || ''; + const activePlayerId = gameState?.active_player_id || ''; + + function harvestTurnBadge(forPlayerId) { + const pid = (forPlayerId || '').toString(); + if (!pid || !activePlayerId) return ''; + const onTurn = (pid === activePlayerId); + const bg = onTurn ? '#e8f7ee' : '#f1f1f1'; + const border = onTurn ? '#8ad0a4' : '#cfcfcf'; + const fg = onTurn ? '#1f6a3a' : '#444'; + const label = onTurn ? 'On-turn harvest' : 'Off-turn harvest'; + return `${label}`; + } + + if (!reqId || reqId === gameState?.game_id) { + panel.innerHTML = ''; + return; + } + + if (reqAction === 'finalize_roll') { + return renderFinalizeRollPrompt(gameState); + } + + if (reqAction === 'domain_self_convert') { + return renderDomainSelfConvertPrompt(gameState); + } + + if (reqAction === 'choose_player') { + return renderDomainChoosePlayer(gameState); + } + if (reqAction === 'choose_monster_strength') { + return renderDomainChooseMonster(gameState); + } + + // Generic "choose ..." prompt from special payouts (e.g. "choose g 1 m 1") + // Engine expects the response to be "choose 1"/"choose 2"/"choose 3". + if (typeof reqAction === 'string' && reqAction.trim().startsWith('choose ')) { + return renderChoosePrompt(gameState, reqAction); + } + + if (reqAction === 'manual_harvest') { + const slots = Array.isArray(gameState?.harvest_prompt_slots) ? gameState.harvest_prompt_slots : []; + const isYou = (playerId && reqId === playerId); + if (!isYou) { + const badge = harvestTurnBadge(reqId); + panel.innerHTML = `
    +
    +
    Manual harvest in progress for ${escapeHtml(reqId)} (${slots.length} card(s)).
    + ${badge} +
    +
    `; + return; + } + if (!slots.length) { + const badge = harvestTurnBadge(reqId); + panel.innerHTML = `
    +
    +
    Harvest: no slots (try Refresh).
    + ${badge} +
    +
    `; + return; + } + const thiefNote = slots.some(s => s.kind === 'citizen' && s.is_thief) + ? '
    If you have the Thief, harvest that citizen before other citizens.
    ' + : ''; + const badge = harvestTurnBadge(reqId); + const btns = slots.map(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 = `${escapeHtml(s.name || '')} (${escapeHtml(s.kind)} #${escapeHtml(String(s.card_id))}${copy}${dup})`; + const sk = escapeHtml(s.slot_key || ''); + return ``; + }).join(' '); + panel.innerHTML = ` +
    +
    +
    Harvest (choose order)
    + ${badge} +
    + ${thiefNote} +
    ${btns}
    +
    `; + return; + } + + if (reqAction !== 'bonus_resource_choice') { + if (reqAction === 'standard_action') { + return renderStandardActionPanel(gameState); + } + + panel.innerHTML = `
    + Waiting on required action from ${reqId}: ${reqAction} +
    `; + return; + } + + const isYou = (playerId && reqId === playerId); + if (!isYou) { + const badge = harvestTurnBadge(reqId); + panel.innerHTML = `
    +
    +
    Harvest bonus choice pending for ${reqId}.
    + ${badge} +
    +
    `; + return; + } + + const badge = harvestTurnBadge(reqId); + panel.innerHTML = ` +
    +
    +
    Harvest bonus: choose +1 resource
    + ${badge} +
    +
    + + + +
    +
    + `; + } + + 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) { + // Expected formats: + // - "choose g 1 m 1" (two options) + // - "choose g 1 s 1 m 1" (three options) + const parts = (cmd || '').toString().trim().split(/\s+/); + if (!parts.length || parts[0] !== 'choose') return []; + const options = []; + // pairs start at index 1: [token, amount] + 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 renderChoosePrompt(gameState, chooseCmd) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + const req = gameState?.action_required || {}; + const reqId = req?.id || ''; + const isYou = (playerId && reqId === playerId); + const pendingChoice = gameState?.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; + } + if (!options.length) { + panel.innerHTML = `
    + Waiting on required action from ${reqId}: ${escapeHtml(chooseCmd)} +
    `; + return; + } + + if (!isYou) { + panel.innerHTML = `
    + Waiting on required action from ${reqId}: ${escapeHtml(chooseCmd)} +
    `; + return; + } + + const buttons = options.map((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 isCitizen = token.trim().toLowerCase().startsWith('citizens.'); + if ((token || '').toString().trim().toLowerCase() === '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 ``; + } + if (isCitizen) { + 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 ``; + } + return ``; + }).join(''); + + panel.innerHTML = ` +
    +
    Choose one
    +
    + ${buttons} +
    +
    + `; + } + + function renderFinalizeRollPrompt(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const rolled1 = clampDie(gameState?.rolled_die_one ?? gameState?.die_one ?? 1); + const rolled2 = clampDie(gameState?.rolled_die_two ?? gameState?.die_two ?? 1); + + if (!isYou) { + panel.innerHTML = `
    + Waiting on required action from ${escapeHtml(reqId)}: finalize_roll +
    `; + return; + } + + const player = Array.isArray(gameState?.player_list) + ? gameState.player_list.find(p => (p?.player_id || '') === playerId) + : null; + const options = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number); + const keepBtn = ``; + const modBtns = options.map((o) => { + const fromVal = (o.die === 1) ? rolled1 : rolled2; + const d1 = (o.die === 1) ? o.target : rolled1; + const d2 = (o.die === 2) ? o.target : rolled2; + return ``; + }).join(' '); + const note = options.length + ? '
    Choose a roll modifier or keep the rolled dice.
    ' + : '
    No roll modifiers available; finalize to continue.
    '; + + panel.innerHTML = ` +
    +
    Finalize Roll
    +
    Rolled: ${rolled1} + ${rolled2}
    +
    + ${keepBtn} + ${modBtns} +
    + ${note} +
    + `; + } + + async function sendChooseIndex(n) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: `choose ${Number(n)}` + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendChoosePlayerIndex(n) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: `choose_player ${Number(n)}` + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendChooseMonsterIndex(n) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: `choose_monster ${Number(n)}` + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendDomainManipulateSkip() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'skip' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + 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 renderDomainSelfConvertPrompt(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.pending_required_choice || null; + const dn = (prc?.domain_name || 'Domain').toString(); + const kv = prc?.kv || {}; + const explain = selfConvertExplain(kv); + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} optional activation trade. +
    `; + return; + } + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: optional trade
    +
    ${escapeHtml(explain)}
    +
    + + +
    +
    `; + } + + async function sendSelfConvertConfirm() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'confirm_self_convert' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendSelfConvertDecline() { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: 'skip' + }) + }); + getGameState(false); + } catch (e) { + console.error(e); + } + } + + function renderDomainChoosePlayer(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.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.'; + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} to choose a player for ${escapeHtml(dn)}. +
    `; + return; + } + const kv = prc?.item?.kv || {}; + const skipLabel = (prc?.allow_skip && domainEffectGainIsVp(kv)) + ? 'Decline (no pay, no VP)' + : 'Skip (optional)'; + const skipBtn = prc?.allow_skip + ? `` + : ''; + const btns = opts.map((o, idx) => { + const nm = escapeHtml((o?.name || o?.player_id || '?').toString()); + return ``; + }).join(' '); + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: choose another player
    +
    ${escapeHtml(explain)}
    +
    ${btns}${skipBtn}
    +
    `; + } + + function renderDomainChooseMonster(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const req = gameState?.action_required || {}; + const reqId = (req?.id || '').toString(); + const isYou = (playerId && reqId === playerId); + const prc = gameState?.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; + if (!isYou) { + panel.innerHTML = `
    + Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} (monster +${delta} strength cost). +
    `; + return; + } + const btns = opts.map((o, idx) => { + const nm = escapeHtml((o?.name || '?').toString()); + return ``; + }).join(' '); + panel.innerHTML = ` +
    +
    ${escapeHtml(dn)}: add +${delta} to a center monster strength cost
    +
    ${btns}
    +
    `; + } + + // Concurrent (non-ordered) prompt rendering. + // + // The server exposes `concurrent_action = { kind, pending, completed, ... }`. + // Every participant sees this state at the same time; players in `pending` + // can submit a response in any order, and the game only advances once + // `pending` is empty. To add a new kind, register a renderer in + // CONCURRENT_RENDERERS keyed on the same `kind` used server-side. + const CONCURRENT_RENDERERS = { + choose_duke: renderChooseDukeConcurrent, + flip_one_citizen: renderFlipOneCitizenConcurrent, + }; + + function renderConcurrentActionPanel(gameState, concurrent) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + const renderer = CONCURRENT_RENDERERS[concurrent.kind]; + if (renderer) { + return renderer(gameState, concurrent); + } + + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + panel.innerHTML = `
    + Waiting on concurrent action ${escapeHtml(concurrent.kind || 'unknown')} + (${pending.length} player(s) still need to respond). +
    `; + } + + function pendingPlayerLabels(gameState, pending) { + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + return (pending || []).map(pid => { + const p = players.find(x => x?.player_id === pid); + return p?.name ? `${p.name}` : pid; + }); + } + + function renderChooseDukeConcurrent(gameState, concurrent) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const completed = Array.isArray(concurrent.completed) ? concurrent.completed : []; + const isPending = !!(playerId && pending.includes(playerId)); + const totalParticipants = pending.length + completed.length; + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const you = players.find(p => p?.player_id === playerId) || null; + const waitingLabels = pendingPlayerLabels(gameState, pending); + + const statusLine = `
    + Starting setup: ${completed.length}/${totalParticipants} duke choice(s) submitted. + ${pending.length ? `Waiting on: ${escapeHtml(waitingLabels.join(', '))}.` : ''} +
    `; + + if (!isPending) { + const youDone = !!(playerId && completed.includes(playerId)); + const yourLine = youDone + ? `
    You have already chosen your duke. Waiting on the other player(s).
    ` + : `
    Starting setup is in progress.
    `; + panel.innerHTML = `
    + ${statusLine}${yourLine} +
    `; + return; + } + + const dukes = Array.isArray(you?.owned_dukes) ? you.owned_dukes : []; + if (!dukes.length) { + panel.innerHTML = `
    + ${statusLine}
    No dukes found to choose from.
    +
    `; + return; + } + + const buttons = dukes.map(d => { + const id = d?.duke_id; + const name = d?.name || `Duke #${id}`; + const fullText = cardFullText(d); + const sub = fullText ? `
    ${escapeHtml(fullText)}
    ` : ''; + return `
    +
    ${escapeHtml(name)} (#${escapeHtml(id)})
    + ${sub} +
    + +
    +
    `; + }).join(''); + + panel.innerHTML = ` +
    + ${statusLine} +
    Choose 1 duke to keep
    +
    ${buttons}
    +
    + `; + } + + function renderFlipOneCitizenConcurrent(gameState, concurrent) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + + const pending = Array.isArray(concurrent.pending) ? concurrent.pending : []; + const completed = Array.isArray(concurrent.completed) ? concurrent.completed : []; + const isPending = !!(playerId && pending.includes(playerId)); + const totalParticipants = pending.length + completed.length; + const data = concurrent.data || {}; + const buyerId = (data.buyer_id || '').toString(); + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const buyer = players.find(p => (p?.player_id || '') === buyerId) || null; + const buyerTag = buyer?.name ? `${escapeHtml(buyer.name)}` : (buyerId ? `${escapeHtml(buyerId)}` : ''); + const you = players.find(p => p?.player_id === playerId) || null; + const waitingLabels = pendingPlayerLabels(gameState, pending); + + const statusLine = `
    + Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted. + ${pending.length ? `Waiting on: ${escapeHtml(waitingLabels.join(', '))}.` : ''} + ${buyerTag ? `
    Triggered by ${buyerTag}.
    ` : ''} +
    `; + + if (!isPending) { + const youDone = !!(playerId && completed.includes(playerId)); + const yourLine = 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).
    `; + panel.innerHTML = `
    + ${statusLine}${yourLine} +
    `; + return; + } + + const citizens = Array.isArray(you?.owned_citizens) ? you.owned_citizens : []; + const choices = []; + citizens.forEach((c, idx) => { + if (!c || c.is_flipped) return; + const nm = (c.name || `Citizen #${idx}`).toString(); + choices.push({ idx, card: c, nm }); + }); + + if (!choices.length) { + panel.innerHTML = `
    + ${statusLine}
    No face-up citizens on your tableau — contact host if this seems wrong.
    +
    `; + return; + } + + const buttons = choices.map(({ idx, card, nm }) => { + const rm = card.roll_match1 !== undefined || card.roll_match2 !== undefined + ? ` · Roll ${card.roll_match1 ?? ''}/${card.roll_match2 ?? ''}` + : ''; + const gc = card.gold_cost !== undefined ? ` · Cost ${card.gold_cost}g` : ''; + return `
    +
    ${escapeHtml(nm)} (slot #${idx})
    +
    ${escapeHtml(rm + gc)}
    +
    + +
    +
    `; + }).join(''); + + panel.innerHTML = ` +
    + ${statusLine} +
    Choose 1 citizen to flip face-down
    +
    ${buttons}
    +
    + `; + } + + async function submitConcurrentAction(kind, response) { + if (!playerId || !currentGameId) return; + try { + const res = await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'submit_concurrent_action', + kind: String(kind), + response: String(response) + }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.detail || res.statusText || 'Submit failed'); + return; + } + getGameState(false); + } catch (e) { + console.error(e); + } + } + + 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); + + // Rule: you must contribute at least 1 of a required color to use magic as wild. + // Example: cost S8 cannot be paid with M8 alone; you need at least S1, then M can cover the rest. + 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; + + // Payment split used for sending action requests (avoid going negative server-side). + 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 topOfStack(stack) { + if (!Array.isArray(stack) || stack.length === 0) return null; + return stack[stack.length - 1]; + } + + 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 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()); + } + } + // Backward-compatibility for seed data where passive_effect is NULL + // and behavior only exists in human-readable card text. + 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 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 readPayRow(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 capturePayEditorRenderState(panel) { + const state = {}; + if (!panel) return state; + const active = document.activeElement; + panel.querySelectorAll('.pay-cost-key').forEach((el) => { + const key = el.getAttribute('data-pay-key'); + if (!key) return; + const row = el.closest('.pay-row'); + const box = document.getElementById('pay-editor-' + key); + if (!row || !box) return; + const gEl = row.querySelector('.pay-g'); + const sEl = row.querySelector('.pay-s'); + const mEl = row.querySelector('.pay-m'); + const entry = { + gold: gEl ? gEl.value : '', + strength: sEl ? sEl.value : '', + magic: mEl ? mEl.value : '', + focusClass: '', + }; + if (active && row.contains(active)) { + if (active.classList.contains('pay-g')) entry.focusClass = 'pay-g'; + else if (active.classList.contains('pay-s')) entry.focusClass = 'pay-s'; + else if (active.classList.contains('pay-m')) entry.focusClass = 'pay-m'; + } + state[key] = entry; + }); + return state; + } + + function restorePayEditorRenderState(panel, state) { + if (!panel || !state) return; + let focusTarget = null; + panel.querySelectorAll('.pay-cost-key').forEach((el) => { + const key = el.getAttribute('data-pay-key'); + const entry = key ? state[key] : null; + if (!entry) return; + const row = el.closest('.pay-row'); + const box = document.getElementById('pay-editor-' + key); + if (!row || !box) return; + const gEl = row.querySelector('.pay-g'); + const sEl = row.querySelector('.pay-s'); + const mEl = row.querySelector('.pay-m'); + if (gEl) gEl.value = clampPayInt(entry.gold, gEl.min, gEl.max); + if (sEl) sEl.value = clampPayInt(entry.strength, sEl.min, sEl.max); + if (mEl) mEl.value = clampPayInt(entry.magic, mEl.min, mEl.max); + if (entry.focusClass) { + focusTarget = row.querySelector('.' + entry.focusClass); + } + }); + if (focusTarget) { + focusTarget.focus(); + } + } + + async function hireCitizenFromRow(btn) { + const row = btn.closest('.pay-row'); + if (!row || !playerId || !currentGameId) return; + const p = readPayRow(row); + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'hire_citizen', + citizen_id: Number(row.dataset.citizenId), + payment: { gold: p.gold, strength: p.strength, magic: p.magic } + }) + }); + getGameState(false); + } + + async function buildDomainFromRow(btn) { + const row = btn.closest('.pay-row'); + if (!row || !playerId || !currentGameId) return; + const p = readPayRow(row); + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'build_domain', + domain_id: Number(row.dataset.domainId), + payment: { gold: p.gold, strength: p.strength, magic: p.magic } + }) + }); + getGameState(false); + } + + async function slayMonsterFromRow(btn) { + const row = btn.closest('.pay-row'); + if (!row || !playerId || !currentGameId) return; + const p = readPayRow(row); + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'slay_monster', + monster_id: Number(row.dataset.monsterId), + payment: { gold: p.gold, strength: p.strength, magic: p.magic } + }) + }); + getGameState(false); + } + + function renderStandardActionPanel(gameState) { + const panel = document.getElementById('choicePanel'); + if (!panel) return; + const payEditorState = capturePayEditorRenderState(panel); + + const req = gameState?.action_required || {}; + const reqId = req?.id || ''; + const isYou = (playerId && reqId === playerId); + const phase = (gameState?.phase || '').toString(); + const actionsRemaining = Number(gameState?.actions_remaining || 0); + + if (!reqId || reqId === gameState?.game_id || phase !== 'action') { + panel.innerHTML = ''; + return; + } + + const players = Array.isArray(gameState?.player_list) ? gameState.player_list : []; + const you = players.find(p => p?.player_id === playerId) || null; + const active = players.find(p => p?.player_id === reqId) || null; + + const p = (isYou ? you : active); + const G = Number(p?.gold_score || 0); + const S = Number(p?.strength_score || 0); + const M = Number(p?.magic_score || 0); + const V = Number(p?.victory_score || 0); + const tn = Number(gameState?.turn_number); + const emeraldActive = hasActionEffectFlag(p, 'action.emeraldstronghold', tn); + const pratchettActive = hasActionEffectFlag(p, 'action.pratchettsplateau', tn); + + const affordCitizens = []; + const affordDomains = []; + const affordMonsters = []; + + // Evaluate citizens (top of each stack, accessible only) + const citizenGrid = Array.isArray(gameState?.citizen_grid) ? gameState.citizen_grid : []; + citizenGrid.forEach((stack, idx) => { + const top = topOfStack(stack); + if (!top) return; + const baseCost = Number(top.gold_cost || 0); + const surcharge = emeraldActive ? 0 : ownedNameCount(p, top.name); + const scaledCost = baseCost + surcharge; + const evalRes = canAffordCost(p, { gold: scaledCost, strength: 0, magicMin: 0 }); + console.log('[AFFORD_CHECK] citizen', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes }); + if (top.is_accessible && evalRes.ok) { + affordCitizens.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, scaledCost, surcharge, baseCost, emeraldActive }); + } + }); + + // Evaluate domains (top visible & accessible) + const domainGrid = Array.isArray(gameState?.domain_grid) ? gameState.domain_grid : []; + domainGrid.forEach((stack, idx) => { + const top = topOfStack(stack); + if (!top) return; + const baseCost = Number(top.gold_cost || 0); + const effectiveGold = Math.max(0, baseCost - (pratchettActive ? 1 : 0)); + const evalRes = canAffordCost(p, { gold: effectiveGold, strength: 0, magicMin: 0 }); + console.log('[AFFORD_CHECK] domain', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes }); + if (top.is_visible && top.is_accessible && evalRes.ok) { + affordDomains.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, baseCost, effectiveGold, pratchettActive }); + } + }); + + // Evaluate monsters (top of each stack, accessible only; magic has minimum requirement) + const monsterGrid = Array.isArray(gameState?.monster_grid) ? gameState.monster_grid : []; + monsterGrid.forEach((stack, idx) => { + const top = topOfStack(stack); + if (!top) return; + const evalRes = canAffordCost(p, { gold: 0, strength: Number(top.strength_cost || 0), magicMin: Number(top.magic_cost || 0) }); + console.log('[AFFORD_CHECK] monster', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes }); + if (top.is_accessible && evalRes.ok) { + affordMonsters.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes }); + } + }); + + const header = isYou + ? `
    Your action (${actionsRemaining} remaining)
    ` + : `
    Waiting on ${active?.name || reqId} to act (${actionsRemaining} remaining)
    `; + + const resourcesLine = `
    + Resources: G ${G} · S ${S} · M ${M} · VP ${V} +
    `; + const activeEffects = []; + if (emeraldActive) activeEffects.push('Emerald Stronghold: ignore citizen duplicate surcharge'); + if (pratchettActive) activeEffects.push("Pratchett's Plateau: domains cost 1 less gold"); + const effectsBanner = activeEffects.length + ? `
    Active effects: ${escapeHtml(activeEffects.join(' · '))}
    ` + : ''; + + const takeResourceRow = isYou + ? `
    + Take resource (uses 1 action, gain +1): + + + +
    ` + : ''; + + const listSection = (title, items, renderItem) => { + if (!items.length) return `
    ${title}: none affordable
    `; + const rows = items.map(renderItem).join(''); + return `
    ${title}:
    ${rows}
    `; + }; + + const citizenHtml = listSection('Citizens', affordCitizens, (it) => { + const c = it.card; + const key = 'c-' + c.citizen_id; + const cost = Number(it.scaledCost ?? c.gold_cost ?? 0); + const pay = it.pay; + const rc = citizenRoleCounts(c); + const rbits = []; + if (rc.sn) rbits.push('Shadow+' + rc.sn); + if (rc.hn) rbits.push('Holy+' + rc.hn); + if (rc.son) rbits.push('Soldier+' + rc.son); + if (rc.wn) rbits.push('Worker+' + rc.wn); + const roleHint = rbits.length ? ' Roles: ' + rbits.join(', ') + '' : ''; + const dupHint = Number(it.surcharge || 0) + ? ' (base ' + Number(it.baseCost || 0) + ' + ' + Number(it.surcharge || 0) + ' dupes)' + : ''; + const emeraldHint = (!Number(it.surcharge || 0) && it.emeraldActive) + ? ' (Emerald: no duplicate surcharge)' + : ''; + const rulesText = cardFullText(c); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; + const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + dupHint + emeraldHint + ' · Stack ' + it.stackSize; + const btn = isYou ? '' : ''; + return '
    ' + + btn + + ' ' + escapeHtml(c.name) + ' (#' + c.citizen_id + ')' + roleHint + rulesLine + '' + + ' ' + + '' + costSummary + '' + + '
    ' + + '' + + '' + + '' + + '' + + '
    '; + }); + + const domainHtml = listSection('Domains (visible tops)', affordDomains, (it) => { + const d = it.card; + const key = 'd-' + d.domain_id; + const cost = Number(it.effectiveGold ?? d.gold_cost ?? 0); + const pay = it.pay; + const pratchettHint = (it.pratchettActive && Number(it.baseCost || 0) !== cost) + ? ' (base ' + Number(it.baseCost || 0) + ' - 1 Pratchett)' + : ''; + const rulesText = cardFullText(d); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; + const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + pratchettHint + ' · Stack ' + it.stackSize; + const btn = isYou ? '' : ''; + return '
    ' + + btn + + ' ' + escapeHtml(d.name) + ' (#' + d.domain_id + ')' + rulesLine + '' + + ' ' + + '' + costSummary + '' + + '
    ' + + '' + + '' + + '' + + '' + + '
    '; + }); + + const monsterHtml = listSection('Monsters (top of each stack)', affordMonsters, (it) => { + const mcard = it.card; + const key = 'm-' + mcard.monster_id; + const sCost = Number(mcard.strength_cost || 0); + const mMin = Number(mcard.magic_cost || 0); + const pay = it.pay; + const rulesText = cardFullText(mcard); + const rulesLine = rulesText + ? '
    ' + escapeHtml(rulesText) + '
    ' + : ''; + const costSummary = 'Cost: S ' + sCost + ' + M ' + mMin + ' min · pay S' + pay.payStrength + ', M' + pay.payMagic + ' · Stack ' + it.stackSize; + const btn = isYou ? '' : ''; + return '
    ' + + btn + + ' ' + escapeHtml(mcard.name) + ' (#' + mcard.monster_id + ')' + rulesLine + '' + + ' ' + + '' + costSummary + '' + + '
    ' + + '' + + '' + + '' + + '' + + '
    '; + }); + + panel.innerHTML = ` +
    + ${header} + ${resourcesLine} + ${effectsBanner} + ${takeResourceRow} + ${citizenHtml} + ${domainHtml} + ${monsterHtml} +
    + `; + restorePayEditorRenderState(panel, payEditorState); + } + + async function takeResourceFromChoice(resource) { + if (!playerId || !currentGameId) return; + const r = (resource || '').toString().trim().toLowerCase(); + if (!['gold', 'strength', 'magic'].includes(r)) return; + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'take_resource', + resource: r + }) + }); + getGameState(false); + } + + async function sendBonusChoice(resource) { + if (!playerId || !currentGameId) return; + try { + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'act_on_required_action', + action: resource + }) + }); + // Refresh state so UI updates immediately + getGameState(false); + } catch (e) { + console.error(e); + } + } + + async function sendHarvestCard(slotKey, options = {}) { + if (!playerId || !currentGameId) return; + const suppressAlert = !!(options && options.suppressAlert); + const sk = (slotKey || '').toString().trim(); + if (!sk) return; + try { + const res = await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'harvest_card', + harvest_slot_key: sk + }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + if (!suppressAlert) { + alert(err.detail || res.statusText || 'Harvest failed'); + } + return; + } + getGameState(false); + } catch (e) { + console.error(e); + if (!suppressAlert) { + alert(e.message || 'Harvest failed'); + } + } + } + + async function hireCitizen(citizenId, goldCost, magicCost) { + if (!playerId || !currentGameId) return; + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'hire_citizen', + citizen_id: citizenId, + payment: { + gold: Number(goldCost || 0), + strength: 0, + magic: Number(magicCost || 0) + } + }) + }); + getGameState(false); + } + + async function buildDomain(domainId, goldCost, magicCost) { + if (!playerId || !currentGameId) return; + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'build_domain', + domain_id: domainId, + payment: { + gold: Number(goldCost || 0), + strength: 0, + magic: Number(magicCost || 0) + } + }) + }); + getGameState(false); + } + + async function slayMonster(monsterId, strengthCost, magicCost) { + if (!playerId || !currentGameId) return; + await fetch(`/api/game/${currentGameId}/action`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + player_id: playerId, + action_type: 'slay_monster', + monster_id: monsterId, + payment: { + gold: 0, + strength: Number(strengthCost || 0), + magic: Number(magicCost || 0) + } + }) + }); + getGameState(false); + } + + // Auto-refresh lobby status + setInterval(getLobbyStatus, 2000); diff --git a/static/dev-client/index.html b/static/dev-client/index.html new file mode 100644 index 0000000..f4d4e3c --- /dev/null +++ b/static/dev-client/index.html @@ -0,0 +1,95 @@ + + + + VCK Online - Dev Client + + + + +

    VCK Online - Development Client

    + +
    +

    Lobby

    +
    + + + +
    +
    + +
    +
    +
    +
    + +
    +

    Game

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Game log

    +
    +
    + +
    +
    Tableau seats: buttons are arranged in turn order around the Board.
    +
    +
    +
    + Game state JSON +
    
    +            
    +
    + + + + + + +