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

2539 lines
133 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

let playerId = localStorage.getItem('playerId') || '';
let currentGameId = localStorage.getItem('gameId') || '';
/** Avoid duplicate tabs when toggleReady and lobby poll both notice the new game */
let openedVisualGameTabForGameId = '';
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();
function openVisualGameClientTab(gameId) {
if (!gameId || !playerId) return;
if (openedVisualGameTabForGameId === gameId) return;
openedVisualGameTabForGameId = gameId;
const q = new URLSearchParams({ game_id: gameId, player_id: playerId });
window.open(`${location.origin}/?${q}`, '_blank', 'noopener,noreferrer');
}
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 = '<h3>Players in Lobby:</h3>';
data.lobby.forEach(p => {
const debugTag = p.debug_starting_resources ? ' <span class="mini">(debug 100/100/100)</span>' : '';
html += `<div class="lobby-player ${p.is_ready ? 'ready' : ''}">
${p.name}${debugTag} - ${p.is_ready ? 'Ready' : 'Not Ready'}
${p.player_id === playerId ? '<button onclick="toggleReady()">Toggle Ready</button>' : ''}
</div>`;
});
html += `<p>Active games: ${data.game_count}</p>`;
if (data.in_game) {
html += `<p><strong>You are in game: ${data.game_id}</strong></p>`;
if (data.game_id && data.game_id !== currentGameId) {
openVisualGameClientTab(data.game_id);
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);
openVisualGameClientTab(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 = () => {
window.open(`/?game_id=${currentGameId}&player_id=${playerId}`, '_blank');
};
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' || trimmed === 'harvest_optional_exchange') 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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 `<span class="pill"><strong>${escapeHtml(label)}:</strong> ${escapeHtml(value)}</span>`;
}
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 `<div class="item"><div class="item-title">${escapeHtml(String(card))}</div></div>`;
}
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
? `<div class="item-sub" style="margin-top:4px;"><strong>Roles:</strong> ${escapeHtml(roleParts.join(' · '))}</div>`
: '';
const subtitle = hints.length ? `<div class="item-sub">${escapeHtml(hints.join(' · '))}</div>` : '';
const fullText = cardFullText(card);
const rulesText = fullText
? `<div class="item-sub" style="margin-top:6px;white-space:pre-wrap;color:#333;">${escapeHtml(fullText)}</div>`
: '';
const idText = id !== '' ? ` <span class="mini">(#${escapeHtml(id)})</span>` : '';
const qty = Number(count) || 1;
const qtyText = qty > 1 ? ` <span class="mini">x${qty}</span>` : '';
return `<div class="item"><div class="item-title">${escapeHtml(name)}${qtyText}${idText}</div>${subtitle}${roleBlock}${rulesText}</div>`;
}
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 `<div class="tableau-card"><h3>${escapeHtml(title)}</h3><div class="mini">none</div></div>`;
}
const grouped = groupCardsForTableau(arr);
// If grouping failed (unexpected contents), keep the original behavior.
if (!grouped) {
return `<div class="tableau-card">
<h3>${escapeHtml(title)} <span class="mini">(${arr.length})</span></h3>
<div class="list">${arr.map(renderCardItem).join('')}</div>
</div>`;
}
return `<div class="tableau-card">
<h3>${escapeHtml(title)} <span class="mini">(${arr.length} total, ${grouped.length} types)</span></h3>
<div class="list">${grouped.map(x => renderCardItem(x.card, x.count)).join('')}</div>
</div>`;
}
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 = `<div class="tableau-card"><h3>Not joined</h3><div class="mini">Join the lobby first so we know your player id.</div></div>`;
return;
}
if (!subject) {
if (titleEl) titleEl.textContent = 'My Tableau';
bodyEl.innerHTML = `<div class="tableau-card"><h3>Player not in game</h3><div class="mini">No player with id <code>${escapeHtml(playerId)}</code> found in this game state.</div></div>`;
return;
}
} else if (!subject) {
if (titleEl) titleEl.textContent = 'Tableau';
bodyEl.innerHTML = `<div class="tableau-card"><h3>Player not in game</h3><div class="mini">No player with id <code>${escapeHtml(targetPlayerId)}</code> in this game state.</div></div>`;
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 = `
<div class="kv">
${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)}
</div>
`;
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 = `<div class="mini" style="margin-bottom:12px;">
<strong>Duke:</strong> ${escapeHtml(dukeName)}
${dukeText ? `<div style="margin-top:6px;white-space:pre-wrap;color:#333;">${escapeHtml(dukeText)}</div>` : ''}
</div>`;
bodyEl.innerHTML = `
${resourceRow}
${dukeLine}
<div class="tableau-grid">
${renderCardList('Starters', subject.owned_starters)}
${renderCardList('Citizens', subject.owned_citizens)}
${renderCardList('Monsters', subject.owned_monsters)}
${renderCardList('Domains', subject.owned_domains)}
</div>
`;
}
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 `<div class="list">${cards.map((card, i) => {
const role = i === 0 ? 'Top (slayable)' : 'Buried';
return `<div><div class="mini" style="margin-bottom:4px;">${role}</div>${renderCardItem(card)}</div>`;
}).join('')}</div>`;
}
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 `<div class="item"><div class="item-title">Stack ${idx + 1}</div><div class="mini">empty</div></div>`;
}
const top = topOfStack(stack);
if (!top) {
return `<div class="item"><div class="item-title">Stack ${idx + 1}</div><div class="mini">empty</div></div>`;
}
const meta = boardStackMeta(kind, top, depth);
const expandControl = kind === 'monster'
? `<button type="button" onclick="toggleMonsterStackExpand(${idx})">${isMonsterStackExpanded(idx) ? 'Collapse' : 'Expand'}</button>`
: '';
const cardHtml = kind === 'monster'
? renderMonsterStackCards(stack, idx)
: renderCardItem(top);
return `<div class="item" style="margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid #eee;">
<div class="item-title">Stack ${idx + 1}</div>
${expandControl ? `<div style="margin:6px 0;">${expandControl}</div>` : ''}
<div class="mini" style="margin-bottom:6px;">${escapeHtml(meta)}</div>
${cardHtml}
</div>`;
});
return `<div class="tableau-card"><h3>${escapeHtml(title)}</h3><div class="list">${blocks.join('')}</div></div>`;
}
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 = `
<div class="mini" style="margin-bottom:12px;">Top of each stack is the play surface. Monster stacks can be expanded to view buried cards, but only the top monster is slayable.</div>
<div class="tableau-grid">
${renderBoardStackSection('Citizens (market)', citizenGrid, 'citizen')}
${renderBoardStackSection('Domains', domainGrid, 'domain')}
${renderBoardStackSection('Monsters', monsterGrid, 'monster')}
</div>
`;
}
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 `<div class="game-log-line"><span class="game-log-tick">[${tick}]</span>${msg}</div>`;
}).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(`<strong>${rolled1}</strong> + <strong>${rolled2}</strong> = <strong>${rolledSum}</strong>`);
else parts.push(`<strong>Dice</strong>: not rolled`);
if (rolled1 && rolled2 && final1 && final2 && (rolled1 !== final1 || rolled2 !== final2)) {
parts.push(`Final <strong>${final1}</strong> + <strong>${final2}</strong> = <strong>${finalSum}</strong>`);
}
if ((gameState?.phase || '') === 'roll_pending') {
parts.push(`<span style="display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid #d6b26c;background:#fff6d8;color:#5b420a;font-weight:800;font-size:12px;">Awaiting finalize</span>`);
}
if ((gameState?.phase || '') === 'action_end_pending') {
parts.push(`<span style="display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid #c9d6ea;background:#eef5ff;color:#1f3a5f;font-weight:800;font-size:12px;">Action-end domains</span>`);
}
if (turn !== undefined) parts.push(`Turn <strong>${turn}</strong>`);
if (phase) parts.push(`Phase <strong>${phase}</strong>`);
if (actionsRemaining !== undefined) parts.push(`Actions remaining <strong>${actionsRemaining}</strong>`);
if (active) parts.push(`Active <code>${active}</code>`);
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 = '<strong>Roll phase effects:</strong> none';
} else {
const rows = effects.map((e) => {
return `<li><strong>${escapeHtml(e.domainName)}</strong>: set one die to <strong>${escapeHtml(String(e.target))}</strong> (cost: <code>${escapeHtml(e.costSpec)}</code>)</li>`;
}).join('');
effectsEl.innerHTML = `<strong>Roll phase effects (active player):</strong><ul>${rows}</ul>`;
}
}
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 = `
<div class="delta-grid">
<span class="delta-name">${name}</span>
<span class="delta-cell"><span class="delta-label">ΔG</span><span class="delta-value ${cls(g)}">${fmt(g)}</span></span>
<span class="delta-cell"><span class="delta-label">ΔS</span><span class="delta-value ${cls(s)}">${fmt(s)}</span></span>
<span class="delta-cell"><span class="delta-label">ΔM</span><span class="delta-value ${cls(m)}">${fmt(m)}</span></span>
<span class="delta-cell"><span class="delta-label">ΔVP</span><span class="delta-value ${cls(v)}">${fmt(v)}</span></span>
<span class="delta-muted">Totals</span>
<span class="delta-cell"><span class="delta-label">G</span><span class="delta-value delta-totals">${G}</span></span>
<span class="delta-cell"><span class="delta-label">S</span><span class="delta-value delta-totals">${S}</span></span>
<span class="delta-cell"><span class="delta-label">M</span><span class="delta-value delta-totals">${M}</span></span>
<span class="delta-cell"><span class="delta-label">VP</span><span class="delta-value delta-totals">${V}</span></span>
</div>
`;
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 `<span style="display:inline-block;padding:2px 8px;border-radius:999px;border:1px solid ${border};background:${bg};color:${fg};font-size:12px;font-weight:700;">${label}</span>`;
}
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 === 'harvest_optional_exchange') {
return renderHarvestOptionalExchangePrompt(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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<div>Manual harvest in progress for <code>${escapeHtml(reqId)}</code> (${slots.length} card(s)).</div>
${badge}
</div>
</div>`;
return;
}
if (!slots.length) {
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<div>Harvest: no slots (try Refresh).</div>
${badge}
</div>
</div>`;
return;
}
const thiefNote = slots.some(s => s.kind === 'citizen' && s.is_thief)
? '<div class="mini" style="margin-bottom:8px;">If you have the Thief, harvest that citizen before other citizens.</div>'
: '';
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 `<button type="button" onclick="sendHarvestCard('${sk}')">Harvest: ${label}</button>`;
}).join(' ');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<div style="font-weight:700;">Harvest (choose order)</div>
${badge}
</div>
${thiefNote}
<div style="display:flex;gap:8px;flex-wrap:wrap;">${btns}</div>
</div>`;
return;
}
if (reqAction !== 'bonus_resource_choice') {
if (reqAction === 'standard_action') {
return renderStandardActionPanel(gameState);
}
panel.innerHTML = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on required action from <code>${reqId}</code>: <strong>${reqAction}</strong>
</div>`;
return;
}
const isYou = (playerId && reqId === playerId);
if (!isYou) {
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<div>Harvest bonus choice pending for <code>${reqId}</code>.</div>
${badge}
</div>
</div>`;
return;
}
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<div style="font-weight:700;">Harvest bonus: choose +1 resource</div>
${badge}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="sendBonusChoice('gold')">+1 Gold</button>
<button onclick="sendBonusChoice('strength')">+1 Strength</button>
<button onclick="sendBonusChoice('magic')">+1 Magic</button>
</div>
</div>
`;
}
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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on required action from <code>${reqId}</code>: <strong>${escapeHtml(chooseCmd)}</strong>
</div>`;
return;
}
if (!isYou) {
panel.innerHTML = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on required action from <code>${reqId}</code>: <strong>${escapeHtml(chooseCmd)}</strong>
</div>`;
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 `<button onclick="sendChooseIndex(${idx + 1})">+(${escapeHtml(mText)} x ${escapeHtml(area)}) ${escapeHtml(rLabel)}</button>`;
}
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 `<button onclick="sendChooseIndex(${idx + 1})">Gain ${escapeHtml(prettyAmt)} ${escapeHtml(who)}${escapeHtml(extraSuffix)}</button>`;
}
return `<button onclick="sendChooseIndex(${idx + 1})">+${escapeHtml(prettyAmt)} ${escapeHtml(label)}</button>`;
}).join('');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">Choose one</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
${buttons}
</div>
</div>
`;
}
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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on required action from <code>${escapeHtml(reqId)}</code>: <strong>finalize_roll</strong>
</div>`;
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 = `<button onclick="sendFinalizeRollChoice(${rolled1}, ${rolled2})">Keep ${rolled1} + ${rolled2}</button>`;
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 `<button onclick="sendFinalizeRollChoice(${d1}, ${d2})">Set die ${o.die}: ${fromVal}${o.target} (pay ${o.costGold}g via ${escapeHtml(o.domainName)})</button>`;
}).join(' ');
const note = options.length
? '<div class="mini" style="margin-top:6px;">Choose a roll modifier or keep the rolled dice.</div>'
: '<div class="mini" style="margin-top:6px;">No roll modifiers available; finalize to continue.</div>';
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">Finalize Roll</div>
<div style="margin-bottom:8px;">Rolled: <strong>${rolled1} + ${rolled2}</strong></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
${keepBtn}
${modBtns}
</div>
${note}
</div>
`;
}
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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on <code>${escapeHtml(reqId)}</code> for <strong>${escapeHtml(dn)}</strong> optional activation trade.
</div>`;
return;
}
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">${escapeHtml(dn)}: optional trade</div>
<div class="mini" style="margin-bottom:8px;color:#333;">${escapeHtml(explain)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" onclick="sendSelfConvertConfirm()">Confirm trade</button>
<button type="button" onclick="sendSelfConvertDecline()">Decline</button>
</div>
</div>`;
}
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 harvestExchangeExplain(command) {
const parts = (command || '').trim().split(/\s+/);
if (parts.length < 5 || parts[0].toLowerCase() !== 'exchange') return (command || '').trim() || 'Optional harvest exchange.';
const pay = parts[1].toLowerCase();
const payN = parts[2];
const gain = parts[3].toLowerCase();
const gainN = parts[4];
const labels = { g: 'gold', s: 'strength', m: 'magic', v: 'victory points' };
return `Pay ${payN} ${labels[pay] || pay}, gain ${gainN} ${labels[gain] || gain}.`;
}
function renderHarvestOptionalExchangePrompt(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 cmd = (prc?.command || '').toString();
const explain = harvestExchangeExplain(cmd);
if (!isYou) {
panel.innerHTML = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on <code>${escapeHtml(reqId)}</code> — optional citizen harvest exchange.
</div>`;
return;
}
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">Harvest: optional exchange</div>
<div class="mini" style="margin-bottom:8px;color:#333;">${escapeHtml(explain)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" onclick="sendHarvestExchangeConfirm()">Take exchange</button>
<button type="button" onclick="sendHarvestExchangeSkip()">Skip (keep resources)</button>
</div>
</div>`;
}
async function sendHarvestExchangeConfirm() {
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_harvest_exchange'
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendHarvestExchangeSkip() {
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_harvest_exchange'
})
});
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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on <code>${escapeHtml(reqId)}</code> to choose a player for <strong>${escapeHtml(dn)}</strong>.
</div>`;
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
? `<button type="button" onclick="sendDomainManipulateSkip()" style="margin-left:8px;">${escapeHtml(skipLabel)}</button>`
: '';
const btns = opts.map((o, idx) => {
const nm = escapeHtml((o?.name || o?.player_id || '?').toString());
return `<button type="button" onclick="sendChoosePlayerIndex(${idx + 1})">${nm}</button>`;
}).join(' ');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">${escapeHtml(dn)}: choose another player</div>
<div class="mini" style="margin-bottom:8px;color:#333;">${escapeHtml(explain)}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">${btns}${skipBtn}</div>
</div>`;
}
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 = `<div style="padding:8px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on <code>${escapeHtml(reqId)}</code> for <strong>${escapeHtml(dn)}</strong> (monster +${delta} strength cost).
</div>`;
return;
}
const btns = opts.map((o, idx) => {
const nm = escapeHtml((o?.name || '?').toString());
return `<button type="button" onclick="sendChooseMonsterIndex(${idx + 1})">${nm}</button>`;
}).join(' ');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
<div style="font-weight:700;margin-bottom:8px;">${escapeHtml(dn)}: add +${delta} to a center monster strength cost</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">${btns}</div>
</div>`;
}
// 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 = `<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
Waiting on concurrent action <strong>${escapeHtml(concurrent.kind || 'unknown')}</strong>
(${pending.length} player(s) still need to respond).
</div>`;
}
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 = `<div class="mini" style="margin-bottom:8px;">
Starting setup: ${completed.length}/${totalParticipants} duke choice(s) submitted.
${pending.length ? `Waiting on: <strong>${escapeHtml(waitingLabels.join(', '))}</strong>.` : ''}
</div>`;
if (!isPending) {
const youDone = !!(playerId && completed.includes(playerId));
const yourLine = youDone
? `<div>You have already chosen your duke. Waiting on the other player(s).</div>`
: `<div>Starting setup is in progress.</div>`;
panel.innerHTML = `<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
${statusLine}${yourLine}
</div>`;
return;
}
const dukes = Array.isArray(you?.owned_dukes) ? you.owned_dukes : [];
if (!dukes.length) {
panel.innerHTML = `<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
${statusLine}<div>No dukes found to choose from.</div>
</div>`;
return;
}
const buttons = dukes.map(d => {
const id = d?.duke_id;
const name = d?.name || `Duke #${id}`;
const fullText = cardFullText(d);
const sub = fullText ? `<div style="color:#333;font-size:13px;margin-top:6px;white-space:pre-wrap;">${escapeHtml(fullText)}</div>` : '';
return `<div style="border:1px solid #e6e6e6;background:#fff;border-radius:10px;padding:10px;">
<div style="font-weight:800;">${escapeHtml(name)} <span style="color:#666;font-weight:600;">(#${escapeHtml(id)})</span></div>
${sub}
<div style="margin-top:8px;">
<button onclick="submitConcurrentAction('choose_duke', ${Number(id)})">Keep this duke</button>
</div>
</div>`;
}).join('');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
${statusLine}
<div style="font-weight:800;margin-bottom:8px;">Choose 1 duke to keep</div>
<div style="display:flex;flex-direction:column;gap:8px;">${buttons}</div>
</div>
`;
}
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 ? `<code>${escapeHtml(buyerId)}</code>` : '');
const you = players.find(p => p?.player_id === playerId) || null;
const waitingLabels = pendingPlayerLabels(gameState, pending);
const statusLine = `<div class="mini" style="margin-bottom:8px;">
Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted.
${pending.length ? `Waiting on: <strong>${escapeHtml(waitingLabels.join(', '))}</strong>.` : ''}
${buyerTag ? `<div style="margin-top:6px;">Triggered by <strong>${buyerTag}</strong>.</div>` : ''}
</div>`;
if (!isPending) {
const youDone = !!(playerId && completed.includes(playerId));
const yourLine = youDone
? `<div>You already chose a citizen to flip. Waiting on other players.</div>`
: `<div>You have no pending flip choice (no eligible citizens, or not in this prompt).</div>`;
panel.innerHTML = `<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
${statusLine}${yourLine}
</div>`;
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 = `<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#fff6d8;">
${statusLine}<div>No face-up citizens on your tableau — contact host if this seems wrong.</div>
</div>`;
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 `<div style="border:1px solid #e6e6e6;background:#fff;border-radius:10px;padding:10px;margin-bottom:8px;">
<div style="font-weight:800;">${escapeHtml(nm)} <span style="color:#666;font-weight:600;">(slot #${idx})</span></div>
<div style="color:#555;font-size:13px;margin-top:4px;">${escapeHtml(rm + gc)}</div>
<div style="margin-top:8px;">
<button type="button" onclick="submitConcurrentAction('flip_one_citizen', '${idx}')">Flip this citizen face-down</button>
</div>
</div>`;
}).join('');
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
${statusLine}
<div style="font-weight:800;margin-bottom:8px;">Choose 1 citizen to flip face-down</div>
<div style="display:flex;flex-direction:column;">${buttons}</div>
</div>
`;
}
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
? `<div style="font-weight:700;margin-bottom:6px;">Your action (${actionsRemaining} remaining)</div>`
: `<div style="font-weight:700;margin-bottom:6px;">Waiting on ${active?.name || reqId} to act (${actionsRemaining} remaining)</div>`;
const resourcesLine = `<div style="margin-bottom:8px;">
Resources: <strong>G ${G}</strong> · <strong>S ${S}</strong> · <strong>M ${M}</strong> · <strong>VP ${V}</strong>
</div>`;
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
? `<div class="mini" style="margin-bottom:8px;padding:6px 8px;border:1px solid #d5e8ff;background:#f4f9ff;border-radius:6px;">Active effects: ${escapeHtml(activeEffects.join(' · '))}</div>`
: '';
const takeResourceRow = isYou
? `<div style="margin-top:10px;padding-top:10px;border-top:1px solid #ccc;">
<strong>Take resource</strong> (uses 1 action, gain +1):
<button type="button" style="margin-left:6px;" onclick="takeResourceFromChoice('gold')">+1 Gold</button>
<button type="button" style="margin-left:4px;" onclick="takeResourceFromChoice('strength')">+1 Strength</button>
<button type="button" style="margin-left:4px;" onclick="takeResourceFromChoice('magic')">+1 Magic</button>
</div>`
: '';
const listSection = (title, items, renderItem) => {
if (!items.length) return `<div style="margin-top:8px;"><strong>${title}:</strong> <span style="color:#666;">none affordable</span></div>`;
const rows = items.map(renderItem).join('');
return `<div style="margin-top:8px;"><strong>${title}:</strong><div style="display:flex;flex-direction:column;gap:6px;margin-top:6px;">${rows}</div></div>`;
};
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 ? ' <span style="color:#555;">Roles: ' + rbits.join(', ') + '</span>' : '';
const dupHint = Number(it.surcharge || 0)
? ' <span style="color:#666;">(base ' + Number(it.baseCost || 0) + ' + ' + Number(it.surcharge || 0) + ' dupes)</span>'
: '';
const emeraldHint = (!Number(it.surcharge || 0) && it.emeraldActive)
? ' <span style="color:#666;">(Emerald: no duplicate surcharge)</span>'
: '';
const rulesText = cardFullText(c);
const rulesLine = rulesText
? '<div style="margin-top:3px;color:#555;white-space:pre-wrap;">' + escapeHtml(rulesText) + '</div>'
: '';
const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + dupHint + emeraldHint + ' · Stack ' + it.stackSize;
const btn = isYou ? '<button type="button" onclick="hireCitizenFromRow(this)">Hire</button>' : '';
return '<div class="pay-row" data-citizen-id="' + c.citizen_id + '">' +
btn +
' <span><strong>' + escapeHtml(c.name) + '</strong> (#' + c.citizen_id + ')' + roleHint + rulesLine + '</span>' +
' <span class="cost-line" style="color:#555;">' +
'<span class="pay-cost-key" data-pay-key="' + key + '">' + costSummary + '</span>' +
'<div id="pay-editor-' + key + '" class="pay-controls">' +
'<span style="display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
'<label>G <input type="number" class="pay-g" min="0" max="' + G + '" value="' + pay.payGold + '"></label>' +
'<label>S <input type="number" class="pay-s" min="0" max="0" value="0" title="Citizens use gold and magic only"></label>' +
'<label>M <input type="number" class="pay-m" min="0" max="' + M + '" value="' + pay.payMagic + '"></label>' +
'</span></div></span></div>';
});
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)
? ' <span style="color:#666;">(base ' + Number(it.baseCost || 0) + ' - 1 Pratchett)</span>'
: '';
const rulesText = cardFullText(d);
const rulesLine = rulesText
? '<div style="margin-top:3px;color:#555;white-space:pre-wrap;">' + escapeHtml(rulesText) + '</div>'
: '';
const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + pratchettHint + ' · Stack ' + it.stackSize;
const btn = isYou ? '<button type="button" onclick="buildDomainFromRow(this)">Build</button>' : '';
return '<div class="pay-row" data-domain-id="' + d.domain_id + '">' +
btn +
' <span><strong>' + escapeHtml(d.name) + '</strong> (#' + d.domain_id + ')' + rulesLine + '</span>' +
' <span class="cost-line" style="color:#555;">' +
'<span class="pay-cost-key" data-pay-key="' + key + '">' + costSummary + '</span>' +
'<div id="pay-editor-' + key + '" class="pay-controls">' +
'<span style="display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
'<label>G <input type="number" class="pay-g" min="0" max="' + G + '" value="' + pay.payGold + '"></label>' +
'<label>S <input type="number" class="pay-s" min="0" max="0" value="0" title="Domains use gold and magic only"></label>' +
'<label>M <input type="number" class="pay-m" min="0" max="' + M + '" value="' + pay.payMagic + '"></label>' +
'</span></div></span></div>';
});
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
? '<div style="margin-top:3px;color:#555;white-space:pre-wrap;">' + escapeHtml(rulesText) + '</div>'
: '';
const costSummary = 'Cost: S ' + sCost + ' + M ' + mMin + ' min · pay S' + pay.payStrength + ', M' + pay.payMagic + ' · Stack ' + it.stackSize;
const btn = isYou ? '<button type="button" onclick="slayMonsterFromRow(this)">Slay</button>' : '';
return '<div class="pay-row" data-monster-id="' + mcard.monster_id + '">' +
btn +
' <span><strong>' + escapeHtml(mcard.name) + '</strong> (#' + mcard.monster_id + ')' + rulesLine + '</span>' +
' <span class="cost-line" style="color:#555;">' +
'<span class="pay-cost-key" data-pay-key="' + key + '">' + costSummary + '</span>' +
'<div id="pay-editor-' + key + '" class="pay-controls">' +
'<span style="display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;">' +
'<label>G <input type="number" class="pay-g" min="0" max="0" value="0" title="Monsters use strength and magic only"></label>' +
'<label>S <input type="number" class="pay-s" min="0" max="' + S + '" value="' + pay.payStrength + '"></label>' +
'<label>M <input type="number" class="pay-m" min="0" max="' + M + '" value="' + pay.payMagic + '"></label>' +
'</span></div></span></div>';
});
panel.innerHTML = `
<div style="padding:10px;border:1px solid #ddd;border-radius:8px;background:#eef7ff;">
${header}
${resourcesLine}
${effectsBanner}
${takeResourceRow}
${citizenHtml}
${domainHtml}
${monsterHtml}
</div>
`;
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);