Files
canopy-game/src/main.ts

1509 lines
55 KiB
TypeScript

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<SetupState> = {}) {
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 `
<div class="modal-backdrop" id="new-game-modal-backdrop">
<section class="modal setup-modal" role="dialog" aria-modal="true" aria-labelledby="new-game-title">
<header class="modal-header">
<div class="modal-header__title">
<p class="eyebrow">New Game</p>
<h1 id="new-game-title">Configure the next canopy</h1>
</div>
<button class="ghost-button" id="close-new-game">Close</button>
</header>
<nav class="setup-tabs" aria-label="Setup categories">
<button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button>
<button class="setup-tab${setupTab === "rules" ? " setup-tab--active" : ""}" data-setup-tab="rules"><span aria-hidden="true">⚖</span><span>Rules</span></button>
<button class="setup-tab${setupTab === "events" ? " setup-tab--active" : ""}" data-setup-tab="events"><span aria-hidden="true">☀</span><span>Events</span></button>
<button class="setup-tab${setupTab === "players" ? " setup-tab--active" : ""}" data-setup-tab="players"><span aria-hidden="true">◉</span><span>Players</span></button>
</nav>
<div class="modal-body">
${setupTab === "board" ? `
<section class="setup-section">
<h2 class="setup-section__title">Board Settings</h2>
<div class="setup-grid">
<label class="setup-field setup-field--range">
<span class="setup-field__label">Players</span>
<div class="setup-field__input">
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" />
<strong class="setup-field__value">${setup.playerCount}</strong>
</div>
</label>
<label class="setup-field">
<span class="setup-field__label">Columns</span>
<input id="column-count" type="number" min="6" max="24" step="1" value="${setup.columns}" />
</label>
<label class="setup-field">
<span class="setup-field__label">Rows</span>
<input id="row-count" type="number" min="6" max="24" step="1" value="${setup.rows}" />
</label>
<label class="setup-field">
<span class="setup-field__label">Starting Nodes</span>
<input id="starting-nodes" type="number" min="1" max="${maxSeeds}" step="1" value="${setup.startingNodesPerPlayer}" />
</label>
</div>
</section>
` : ""}
${setupTab === "rules" ? `
<section class="setup-section">
<h2 class="setup-section__title">Game Rules</h2>
<div class="setup-grid setup-grid--2col">
<label class="setup-field">
<span class="setup-field__label">Win Condition</span>
<select id="win-condition">
<option value="rounds" ${setup.winCondition === "rounds" ? "selected" : ""}>Round Limit</option>
<option value="top_leaves" ${setup.winCondition === "top_leaves" ? "selected" : ""}>Top Leaves</option>
</select>
</label>
${setup.winCondition === "rounds" ? `
<label class="setup-field">
<span class="setup-field__label">Max Rounds</span>
<input id="max-rounds" type="number" min="1" max="99" step="1" value="${setup.maxRounds}" />
</label>
` : `
<label class="setup-field">
<span class="setup-field__label">Top Leaf Target</span>
<input id="top-leaf-target" type="number" min="1" max="${setup.columns}" step="1" value="${setup.topLeafTarget}" />
</label>
`}
<label class="setup-field">
<span class="setup-field__label">Initiative</span>
<select id="initiative-mode">
<option value="fixed" ${setup.initiativeMode === "fixed" ? "selected" : ""}>Fixed Order</option>
<option value="bid" ${setup.initiativeMode === "bid" ? "selected" : ""}>Seat Draft</option>
</select>
</label>
${setup.initiativeMode === "bid" ? `
<label class="setup-field">
<span class="setup-field__label">Bid Order</span>
<select id="bidding-order-rule">
<option value="rotating" ${setup.biddingOrderRule === "rotating" ? "selected" : ""}>Rotating</option>
<option value="lowest_growth_income" ${setup.biddingOrderRule === "lowest_growth_income" ? "selected" : ""}>Lowest Growth</option>
</select>
</label>
` : ""}
<label class="setup-field setup-field--checkbox">
<span class="setup-field__label">Weather Draft</span>
<input id="weather-draft-toggle" type="checkbox" ${setup.weatherDraftEnabled ? "checked" : ""} />
</label>
${setup.weatherDraftEnabled ? `
<label class="setup-field">
<span class="setup-field__label">Weather cards in draft</span>
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
</label>
` : ""}
</div>
</section>
` : ""}
${setupTab === "events" ? `
<section class="setup-section">
<h2 class="setup-section__title">Random Events</h2>
<div class="setup-grid setup-grid--3col">
<label class="setup-field">
<span class="setup-field__label">Sunbeam %</span>
<input id="sunbeam-chance" type="number" min="0" max="100" step="5" value="${setup.sunbeamChance}" />
</label>
<label class="setup-field">
<span class="setup-field__label">Disease %</span>
<input id="disease-chance" type="number" min="0" max="100" step="5" value="${setup.diseaseChance}" />
</label>
</div>
</section>
` : ""}
${setupTab === "players" ? `
<section class="setup-section">
<h2 class="setup-section__title">Players & Colors</h2>
<p class="setup-section__help">Reorder players to set turn order. Starting positions are auto-assigned.</p>
<div class="player-list">
${previewPlayers.map((currentPlayer, index) => `
<div class="player-row">
<div class="player-row__info">
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
<span class="player-row__name">${currentPlayer.name}</span>
</div>
<div class="player-row__actions">
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
<button class="mini-button" data-move-player="${index}" data-direction="down" ${index === previewPlayers.length - 1 ? "disabled" : ""}>↓</button>
</div>
</div>
`).join("")}
</div>
</section>
<section class="setup-section">
<h2 class="setup-section__title">Starting columns</h2>
<p class="setup-section__help">Use 1-based column numbers. Duplicate or invalid picks are auto-corrected.</p>
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
<div class="player-list">
${previewPlayers.map((currentPlayer, index) => `
<label class="setup-field">
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" />
</label>
`).join("")}
</div>
</section>
` : ""}
</div>
<footer class="modal-footer">
<button class="ghost-button" id="cancel-new-game">Cancel</button>
<button id="start-new-game">Start New Game</button>
</footer>
</section>
</div>
`;
}
function renderWeatherDraftModal() {
if (state.phase !== "weather" || !state.weatherDraft) {
return "";
}
const draft = state.weatherDraft;
const currentPlayer = getCurrentWeatherDraftPlayer();
return `
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title">
<div class="panel__title-row">
<div>
<p class="eyebrow">Round ${state.round}</p>
<h1 id="weather-title">Weather Draft</h1>
</div>
</div>
<div class="seed-editor">
<p class="seed-help">${currentPlayer?.name ?? "A player"} must draft one card for this round or ban one to deny it.</p>
<div class="weather-key" aria-label="Weather action key">
<span><strong>☀ Draft</strong>: apply to the board for 1 round</span>
<span><strong>✕ Ban</strong>: remove it this round</span>
</div>
<div class="initiative-order-row">
${getOrderedPlayers(draft.playerOrder).map((player, index) => `<span class="initiative-pill${index === draft.draftIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
</div>
</div>
<div class="weather-grid">
${draft.row.map((cardId) => {
const card = getWeatherCard(cardId);
const drafted = draft.drafted.includes(cardId);
const banned = draft.banned.includes(cardId);
const available = !drafted && !banned;
return `
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
<div>
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
<h2>${card?.title ?? cardId}</h2>
<p>${card?.description ?? ""}</p>
</div>
${available ? `
<div class="weather-card__actions">
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-card="${cardId}">
<span class="weather-action__icon" aria-hidden="true">☀</span>
<span><strong>Draft</strong></span>
</button>
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-card="${cardId}">
<span class="weather-action__icon" aria-hidden="true">✕</span>
<span><strong>Ban</strong></span>
</button>
</div>
` : ""}
</article>
`;
}).join("")}
</div>
</section>
`;
}
function renderInitiativeModal() {
if (state.phase !== "initiative" || !state.initiativeDraft) {
return "";
}
const draft = state.initiativeDraft;
const currentBidder = getCurrentBiddingPlayer();
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
return `
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
<div class="panel__title-row">
<div>
<p class="eyebrow">Round ${state.round}</p>
<h1 id="initiative-title">Initiative Draft</h1>
</div>
</div>
<div class="seed-editor">
<p class="seed-help">${currentBidder?.name ?? "A player"} chooses a seat. Earlier seats gain extra growth before the round begins.</p>
<div class="initiative-order-row">
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")}
</div>
</div>
<div class="initiative-seat-grid">
${draft.seatAssignments.map((playerId, seatIndex) => {
const assignedPlayer = playerId === null ? null : state.players[playerId];
const bonus = draft.seatBonuses[seatIndex] ?? 0;
const disabled = playerId !== null ? "disabled" : "";
return `
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
<strong>Seat ${seatIndex + 1}</strong>
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
</button>
`;
}).join("")}
</div>
</section>
`;
}
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 `
<article class="score-card${isActive ? " active" : ""}" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
<div class="score-card__head">
<div class="score-card__identity">
<span class="player-dot"></span>
<h2>${player.name}</h2>
</div>
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
</div>
<div class="score-card__numbers">
<div>
<span>Current</span>
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
</div>
<div>
<span>Energy</span>
<strong class="${growthChanged ? "score-value changed" : "score-value"}">${player.growthPoints}</strong>
</div>
<div>
<span>Bank</span>
<strong class="${bankChanged ? "score-value changed" : "score-value"}">${player.bankedPoints}</strong>
</div>
</div>
<div class="score-card__footer">
<span>Seat ${seatIndex === -1 ? "-" : seatIndex + 1}</span>
</div>
</article>
`;
}).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 `<div class="board__energy-cell board__energy-cell--sunlight" style="--flash-delay: ${index * 65}ms; --flash-color: ${flashColor}; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
});
}).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 `
<div
class="board__energy-cell${state.animation?.phase === "bonus" ? " board__energy-cell--bonus" : ""}"
style="--flash-delay: ${index * 175}ms; --flash-color: ${player.color}; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"
></div>
`;
});
}).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 `<g class="board__root-burst" style="--trace-delay: 300ms; --burst-color: ${player.color};" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+${burst.count}</text></g>`;
}).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 `<g class="board__disease-mark" style="--trace-delay: ${index * 100}ms;" transform="translate(${x} ${y})"><circle r="2.2"></circle><path d="M-1.2 -1.2 L1.2 1.2 M1.2 -1.2 L-1.2 1.2" /></g>`;
}).join("");
const bonusSunbeam = !state.animation.bonusTrace
? ""
: `
<div class="board__drop board__drop--bonus" style="--drop-x: ${state.animation.bonusTrace.ray.x}%; --drop-end: ${state.animation.bonusTrace.ray.y}%; --drop-color: #ffd85e;">
<span class="board__drop-core"></span>
<span class="board__drop-spark board__drop-spark--a"></span>
<span class="board__drop-spark board__drop-spark--b"></span>
<span class="board__drop-spark board__drop-spark--c"></span>
</div>
`;
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 `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
}).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 `<g class="board__sunbeam-burst" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+1</text></g>`;
})();
return `
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
</div>
<div class="board__energy-layer" aria-hidden="true">
${sunlightCells}
${flashes}
${state.animation.phase === "bonus" ? bonusFlashes : ""}
</div>
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
${roots}
${disease}
${sunbeam}
</svg>
`;
}
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 `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-width="0.9" stroke-linecap="round" />`;
}).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 `
<button
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
data-row="${row}"
data-column="${column}"
style="--column-tint: ${background}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
${isInteractionLocked() ? "disabled" : ""}
>
<span class="cell__shade"></span>
${target ? `<span class="cell__target-label">${target.cost}</span>` : ""}
${isRoot ? '<span class="cell__root-ring"></span>' : ""}
${player ? '<span class="cell__node"></span>' : ""}
</button>
`;
}).join("");
}).join("");
return `
<section class="board-shell">
<div class="board${state.animation ? ` board--${state.animation.phase}` : ""}" style="--board-columns: ${columns}; --board-rows: ${rows}; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr);">
<svg class="board__lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
${lines}
</svg>
${cells}
${renderAnimationOverlay(columns, rows)}
</div>
</section>
`;
}
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
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
const card = getWeatherCard(cardId);
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span></div>`;
}).join("")}</div>`
: `<p class="effect-empty">No weather effects active.</p>`;
const nextGrowthText = state.roundSummary
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
: "Next round growth = 1 + columns owned + any banked growth.";
return `
<aside class="sidebar">
<section class="panel controls-panel">
<div class="panel__title-row">
<div>
<p class="eyebrow">Canopy</p>
<h1>Sunlight decides the next round.</h1>
</div>
<button class="ghost-button" id="new-game">New Game</button>
</div>
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
<p class="eyebrow">Round ${state.round}</p>
<h2>${getTurnLabel()}</h2>
<p>${state.phase === "initiative" ? "Choose a public seat for this round. Earlier seats gain more growth, later seats act later." : state.gameOver ? "Tallies are final." : "Spend growth by extending upward. Vertical costs 1. Diagonal costs 2. Click a glowing new node to undo back to that point before you commit the turn."}</p>
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"} this turn. ${player.bankedPoints} banked for the next round.</p>` : ""}
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
<p>Turn order: ${turnOrderSummary}</p>
<p>${getWinConditionSummary()}</p>
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
</div>
<div class="button-row">
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
<button id="bank-turn" class="ghost-button" ${boardLocked || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button>
</div>
</section>
<section class="panel status-panel">
<h2>Round economy</h2>
<p>${nextGrowthText}</p>
${activeEffectsMarkup}
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
</section>
<section class="panel log-panel">
<h2>Round log</h2>
<div class="log-list">
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
</div>
</section>
<section class="panel finish-panel">
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
</section>
</aside>
`;
}
function attachEvents() {
document.querySelectorAll<HTMLElement>(".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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLSelectElement>("#initiative-mode")?.addEventListener("change", (event) => {
setup.initiativeMode = (event.currentTarget as HTMLSelectElement).value as SetupState["initiativeMode"];
render();
});
document.querySelector<HTMLSelectElement>("#bidding-order-rule")?.addEventListener("change", (event) => {
setup.biddingOrderRule = (event.currentTarget as HTMLSelectElement).value as SetupState["biddingOrderRule"];
});
document.querySelector<HTMLInputElement>("#weather-draft-toggle")?.addEventListener("change", (event) => {
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
render();
});
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
render();
});
document.querySelector<HTMLSelectElement>("#win-condition")?.addEventListener("change", (event) => {
setup.winCondition = (event.currentTarget as HTMLSelectElement).value as SetupState["winCondition"];
render();
});
document.querySelector<HTMLInputElement>("#max-rounds")?.addEventListener("change", (event) => {
setup.maxRounds = Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1);
});
document.querySelector<HTMLInputElement>("#top-leaf-target")?.addEventListener("change", (event) => {
setup.topLeafTarget = Math.max(1, Math.min(setup.columns, Number((event.currentTarget as HTMLInputElement).value) || 1));
});
document.querySelectorAll<HTMLInputElement>(".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<HTMLElement>("[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<HTMLElement>("[data-root-shift]").forEach((button) => {
button.addEventListener("click", () => {
shiftSelectedRoot(Number(button.dataset.rootShift));
});
});
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
button.addEventListener("click", () => {
chooseInitiativeSeat(Number(button.dataset.seatChoice));
});
});
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
button.addEventListener("click", () => {
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
});
});
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {
button.addEventListener("click", () => {
setSetupTab(button.dataset.setupTab as typeof setupTab);
});
});
}
function render() {
const playerCount = state.players.length;
app.innerHTML = `
<main class="layout" style="--player-count: ${playerCount}">
<section class="game-area">
${renderBoard()}
</section>
${renderSidebar()}
<footer class="scoreboard">
${renderScoreboard()}
</footer>
</main>
${renderNewGameModal()}
${renderInitiativeModal()}
${renderWeatherDraftModal()}
`;
attachEvents();
previousScoreSnapshot = getScoreSnapshot();
}
render();