diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..b5e835e --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,5 @@ +[phases.build] +cmds = ["npm ci", "npm run build"] + +[start] +cmd = "npx serve dist -l 3000 -s" diff --git a/src/main.ts b/src/main.ts index 0cba93d..0b55df7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,15 @@ import { maybeRollSunbeam as maybeRollSunbeamForState, scoreColumns as scoreColumnsForState, } from "./rules-scoring"; +import { + createInitiativeDraft, +} from "./rules-initiative"; +import { + createWeatherDraft, + getCurrentWeatherPlayerId, + getWeatherCard, + isWeatherCardAvailable, +} from "./rules-weather"; import { createInitialState, createPlayers, @@ -38,6 +47,7 @@ import type { SetupState, ShiftMove, TurnMove, + WeatherCardId, } from "./types"; import { keyFor, parseKey, tint, wait } from "./utils"; @@ -53,12 +63,47 @@ let state: GameState = createInitialState(setup); let isNewGameModalOpen = false; let previousScoreSnapshot: ScoreSnapshot[] | null = null; +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() { - return state.players.map((player) => ({ - totalScore: player.totalScore, - roundScore: player.roundScore, + const exposureScores = getLiveExposureScores(); + return state.players.map((player, index) => ({ + currentExposure: exposureScores[index], growthPoints: player.growthPoints, bankedPoints: player.bankedPoints, + lifetimeGrowthIncome: player.lifetimeGrowthIncome, })); } @@ -66,6 +111,47 @@ 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 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); } @@ -232,7 +318,160 @@ function updateSelection(sourceKey: NodeKey | null = null) { } function isInteractionLocked() { - return state.gameOver || Boolean(state.animation); + 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() { @@ -241,9 +480,10 @@ function advanceTurn() { return; } + const currentOrderIndex = Math.max(0, state.turnOrder.indexOf(state.activePlayerId)); let nextPlayerId = state.activePlayerId; - for (let step = 0; step < state.players.length; step += 1) { - nextPlayerId = (nextPlayerId + 1) % state.players.length; + 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)) { @@ -258,21 +498,6 @@ function advanceTurn() { endRound(); } -function moveToFirstPlayableTurn() { - const nextPlayerId = state.players.findIndex((player) => playerHasLegalMove(player)); - - if (nextPlayerId === -1) { - state.gameOver = true; - state.history.unshift("No player can grow further. Final totals are locked."); - return false; - } - - state.activePlayerId = nextPlayerId; - state.turnMoves = []; - updateSelection(null); - return true; -} - function undoMovesThrough(targetKey: NodeKey) { const player = getCurrentPlayer(); const moveIndex = findTurnMoveIndex(targetKey); @@ -386,6 +611,7 @@ function bankGrowthAndEndTurn() { } async function endRound() { + state.phase = "round_end"; const { scores, columnResults, energySimulation } = scoreColumns(); const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores); const { killedKeys, event: diseaseEvent } = maybeRollDisease(); @@ -431,7 +657,9 @@ async function endRound() { player.roundScore = scores[index]; player.totalScore += scores[index]; player.bonusPoints = nextGrowth[index] - scores[index]; - player.growthPoints = nextGrowth[index] + player.bankedPoints; + player.growthPoints = player.bankedPoints; + player.lifetimeGrowthIncome += nextGrowth[index]; + player.growthPoints += nextGrowth[index]; player.bankedPoints = 0; player.passed = false; }); @@ -450,25 +678,27 @@ async function endRound() { }); state.animation = null; + state.activeRoundEffects = []; const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player)); - if (boardFull) { + if (boardFull || hasReachedWinCondition()) { state.gameOver = true; - state.history.unshift("The canopy is complete. Final totals are locked."); + 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.`); - moveToFirstPlayableTurn(); + beginRound(); render(); } function resetGame() { roundAnimationToken += 1; state = createInitialState(setup); - moveToFirstPlayableTurn(); + beginRound(); render(); } @@ -494,6 +724,7 @@ function finishGameNow() { roundAnimationToken += 1; state.animation = null; + state.phase = "game_over"; const { scores, columnResults } = scoreColumns(); state.players.forEach((player, index) => { player.roundScore = scores[index]; @@ -573,10 +804,44 @@ function renderNewGameModal() { Disease % -