import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types"; import { keyFor, parseKey, shuffleArray } from "./utils"; import { buildChildrenMap, buildParentMap } from "./rules-board"; function getColumnRegion(state: GameState, column: number) { const third = state.config.columns / 3; if (column < third) { return "left"; } if (column >= state.config.columns - third) { return "right"; } return "center"; } function getLeafCounts(state: GameState) { const childrenMap = buildChildrenMap(state); const counts = state.players.map(() => 0); Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { if (!(childrenMap.get(nodeKey)?.length)) { counts[node.ownerId] += 1; } }); return counts; } function getColumnPresence(state: GameState, column: number) { const owners = new Set(); for (let row = 0; row < state.config.rows; row += 1) { const ownerId = state.nodes.get(keyFor(row, column))?.ownerId; if (ownerId !== undefined) { owners.add(ownerId); } } return [...owners]; } function addRoundedHalfBonus(scores: number[], counts: number[]) { counts.forEach((count, playerId) => { if (count > 0) { scores[playerId] += Math.ceil(count * 0.5); } }); } function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: PlayerId, playersPresent: PlayerId[]) { const contested = playersPresent.length > 1; if (!contested) { scores[ownerId] += 1; return; } if (state.activeRoundEffects.includes("stalemate")) { return; } if (state.activeRoundEffects.includes("split_light")) { playersPresent.forEach((playerId) => { scores[playerId] += 0.5; }); return; } scores[ownerId] += 1; } function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) { if (state.activeRoundEffects.length === 0) { return; } const leafCounts = getLeafCounts(state); const childrenMap = buildChildrenMap(state); const tallestLeaves = state.players.map(() => null as number | null); Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { const leafCount = leafCounts[node.ownerId]; if (leafCount <= 0) { return; } if (childrenMap.get(nodeKey)?.length) { return; } const { row } = parseKey(nodeKey); const currentTallest = tallestLeaves[node.ownerId]; if (currentTallest === null || row < currentTallest) { tallestLeaves[node.ownerId] = row; } }); state.activeRoundEffects.forEach((effectId) => { if (effectId === "leaf_surge") { leafCounts.forEach((count, playerId) => { scores[playerId] += count; }); return; } if (effectId === "branching_season") { leafCounts.forEach((count, playerId) => { scores[playerId] += Math.max(0, count - 1); }); return; } if (effectId === "tall_reward") { const bestRow = tallestLeaves.reduce((currentBest, row) => { if (row === null) { return currentBest; } if (currentBest === null || row < currentBest) { return row; } return currentBest; }, null); if (bestRow !== null) { tallestLeaves.forEach((row, playerId) => { if (row === bestRow) { scores[playerId] += 2; } }); } return; } if (effectId === "wide_reach") { const maxScore = Math.max(...energySimulation.scores); energySimulation.scores.forEach((score, playerId) => { if (score === maxScore && maxScore > 0) { scores[playerId] += 2; } }); return; } const westLightCounts = state.players.map(() => 0); const eastLightCounts = state.players.map(() => 0); const highNoonCounts = state.players.map(() => 0); energySimulation.columns.forEach((column) => { if (!column.intercepted || column.ownerId === null) { return; } const region = getColumnRegion(state, column.column); if (region === "left") { westLightCounts[column.ownerId] += 1; } if (region === "right") { eastLightCounts[column.ownerId] += 1; } if (region === "center") { highNoonCounts[column.ownerId] += 1; } if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) { scores[column.ownerId] += 1; } }); if (effectId === "west_light") { addRoundedHalfBonus(scores, westLightCounts); } if (effectId === "east_light") { addRoundedHalfBonus(scores, eastLightCounts); } if (effectId === "high_noon") { addRoundedHalfBonus(scores, highNoonCounts); } }); } export function buildEnergySimulation(state: GameState): EnergySimulation { const parentMap = buildParentMap(state); const columns = []; const scores = state.players.map(() => 0); for (let column = 0; column < state.config.columns; column += 1) { let hitNodeKey: NodeKey | null = null; for (let row = 0; row < state.config.rows; row += 1) { const nodeKey = keyFor(row, column); if (state.nodes.has(nodeKey)) { hitNodeKey = nodeKey; break; } } if (!hitNodeKey) { columns.push({ column, terminalRow: state.config.rows - 1, intercepted: false, ownerId: null, playersPresent: [], hitNode: null, rootKey: null, branchNodes: [], branchEdges: [], }); continue; } const hitNode = parseKey(hitNodeKey); const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId; const playersPresent = getColumnPresence(state, column); const branchNodes = [hitNode]; const branchEdges = []; let cursor = hitNodeKey; while (parentMap.has(cursor)) { const parentKey = parentMap.get(cursor) as NodeKey; branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) }); branchNodes.push(parseKey(parentKey)); cursor = parentKey; } applyColumnBaseEnergy(state, scores, ownerId, playersPresent); columns.push({ column, terminalRow: hitNode.row, intercepted: true, ownerId, playersPresent, hitNode, rootKey: cursor, branchNodes, branchEdges, }); } const rootBurstMap = columns.reduce((map, column) => { if (!column.intercepted || !column.rootKey) { return map; } const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 }; entry.count += 1; map.set(column.rootKey, entry); return map; }, new Map()); const rootBursts: RootBurst[] = [...rootBurstMap.values()]; applyWeatherEffects(state, scores, { scores, columns, rootBursts }); return { scores, columns, rootBursts, }; } export function buildRoundAnimation( state: GameState, energySimulation: EnergySimulation, sunbeamPlayerId: PlayerId | null, diseaseKeys: NodeKey[], ): RoundAnimation { const traces = energySimulation.columns .filter((column) => column.intercepted) .map((column) => ({ playerId: column.ownerId as PlayerId, verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })), ray: { x: ((column.column + 0.5) / state.config.columns) * 100, y: ((column.terminalRow + 0.5) / state.config.rows) * 100, }, rootKey: column.rootKey, branchNodes: column.branchNodes, })); const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null; const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null; return { phase: "sunlight", columns: energySimulation.columns, traces, rootBursts: energySimulation.rootBursts, sunbeamPlayerId, bonusTrace, bonusBurst, diseaseKeys, }; } export function scoreColumns(state: GameState) { const energySimulation = buildEnergySimulation(state); const columnResults = energySimulation.columns.map((column) => ({ column: column.column, ownerId: column.ownerId, topRow: column.intercepted ? column.terminalRow : null, tied: false, })); return { scores: energySimulation.scores, columnResults, energySimulation }; } export function maybeRollSunbeam(state: GameState, scores: number[]) { const nextGrowth = scores.map((score) => score + 1); const { sunbeamChance } = state.randomEffects; if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) { return { nextGrowth, event: null, awardedPlayer: null, }; } const awardedPlayer = Math.floor(Math.random() * state.players.length); nextGrowth[awardedPlayer] += 1; return { nextGrowth, awardedPlayer, event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`, }; } export function maybeRollDisease(state: GameState) { const { diseaseChance } = state.randomEffects; if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) { return { killedKeys: [], event: null, }; } const childrenMap = buildChildrenMap(state); const parentMap = buildParentMap(state); const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { const { row } = parseKey(nodeKey); return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey); }); if (twigKeys.length === 0) { return { killedKeys: [], event: null, }; } const shuffled = shuffleArray(twigKeys); const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length))); const killedKeys = shuffled.slice(0, killCount); return { killedKeys, event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`, }; }