more card rule tweaks
This commit is contained in:
363
src/engine/rules-scoring.ts
Normal file
363
src/engine/rules-scoring.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user