Files
canopy-game/src/engine/rules-scoring.ts
2026-04-10 16:13:45 -04:00

364 lines
9.9 KiB
TypeScript

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<PlayerId>();
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<number | null>((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<NodeKey, RootBurst>());
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.`,
};
}