let playerId = localStorage.getItem('playerId') || '';
let currentGameId = localStorage.getItem('gameId') || '';
let lastGameState = null;
let lastRenderedGameLogKey = null;
let finalizeRollInFlight = false;
let autoHarvestInFlight = false;
function getDebugStartingResourcesEnabled() {
return localStorage.getItem('debugStartingResourcesEnabled') === 'true';
}
function getAutoHarvestEnabled() {
const v = localStorage.getItem('autoHarvestEnabled');
if (v === null) return true;
return v === 'true';
}
function syncDebugStartingResourcesUiFromStorage() {
const el = document.getElementById('debugStartingResourcesEnabled');
if (!el) return;
el.checked = getDebugStartingResourcesEnabled();
}
function wireDebugStartingResourcesUi() {
const el = document.getElementById('debugStartingResourcesEnabled');
if (!el) return;
el.addEventListener('change', () => {
localStorage.setItem('debugStartingResourcesEnabled', String(!!el.checked));
});
syncDebugStartingResourcesUiFromStorage();
}
function syncAutoHarvestUiFromStorage() {
const el = document.getElementById('autoHarvestEnabled');
if (!el) return;
el.checked = getAutoHarvestEnabled();
}
function wireAutoHarvestUi() {
const el = document.getElementById('autoHarvestEnabled');
if (!el) return;
el.addEventListener('change', () => {
localStorage.setItem('autoHarvestEnabled', String(!!el.checked));
});
syncAutoHarvestUiFromStorage();
}
function clampDie(n) {
const x = Number(n);
if (!Number.isFinite(x)) return 1;
return Math.max(1, Math.min(6, Math.trunc(x)));
}
function getDiceRigSettings() {
const enabled = localStorage.getItem('diceRigEnabled') === 'true';
const d1 = clampDie(localStorage.getItem('diceRigDie1') || 1);
const d2 = clampDie(localStorage.getItem('diceRigDie2') || 1);
return { enabled, d1, d2 };
}
function setDiceRigSettings(next) {
localStorage.setItem('diceRigEnabled', String(!!next.enabled));
localStorage.setItem('diceRigDie1', String(clampDie(next.d1)));
localStorage.setItem('diceRigDie2', String(clampDie(next.d2)));
}
function syncDiceRigUiFromStorage() {
const enabledEl = document.getElementById('diceOverrideEnabled');
const d1El = document.getElementById('diceOverrideDie1');
const d2El = document.getElementById('diceOverrideDie2');
if (!enabledEl || !d1El || !d2El) return;
const s = getDiceRigSettings();
enabledEl.checked = !!s.enabled;
d1El.value = String(s.d1);
d2El.value = String(s.d2);
d1El.disabled = !s.enabled;
d2El.disabled = !s.enabled;
}
function wireDiceRigUi() {
const enabledEl = document.getElementById('diceOverrideEnabled');
const d1El = document.getElementById('diceOverrideDie1');
const d2El = document.getElementById('diceOverrideDie2');
if (!enabledEl || !d1El || !d2El) return;
const onChange = () => {
setDiceRigSettings({
enabled: enabledEl.checked,
d1: d1El.value,
d2: d2El.value,
});
syncDiceRigUiFromStorage();
// If we're currently waiting on a pending roll, apply immediately.
if (lastGameState) maybeFinalizePendingRoll(lastGameState);
};
enabledEl.addEventListener('change', onChange);
d1El.addEventListener('change', onChange);
d2El.addEventListener('change', onChange);
d1El.addEventListener('input', onChange);
d2El.addEventListener('input', onChange);
syncDiceRigUiFromStorage();
}
// Poll handle used while a concurrent (non-ordered) prompt is active so
// every browser session sees other players' progress in near-real-time.
// Intentionally NOT an unconditional global poll: the standard-action panel
// rebuilds payment inputs from gameState, and polling would wipe in-progress edits.
// We do poll when waiting on others (passive) or during concurrent_action (below).
let concurrentPollHandle = null;
let passiveGamePollHandle = null;
if (playerId) {
document.getElementById('playerId').textContent = 'Player ID: ' + playerId;
}
wireDiceRigUi();
wireDebugStartingResourcesUi();
wireAutoHarvestUi();
async function joinLobby() {
const name = document.getElementById('playerName').value;
if (!name) {
alert('Please enter a name');
return;
}
try {
const response = await fetch('/api/lobby/join', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
const data = await response.json();
playerId = data.player_id;
localStorage.setItem('playerId', playerId);
document.getElementById('playerId').textContent = 'Player ID: ' + playerId;
getLobbyStatus();
} catch (error) {
alert('Error: ' + error.message);
}
}
async function getLobbyStatus() {
if (!playerId) return;
try {
const response = await fetch(`/api/lobby/status?player_id=${playerId}`);
const data = await response.json();
let html = '
Players in Lobby: ';
data.lobby.forEach(p => {
const debugTag = p.debug_starting_resources ? ' (debug 100/100/100) ' : '';
html += `
${p.name}${debugTag} - ${p.is_ready ? 'Ready' : 'Not Ready'}
${p.player_id === playerId ? 'Toggle Ready ' : ''}
`;
});
html += `Active games: ${data.game_count}
`;
if (data.in_game) {
html += `You are in game: ${data.game_id}
`;
if (data.game_id && data.game_id !== currentGameId) {
currentGameId = data.game_id;
localStorage.setItem('gameId', currentGameId);
// Fetch immediately when we first learn the game id
getGameState(false);
}
} else {
// If server says we're not in a game, clear any stale id
if (currentGameId) {
currentGameId = '';
localStorage.removeItem('gameId');
stopGamePollingIntervals();
}
}
document.getElementById('lobbyStatus').innerHTML = html;
} catch (error) {
console.error('Error:', error);
}
}
async function toggleReady() {
if (!playerId) return;
try {
const response = await fetch('/api/lobby/ready', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
debug_starting_resources: getDebugStartingResourcesEnabled(),
})
});
const data = await response.json();
if (data.game_id) {
currentGameId = data.game_id;
localStorage.setItem('gameId', currentGameId);
alert('Game started! Game ID: ' + data.game_id);
// Immediately fetch state so the Game section fills in
getGameState(false);
}
getLobbyStatus();
} catch (error) {
console.error('Error:', error);
}
}
function applyGameStateClientUpdate(data) {
lastGameState = data;
renderDice(data);
syncDiceRigUiFromStorage();
maybeFinalizePendingRoll(data);
const pre = document.getElementById('gameState');
if (pre) pre.textContent = JSON.stringify(data, null, 2);
updateConcurrentPolling(data);
updatePassiveGamePolling(data);
refreshTableauActionButtons(data);
maybeAutoHarvest(data);
}
function maybeAutoHarvest(gameState) {
if (!playerId || !currentGameId) return;
if (!gameState || autoHarvestInFlight) return;
if (!getAutoHarvestEnabled()) return;
const concurrent = gameState?.concurrent_action || null;
if (concurrent && Array.isArray(concurrent.pending) && concurrent.pending.length > 0) return;
const req = gameState?.action_required || {};
const reqId = (req?.id || '').toString();
const reqAction = (req?.action || '').toString();
if (reqAction !== 'manual_harvest') return;
if (!reqId || reqId !== playerId) return;
const slots = Array.isArray(gameState?.harvest_prompt_slots) ? gameState.harvest_prompt_slots : [];
if (slots.length !== 1) return;
const slotKey = (slots[0]?.slot_key || '').toString().trim();
if (!slotKey) return;
autoHarvestInFlight = true;
sendHarvestCard(slotKey, { suppressAlert: true })
.finally(() => { autoHarvestInFlight = false; });
}
async function maybeFinalizePendingRoll(gameState) {
if (!playerId || !currentGameId) return;
if (!gameState) return;
if (finalizeRollInFlight) return;
const phase = (gameState.phase || '').toString();
if (phase !== 'roll_pending') return;
const req = gameState.action_required || {};
const reqId = (req.id || '').toString();
const reqAction = (req.action || '').toString();
if (reqAction !== 'finalize_roll') return;
if (reqId !== playerId) return;
const rolled1 = clampDie(gameState.rolled_die_one ?? gameState.die_one ?? 1);
const rolled2 = clampDie(gameState.rolled_die_two ?? gameState.die_two ?? 1);
const s = getDiceRigSettings();
if (!s.enabled) {
const player = Array.isArray(gameState?.player_list)
? gameState.player_list.find(p => (p?.player_id || '') === playerId)
: null;
const opts = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number);
if (opts.length > 0) {
// Do not auto-finalize when roll modifiers are available.
return;
}
}
const final1 = s.enabled ? clampDie(s.d1) : rolled1;
const final2 = s.enabled ? clampDie(s.d2) : rolled2;
finalizeRollInFlight = true;
try {
const res = await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'finalize_roll',
die_one: final1,
die_two: final2
})
});
const payload = await res.json();
if (!res.ok) {
console.error(payload);
return;
}
if (payload && payload.game_state) {
applyGameStateClientUpdate(payload.game_state);
} else {
getGameState(false);
}
} catch (e) {
console.error(e);
} finally {
finalizeRollInFlight = false;
}
}
function ownedCitizenRoleSelectorCount(player, roleSelector) {
const role = (roleSelector || '').toString().trim().toLowerCase();
if (!role) return 0;
const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : [];
const keyByRole = {
holy_citizen: 'holy_count',
shadow_citizen: 'shadow_count',
soldier_citizen: 'soldier_count',
worker_citizen: 'worker_count',
};
const key = keyByRole[role];
if (!key) return 0;
let n = 0;
citizens.forEach((c) => {
if (Number(c?.[key] || 0) > 0) n += 1;
});
return n;
}
function domainPassiveOnBuildTurnCooldown(domain, turnNumber) {
const acq = domain?.acquired_turn_number;
if (acq === undefined || acq === null) return false;
const t = Number(turnNumber);
if (!Number.isFinite(t)) return false;
return Number(acq) === t;
}
function parseRollSetOneDieEffects(player, turnNumber) {
const out = [];
const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : [];
domains.forEach((d) => {
if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return;
const raw = (d?.passive_effect ?? '').toString().trim();
if (!raw) return;
const parts = raw.split(/\s+/);
const head0 = (parts[0] || '').toLowerCase().replace(/:/g, '.');
if (!parts.length || head0 !== 'roll.set_one_die') return;
const kv = {};
for (let i = 1; i < parts.length; i += 1) {
const p = parts[i];
const eq = p.indexOf('=');
if (eq < 0) continue;
const k = p.slice(0, eq).trim().toLowerCase();
const v = p.slice(eq + 1).trim();
kv[k] = v;
}
const target = Number(kv.target);
const costSpec = (kv.cost || '').toString().trim().toLowerCase();
if (!Number.isFinite(target) || target < 1 || target > 6 || !costSpec) return;
out.push({ domainName: (d?.name || 'Domain').toString(), target, costSpec });
});
return out;
}
function rollEffectCostGold(player, costSpec) {
const spec = (costSpec || '').toString().trim().toLowerCase();
if (spec.startsWith('g:')) {
const n = Number(spec.slice(2));
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
if (spec.startsWith('g_per_owned_role:')) {
const role = spec.slice('g_per_owned_role:'.length);
return ownedCitizenRoleSelectorCount(player, role);
}
if (spec === 'g:per_owned_holy_citizen' || spec === 'per_owned_holy_citizen') {
return ownedCitizenRoleSelectorCount(player, 'holy_citizen');
}
return null;
}
function listRollSetOneDieOptions(player, rolled1, rolled2, turnNumber) {
const effects = parseRollSetOneDieEffects(player, turnNumber);
const gold = Number(player?.gold_score || 0);
const options = [];
effects.forEach((e) => {
const costGold = rollEffectCostGold(player, e.costSpec);
if (costGold === null || gold < costGold) return;
if (Number(rolled1) !== Number(e.target)) {
options.push({ die: 1, target: Number(e.target), costGold, domainName: e.domainName });
}
if (Number(rolled2) !== Number(e.target)) {
options.push({ die: 2, target: Number(e.target), costGold, domainName: e.domainName });
}
});
return options;
}
async function sendFinalizeRollChoice(d1, d2) {
if (!playerId || !currentGameId) return;
if (finalizeRollInFlight) return;
finalizeRollInFlight = true;
try {
const res = await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'finalize_roll',
die_one: clampDie(d1),
die_two: clampDie(d2)
})
});
const payload = await res.json();
if (!res.ok) {
alert(payload?.detail || 'Finalize roll failed');
return;
}
if (payload && payload.game_state) applyGameStateClientUpdate(payload.game_state);
else getGameState(false);
} catch (e) {
console.error(e);
} finally {
finalizeRollInFlight = false;
}
}
function refreshTableauActionButtons(gameState) {
const wrap = document.getElementById('tableauSeatLayout');
if (!wrap) return;
wrap.innerHTML = '';
const players = gameState && Array.isArray(gameState.player_list) ? gameState.player_list : [];
const possessive = (name) => {
const s = (name ?? '').toString().trim();
if (!s) return 'Player';
const lower = s.toLowerCase();
if (lower.endsWith('s')) return `${s}'`;
return `${s}'s`;
};
// Board button (center)
const boardBtn = document.createElement('button');
boardBtn.type = 'button';
boardBtn.className = 'tableau-seat-btn board-seat';
boardBtn.textContent = 'Board';
boardBtn.style.left = '50%';
boardBtn.style.top = '50%';
boardBtn.onclick = () => { openBoardTableau(); };
wrap.appendChild(boardBtn);
const cleanPlayers = players.filter(p => p && p.player_id);
const n = cleanPlayers.length;
if (!n) return;
const seatAnglesDeg = (count) => {
if (count === 1) return [-90];
if (count === 2) return [180, 0]; // left / right
if (count === 3) return [-90, 150, 30]; // triangle around board
if (count === 4) return [-90, 0, 90, 180]; // top / right / bottom / left
// 5+ evenly spaced circle, starting at top, clockwise
const out = [];
for (let i = 0; i < count; i++) out.push(-90 + (360 * i) / count);
return out;
};
const angles = seatAnglesDeg(n);
const w = wrap.clientWidth || 760;
const h = wrap.clientHeight || 220;
const radius = Math.max(70, Math.min(w, h) * 0.42);
const firstPid = cleanPlayers[0]?.player_id || '';
cleanPlayers.forEach((p, idx) => {
const pid = p.player_id;
const nm = ((p.name ?? '').toString().trim() || pid);
const isSelf = pid === playerId;
const isFirst = pid === firstPid;
const label = isSelf ? 'My Tableau' : `${possessive(nm)} Tableau`;
const deg = angles[idx % angles.length];
const rad = (deg * Math.PI) / 180;
const x = (w / 2) + radius * Math.cos(rad);
const y = (h / 2) + radius * Math.sin(rad);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tableau-seat-btn' + (isFirst ? ' first-seat' : '');
btn.textContent = isFirst ? `${label} (First)` : label;
btn.style.left = `${x}px`;
btn.style.top = `${y}px`;
btn.onclick = () => { openSeatTableau(pid); };
wrap.appendChild(btn);
});
}
async function getGameState(forcePrompt = true) {
let gameId = currentGameId;
if (!gameId && forcePrompt) {
gameId = prompt('Enter game ID:');
}
if (!gameId) return;
currentGameId = gameId;
localStorage.setItem('gameId', currentGameId);
try {
const qs = playerId ? `?player_id=${encodeURIComponent(playerId)}` : '';
const response = await fetch(`/api/game/${gameId}/state${qs}`);
const data = await response.json();
applyGameStateClientUpdate(data);
} catch (error) {
alert('Error: ' + error.message);
}
}
async function ensureGameStateForTableau() {
if (!currentGameId) {
alert('No game id yet. Start a game or refresh lobby status first.');
return false;
}
if (lastGameState && lastGameState.game_id === currentGameId) return true;
try {
const qs = playerId ? `?player_id=${encodeURIComponent(playerId)}` : '';
const response = await fetch(`/api/game/${currentGameId}/state${qs}`);
const data = await response.json();
applyGameStateClientUpdate(data);
return true;
} catch (e) {
alert('Error: ' + e.message);
return false;
}
}
function stopGamePollingIntervals() {
if (concurrentPollHandle) {
clearInterval(concurrentPollHandle);
concurrentPollHandle = null;
}
if (passiveGamePollHandle) {
clearInterval(passiveGamePollHandle);
passiveGamePollHandle = null;
}
}
function updateConcurrentPolling(gameState) {
// Only poll while a concurrent action is active. This keeps
// non-pending players' UI honest as others submit, without
// disturbing the rest of the in-game UI (which rebuilds inputs
// on every render).
const ca = gameState?.concurrent_action || null;
const pend = ca && Array.isArray(ca.pending) ? ca.pending : [];
const shouldPoll = pend.length > 0;
if (shouldPoll && !concurrentPollHandle) {
concurrentPollHandle = setInterval(() => {
if (!currentGameId) return;
getGameState(false);
}, 1500);
} else if (!shouldPoll && concurrentPollHandle) {
clearInterval(concurrentPollHandle);
concurrentPollHandle = null;
}
}
function localGameUiIsFragile(gameState) {
if (!playerId || !gameState) return false;
const ca = gameState.concurrent_action || null;
const pend = ca && Array.isArray(ca.pending) ? ca.pending : [];
if (pend.length && pend.includes(playerId)) return true;
const req = gameState.action_required || {};
const reqId = req.id || '';
const reqAction = (req.action || '').toString();
if (!reqId || reqId === gameState.game_id) return false;
if (reqId !== playerId) return false;
if (reqAction === 'manual_harvest') return true;
if (reqAction === 'bonus_resource_choice') return true;
const trimmed = reqAction.trim();
if (trimmed.startsWith('choose ')) return true;
if (trimmed === 'choose_player' || trimmed === 'choose_monster_strength' || trimmed === 'domain_self_convert') return true;
if (reqAction === 'standard_action' && (gameState.phase || '') === 'action') return true;
return false;
}
function updatePassiveGamePolling(gameState) {
if (!currentGameId) {
if (passiveGamePollHandle) {
clearInterval(passiveGamePollHandle);
passiveGamePollHandle = null;
}
return;
}
const ca = gameState?.concurrent_action || null;
const pend = ca && Array.isArray(ca.pending) ? ca.pending : [];
const concurrentBlocking = pend.length > 0;
const fragile = localGameUiIsFragile(gameState);
const shouldPoll = !concurrentBlocking && !fragile;
if (shouldPoll && !passiveGamePollHandle) {
passiveGamePollHandle = setInterval(() => {
if (!currentGameId) {
clearInterval(passiveGamePollHandle);
passiveGamePollHandle = null;
return;
}
getGameState(false);
}, 2000);
} else if (!shouldPoll && passiveGamePollHandle) {
clearInterval(passiveGamePollHandle);
passiveGamePollHandle = null;
}
}
function escapeHtml(s) {
return (s ?? '').toString()
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function openModal() {
const m = document.getElementById('tableauModal');
if (m) m.classList.add('open');
}
function closeTableau() {
const m = document.getElementById('tableauModal');
if (m) m.classList.remove('open');
}
function onTableauBackdropClick(e) {
// Click-out closes (panel stops propagation)
closeTableau();
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeTableau();
});
function pill(label, value) {
return `${escapeHtml(label)}: ${escapeHtml(value)} `;
}
function citizenRoleCounts(card) {
const r = card && card.roles;
if (r && typeof r === 'object') {
return {
sn: Number(r.shadow) || 0,
hn: Number(r.holy) || 0,
son: Number(r.soldier) || 0,
wn: Number(r.worker) || 0,
};
}
return {
sn: Number(card.shadow_count) || 0,
hn: Number(card.holy_count) || 0,
son: Number(card.soldier_count) || 0,
wn: Number(card.worker_count) || 0,
};
}
function formatHarvestGSM(card, onTurn) {
const g = onTurn ? 'gold_payout_on_turn' : 'gold_payout_off_turn';
const s = onTurn ? 'strength_payout_on_turn' : 'strength_payout_off_turn';
const m = onTurn ? 'magic_payout_on_turn' : 'magic_payout_off_turn';
const gv = Number(card[g]) || 0;
const sv = Number(card[s]) || 0;
const mv = Number(card[m]) || 0;
return `G ${gv}, S ${sv}, M ${mv}`;
}
function pushHarvestHints(hints, card) {
const hasOn = card.gold_payout_on_turn !== undefined || card.strength_payout_on_turn !== undefined || card.magic_payout_on_turn !== undefined;
const hasOff = card.gold_payout_off_turn !== undefined || card.strength_payout_off_turn !== undefined || card.magic_payout_off_turn !== undefined;
if (!hasOn && !hasOff) return;
const onStr = formatHarvestGSM(card, true);
const offStr = formatHarvestGSM(card, false);
if (onStr === offStr) {
hints.push(`Harvest: ${onStr} (on & off turn)`);
} else {
hints.push(`Harvest (on turn): ${onStr}`);
hints.push(`Harvest (off turn): ${offStr}`);
}
}
function renderCardItem(card, count = 1) {
if (!card || typeof card !== 'object') {
return `${escapeHtml(String(card))}
`;
}
const name = card.name || card.title || '(unnamed)';
const id = card.starter_id || card.citizen_id || card.monster_id || card.domain_id || card.duke_id || card.id || '';
const isCitizen = card.citizen_id !== undefined && card.citizen_id !== null;
const hints = [];
if (card.roll_match1 !== undefined || card.roll_match2 !== undefined) {
const rm1 = card.roll_match1 ?? '';
const rm2 = card.roll_match2 ?? '';
hints.push(`Roll: ${rm1}${rm2 !== '' ? '/' + rm2 : ''}`);
}
if (card.gold_cost !== undefined) hints.push(`Gold cost: ${card.gold_cost}`);
if (card.strength_cost !== undefined) hints.push(`Strength cost: ${card.strength_cost}`);
if (card.magic_cost !== undefined) hints.push(`Magic cost: ${card.magic_cost}`);
pushHarvestHints(hints, card);
if (isCitizen && card.is_flipped) hints.push('Flipped — no harvest payout / roll spend counts');
const { sn, hn, son, wn } = citizenRoleCounts(card);
const roleParts = [];
if (sn > 0) roleParts.push(`Shadow +${sn}`);
if (hn > 0) roleParts.push(`Holy +${hn}`);
if (son > 0) roleParts.push(`Soldier +${son}`);
if (wn > 0) roleParts.push(`Worker +${wn}`);
const isDomain = card.domain_id !== undefined && card.domain_id !== null;
const showRoleRow = (isCitizen || isDomain) && roleParts.length;
const roleBlock = showRoleRow
? `Roles: ${escapeHtml(roleParts.join(' · '))}
`
: '';
const subtitle = hints.length ? `${escapeHtml(hints.join(' · '))}
` : '';
const fullText = cardFullText(card);
const rulesText = fullText
? `${escapeHtml(fullText)}
`
: '';
const idText = id !== '' ? ` (#${escapeHtml(id)}) ` : '';
const qty = Number(count) || 1;
const qtyText = qty > 1 ? ` x${qty} ` : '';
return `${escapeHtml(name)}${qtyText}${idText}
${subtitle}${roleBlock}${rulesText}
`;
}
function groupCardsForTableau(cards) {
const arr = Array.isArray(cards) ? cards : [];
const map = new Map();
arr.forEach((c) => {
if (!c || typeof c !== 'object') return;
const name = (c.name || c.title || '').toString().trim();
const id = c.starter_id || c.citizen_id || c.monster_id || c.domain_id || c.duke_id || c.id || '';
const isCitizenKey = c.citizen_id !== undefined && c.citizen_id !== null;
const flipSeg = isCitizenKey ? `||flip:${c.is_flipped ? 1 : 0}` : '';
const key = `${name}||${id}${flipSeg}`;
const cur = map.get(key);
if (cur) cur.count += 1;
else map.set(key, { card: c, count: 1, sortName: name.toLowerCase(), sortId: String(id) });
});
// If we saw non-objects in the list, just fall back to rendering raw items.
if (map.size === 0 && arr.length) return null;
return Array.from(map.values()).sort((a, b) => {
if (a.sortName < b.sortName) return -1;
if (a.sortName > b.sortName) return 1;
if (a.sortId < b.sortId) return -1;
if (a.sortId > b.sortId) return 1;
return 0;
});
}
function renderCardList(title, cards) {
const arr = Array.isArray(cards) ? cards : [];
if (!arr.length) {
return ``;
}
const grouped = groupCardsForTableau(arr);
// If grouping failed (unexpected contents), keep the original behavior.
if (!grouped) {
return `
${escapeHtml(title)} (${arr.length})
${arr.map(renderCardItem).join('')}
`;
}
return `
${escapeHtml(title)} (${arr.length} total, ${grouped.length} types)
${grouped.map(x => renderCardItem(x.card, x.count)).join('')}
`;
}
function cardFullText(card) {
if (!card || typeof card !== 'object') return '';
// Prefer an explicit "text" field (Domains have this).
const rawText = (card.text ?? '').toString().trim();
if (rawText) return rawText;
// Otherwise synthesize from other special/effect fields we already serialize.
const parts = [];
// Include baseline harvest payouts so market rows show core card behavior,
// not just special/passive text.
pushHarvestHints(parts, card);
// Monsters use reward fields instead of harvest payout fields.
if (card.monster_id !== undefined && card.monster_id !== null) {
const vp = Number(card.vp_reward || 0);
const gr = Number(card.gold_reward || 0);
const sr = Number(card.strength_reward || 0);
const mr = Number(card.magic_reward || 0);
parts.push(`Reward: VP ${vp} · G ${gr} · S ${sr} · M ${mr}`);
}
const passive = (card.passive_effect ?? '').toString().trim();
const activation = (card.activation_effect ?? '').toString().trim();
if (passive) parts.push(`Passive: ${passive}`);
if (activation) parts.push(`Activation: ${activation}`);
const spOn = (card.special_payout_on_turn ?? '').toString().trim();
const spOff = (card.special_payout_off_turn ?? '').toString().trim();
if (spOn) parts.push(`Special (on turn): ${spOn}`);
if (spOff) parts.push(`Special (off turn): ${spOff}`);
const specialReward = (card.special_reward ?? '').toString().trim();
const specialCost = (card.special_cost ?? '').toString().trim();
if (specialReward) parts.push(`Special reward: ${specialReward}`);
if (specialCost) parts.push(`Special cost: ${specialCost}`);
// Dukes don't currently have rules text in data, so show their multipliers as the "text".
if (card.duke_id !== undefined) {
const mults = [];
const add = (label, val) => {
if (val === undefined || val === null) return;
const n = Number(val);
if (!Number.isFinite(n) || n === 0) return;
mults.push(`${label}×${n}`);
};
const addResource = (label, val) => {
if (val === undefined || val === null) return;
const n = Number(val);
if (!Number.isFinite(n) || n === 0) return;
// Duke resource scaling is "per N resources" (reciprocal display).
mults.push(`${label}×1/${n}`);
};
addResource('Gold', card.gold_multiplier);
addResource('Strength', card.strength_multiplier);
addResource('Magic', card.magic_multiplier);
add('Shadow', card.shadow_multiplier);
add('Holy', card.holy_multiplier);
add('Soldier', card.soldier_multiplier);
add('Worker', card.worker_multiplier);
add('Monster', card.monster_multiplier);
add('Citizen', card.citizen_multiplier);
add('Domain', card.domain_multiplier);
add('Boss', card.boss_multiplier);
add('Minion', card.minion_multiplier);
add('Beast', card.beast_multiplier);
add('Titan', card.titan_multiplier);
if (mults.length) parts.unshift(mults.join(' · '));
}
// Newlines separate rule lines when shown in pre-wrap text.
return parts.join('\n').trim();
}
function renderPlayerTableau(gameState, targetPlayerId, isSelfView) {
const titleEl = document.getElementById('tableauTitle');
const bodyEl = document.getElementById('tableauBody');
if (!bodyEl) return;
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
const subject = players.find(p => p?.player_id === targetPlayerId) || null;
if (isSelfView) {
if (!playerId) {
if (titleEl) titleEl.textContent = 'My Tableau';
bodyEl.innerHTML = `Not joined Join the lobby first so we know your player id.
`;
return;
}
if (!subject) {
if (titleEl) titleEl.textContent = 'My Tableau';
bodyEl.innerHTML = `Player not in game No player with id ${escapeHtml(playerId)} found in this game state.
`;
return;
}
} else if (!subject) {
if (titleEl) titleEl.textContent = 'Tableau';
bodyEl.innerHTML = `Player not in game No player with id ${escapeHtml(targetPlayerId)} in this game state.
`;
return;
}
const displayName = ((subject.name ?? '').toString().trim() || subject.player_id || 'Player');
if (titleEl) {
const possessive = (name) => {
const s = (name ?? '').toString().trim();
if (!s) return 'Player';
const lower = s.toLowerCase();
if (lower.endsWith('s')) return `${s}'`;
return `${s}'s`;
};
titleEl.textContent = isSelfView ? 'My Tableau' : `${possessive(displayName)} Tableau`;
}
const resourceRow = `
${pill('Gold', subject.gold_score ?? 0)}
${pill('Strength', subject.strength_score ?? 0)}
${pill('Magic', subject.magic_score ?? 0)}
${pill('Victory', subject.victory_score ?? 0)}
${pill('Shadow', subject.shadow_count ?? 0)}
${pill('Holy', subject.holy_count ?? 0)}
${pill('Soldier', subject.soldier_count ?? 0)}
${pill('Worker', subject.worker_count ?? 0)}
`;
const dukes = Array.isArray(subject.owned_dukes) ? subject.owned_dukes : [];
const duke = dukes.length ? dukes[0] : null;
const dukeName = duke ? (duke?.name || 'Duke') : 'None';
const dukeText = duke ? cardFullText(duke) : '';
const dukeLine = `
Duke: ${escapeHtml(dukeName)}
${dukeText ? `
${escapeHtml(dukeText)}
` : ''}
`;
bodyEl.innerHTML = `
${resourceRow}
${dukeLine}
${renderCardList('Starters', subject.owned_starters)}
${renderCardList('Citizens', subject.owned_citizens)}
${renderCardList('Monsters', subject.owned_monsters)}
${renderCardList('Domains', subject.owned_domains)}
`;
}
function boardStackMeta(kind, top, depth) {
const bits = [];
bits.push(depth + ' card' + (depth === 1 ? '' : 's'));
if (kind === 'citizen' || kind === 'monster') {
bits.push(top.is_accessible ? 'top accessible' : 'top not accessible');
}
if (kind === 'domain') {
bits.push(top.is_visible ? 'top visible' : 'top hidden');
bits.push(top.is_accessible ? 'top accessible' : 'top not accessible');
}
return bits.join(' · ');
}
const expandedMonsterStacks = new Set();
function isMonsterStackExpanded(stackIndex) {
return expandedMonsterStacks.has(Number(stackIndex));
}
function toggleMonsterStackExpand(stackIndex) {
const idx = Number(stackIndex);
if (Number.isNaN(idx)) return;
if (expandedMonsterStacks.has(idx)) expandedMonsterStacks.delete(idx);
else expandedMonsterStacks.add(idx);
if (lastGameState) renderBoardTableau(lastGameState);
}
window.toggleMonsterStackExpand = toggleMonsterStackExpand;
function renderMonsterStackCards(stack, stackIndex) {
const expanded = isMonsterStackExpanded(stackIndex);
const top = topOfStack(stack);
if (!top) return '';
if (!expanded) {
return renderCardItem(top);
}
const cards = [...stack].reverse();
return `${cards.map((card, i) => {
const role = i === 0 ? 'Top (slayable)' : 'Buried';
return `
${role}
${renderCardItem(card)}
`;
}).join('')}
`;
}
function renderBoardStackSection(title, grid, kind) {
const g = Array.isArray(grid) ? grid : [];
const blocks = g.map((stack, idx) => {
const depth = Array.isArray(stack) ? stack.length : 0;
if (!depth) {
return ``;
}
const top = topOfStack(stack);
if (!top) {
return ``;
}
const meta = boardStackMeta(kind, top, depth);
const expandControl = kind === 'monster'
? `${isMonsterStackExpanded(idx) ? 'Collapse' : 'Expand'} `
: '';
const cardHtml = kind === 'monster'
? renderMonsterStackCards(stack, idx)
: renderCardItem(top);
return `
Stack ${idx + 1}
${expandControl ? `
${expandControl}
` : ''}
${escapeHtml(meta)}
${cardHtml}
`;
});
return `${escapeHtml(title)} ${blocks.join('')}
`;
}
function renderBoardTableau(gameState) {
const titleEl = document.getElementById('tableauTitle');
const bodyEl = document.getElementById('tableauBody');
if (!bodyEl) return;
if (titleEl) titleEl.textContent = 'Board (stacks)';
const citizenGrid = Array.isArray(gameState?.citizen_grid) ? gameState.citizen_grid : [];
const domainGrid = Array.isArray(gameState?.domain_grid) ? gameState.domain_grid : [];
const monsterGrid = Array.isArray(gameState?.monster_grid) ? gameState.monster_grid : [];
bodyEl.innerHTML = `
Top of each stack is the play surface. Monster stacks can be expanded to view buried cards, but only the top monster is slayable.
${renderBoardStackSection('Citizens (market)', citizenGrid, 'citizen')}
${renderBoardStackSection('Domains', domainGrid, 'domain')}
${renderBoardStackSection('Monsters', monsterGrid, 'monster')}
`;
}
async function openMyTableau() {
if (!(await ensureGameStateForTableau())) return;
renderPlayerTableau(lastGameState, playerId, true);
openModal();
}
async function openPlayerTableau(targetPlayerId) {
if (!targetPlayerId) return;
if (!(await ensureGameStateForTableau())) return;
renderPlayerTableau(lastGameState, targetPlayerId, false);
openModal();
}
async function openSeatTableau(targetPlayerId) {
if (!targetPlayerId) return;
if (!(await ensureGameStateForTableau())) return;
const isSelf = targetPlayerId === playerId;
renderPlayerTableau(lastGameState, targetPlayerId, isSelf);
openModal();
}
async function openBoardTableau() {
if (!(await ensureGameStateForTableau())) return;
renderBoardTableau(lastGameState);
openModal();
}
function diePipMask(value) {
// grid indices: 0 1 2 / 3 4 5 / 6 7 8
// positions: TL, TC, TR, ML, MC, MR, BL, BC, BR
const masks = {
1: [4],
2: [0, 8],
3: [0, 4, 8],
4: [0, 2, 6, 8],
5: [0, 2, 4, 6, 8],
6: [0, 2, 3, 5, 6, 8]
};
return masks[value] || [];
}
function buildDie(value, status, title) {
const die = document.createElement('div');
die.className = 'die' + (status ? ` ${status}` : '');
const on = new Set(diePipMask(value));
for (let i = 0; i < 9; i++) {
const pip = document.createElement('div');
pip.className = 'pip' + (on.has(i) ? '' : ' off');
die.appendChild(pip);
}
die.title = title || `d${value || 0}`;
return die;
}
function renderGameLog(gameState) {
const el = document.getElementById('gameLog');
if (!el) return;
const entries = Array.isArray(gameState?.game_log) ? gameState.game_log : [];
const logKey = JSON.stringify([
gameState?.game_id || currentGameId || '',
entries.map(e => [
e && e.tick !== undefined && e.tick !== null ? e.tick : '',
(e && (e.msg || e.message)) || ''
])
]);
if (logKey === lastRenderedGameLogKey) return;
const wasAtBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 8;
const previousScrollTop = el.scrollTop;
const shouldAutoScroll = lastRenderedGameLogKey === null || wasAtBottom;
lastRenderedGameLogKey = logKey;
if (!entries.length) {
el.textContent = '(No events yet.)';
return;
}
el.innerHTML = entries.map(e => {
const tick = e && e.tick !== undefined && e.tick !== null ? e.tick : '';
const msg = escapeHtml(String((e && e.msg) || (e && e.message) || ''));
return `[${tick}] ${msg}
`;
}).join('');
el.scrollTop = shouldAutoScroll ? el.scrollHeight : previousScrollTop;
}
function renderDice(gameState) {
const rolled1 = Number(gameState?.rolled_die_one ?? gameState?.die_one ?? 0);
const rolled2 = Number(gameState?.rolled_die_two ?? gameState?.die_two ?? 0);
const rolledSum = Number(gameState?.rolled_die_sum ?? ((rolled1 || 0) + (rolled2 || 0)) ?? 0);
const final1 = Number(gameState?.die_one || 0);
const final2 = Number(gameState?.die_two || 0);
const finalSum = Number(gameState?.die_sum || 0);
const diceEl = document.getElementById('dice');
const metaEl = document.getElementById('diceMeta');
const effectsEl = document.getElementById('rollEffects');
const deltaEl = document.getElementById('harvestDeltas');
if (!diceEl || !metaEl) return;
const die1Changed = rolled1 && final1 && rolled1 !== final1;
const die2Changed = rolled2 && final2 && rolled2 !== final2;
const die1Status = die1Changed ? (final1 > rolled1 ? 'increase' : 'decrease') : '';
const die2Status = die2Changed ? (final2 > rolled2 ? 'increase' : 'decrease') : '';
const die1Display = die1Changed ? final1 : rolled1;
const die2Display = die2Changed ? final2 : rolled2;
diceEl.innerHTML = '';
diceEl.appendChild(buildDie(die1Display, die1Status, die1Changed ? `d${final1} (rolled ${rolled1})` : `d${die1Display}`));
diceEl.appendChild(buildDie(die2Display, die2Status, die2Changed ? `d${final2} (rolled ${rolled2})` : `d${die2Display}`));
const turn = gameState?.turn_number;
const phase = gameState?.phase;
const active = gameState?.active_player_id;
const actionsRemaining = gameState?.actions_remaining;
const parts = [];
if (rolled1 && rolled2) parts.push(`${rolled1} + ${rolled2} = ${rolledSum} `);
else parts.push(`Dice : not rolled`);
if (rolled1 && rolled2 && final1 && final2 && (rolled1 !== final1 || rolled2 !== final2)) {
parts.push(`Final ${final1} + ${final2} = ${finalSum} `);
}
if ((gameState?.phase || '') === 'roll_pending') {
parts.push(`Awaiting finalize `);
}
if ((gameState?.phase || '') === 'action_end_pending') {
parts.push(`Action-end domains `);
}
if (turn !== undefined) parts.push(`Turn ${turn} `);
if (phase) parts.push(`Phase ${phase} `);
if (actionsRemaining !== undefined) parts.push(`Actions remaining ${actionsRemaining} `);
if (active) parts.push(`Active ${active}`);
metaEl.innerHTML = parts.join(' · ');
renderGameLog(gameState);
// Update rig hint text.
const hintEl = document.getElementById('dicePanelHint');
if (hintEl) {
const s = getDiceRigSettings();
const msg = s.enabled
? `Enabled: will finalize as ${clampDie(s.d1)} + ${clampDie(s.d2)} (graphic still shows the rolled dice).`
: `Disabled: roll finalizes as the rolled dice.`;
hintEl.textContent = msg;
}
if (effectsEl) {
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
const activePlayerId = (gameState?.active_player_id || '').toString();
const activePlayer = players.find(p => (p?.player_id || '').toString() === activePlayerId) || null;
const effects = parseRollSetOneDieEffects(activePlayer, gameState.turn_number);
if (!effects.length) {
effectsEl.innerHTML = 'Roll phase effects: none';
} else {
const rows = effects.map((e) => {
return `${escapeHtml(e.domainName)} : set one die to ${escapeHtml(String(e.target))} (cost: ${escapeHtml(e.costSpec)}) `;
}).join('');
effectsEl.innerHTML = `Roll phase effects (active player): `;
}
}
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
if (deltaEl) {
deltaEl.innerHTML = '';
players.forEach(p => {
const d = p?.harvest_delta || {};
const g = Number(d.gold || 0);
const s = Number(d.strength || 0);
const m = Number(d.magic || 0);
const v = Number(d.victory || 0);
const G = Number(p?.gold_score || 0);
const S = Number(p?.strength_score || 0);
const M = Number(p?.magic_score || 0);
const V = Number(p?.victory_score || 0);
const card = document.createElement('div');
card.className = 'delta-card';
const name = p?.name || (p?.player_id ? p.player_id.slice(0, 6) : 'Player');
const fmt = (n) => (n > 0 ? `+${n}` : `${n}`);
const cls = (n) => (n > 0 ? 'delta-pos' : (n < 0 ? 'delta-neg' : 'delta-zero'));
card.innerHTML = `
${name}
ΔG ${fmt(g)}
ΔS ${fmt(s)}
ΔM ${fmt(m)}
ΔVP ${fmt(v)}
Totals
G ${G}
S ${S}
M ${M}
VP ${V}
`;
deltaEl.appendChild(card);
});
}
renderChoicePanel(gameState);
}
function renderChoicePanel(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
// Concurrent (non-ordered) prompts always take precedence over
// turn-based action_required: while one is active the engine
// will not advance and no per-player turn prompts are valid.
const concurrent = gameState?.concurrent_action || null;
const concurrentPending = concurrent && Array.isArray(concurrent.pending) ? concurrent.pending : [];
if (concurrentPending.length > 0) {
return renderConcurrentActionPanel(gameState, concurrent);
}
const req = gameState?.action_required || {};
const reqId = req?.id || '';
const reqAction = req?.action || '';
const activePlayerId = gameState?.active_player_id || '';
function harvestTurnBadge(forPlayerId) {
const pid = (forPlayerId || '').toString();
if (!pid || !activePlayerId) return '';
const onTurn = (pid === activePlayerId);
const bg = onTurn ? '#e8f7ee' : '#f1f1f1';
const border = onTurn ? '#8ad0a4' : '#cfcfcf';
const fg = onTurn ? '#1f6a3a' : '#444';
const label = onTurn ? 'On-turn harvest' : 'Off-turn harvest';
return `${label} `;
}
if (!reqId || reqId === gameState?.game_id) {
panel.innerHTML = '';
return;
}
if (reqAction === 'finalize_roll') {
return renderFinalizeRollPrompt(gameState);
}
if (reqAction === 'domain_self_convert') {
return renderDomainSelfConvertPrompt(gameState);
}
if (reqAction === 'choose_player') {
return renderDomainChoosePlayer(gameState);
}
if (reqAction === 'choose_monster_strength') {
return renderDomainChooseMonster(gameState);
}
// Generic "choose ..." prompt from special payouts (e.g. "choose g 1 m 1")
// Engine expects the response to be "choose 1"/"choose 2"/"choose 3".
if (typeof reqAction === 'string' && reqAction.trim().startsWith('choose ')) {
return renderChoosePrompt(gameState, reqAction);
}
if (reqAction === 'manual_harvest') {
const slots = Array.isArray(gameState?.harvest_prompt_slots) ? gameState.harvest_prompt_slots : [];
const isYou = (playerId && reqId === playerId);
if (!isYou) {
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `
Manual harvest in progress for ${escapeHtml(reqId)} (${slots.length} card(s)).
${badge}
`;
return;
}
if (!slots.length) {
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `
Harvest: no slots (try Refresh).
${badge}
`;
return;
}
const thiefNote = slots.some(s => s.kind === 'citizen' && s.is_thief)
? 'If you have the Thief, harvest that citizen before other citizens.
'
: '';
const badge = harvestTurnBadge(reqId);
const btns = slots.map(s => {
const ai = Number(s.activation_index);
const dup = Number.isFinite(ai) && ai > 0 ? ` · #${ai + 1}` : '';
const ci = Number(s.card_idx);
const copy = Number.isFinite(ci) ? ` · copy ${ci + 1}` : '';
const label = `${escapeHtml(s.name || '')} (${escapeHtml(s.kind)} #${escapeHtml(String(s.card_id))}${copy}${dup})`;
const sk = escapeHtml(s.slot_key || '');
return `Harvest: ${label} `;
}).join(' ');
panel.innerHTML = `
Harvest (choose order)
${badge}
${thiefNote}
${btns}
`;
return;
}
if (reqAction !== 'bonus_resource_choice') {
if (reqAction === 'standard_action') {
return renderStandardActionPanel(gameState);
}
panel.innerHTML = `
Waiting on required action from ${reqId}: ${reqAction}
`;
return;
}
const isYou = (playerId && reqId === playerId);
if (!isYou) {
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `
Harvest bonus choice pending for ${reqId}.
${badge}
`;
return;
}
const badge = harvestTurnBadge(reqId);
panel.innerHTML = `
Harvest bonus: choose +1 resource
${badge}
+1 Gold
+1 Strength
+1 Magic
`;
}
function labelForChoiceToken(tok) {
const t = (tok || '').toString().trim().toLowerCase();
if (t === 'g') return 'Gold';
if (t === 's') return 'Strength';
if (t === 'm') return 'Magic';
if (t === 'v') return 'Victory';
if (t.startsWith('citizens.')) {
const name = t.split('.', 2)[1] || '';
return name ? `${name} citizen` : 'Citizen';
}
return tok;
}
function parseChooseCommand(cmd) {
// Expected formats:
// - "choose g 1 m 1" (two options)
// - "choose g 1 s 1 m 1" (three options)
const parts = (cmd || '').toString().trim().split(/\s+/);
if (!parts.length || parts[0] !== 'choose') return [];
const options = [];
// pairs start at index 1: [token, amount]
for (let i = 1; i + 1 < parts.length; i += 2) {
const token = parts[i];
const amount = parts[i + 1];
const tl = (token || '').toString().trim().toLowerCase();
if (!(tl === 'g' || tl === 's' || tl === 'm' || tl === 'v' || tl.startsWith('citizens.'))) continue;
options.push({ token, amount });
if (options.length >= 3) break;
}
return options;
}
function renderChoosePrompt(gameState, chooseCmd) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const req = gameState?.action_required || {};
const reqId = req?.id || '';
const isYou = (playerId && reqId === playerId);
const pendingChoice = gameState?.pending_required_choice || null;
let options = parseChooseCommand(chooseCmd);
if (
pendingChoice &&
pendingChoice.kind === 'special_payout_choose' &&
Array.isArray(pendingChoice.options) &&
pendingChoice.options.length
) {
options = pendingChoice.options;
}
if (!options.length) {
panel.innerHTML = `
Waiting on required action from ${reqId}: ${escapeHtml(chooseCmd)}
`;
return;
}
if (!isYou) {
panel.innerHTML = `
Waiting on required action from ${reqId}: ${escapeHtml(chooseCmd)}
`;
return;
}
const buttons = options.map((opt, idx) => {
const token = (opt?.token || '').toString();
const label = labelForChoiceToken(token);
const amt = Number(opt.amount);
const prettyAmt = Number.isFinite(amt) ? amt : opt.amount;
const isCitizen = token.trim().toLowerCase().startsWith('citizens.');
if ((token || '').toString().trim().toLowerCase() === 'count_area') {
const area = (opt?.area ?? '').toString();
const res = (opt?.resource ?? '').toString().toLowerCase();
const mult = Number(opt?.mult);
const rLabel = labelForChoiceToken(res);
const mText = Number.isFinite(mult) ? mult : opt?.mult;
return `+(${escapeHtml(mText)} x ${escapeHtml(area)}) ${escapeHtml(rLabel)} `;
}
if (isCitizen) {
const name = (opt?.name ?? '').toString().trim();
const extras = Array.isArray(opt?.extras) ? opt.extras : [];
const extraText = extras.map(e => {
const et = (e?.token ?? '').toString().toLowerCase();
const ea = Number(e?.amount);
const el = labelForChoiceToken(et);
const an = Number.isFinite(ea) ? ea : e?.amount;
return `+${an} ${el}`;
}).join(' + ');
const extraSuffix = extraText ? ` + ${extraText}` : '';
const who = name ? `${name} citizen` : label;
return `Gain ${escapeHtml(prettyAmt)} ${escapeHtml(who)}${escapeHtml(extraSuffix)} `;
}
return `+${escapeHtml(prettyAmt)} ${escapeHtml(label)} `;
}).join('');
panel.innerHTML = `
`;
}
function renderFinalizeRollPrompt(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const req = gameState?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = (playerId && reqId === playerId);
const rolled1 = clampDie(gameState?.rolled_die_one ?? gameState?.die_one ?? 1);
const rolled2 = clampDie(gameState?.rolled_die_two ?? gameState?.die_two ?? 1);
if (!isYou) {
panel.innerHTML = `
Waiting on required action from ${escapeHtml(reqId)}: finalize_roll
`;
return;
}
const player = Array.isArray(gameState?.player_list)
? gameState.player_list.find(p => (p?.player_id || '') === playerId)
: null;
const options = listRollSetOneDieOptions(player, rolled1, rolled2, gameState.turn_number);
const keepBtn = `Keep ${rolled1} + ${rolled2} `;
const modBtns = options.map((o) => {
const fromVal = (o.die === 1) ? rolled1 : rolled2;
const d1 = (o.die === 1) ? o.target : rolled1;
const d2 = (o.die === 2) ? o.target : rolled2;
return `Set die ${o.die}: ${fromVal} → ${o.target} (pay ${o.costGold}g via ${escapeHtml(o.domainName)}) `;
}).join(' ');
const note = options.length
? 'Choose a roll modifier or keep the rolled dice.
'
: 'No roll modifiers available; finalize to continue.
';
panel.innerHTML = `
Finalize Roll
Rolled: ${rolled1} + ${rolled2}
${keepBtn}
${modBtns}
${note}
`;
}
async function sendChooseIndex(n) {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: `choose ${Number(n)}`
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendChoosePlayerIndex(n) {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: `choose_player ${Number(n)}`
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendChooseMonsterIndex(n) {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: `choose_monster ${Number(n)}`
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendDomainManipulateSkip() {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: 'skip'
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
function resourceSpecLabel(spec) {
const raw = (spec || '').toString().trim().toLowerCase();
const m = /^(g|s|m|v|vp)\s*:\s*(\d+)$/.exec(raw);
if (!m) return raw || '';
const n = Number(m[2]);
const k = m[1] === 'vp' ? 'v' : m[1];
const word = k === 'g' ? 'gold' : k === 's' ? 'strength' : k === 'm' ? 'magic' : 'VP';
const unit = k === 'v' ? '' : ' ';
return k === 'v' ? `${n} VP` : `${n}${unit}${word}`;
}
function domainEffectGainIsVp(kv) {
const g = (kv?.gain ?? '').toString().trim().toLowerCase();
return g.startsWith('v:') || g.startsWith('vp:');
}
function domainManipulateExplain(prc) {
const item = prc?.item || {};
const mode = (item.mode || '').toString().trim().toLowerCase();
const kv = item.kv || {};
if (mode === 'pay_to_player') {
const pay = resourceSpecLabel(kv.pay);
const gain = resourceSpecLabel(kv.gain);
const gainLine = gain ? ` Gain ${gain} from the bank (not from that player).` : '';
let decline = '';
if (prc?.allow_skip && domainEffectGainIsVp(kv)) {
decline = ' You may decline: no payment and no VP.';
} else if (prc?.allow_skip) {
decline = ' You may skip this optional effect.';
}
return `Pay ${pay || '(see rules)'} to the player you choose.${gainLine}${decline}`;
}
if (mode === 'take_from_player') {
const take = resourceSpecLabel(kv.take);
return `Take ${take || '(see rules)'} from the player you choose.`;
}
return 'Choose another player.';
}
function selfConvertExplain(kv) {
const pay = resourceSpecLabel(kv?.pay);
const gain = resourceSpecLabel(kv?.gain);
return `Trade ${pay || '?'} from your supply for ${gain || '?'} (bank).`;
}
function renderDomainSelfConvertPrompt(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const req = gameState?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = (playerId && reqId === playerId);
const prc = gameState?.pending_required_choice || null;
const dn = (prc?.domain_name || 'Domain').toString();
const kv = prc?.kv || {};
const explain = selfConvertExplain(kv);
if (!isYou) {
panel.innerHTML = `
Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} optional activation trade.
`;
return;
}
panel.innerHTML = `
${escapeHtml(dn)}: optional trade
${escapeHtml(explain)}
Confirm trade
Decline
`;
}
async function sendSelfConvertConfirm() {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: 'confirm_self_convert'
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendSelfConvertDecline() {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: 'skip'
})
});
getGameState(false);
} catch (e) {
console.error(e);
}
}
function renderDomainChoosePlayer(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const req = gameState?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = (playerId && reqId === playerId);
const prc = gameState?.pending_required_choice || null;
const opts = Array.isArray(prc?.options) ? prc.options : [];
const dn = (prc?.item?.domain_name || 'Domain').toString();
const explain = prc?.kind === 'domain_manipulate_player'
? domainManipulateExplain(prc)
: 'Choose another player.';
if (!isYou) {
panel.innerHTML = `
Waiting on ${escapeHtml(reqId)} to choose a player for ${escapeHtml(dn)} .
`;
return;
}
const kv = prc?.item?.kv || {};
const skipLabel = (prc?.allow_skip && domainEffectGainIsVp(kv))
? 'Decline (no pay, no VP)'
: 'Skip (optional)';
const skipBtn = prc?.allow_skip
? `${escapeHtml(skipLabel)} `
: '';
const btns = opts.map((o, idx) => {
const nm = escapeHtml((o?.name || o?.player_id || '?').toString());
return `${nm} `;
}).join(' ');
panel.innerHTML = `
${escapeHtml(dn)}: choose another player
${escapeHtml(explain)}
${btns}${skipBtn}
`;
}
function renderDomainChooseMonster(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const req = gameState?.action_required || {};
const reqId = (req?.id || '').toString();
const isYou = (playerId && reqId === playerId);
const prc = gameState?.pending_required_choice || null;
const opts = Array.isArray(prc?.options) ? prc.options : [];
const dn = (prc?.domain_name || 'Domain').toString();
const delta = Number(prc?.delta) || 0;
if (!isYou) {
panel.innerHTML = `
Waiting on ${escapeHtml(reqId)} for ${escapeHtml(dn)} (monster +${delta} strength cost).
`;
return;
}
const btns = opts.map((o, idx) => {
const nm = escapeHtml((o?.name || '?').toString());
return `${nm} `;
}).join(' ');
panel.innerHTML = `
${escapeHtml(dn)}: add +${delta} to a center monster strength cost
${btns}
`;
}
// Concurrent (non-ordered) prompt rendering.
//
// The server exposes `concurrent_action = { kind, pending, completed, ... }`.
// Every participant sees this state at the same time; players in `pending`
// can submit a response in any order, and the game only advances once
// `pending` is empty. To add a new kind, register a renderer in
// CONCURRENT_RENDERERS keyed on the same `kind` used server-side.
const CONCURRENT_RENDERERS = {
choose_duke: renderChooseDukeConcurrent,
flip_one_citizen: renderFlipOneCitizenConcurrent,
};
function renderConcurrentActionPanel(gameState, concurrent) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const renderer = CONCURRENT_RENDERERS[concurrent.kind];
if (renderer) {
return renderer(gameState, concurrent);
}
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
panel.innerHTML = `
Waiting on concurrent action ${escapeHtml(concurrent.kind || 'unknown')}
(${pending.length} player(s) still need to respond).
`;
}
function pendingPlayerLabels(gameState, pending) {
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
return (pending || []).map(pid => {
const p = players.find(x => x?.player_id === pid);
return p?.name ? `${p.name}` : pid;
});
}
function renderChooseDukeConcurrent(gameState, concurrent) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
const completed = Array.isArray(concurrent.completed) ? concurrent.completed : [];
const isPending = !!(playerId && pending.includes(playerId));
const totalParticipants = pending.length + completed.length;
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
const you = players.find(p => p?.player_id === playerId) || null;
const waitingLabels = pendingPlayerLabels(gameState, pending);
const statusLine = `
Starting setup: ${completed.length}/${totalParticipants} duke choice(s) submitted.
${pending.length ? `Waiting on: ${escapeHtml(waitingLabels.join(', '))} .` : ''}
`;
if (!isPending) {
const youDone = !!(playerId && completed.includes(playerId));
const yourLine = youDone
? `You have already chosen your duke. Waiting on the other player(s).
`
: `Starting setup is in progress.
`;
panel.innerHTML = `
${statusLine}${yourLine}
`;
return;
}
const dukes = Array.isArray(you?.owned_dukes) ? you.owned_dukes : [];
if (!dukes.length) {
panel.innerHTML = `
${statusLine}
No dukes found to choose from.
`;
return;
}
const buttons = dukes.map(d => {
const id = d?.duke_id;
const name = d?.name || `Duke #${id}`;
const fullText = cardFullText(d);
const sub = fullText ? `${escapeHtml(fullText)}
` : '';
return `
${escapeHtml(name)} (#${escapeHtml(id)})
${sub}
Keep this duke
`;
}).join('');
panel.innerHTML = `
${statusLine}
Choose 1 duke to keep
${buttons}
`;
}
function renderFlipOneCitizenConcurrent(gameState, concurrent) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const pending = Array.isArray(concurrent.pending) ? concurrent.pending : [];
const completed = Array.isArray(concurrent.completed) ? concurrent.completed : [];
const isPending = !!(playerId && pending.includes(playerId));
const totalParticipants = pending.length + completed.length;
const data = concurrent.data || {};
const buyerId = (data.buyer_id || '').toString();
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
const buyer = players.find(p => (p?.player_id || '') === buyerId) || null;
const buyerTag = buyer?.name ? `${escapeHtml(buyer.name)}` : (buyerId ? `${escapeHtml(buyerId)}` : '');
const you = players.find(p => p?.player_id === playerId) || null;
const waitingLabels = pendingPlayerLabels(gameState, pending);
const statusLine = `
Cursed Cavern — flip one citizen face-down: ${completed.length}/${totalParticipants} player choice(s) submitted.
${pending.length ? `Waiting on:
${escapeHtml(waitingLabels.join(', '))} .` : ''}
${buyerTag ? `
Triggered by ${buyerTag} .
` : ''}
`;
if (!isPending) {
const youDone = !!(playerId && completed.includes(playerId));
const yourLine = youDone
? `You already chose a citizen to flip. Waiting on other players.
`
: `You have no pending flip choice (no eligible citizens, or not in this prompt).
`;
panel.innerHTML = `
${statusLine}${yourLine}
`;
return;
}
const citizens = Array.isArray(you?.owned_citizens) ? you.owned_citizens : [];
const choices = [];
citizens.forEach((c, idx) => {
if (!c || c.is_flipped) return;
const nm = (c.name || `Citizen #${idx}`).toString();
choices.push({ idx, card: c, nm });
});
if (!choices.length) {
panel.innerHTML = `
${statusLine}
No face-up citizens on your tableau — contact host if this seems wrong.
`;
return;
}
const buttons = choices.map(({ idx, card, nm }) => {
const rm = card.roll_match1 !== undefined || card.roll_match2 !== undefined
? ` · Roll ${card.roll_match1 ?? ''}/${card.roll_match2 ?? ''}`
: '';
const gc = card.gold_cost !== undefined ? ` · Cost ${card.gold_cost}g` : '';
return `
${escapeHtml(nm)} (slot #${idx})
${escapeHtml(rm + gc)}
Flip this citizen face-down
`;
}).join('');
panel.innerHTML = `
${statusLine}
Choose 1 citizen to flip face-down
${buttons}
`;
}
async function submitConcurrentAction(kind, response) {
if (!playerId || !currentGameId) return;
try {
const res = await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'submit_concurrent_action',
kind: String(kind),
response: String(response)
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.detail || res.statusText || 'Submit failed');
return;
}
getGameState(false);
} catch (e) {
console.error(e);
}
}
function canAffordCost(player, cost) {
const G = Number(player?.gold_score || 0);
const S = Number(player?.strength_score || 0);
const M = Number(player?.magic_score || 0);
const goldCost = Number(cost?.gold || 0);
const strengthCost = Number(cost?.strength || 0);
const magicMin = Number(cost?.magicMin || 0);
const remainingMagic = M - magicMin;
if (remainingMagic < 0) return { ok: false };
const deficitGold = Math.max(0, goldCost - G);
const deficitStrength = Math.max(0, strengthCost - S);
// Rule: you must contribute at least 1 of a required color to use magic as wild.
// Example: cost S8 cannot be paid with M8 alone; you need at least S1, then M can cover the rest.
if (goldCost > 0 && deficitGold > 0 && G <= 0) return { ok: false };
if (strengthCost > 0 && deficitStrength > 0 && S <= 0) return { ok: false };
const ok = (deficitGold + deficitStrength) <= remainingMagic;
// Payment split used for sending action requests (avoid going negative server-side).
const payGold = Math.min(G, goldCost);
const payStrength = Math.min(S, strengthCost);
const payMagic = magicMin + deficitGold + deficitStrength;
return { ok, payGold, payStrength, payMagic, deficitGold, deficitStrength, remainingMagic };
}
function topOfStack(stack) {
if (!Array.isArray(stack) || stack.length === 0) return null;
return stack[stack.length - 1];
}
function ownedNameCount(player, name) {
const target = (name ?? '').toString();
if (!target) return 0;
const starters = Array.isArray(player?.owned_starters) ? player.owned_starters : [];
const citizens = Array.isArray(player?.owned_citizens) ? player.owned_citizens : [];
let n = 0;
starters.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; });
citizens.forEach(c => { if ((c?.name ?? '').toString() === target) n += 1; });
return n;
}
function normalizedPassiveEffects(player, turnNumber) {
const out = [];
const domains = Array.isArray(player?.owned_domains) ? player.owned_domains : [];
domains.forEach((d) => {
if (domainPassiveOnBuildTurnCooldown(d, turnNumber)) return;
const name = (d?.name ?? '').toString().trim().toLowerCase();
const text = (d?.text ?? '').toString().trim().toLowerCase();
const raw = (d?.passive_effect ?? '').toString().trim().toLowerCase();
if (raw) {
out.push(raw);
const nrm = raw.replace(/effect:add/g, 'effect.add').replace(/action:/g, 'action.');
if (nrm.startsWith('effect.add ')) {
out.push(nrm.slice('effect.add '.length).trim());
}
}
// Backward-compatibility for seed data where passive_effect is NULL
// and behavior only exists in human-readable card text.
if (name.includes('emerald stronghold') || (text.includes("ignore '+'") && text.includes('buying citizens'))) {
out.push('action.emeraldstronghold');
}
if (name.includes("pratchett") || (text.includes('1gp less') && text.includes('domain'))) {
out.push('action.pratchettsplateau');
}
});
return out;
}
function hasActionEffectFlag(player, flag, turnNumber) {
const target = (flag ?? '').toString().trim().toLowerCase();
if (!target) return false;
const effects = normalizedPassiveEffects(player, turnNumber);
return effects.includes(target);
}
function clampPayInt(value, minV, maxV) {
let n = Math.floor(Number(value));
if (!Number.isFinite(n)) n = 0;
const lo = Math.floor(Number(minV) || 0);
const hiRaw = maxV === '' || maxV === undefined || maxV === null ? null : Number(maxV);
const hi = hiRaw === null || !Number.isFinite(hiRaw) ? null : Math.floor(hiRaw);
n = Math.max(lo, n);
if (hi !== null) n = Math.min(hi, n);
return n;
}
function readPayRow(row) {
const gEl = row.querySelector('.pay-g');
const sEl = row.querySelector('.pay-s');
const mEl = row.querySelector('.pay-m');
const g = (!gEl || gEl.disabled) ? 0 : clampPayInt(gEl.value, gEl.min, gEl.max);
const s = (!sEl || sEl.disabled) ? 0 : clampPayInt(sEl.value, sEl.min, sEl.max);
const m = (!mEl || mEl.disabled) ? 0 : clampPayInt(mEl.value, mEl.min, mEl.max);
return { gold: g, strength: s, magic: m };
}
function capturePayEditorRenderState(panel) {
const state = {};
if (!panel) return state;
const active = document.activeElement;
panel.querySelectorAll('.pay-cost-key').forEach((el) => {
const key = el.getAttribute('data-pay-key');
if (!key) return;
const row = el.closest('.pay-row');
const box = document.getElementById('pay-editor-' + key);
if (!row || !box) return;
const gEl = row.querySelector('.pay-g');
const sEl = row.querySelector('.pay-s');
const mEl = row.querySelector('.pay-m');
const entry = {
gold: gEl ? gEl.value : '',
strength: sEl ? sEl.value : '',
magic: mEl ? mEl.value : '',
focusClass: '',
};
if (active && row.contains(active)) {
if (active.classList.contains('pay-g')) entry.focusClass = 'pay-g';
else if (active.classList.contains('pay-s')) entry.focusClass = 'pay-s';
else if (active.classList.contains('pay-m')) entry.focusClass = 'pay-m';
}
state[key] = entry;
});
return state;
}
function restorePayEditorRenderState(panel, state) {
if (!panel || !state) return;
let focusTarget = null;
panel.querySelectorAll('.pay-cost-key').forEach((el) => {
const key = el.getAttribute('data-pay-key');
const entry = key ? state[key] : null;
if (!entry) return;
const row = el.closest('.pay-row');
const box = document.getElementById('pay-editor-' + key);
if (!row || !box) return;
const gEl = row.querySelector('.pay-g');
const sEl = row.querySelector('.pay-s');
const mEl = row.querySelector('.pay-m');
if (gEl) gEl.value = clampPayInt(entry.gold, gEl.min, gEl.max);
if (sEl) sEl.value = clampPayInt(entry.strength, sEl.min, sEl.max);
if (mEl) mEl.value = clampPayInt(entry.magic, mEl.min, mEl.max);
if (entry.focusClass) {
focusTarget = row.querySelector('.' + entry.focusClass);
}
});
if (focusTarget) {
focusTarget.focus();
}
}
async function hireCitizenFromRow(btn) {
const row = btn.closest('.pay-row');
if (!row || !playerId || !currentGameId) return;
const p = readPayRow(row);
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'hire_citizen',
citizen_id: Number(row.dataset.citizenId),
payment: { gold: p.gold, strength: p.strength, magic: p.magic }
})
});
getGameState(false);
}
async function buildDomainFromRow(btn) {
const row = btn.closest('.pay-row');
if (!row || !playerId || !currentGameId) return;
const p = readPayRow(row);
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'build_domain',
domain_id: Number(row.dataset.domainId),
payment: { gold: p.gold, strength: p.strength, magic: p.magic }
})
});
getGameState(false);
}
async function slayMonsterFromRow(btn) {
const row = btn.closest('.pay-row');
if (!row || !playerId || !currentGameId) return;
const p = readPayRow(row);
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'slay_monster',
monster_id: Number(row.dataset.monsterId),
payment: { gold: p.gold, strength: p.strength, magic: p.magic }
})
});
getGameState(false);
}
function renderStandardActionPanel(gameState) {
const panel = document.getElementById('choicePanel');
if (!panel) return;
const payEditorState = capturePayEditorRenderState(panel);
const req = gameState?.action_required || {};
const reqId = req?.id || '';
const isYou = (playerId && reqId === playerId);
const phase = (gameState?.phase || '').toString();
const actionsRemaining = Number(gameState?.actions_remaining || 0);
if (!reqId || reqId === gameState?.game_id || phase !== 'action') {
panel.innerHTML = '';
return;
}
const players = Array.isArray(gameState?.player_list) ? gameState.player_list : [];
const you = players.find(p => p?.player_id === playerId) || null;
const active = players.find(p => p?.player_id === reqId) || null;
const p = (isYou ? you : active);
const G = Number(p?.gold_score || 0);
const S = Number(p?.strength_score || 0);
const M = Number(p?.magic_score || 0);
const V = Number(p?.victory_score || 0);
const tn = Number(gameState?.turn_number);
const emeraldActive = hasActionEffectFlag(p, 'action.emeraldstronghold', tn);
const pratchettActive = hasActionEffectFlag(p, 'action.pratchettsplateau', tn);
const affordCitizens = [];
const affordDomains = [];
const affordMonsters = [];
// Evaluate citizens (top of each stack, accessible only)
const citizenGrid = Array.isArray(gameState?.citizen_grid) ? gameState.citizen_grid : [];
citizenGrid.forEach((stack, idx) => {
const top = topOfStack(stack);
if (!top) return;
const baseCost = Number(top.gold_cost || 0);
const surcharge = emeraldActive ? 0 : ownedNameCount(p, top.name);
const scaledCost = baseCost + surcharge;
const evalRes = canAffordCost(p, { gold: scaledCost, strength: 0, magicMin: 0 });
console.log('[AFFORD_CHECK] citizen', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes });
if (top.is_accessible && evalRes.ok) {
affordCitizens.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, scaledCost, surcharge, baseCost, emeraldActive });
}
});
// Evaluate domains (top visible & accessible)
const domainGrid = Array.isArray(gameState?.domain_grid) ? gameState.domain_grid : [];
domainGrid.forEach((stack, idx) => {
const top = topOfStack(stack);
if (!top) return;
const baseCost = Number(top.gold_cost || 0);
const effectiveGold = Math.max(0, baseCost - (pratchettActive ? 1 : 0));
const evalRes = canAffordCost(p, { gold: effectiveGold, strength: 0, magicMin: 0 });
console.log('[AFFORD_CHECK] domain', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes });
if (top.is_visible && top.is_accessible && evalRes.ok) {
affordDomains.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes, baseCost, effectiveGold, pratchettActive });
}
});
// Evaluate monsters (top of each stack, accessible only; magic has minimum requirement)
const monsterGrid = Array.isArray(gameState?.monster_grid) ? gameState.monster_grid : [];
monsterGrid.forEach((stack, idx) => {
const top = topOfStack(stack);
if (!top) return;
const evalRes = canAffordCost(p, { gold: 0, strength: Number(top.strength_cost || 0), magicMin: Number(top.magic_cost || 0) });
console.log('[AFFORD_CHECK] monster', { stackIndex: idx, stackSize: stack?.length || 0, card: top, player: { G, S, M, V }, eval: evalRes });
if (top.is_accessible && evalRes.ok) {
affordMonsters.push({ card: top, stackIndex: idx, stackSize: stack.length, pay: evalRes });
}
});
const header = isYou
? `Your action (${actionsRemaining} remaining)
`
: `Waiting on ${active?.name || reqId} to act (${actionsRemaining} remaining)
`;
const resourcesLine = `
Resources: G ${G} · S ${S} · M ${M} · VP ${V}
`;
const activeEffects = [];
if (emeraldActive) activeEffects.push('Emerald Stronghold: ignore citizen duplicate surcharge');
if (pratchettActive) activeEffects.push("Pratchett's Plateau: domains cost 1 less gold");
const effectsBanner = activeEffects.length
? `Active effects: ${escapeHtml(activeEffects.join(' · '))}
`
: '';
const takeResourceRow = isYou
? `
Take resource (uses 1 action, gain +1):
+1 Gold
+1 Strength
+1 Magic
`
: '';
const listSection = (title, items, renderItem) => {
if (!items.length) return `${title}: none affordable
`;
const rows = items.map(renderItem).join('');
return ``;
};
const citizenHtml = listSection('Citizens', affordCitizens, (it) => {
const c = it.card;
const key = 'c-' + c.citizen_id;
const cost = Number(it.scaledCost ?? c.gold_cost ?? 0);
const pay = it.pay;
const rc = citizenRoleCounts(c);
const rbits = [];
if (rc.sn) rbits.push('Shadow+' + rc.sn);
if (rc.hn) rbits.push('Holy+' + rc.hn);
if (rc.son) rbits.push('Soldier+' + rc.son);
if (rc.wn) rbits.push('Worker+' + rc.wn);
const roleHint = rbits.length ? ' Roles: ' + rbits.join(', ') + ' ' : '';
const dupHint = Number(it.surcharge || 0)
? ' (base ' + Number(it.baseCost || 0) + ' + ' + Number(it.surcharge || 0) + ' dupes) '
: '';
const emeraldHint = (!Number(it.surcharge || 0) && it.emeraldActive)
? ' (Emerald: no duplicate surcharge) '
: '';
const rulesText = cardFullText(c);
const rulesLine = rulesText
? '' + escapeHtml(rulesText) + '
'
: '';
const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + dupHint + emeraldHint + ' · Stack ' + it.stackSize;
const btn = isYou ? 'Hire ' : '';
return '';
});
const domainHtml = listSection('Domains (visible tops)', affordDomains, (it) => {
const d = it.card;
const key = 'd-' + d.domain_id;
const cost = Number(it.effectiveGold ?? d.gold_cost ?? 0);
const pay = it.pay;
const pratchettHint = (it.pratchettActive && Number(it.baseCost || 0) !== cost)
? ' (base ' + Number(it.baseCost || 0) + ' - 1 Pratchett) '
: '';
const rulesText = cardFullText(d);
const rulesLine = rulesText
? '' + escapeHtml(rulesText) + '
'
: '';
const costSummary = 'Cost: G ' + cost + ' · pay G' + pay.payGold + (pay.payMagic ? ', M' + pay.payMagic : '') + pratchettHint + ' · Stack ' + it.stackSize;
const btn = isYou ? 'Build ' : '';
return '';
});
const monsterHtml = listSection('Monsters (top of each stack)', affordMonsters, (it) => {
const mcard = it.card;
const key = 'm-' + mcard.monster_id;
const sCost = Number(mcard.strength_cost || 0);
const mMin = Number(mcard.magic_cost || 0);
const pay = it.pay;
const rulesText = cardFullText(mcard);
const rulesLine = rulesText
? '' + escapeHtml(rulesText) + '
'
: '';
const costSummary = 'Cost: S ' + sCost + ' + M ' + mMin + ' min · pay S' + pay.payStrength + ', M' + pay.payMagic + ' · Stack ' + it.stackSize;
const btn = isYou ? 'Slay ' : '';
return '';
});
panel.innerHTML = `
${header}
${resourcesLine}
${effectsBanner}
${takeResourceRow}
${citizenHtml}
${domainHtml}
${monsterHtml}
`;
restorePayEditorRenderState(panel, payEditorState);
}
async function takeResourceFromChoice(resource) {
if (!playerId || !currentGameId) return;
const r = (resource || '').toString().trim().toLowerCase();
if (!['gold', 'strength', 'magic'].includes(r)) return;
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'take_resource',
resource: r
})
});
getGameState(false);
}
async function sendBonusChoice(resource) {
if (!playerId || !currentGameId) return;
try {
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'act_on_required_action',
action: resource
})
});
// Refresh state so UI updates immediately
getGameState(false);
} catch (e) {
console.error(e);
}
}
async function sendHarvestCard(slotKey, options = {}) {
if (!playerId || !currentGameId) return;
const suppressAlert = !!(options && options.suppressAlert);
const sk = (slotKey || '').toString().trim();
if (!sk) return;
try {
const res = await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'harvest_card',
harvest_slot_key: sk
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (!suppressAlert) {
alert(err.detail || res.statusText || 'Harvest failed');
}
return;
}
getGameState(false);
} catch (e) {
console.error(e);
if (!suppressAlert) {
alert(e.message || 'Harvest failed');
}
}
}
async function hireCitizen(citizenId, goldCost, magicCost) {
if (!playerId || !currentGameId) return;
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'hire_citizen',
citizen_id: citizenId,
payment: {
gold: Number(goldCost || 0),
strength: 0,
magic: Number(magicCost || 0)
}
})
});
getGameState(false);
}
async function buildDomain(domainId, goldCost, magicCost) {
if (!playerId || !currentGameId) return;
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'build_domain',
domain_id: domainId,
payment: {
gold: Number(goldCost || 0),
strength: 0,
magic: Number(magicCost || 0)
}
})
});
getGameState(false);
}
async function slayMonster(monsterId, strengthCost, magicCost) {
if (!playerId || !currentGameId) return;
await fetch(`/api/game/${currentGameId}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player_id: playerId,
action_type: 'slay_monster',
monster_id: monsterId,
payment: {
gold: 0,
strength: Number(strengthCost || 0),
magic: Number(magicCost || 0)
}
})
});
getGameState(false);
}
// Auto-refresh lobby status
setInterval(getLobbyStatus, 2000);