364 lines
9.9 KiB
TypeScript
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.`,
|
|
};
|
|
}
|