import "./styles.css"; import { ROOT_SHIFT_COST, ROUND_ANIMATION_BONUS_MS, ROUND_ANIMATION_BRANCH_MS, ROUND_ANIMATION_SUN_MS, } from "./constants"; import { buildChildrenMap as buildChildrenMapForState, buildParentMap as buildParentMapForState, getColumnLeaders as getColumnLeadersForState, getLegalMovesForSource as getLegalMovesForSourceForState, getNodeOwner as getNodeOwnerForState, getRootShiftMove as getRootShiftMoveForState, playerHasLegalMove as playerHasLegalMoveForState, } from "./rules-board"; import { buildEnergySimulation, buildRoundAnimation as buildRoundAnimationForState, maybeRollDisease as maybeRollDiseaseForState, maybeRollSunbeam as maybeRollSunbeamForState, scoreColumns as scoreColumnsForState, } from "./rules-scoring"; import { createInitiativeDraft, } from "./rules-initiative"; import { WEATHER_CARDS, createWeatherDraft, getCurrentWeatherPlayerId, getWeatherCard, isWeatherCardAvailable, } from "./rules-weather"; import { createInitialState, createPlayers, createRandomizedSeedInputs, createSetupState, getMaxStartingNodesPerPlayer, } from "./state"; import type { GameState, GrowTarget, NodeKey, Player, ScoreSnapshot, SetupState, ShiftMove, TurnMove, WeatherCardId, } from "./types"; import { keyFor, parseKey, tint, wait } from "./utils"; const app = document.querySelector("#app"); if (!(app instanceof HTMLElement)) { throw new Error("#app container not found"); } let roundAnimationToken = 0; let setup: SetupState = createSetupState(); let state: GameState = createInitialState(setup); let isNewGameModalOpen = false; let previousScoreSnapshot: ScoreSnapshot[] | null = null; let setupTab: "board" | "rules" | "events" | "players" = "board"; function rebuildSetup(overrides: Partial = {}) { setup = createSetupState( overrides.playerCount ?? setup.playerCount, overrides.columns ?? setup.columns, overrides.rows ?? setup.rows, overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer, overrides.sunbeamChance ?? setup.sunbeamChance, overrides.diseaseChance ?? setup.diseaseChance, overrides.seedInputs ?? setup.seedInputs, overrides.paletteOrder ?? setup.paletteOrder, overrides.initiativeMode ?? setup.initiativeMode, overrides.biddingOrderRule ?? setup.biddingOrderRule, overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled, overrides.weatherDraftCount ?? setup.weatherDraftCount, overrides.winCondition ?? setup.winCondition, overrides.maxRounds ?? setup.maxRounds, overrides.topLeafTarget ?? setup.topLeafTarget, ); } function getLiveExposureScores() { return buildEnergySimulation(state).scores; } function getTopLeafCount() { const childrenMap = buildChildrenMap(); let count = 0; state.nodes.forEach((_, nodeKey) => { const { row } = parseKey(nodeKey); if (row === 0 && !(childrenMap.get(nodeKey)?.length)) { count += 1; } }); return count; } function hasReachedWinCondition() { if (state.config.winCondition === "rounds") { return state.round >= state.config.maxRounds; } return getTopLeafCount() >= state.config.topLeafTarget; } function getWinConditionSummary() { if (state.config.winCondition === "rounds") { return `Game ends after round ${state.config.maxRounds}.`; } return `Game ends when ${state.config.topLeafTarget} top-row leaf${state.config.topLeafTarget === 1 ? " is" : "s are"} occupied.`; } function getScoreSnapshot() { const exposureScores = getLiveExposureScores(); return state.players.map((player, index) => ({ currentExposure: exposureScores[index], growthPoints: player.growthPoints, bankedPoints: player.bankedPoints, lifetimeGrowthIncome: player.lifetimeGrowthIncome, })); } function getCurrentPlayer() { return state.players[state.activePlayerId]; } function getOrderedPlayers(playerIds: number[]) { return playerIds.map((playerId) => state.players[playerId]); } function getTurnLabel() { if (state.phase === "initiative" && state.initiativeDraft) { return `${getCurrentPlayer().name} drafts initiative`; } if (state.phase === "weather" && state.weatherDraft) { return `${getCurrentPlayer().name} drafts weather`; } return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`; } function isBankingEnabled() { return state.activeRoundEffects.includes("storehouse"); } function awardGrowth(player: Player, amount: number) { if (amount <= 0) { return; } player.growthPoints += amount; player.lifetimeGrowthIncome += amount; } function getCurrentBiddingPlayer() { if (!state.initiativeDraft) { return null; } return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]]; } function getCurrentWeatherDraftPlayer() { if (!state.weatherDraft) { return null; } return state.players[getCurrentWeatherPlayerId(state.weatherDraft)]; } function getNodeOwner(row: number, column: number) { return getNodeOwnerForState(state, row, column); } function buildParentMap() { return buildParentMapForState(state); } function buildChildrenMap(ownerId = null) { return buildChildrenMapForState(state, ownerId); } function getRootShiftMove(sourceKey: NodeKey, delta: number, player: Player) { return getRootShiftMoveForState(state, sourceKey, delta, player); } function getSelectedRootShiftMoves() { const player = getCurrentPlayer(); if (!state.selectedSource) { return []; } return [-1, 1] .map((delta) => getRootShiftMove(state.selectedSource as NodeKey, delta, player)) .filter(Boolean) as ShiftMove[]; } function getLegalMovesForSource(sourceKey: NodeKey, player: Player) { const moves = getLegalMovesForSourceForState(state, sourceKey, player); if (!state.activeRoundEffects.includes("sun_ladder")) { return moves; } return moves.map((move) => move.direction === "vertical" ? { ...move, cost: Math.max(0, move.cost - 1) } : move); } function playerHasLegalMove(player: Player) { return playerHasLegalMoveForState(state, player); } function scoreColumns() { return scoreColumnsForState(state); } function maybeRollSunbeam(scores: number[]) { return maybeRollSunbeamForState(state, scores); } function maybeRollDisease() { return maybeRollDiseaseForState(state); } function buildRoundAnimation(energySimulation, sunbeamPlayerId, diseaseKeys) { return buildRoundAnimationForState(state, energySimulation, sunbeamPlayerId, diseaseKeys); } function getColumnLeaders() { return getColumnLeadersForState(state); } function getMoveUndoKeys(move: TurnMove) { return move.undoKeys ?? ("targetKey" in move ? [move.targetKey] : []); } function findTurnMoveIndex(targetKey: NodeKey) { return state.turnMoves.findIndex((move) => getMoveUndoKeys(move).includes(targetKey)); } function applyShiftMove(move: ShiftMove, player: Player) { move.movedNodes.forEach((node) => { state.nodes.delete(node.fromKey); }); move.movedNodes.forEach((node) => { state.nodes.set(node.toKey, { ownerId: player.id }); }); move.movedEdges.forEach((edgeMove) => { const edgeIndex = state.edges.findIndex((edge) => { return edge.ownerId === edgeMove.before.ownerId && edge.from.row === edgeMove.before.from.row && edge.from.column === edgeMove.before.from.column && edge.to.row === edgeMove.before.to.row && edge.to.column === edgeMove.before.to.column; }); if (edgeIndex !== -1) { state.edges[edgeIndex] = { from: { ...edgeMove.after.from }, to: { ...edgeMove.after.to }, ownerId: edgeMove.after.ownerId, }; } }); player.growthPoints -= move.cost; state.turnMoves.push(move); state.history.unshift(`${player.name} shifted a root ${move.direction} for ${move.cost} point.`); updateSelection(move.selectKey); render(); } function undoTurnMove(move: TurnMove, player: Player) { if (move.type === "shift") { move.movedNodes.forEach((node) => { state.nodes.delete(node.toKey); }); move.movedNodes.forEach((node) => { state.nodes.set(node.fromKey, { ownerId: player.id }); }); move.movedEdges.forEach((edgeMove) => { const edgeIndex = state.edges.findIndex((edge) => { return edge.ownerId === edgeMove.after.ownerId && edge.from.row === edgeMove.after.from.row && edge.from.column === edgeMove.after.from.column && edge.to.row === edgeMove.after.to.row && edge.to.column === edgeMove.after.to.column; }); if (edgeIndex !== -1) { state.edges[edgeIndex] = { from: { ...edgeMove.before.from }, to: { ...edgeMove.before.to }, ownerId: edgeMove.before.ownerId, }; } }); player.growthPoints += move.cost; return; } state.nodes.delete(move.targetKey); const edgeIndex = state.edges.findIndex((edge) => { return edge.ownerId === player.id && edge.from.row === move.from.row && edge.from.column === move.from.column && edge.to.row === move.to.row && edge.to.column === move.to.column; }); if (edgeIndex !== -1) { state.edges.splice(edgeIndex, 1); } player.growthPoints += move.cost; } function applyDisease(killedKeys: NodeKey[]) { const killed = new Set(killedKeys); if (killed.size === 0) { return; } state.edges = state.edges.filter((edge) => { return !killed.has(keyFor(edge.to.row, edge.to.column)) && !killed.has(keyFor(edge.from.row, edge.from.column)); }); killedKeys.forEach((key) => { state.nodes.delete(key); }); } function updateSelection(sourceKey: NodeKey | null = null) { const player = getCurrentPlayer(); state.selectedSource = sourceKey; state.availableTargets = sourceKey ? getLegalMovesForSource(sourceKey, player) : []; } function isInteractionLocked() { return state.gameOver || state.phase !== "turn" || Boolean(state.animation); } function startWeatherDraft() { if (!state.config.weatherDraftEnabled) { moveToFirstPlayableTurn(); return; } state.phase = "weather"; state.turnMoves = []; updateSelection(null); state.weatherDraft = createWeatherDraft(state); state.activePlayerId = state.weatherDraft.playerOrder[0]; state.history.unshift(`Round ${state.round} weather draft begins.`); } function startInitiativeDraft() { state.phase = "initiative"; state.turnMoves = []; updateSelection(null); state.initiativeDraft = createInitiativeDraft(state); state.activePlayerId = state.initiativeDraft.biddingOrder[0]; state.history.unshift(`Round ${state.round} initiative draft begins.`); } function moveToFirstPlayableTurn() { const nextPlayerId = state.turnOrder.find((playerId) => playerHasLegalMove(state.players[playerId])); if (nextPlayerId === undefined) { state.gameOver = true; state.phase = "game_over"; state.history.unshift("No player can grow further. Final totals are locked."); return false; } state.phase = "turn"; state.activePlayerId = nextPlayerId; state.turnMoves = []; updateSelection(null); return true; } function finalizeInitiativeDraft() { const draft = state.initiativeDraft; if (!draft) { return; } const nextTurnOrder = draft.seatAssignments.filter((playerId): playerId is number => playerId !== null); state.turnOrder = nextTurnOrder; state.players.forEach((player) => { player.passed = false; }); nextTurnOrder.forEach((playerId, seatIndex) => { const bonus = draft.seatBonuses[seatIndex] ?? 0; if (bonus > 0) { awardGrowth(state.players[playerId], bonus); } }); const seatSummary = nextTurnOrder .map((playerId, seatIndex) => { const bonus = draft.seatBonuses[seatIndex] ?? 0; return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`; }) .join(" | "); state.initiativeDraft = null; state.history.unshift(`Round ${state.round} initiative set. ${seatSummary}`); startWeatherDraft(); } function chooseInitiativeSeat(seatIndex: number) { const draft = state.initiativeDraft; if (!draft || seatIndex < 0 || seatIndex >= draft.seatAssignments.length || draft.seatAssignments[seatIndex] !== null) { return; } const playerId = draft.biddingOrder[draft.biddingIndex]; draft.seatAssignments[seatIndex] = playerId; state.history.unshift(`${state.players[playerId].name} claimed seat ${seatIndex + 1} for round ${state.round}.`); if (draft.biddingIndex >= draft.biddingOrder.length - 1) { finalizeInitiativeDraft(); render(); return; } draft.biddingIndex += 1; state.activePlayerId = draft.biddingOrder[draft.biddingIndex]; render(); } function beginRound() { if (state.gameOver) { state.phase = "game_over"; return; } state.players.forEach((player) => { player.passed = false; }); if (state.config.initiativeMode === "bid") { startInitiativeDraft(); return; } startWeatherDraft(); } function finalizeWeatherDraft() { if (!state.weatherDraft) { moveToFirstPlayableTurn(); return; } state.activeRoundEffects = [...state.weatherDraft.drafted]; const draftedSummary = state.activeRoundEffects.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", "); const bannedSummary = state.weatherDraft.banned.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", "); state.history.unshift( `Round ${state.round} weather set.${draftedSummary ? ` Active: ${draftedSummary}.` : ""}${bannedSummary ? ` Banned: ${bannedSummary}.` : ""}` ); state.weatherDraft = null; moveToFirstPlayableTurn(); } function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") { const draft = state.weatherDraft; if (!draft || !isWeatherCardAvailable(draft, cardId)) { return; } const playerId = getCurrentWeatherPlayerId(draft); const card = getWeatherCard(cardId); if (action === "draft") { draft.drafted.push(cardId); state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`); } else { draft.banned.push(cardId); state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`); } if (draft.draftIndex >= draft.playerOrder.length - 1) { finalizeWeatherDraft(); render(); return; } draft.draftIndex += 1; state.activePlayerId = draft.playerOrder[draft.draftIndex]; render(); } function advanceTurn() { if (state.players.every((player) => player.passed || !playerHasLegalMove(player))) { endRound(); return; } const currentOrderIndex = Math.max(0, state.turnOrder.indexOf(state.activePlayerId)); let nextPlayerId = state.activePlayerId; for (let step = 1; step <= state.turnOrder.length; step += 1) { nextPlayerId = state.turnOrder[(currentOrderIndex + step) % state.turnOrder.length]; const candidate = state.players[nextPlayerId]; if (!candidate.passed && playerHasLegalMove(candidate)) { state.activePlayerId = nextPlayerId; state.turnMoves = []; updateSelection(null); render(); return; } } endRound(); } function undoMovesThrough(targetKey: NodeKey) { const player = getCurrentPlayer(); const moveIndex = findTurnMoveIndex(targetKey); if (moveIndex === -1) { return false; } const undoneMoves = state.turnMoves.slice(moveIndex).reverse(); undoneMoves.forEach((move) => { undoTurnMove(move, player); }); state.turnMoves = state.turnMoves.slice(0, moveIndex); state.history.unshift(`${player.name} rewound ${undoneMoves.length} move${undoneMoves.length === 1 ? "" : "s"} before ending the turn.`); const lastMove = state.turnMoves.at(-1); const newSelection = lastMove?.selectKey ?? (lastMove && "targetKey" in lastMove ? lastMove.targetKey : null); updateSelection(newSelection); render(); return true; } function growTo(target: GrowTarget) { const player = getCurrentPlayer(); if (!state.selectedSource) { return; } const source = parseKey(state.selectedSource); const targetKey = keyFor(target.row, target.column); if (state.nodes.has(targetKey) || target.cost > player.growthPoints) { return; } state.nodes.set(targetKey, { ownerId: player.id }); state.edges.push({ from: { row: source.row, column: source.column }, to: { row: target.row, column: target.column }, ownerId: player.id, }); state.turnMoves.push({ type: "grow", from: source, to: { row: target.row, column: target.column }, cost: target.cost, targetKey, undoKeys: [targetKey], selectKey: targetKey, }); player.growthPoints -= target.cost; state.history.unshift( `${player.name} grew ${target.direction === "vertical" ? "upward" : `diagonally ${target.direction}`} for ${target.cost} point${target.cost === 1 ? "" : "s"}.` ); updateSelection(targetKey); render(); } function shiftSelectedRoot(delta: number) { const player = getCurrentPlayer(); if (!state.selectedSource) { return; } const move = getRootShiftMove(state.selectedSource, delta, player); if (!move) { return; } applyShiftMove(move, player); } function endTurn() { const player = getCurrentPlayer(); if (player.growthPoints > 0) { const confirmed = window.confirm( `${player.name} still has ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"}. End the turn and lose them?` ); if (!confirmed) { return; } } const lostGrowth = player.growthPoints; state.turnMoves = []; player.growthPoints = 0; player.passed = true; state.history.unshift( `${player.name} ended their turn${lostGrowth > 0 ? ` and let ${lostGrowth} growth point${lostGrowth === 1 ? "" : "s"} wither` : ""}.` ); advanceTurn(); } function bankGrowthAndEndTurn() { if (!isBankingEnabled()) { return; } const player = getCurrentPlayer(); if (player.growthPoints <= 0) { endTurn(); return; } player.bankedPoints += player.growthPoints; state.history.unshift( `${player.name} banked ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"} for next round.` ); player.growthPoints = 0; state.turnMoves = []; player.passed = true; advanceTurn(); } async function endRound() { state.phase = "round_end"; const { scores, columnResults, energySimulation } = scoreColumns(); const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores); const { killedKeys, event: diseaseEvent } = maybeRollDisease(); const token = ++roundAnimationToken; state.animation = buildRoundAnimation(energySimulation, awardedPlayer, killedKeys); render(); await wait(ROUND_ANIMATION_SUN_MS); if (token !== roundAnimationToken) { return; } state.animation = { ...state.animation!, phase: "branches" }; render(); await wait(ROUND_ANIMATION_BRANCH_MS); if (token !== roundAnimationToken) { return; } if (awardedPlayer !== null) { state.animation = { ...state.animation!, phase: "bonus" }; render(); await wait(ROUND_ANIMATION_BONUS_MS); if (token !== roundAnimationToken) { return; } } state.animation = { ...state.animation!, phase: "events" }; render(); await wait(900); if (token !== roundAnimationToken) { return; } applyDisease(killedKeys); state.players.forEach((player, index) => { player.roundScore = scores[index]; player.totalScore += scores[index]; player.bonusPoints = nextGrowth[index] - scores[index]; player.growthPoints = player.bankedPoints; player.lifetimeGrowthIncome += nextGrowth[index]; player.growthPoints += nextGrowth[index]; player.bankedPoints = 0; player.passed = false; }); state.roundSummary = { scores, columnResults, event: [sunbeamEvent, diseaseEvent].filter(Boolean).join(" "), }; state.history.unshift( `Round ${state.round} scored. ${state.players.map((player) => `${player.name}: ${player.roundScore}`).join(" | ")}` ); [sunbeamEvent, diseaseEvent].filter(Boolean).forEach((eventText) => { state.history.unshift(eventText as string); }); state.animation = null; state.activeRoundEffects = []; const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player)); if (boardFull || hasReachedWinCondition()) { state.gameOver = true; state.phase = "game_over"; state.history.unshift(boardFull ? "The canopy is complete. Final totals are locked." : `Win condition reached. ${getWinConditionSummary()}`); render(); return; } state.round += 1; state.history.unshift(`Round ${state.round} begins.`); beginRound(); render(); } function resetGame() { roundAnimationToken += 1; state = createInitialState(setup); beginRound(); render(); } function openNewGameModal() { isNewGameModalOpen = true; render(); } function closeNewGameModal() { isNewGameModalOpen = false; render(); } function startNewGameFromModal() { isNewGameModalOpen = false; resetGame(); } function finishGameNow() { if (state.gameOver) { return; } roundAnimationToken += 1; state.animation = null; state.phase = "game_over"; const { scores, columnResults } = scoreColumns(); state.players.forEach((player, index) => { player.roundScore = scores[index]; player.totalScore += scores[index]; }); state.roundSummary = { scores, columnResults, event: null }; state.gameOver = true; state.history.unshift("Game ended manually. Final scores tallied from the current canopy."); render(); } function getTargetForCell(row: number, column: number) { return state.availableTargets.find((target) => target.row === row && target.column === column) ?? null; } function isPendingTurnNode(row: number, column: number) { const nodeKey = keyFor(row, column); return state.turnMoves.some((move) => getMoveUndoKeys(move).includes(nodeKey)); } function moveSetupPlayer(fromIndex: number, toIndex: number) { if (toIndex < 0 || toIndex >= setup.playerCount) { return; } [setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]]; [setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]]; render(); } function randomizeStartingLocations() { setup.seedInputs = createRandomizedSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer); render(); } function setSetupTab(tab: typeof setupTab) { setupTab = tab; render(); } function renderNewGameModal() { if (!isNewGameModalOpen) { return ""; } const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns); const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder); const draftCountMax = WEATHER_CARDS.length; return ` `; } function renderWeatherDraftModal() { if (state.phase !== "weather" || !state.weatherDraft) { return ""; } const draft = state.weatherDraft; const currentPlayer = getCurrentWeatherDraftPlayer(); return ` `; } function renderInitiativeModal() { if (state.phase !== "initiative" || !state.initiativeDraft) { return ""; } const draft = state.initiativeDraft; const currentBidder = getCurrentBiddingPlayer(); const orderedBidders = getOrderedPlayers(draft.biddingOrder); return ` `; } function renderScoreboard() { const liveExposureScores = getLiveExposureScores(); return state.players.map((player, index) => { const isActive = player.id === state.activePlayerId && !state.gameOver; const seatIndex = state.turnOrder.indexOf(player.id); const previous = previousScoreSnapshot?.[index]; const sunlightChanged = previous && previous.currentExposure !== liveExposureScores[index]; const growthChanged = previous && previous.growthPoints !== player.growthPoints; const bankChanged = previous && previous.bankedPoints !== player.bankedPoints; return `

