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
-
-
- Join Lobby
- Refresh
-
-
-
-
- Debug start resources (100 gold / 100 strength / 100 magic)
-
-
-
-
-
-
-
-
Game
-
-
-
-
-
-
-
-
- Auto-harvest single-option resource prompts
-
-
-
-
-
-
Refresh Game State
-
-
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 ? 'Toggle Ready ' : ''}
+
`;
+ });
+ 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 ``;
+ }
+ 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 ``;
+ }
+ const top = topOfStack(stack);
+ if (!top) {
+ return ``;
+ }
+ const meta = boardStackMeta(kind, top, depth);
+ const expandControl = kind === 'monster'
+ ? `${isMonsterStackExpanded(idx) ? 'Collapse' : 'Expand'} `
+ : '';
+ 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 `Harvest: ${label} `;
+ }).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}
+
+
+ +1 Gold
+ +1 Strength
+ +1 Magic
+
+
+ `;
+ }
+
+ 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 `+(${escapeHtml(mText)} x ${escapeHtml(area)}) ${escapeHtml(rLabel)} `;
+ }
+ 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 `Gain ${escapeHtml(prettyAmt)} ${escapeHtml(who)}${escapeHtml(extraSuffix)} `;
+ }
+ return `+${escapeHtml(prettyAmt)} ${escapeHtml(label)} `;
+ }).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 = `Keep ${rolled1} + ${rolled2} `;
+ 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 `Set die ${o.die}: ${fromVal} → ${o.target} (pay ${o.costGold}g via ${escapeHtml(o.domainName)}) `;
+ }).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)}
+
+ Confirm trade
+ Decline
+
+
`;
+ }
+
+ 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
+ ? `${escapeHtml(skipLabel)} `
+ : '';
+ const btns = opts.map((o, idx) => {
+ const nm = escapeHtml((o?.name || o?.player_id || '?').toString());
+ return `${nm} `;
+ }).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 `${nm} `;
+ }).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}
+
+ Keep this duke
+
+
`;
+ }).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)}
+
+ Flip this citizen face-down
+
+
`;
+ }).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):
+ +1 Gold
+ +1 Strength
+ +1 Magic
+
`
+ : '';
+
+ const listSection = (title, items, renderItem) => {
+ if (!items.length) return `${title}: none affordable
`;
+ const rows = items.map(renderItem).join('');
+ return ``;
+ };
+
+ 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 ? 'Hire ' : '';
+ return '';
+ });
+
+ 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 ? 'Build ' : '';
+ return '';
+ });
+
+ 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 ? 'Slay ' : '';
+ return '';
+ });
+
+ 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
+
+
+ Join Lobby
+ Refresh
+
+
+
+
+ Debug start resources (100 gold / 100 strength / 100 magic)
+
+
+
+
+
+
+
+
Game
+
+
+
+
+
+
+
+
+ Auto-harvest single-option resource prompts
+
+
+
+
+
+
Refresh Game State
+
+
Tableau seats: buttons are arranged in turn order around the Board.
+
+
+
+ Game state JSON
+
+
+
+
+
+
+
+
+
+