${player.name}

Lifetime ${player.lifetimeGrowthIncome}
Current ${liveExposureScores[index]}
Energy ${player.growthPoints}
Bank ${player.bankedPoints}
`; }).join(""); } function renderAnimationOverlay(columns: number, rows: number) { if (!state.animation) { return ""; } const showSunlightWave = state.animation.phase === "sunlight"; const showBranchFlow = state.animation.phase !== "sunlight"; const sunlightCells = showSunlightWave ? state.animation.columns.flatMap((columnState) => { const flashColor = columnState.ownerId === null ? "rgba(255, 241, 186, 0.9)" : state.players[columnState.ownerId].color; const verticalCells = Array.from({ length: columnState.terminalRow + 1 }, (_, row) => ({ row, column: columnState.column })); return verticalCells.map((node, index) => { const left = (node.column / columns) * 100; const top = (node.row / rows) * 100; const width = (1 / columns) * 100; const height = (1 / rows) * 100; return `
`; }); }).join("") : ""; const flashes = showBranchFlow ? state.animation.traces.flatMap((trace) => { const player = state.players[trace.playerId]; const nodes = trace.branchNodes; return nodes.map((node, index) => { const left = (node.column / columns) * 100; const top = (node.row / rows) * 100; const width = (1 / columns) * 100; const height = (1 / rows) * 100; return `
`; }); }).join("") : ""; const roots = state.animation.rootBursts.map((burst) => { const player = state.players[burst.playerId]; const root = parseKey(burst.key); const x = ((root.column + 0.5) / columns) * 100; const y = ((root.row + 0.5) / rows) * 100; return `+${burst.count}`; }).join(""); const disease = state.animation.diseaseKeys.map((key, index) => { const node = parseKey(key); const x = ((node.column + 0.5) / columns) * 100; const y = ((node.row + 0.5) / rows) * 100; return ``; }).join(""); const bonusSunbeam = !state.animation.bonusTrace ? "" : `
`; const bonusFlashes = !state.animation.bonusTrace ? "" : state.animation.bonusTrace.branchNodes.map((node, index) => { const left = (node.column / columns) * 100; const top = (node.row / rows) * 100; const width = (1 / columns) * 100; const height = (1 / rows) * 100; return `
`; }).join(""); const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined ? "" : (() => { const root = parseKey(state.animation?.bonusBurst?.key as string); const x = ((root.column + 0.5) / columns) * 100; const y = ((root.row + 0.5) / rows) * 100; return `+1`; })(); return ` `; } function renderBoard() { const columns = state.config.columns; const rows = state.config.rows; const columnLeaders = getColumnLeaders(); const parentMap = buildParentMap(); const lines = state.edges.map((edge) => { const player = state.players[edge.ownerId]; const x1 = ((edge.from.column + 0.5) / columns) * 100; const y1 = ((edge.from.row + 0.5) / rows) * 100; const x2 = ((edge.to.column + 0.5) / columns) * 100; const y2 = ((edge.to.row + 0.5) / rows) * 100; return ``; }).join(""); const cells = Array.from({ length: rows }, (_, row) => { return Array.from({ length: columns }, (_, column) => { const ownerId = getNodeOwner(row, column); const player = ownerId === null ? null : state.players[ownerId]; const target = getTargetForCell(row, column); const pending = isPendingTurnNode(row, column); const nodeKey = keyFor(row, column); const isRoot = ownerId !== null && row === rows - 1 && !parentMap.has(nodeKey); const columnLeader = columnLeaders[column]; const background = columnLeader.ownerId === null || columnLeader.tied ? "transparent" : tint(state.players[columnLeader.ownerId].color); return ` `; }).join(""); }).join(""); return `
${cells} ${renderAnimationOverlay(columns, rows)}
`; } function renderSidebar() { const player = getCurrentPlayer(); const rootShiftMoves = getSelectedRootShiftMoves(); const boardLocked = isInteractionLocked(); const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${orderedPlayer.name}`).join(" | "); const activeEffectsMarkup = state.activeRoundEffects.length > 0 ? `
${state.activeRoundEffects.map((cardId) => { const card = getWeatherCard(cardId); return `
${card?.title ?? cardId}
`; }).join("")}
` : `

No weather effects active.

`; const nextGrowthText = state.roundSummary ? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ") : "Next round growth = 1 + columns owned + any banked growth."; return ` `; } function attachEvents() { document.querySelectorAll(".cell").forEach((cell) => { cell.addEventListener("click", () => { if (isInteractionLocked()) { return; } const row = Number(cell.dataset.row); const column = Number(cell.dataset.column); const currentPlayer = getCurrentPlayer(); const ownerId = getNodeOwner(row, column); const target = getTargetForCell(row, column); const nodeKey = keyFor(row, column); if (target) { growTo(target); return; } if (ownerId === currentPlayer.id) { const sourceKey = nodeKey; const isPendingNode = isPendingTurnNode(row, column); if (state.selectedSource === sourceKey) { if (isPendingNode && undoMovesThrough(nodeKey)) { return; } updateSelection(null); } else { updateSelection(sourceKey); } render(); } }); }); document.querySelector("#new-game")?.addEventListener("click", openNewGameModal); document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal); document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal); document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal); document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => { if ((event.target as HTMLElement).id === "new-game-modal-backdrop") { closeNewGameModal(); } }); document.querySelector("#end-turn")?.addEventListener("click", endTurn); document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn); document.querySelector("#finish-game")?.addEventListener("click", finishGameNow); document.querySelector("#player-count")?.addEventListener("input", (event) => { const input = event.currentTarget as HTMLInputElement; const output = input.parentElement?.querySelector("strong"); if (output) { output.textContent = input.value; } rebuildSetup({ playerCount: Number(input.value) }); render(); }); document.querySelector("#column-count")?.addEventListener("change", (event) => { const columns = Number((event.currentTarget as HTMLInputElement).value); if (!Number.isInteger(columns)) { return; } rebuildSetup({ columns: Math.max(6, Math.min(24, columns)) }); render(); }); document.querySelector("#row-count")?.addEventListener("change", (event) => { const rows = Number((event.currentTarget as HTMLInputElement).value); if (!Number.isInteger(rows)) { return; } rebuildSetup({ rows: Math.max(6, Math.min(24, rows)) }); render(); }); document.querySelector("#starting-nodes")?.addEventListener("change", (event) => { const nextValue = Number((event.currentTarget as HTMLInputElement).value); if (!Number.isInteger(nextValue)) { return; } rebuildSetup({ startingNodesPerPlayer: Math.max(1, nextValue) }); render(); }); document.querySelector("#sunbeam-chance")?.addEventListener("change", (event) => { setup.sunbeamChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0)); state.randomEffects.sunbeamChance = setup.sunbeamChance; }); document.querySelector("#disease-chance")?.addEventListener("change", (event) => { setup.diseaseChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0)); state.randomEffects.diseaseChance = setup.diseaseChance; }); document.querySelector("#initiative-mode")?.addEventListener("change", (event) => { setup.initiativeMode = (event.currentTarget as HTMLSelectElement).value as SetupState["initiativeMode"]; render(); }); document.querySelector("#bidding-order-rule")?.addEventListener("change", (event) => { setup.biddingOrderRule = (event.currentTarget as HTMLSelectElement).value as SetupState["biddingOrderRule"]; }); document.querySelector("#weather-draft-toggle")?.addEventListener("change", (event) => { setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked; render(); }); document.querySelector("#weather-draft-count")?.addEventListener("change", (event) => { rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) }); render(); }); document.querySelector("#win-condition")?.addEventListener("change", (event) => { setup.winCondition = (event.currentTarget as HTMLSelectElement).value as SetupState["winCondition"]; render(); }); document.querySelector("#max-rounds")?.addEventListener("change", (event) => { setup.maxRounds = Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1); }); document.querySelector("#top-leaf-target")?.addEventListener("change", (event) => { setup.topLeafTarget = Math.max(1, Math.min(setup.columns, Number((event.currentTarget as HTMLInputElement).value) || 1)); }); document.querySelectorAll(".seed-input").forEach((input) => { input.addEventListener("input", (event) => { const target = event.currentTarget as HTMLInputElement; const playerId = Number(target.dataset.playerId); setup.seedInputs[playerId] = target.value; }); }); document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations); document.querySelectorAll("[data-move-player]").forEach((button) => { button.addEventListener("click", () => { const fromIndex = Number(button.dataset.movePlayer); const direction = button.dataset.direction === "up" ? -1 : 1; moveSetupPlayer(fromIndex, fromIndex + direction); }); }); document.querySelectorAll("[data-root-shift]").forEach((button) => { button.addEventListener("click", () => { shiftSelectedRoot(Number(button.dataset.rootShift)); }); }); document.querySelectorAll("[data-seat-choice]").forEach((button) => { button.addEventListener("click", () => { chooseInitiativeSeat(Number(button.dataset.seatChoice)); }); }); document.querySelectorAll("[data-weather-card]").forEach((button) => { button.addEventListener("click", () => { chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban"); }); }); document.querySelectorAll("[data-setup-tab]").forEach((button) => { button.addEventListener("click", () => { setSetupTab(button.dataset.setupTab as typeof setupTab); }); }); } function render() { const playerCount = state.players.length; app.innerHTML = `
${renderBoard()}
${renderSidebar()}
${renderNewGameModal()} ${renderInitiativeModal()} ${renderWeatherDraftModal()} `; attachEvents(); previousScoreSnapshot = getScoreSnapshot(); } render();