Fix weather draft and deploy setup

This commit is contained in:
2026-04-29 12:00:56 -04:00
parent f071837ed6
commit c4da8c942e
10 changed files with 2731 additions and 2670 deletions

View File

@@ -2,7 +2,7 @@
cmds = ["npm run build"] cmds = ["npm run build"]
[start] [start]
cmd = "npx serve dist -l 80 -s" cmd = "serve dist -l 80 -s"
[healthcheck] [healthcheck]
cmd = "curl -f http://localhost:80/ || exit 1" cmd = "curl -f http://localhost:80/ || exit 1"

960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,14 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "npx serve dist -l 80 -s", "start": "serve dist -l 80 -s",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^5.4.19" "vite": "^5.4.19"
},
"dependencies": {
"serve": "^14.2.6"
} }
} }

View File

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

View File

@@ -3,8 +3,9 @@ import { shuffleArray } from "./utils";
export const WEATHER_CARDS: WeatherCardDefinition[] = [ export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." }, { id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." }, { id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1. Player with most branches gets 50% more energy (rounded down)." },
{ id: "storehouse", title: "Storehouse", description: "Banking is enabled this round." }, { id: "storehouse", title: "Storehouse", description: "Banked energy is safe but earns no interest. Lose 1 banked energy (min 0)." },
{ id: "compound_interest", title: "Compound Interest", description: "Gain 20% interest on all banked energy (rounded down)." },
{ id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." }, { id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." },
{ id: "west_light", title: "West Light", description: "Left third energy gets +50%, rounded up." }, { id: "west_light", title: "West Light", description: "Left third energy gets +50%, rounded up." },
{ id: "east_light", title: "East Light", description: "Right third energy gets +50%, rounded up." }, { id: "east_light", title: "East Light", description: "Right third energy gets +50%, rounded up." },
@@ -12,8 +13,9 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." }, { id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." }, { id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
{ id: "tall_reward", title: "Tall Reward", description: "Tallest leaf on the board gives +2." }, { id: "tall_reward", title: "Tall Reward", description: "Tallest leaf on the board gives +2." },
{ id: "deep_roots", title: "Deep Roots", description: "Shortest plant receives +4 energy." },
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." }, { id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
{ id: "split_light", title: "Split Light", description: "Contested columns give half to each player there." }, { id: "split_light", title: "Split Light", description: "Contested columns give 1 energy to each player there." },
]; ];
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>( export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
@@ -22,18 +24,27 @@ export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [ export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] }, { id: "growth_mix", options: ["leaf_surge", "branching_season"] },
{ id: "tempo_tools", options: ["storehouse", "sun_ladder"] }, { id: "banking_mix", options: ["storehouse", "compound_interest"] },
{ id: "tempo", options: ["sun_ladder", "edge_bloom"] },
{ id: "side_bias", options: ["west_light", "east_light"] }, { id: "side_bias", options: ["west_light", "east_light"] },
{ id: "shape_bias", options: ["high_noon", "edge_bloom"] }, { id: "shape", options: ["high_noon", "wide_reach"] },
{ id: "reward_shape", options: ["wide_reach", "tall_reward"] }, { id: "height", options: ["tall_reward", "deep_roots"] },
{ id: "contest_soft", options: ["stalemate", "split_light"] }, { id: "contest_soft", options: ["stalemate", "split_light"] },
]; ];
export function createWeatherDraft(state: GameState): WeatherDraftState { export function createWeatherDraft(state: GameState): WeatherDraftState {
const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount); const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount);
// Rotate draft order based on round number to ensure fairness
const rotatedOrder = [...state.turnOrder];
const rotation = (state.round - 1) % state.turnOrder.length;
if (rotation > 0) {
const start = rotatedOrder.splice(0, rotation);
rotatedOrder.push(...start);
}
return { return {
playerOrder: [...state.turnOrder], playerOrder: rotatedOrder,
draftIndex: 0, draftIndex: 0,
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize), offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
drafted: [], drafted: [],

View File

@@ -6,16 +6,47 @@ export function createDefaultPaletteOrder(playerCount: number) {
return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length); return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length);
} }
function getMinimumColumnsForEvenSpacing(playerCount: number, minColumns: number): number {
// We need columns to be divisible by (playerCount + 1) for even spacing
// Each player needs at least 1 column, so minimum is playerCount
// But for even spacing from edges, we need: columns % (playerCount + 1) === 0
const spacingDivisor = playerCount + 1;
// Start from at least minColumns or playerCount (whichever is larger)
let columns = Math.max(minColumns, playerCount);
// Increase columns until it's divisible by (playerCount + 1)
while (columns % spacingDivisor !== 0) {
columns += 1;
}
return columns;
}
export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) { export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
const totalSeeds = playerCount * startingNodesPerPlayer; // Calculate spacing to place players equidistant from each other and edges
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds)); // Formula: space = columns / (playerCount + 1)
// This ensures equal spacing between players and from edges
const spacing = columns / (playerCount + 1);
return Array.from({ length: playerCount }, (_, playerId) => { return Array.from({ length: playerCount }, (_, playerId) => {
const start = playerId * startingNodesPerPlayer; const playerCenter = Math.round((playerId + 1) * spacing);
return positions
.slice(start, start + startingNodesPerPlayer) // For multiple seeds per player, spread them around the center
.map((column) => String(column + 1)) if (startingNodesPerPlayer === 1) {
.join(", "); return String(playerCenter + 1);
}
// For multiple seeds, alternate left and right from center
const positions = [];
for (let i = 0; i < startingNodesPerPlayer; i++) {
// Alternate: 0, +1, -1, +2, -2, etc.
const offset = i === 0 ? 0 : (i % 2 === 1 ? Math.ceil(i / 2) : -Math.ceil(i / 2));
const pos = Math.max(1, Math.min(columns, playerCenter + offset + 1));
positions.push(pos);
}
return positions.join(", ");
}); });
} }
@@ -80,19 +111,23 @@ export function createSetupState(
initiativeMode: SetupState["initiativeMode"] = "fixed", initiativeMode: SetupState["initiativeMode"] = "fixed",
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating", biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
weatherDraftEnabled = true, weatherDraftEnabled = true,
weatherDraftCount = playerCount + 2, weatherDraftCount = playerCount,
bankingEnabled = true,
winCondition: SetupState["winCondition"] = "rounds", winCondition: SetupState["winCondition"] = "rounds",
maxRounds = 12, maxRounds = 12,
topLeafTarget = 4, topLeafTarget = 4,
): SetupState { ): SetupState {
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); // Adjust columns to ensure even spacing between players and edges
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds); const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
const paletteDefaults = createDefaultPaletteOrder(playerCount); const paletteDefaults = createDefaultPaletteOrder(playerCount);
return { return {
playerCount, playerCount,
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`), playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
columns, columns: adjustedColumns,
rows, rows,
startingNodesPerPlayer: clampedSeeds, startingNodesPerPlayer: clampedSeeds,
sunbeamChance, sunbeamChance,
@@ -103,9 +138,10 @@ export function createSetupState(
biddingOrderRule, biddingOrderRule,
weatherDraftEnabled, weatherDraftEnabled,
weatherDraftCount: Math.max(1, weatherDraftCount), weatherDraftCount: Math.max(1, weatherDraftCount),
bankingEnabled,
winCondition, winCondition,
maxRounds, maxRounds,
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)), topLeafTarget: Math.max(1, Math.min(adjustedColumns, topLeafTarget)),
}; };
} }
@@ -193,6 +229,7 @@ export function createInitialState(setup: SetupState): GameState {
biddingOrderRule: setup.biddingOrderRule, biddingOrderRule: setup.biddingOrderRule,
weatherDraftEnabled: setup.weatherDraftEnabled, weatherDraftEnabled: setup.weatherDraftEnabled,
weatherDraftCount: setup.weatherDraftCount, weatherDraftCount: setup.weatherDraftCount,
bankingEnabled: setup.bankingEnabled,
winCondition: setup.winCondition, winCondition: setup.winCondition,
maxRounds: setup.maxRounds, maxRounds: setup.maxRounds,
topLeafTarget: setup.topLeafTarget, topLeafTarget: setup.topLeafTarget,

View File

@@ -26,6 +26,7 @@ export type SetupState = {
biddingOrderRule: "rotating" | "lowest_growth_income"; biddingOrderRule: "rotating" | "lowest_growth_income";
weatherDraftEnabled: boolean; weatherDraftEnabled: boolean;
weatherDraftCount: number; weatherDraftCount: number;
bankingEnabled: boolean;
winCondition: "rounds" | "top_leaves"; winCondition: "rounds" | "top_leaves";
maxRounds: number; maxRounds: number;
topLeafTarget: number; topLeafTarget: number;
@@ -55,6 +56,7 @@ export type GameConfig = {
biddingOrderRule: SetupState["biddingOrderRule"]; biddingOrderRule: SetupState["biddingOrderRule"];
weatherDraftEnabled: boolean; weatherDraftEnabled: boolean;
weatherDraftCount: number; weatherDraftCount: number;
bankingEnabled: boolean;
winCondition: SetupState["winCondition"]; winCondition: SetupState["winCondition"];
maxRounds: number; maxRounds: number;
topLeafTarget: number; topLeafTarget: number;
@@ -124,6 +126,7 @@ export type RootBurst = {
key: NodeKey; key: NodeKey;
playerId: PlayerId; playerId: PlayerId;
count: number; count: number;
displayCount: number;
}; };
export type EnergySimulation = { export type EnergySimulation = {
@@ -188,6 +191,7 @@ export type WeatherCardId =
| "leaf_surge" | "leaf_surge"
| "branching_season" | "branching_season"
| "storehouse" | "storehouse"
| "compound_interest"
| "sun_ladder" | "sun_ladder"
| "west_light" | "west_light"
| "east_light" | "east_light"
@@ -195,6 +199,7 @@ export type WeatherCardId =
| "edge_bloom" | "edge_bloom"
| "wide_reach" | "wide_reach"
| "tall_reward" | "tall_reward"
| "deep_roots"
| "stalemate" | "stalemate"
| "split_light"; | "split_light";

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@ let isNewGameModalOpen = false;
let previousScoreSnapshot: ScoreSnapshot[] | null = null; let previousScoreSnapshot: ScoreSnapshot[] | null = null;
let setupTab: "board" | "rules" | "events" | "players" = "board"; let setupTab: "board" | "rules" | "events" | "players" = "board";
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null; let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
let isDraftPanelDocked = false;
function rebuildSetup(overrides: Partial<SetupState> = {}) { function rebuildSetup(overrides: Partial<SetupState> = {}) {
setup = createSetupState( setup = createSetupState(
@@ -84,12 +85,32 @@ function rebuildSetup(overrides: Partial<SetupState> = {}) {
overrides.biddingOrderRule ?? setup.biddingOrderRule, overrides.biddingOrderRule ?? setup.biddingOrderRule,
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled, overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
overrides.weatherDraftCount ?? setup.weatherDraftCount, overrides.weatherDraftCount ?? setup.weatherDraftCount,
overrides.bankingEnabled ?? setup.bankingEnabled,
overrides.winCondition ?? setup.winCondition, overrides.winCondition ?? setup.winCondition,
overrides.maxRounds ?? setup.maxRounds, overrides.maxRounds ?? setup.maxRounds,
overrides.topLeafTarget ?? setup.topLeafTarget, overrides.topLeafTarget ?? setup.topLeafTarget,
); );
} }
function escapeHtml(value: unknown) {
return String(value).replace(/[&<>"']/g, (character) => {
switch (character) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case "\"":
return "&quot;";
case "'":
return "&#39;";
default:
return character;
}
});
}
function getLiveExposureScores() { function getLiveExposureScores() {
return buildEnergySimulation(state).scores; return buildEnergySimulation(state).scores;
} }
@@ -142,6 +163,14 @@ function getCurrentPlayer() {
return state.players[state.activePlayerId]; return state.players[state.activePlayerId];
} }
function getTreeOpacity(playerId: number) {
if (state.gameOver) {
return 1;
}
return playerId === state.activePlayerId ? 1 : 0.8;
}
function getPlayerById(playerId: number) { function getPlayerById(playerId: number) {
return state.players.find((player) => player.id === playerId) ?? null; return state.players.find((player) => player.id === playerId) ?? null;
} }
@@ -152,18 +181,19 @@ function getOrderedPlayers(playerIds: number[]) {
function getTurnLabel() { function getTurnLabel() {
if (state.phase === "initiative" && state.initiativeDraft) { if (state.phase === "initiative" && state.initiativeDraft) {
return `${getCurrentPlayer().name} drafts initiative`; return `${escapeHtml(getCurrentPlayer().name)} drafts initiative`;
} }
if (state.phase === "weather" && state.weatherDraft) { if (state.phase === "weather" && state.weatherDraft) {
return `${getCurrentPlayer().name} drafts weather`; return `${escapeHtml(getCurrentPlayer().name)} drafts weather`;
} }
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`; return state.gameOver ? "Game Over" : `${escapeHtml(getCurrentPlayer().name)}'s turn`;
} }
function isBankingEnabled() { function isBankingEnabled() {
return state.activeRoundEffects.includes("storehouse"); // Banking is enabled if setup allows it OR if storehouse effect is active
return state.config.bankingEnabled || state.activeRoundEffects.includes("storehouse");
} }
function awardGrowth(player: Player, amount: number) { function awardGrowth(player: Player, amount: number) {
@@ -393,6 +423,7 @@ function startWeatherDraft() {
} }
state.phase = "weather"; state.phase = "weather";
isDraftPanelDocked = false;
state.turnMoves = []; state.turnMoves = [];
updateSelection(null); updateSelection(null);
state.weatherDraft = createWeatherDraft(state); state.weatherDraft = createWeatherDraft(state);
@@ -402,6 +433,7 @@ function startWeatherDraft() {
function startInitiativeDraft() { function startInitiativeDraft() {
state.phase = "initiative"; state.phase = "initiative";
isDraftPanelDocked = false;
state.turnMoves = []; state.turnMoves = [];
updateSelection(null); updateSelection(null);
state.initiativeDraft = createInitiativeDraft(state); state.initiativeDraft = createInitiativeDraft(state);
@@ -516,26 +548,31 @@ function finalizeWeatherDraft() {
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") { function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
const draft = state.weatherDraft; const draft = state.weatherDraft;
if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) { const offer = draft?.offers.find((entry) => entry.id === offerId);
if (!draft || !offer) {
return; return;
} }
const offer = draft.offers.find((entry) => entry.id === offerId);
const otherCardId = offer?.options.find((option) => option !== cardId) ?? null; const otherCardId = offer?.options.find((option) => option !== cardId) ?? null;
const playerId = getCurrentWeatherPlayerId(draft); const playerId = getCurrentWeatherPlayerId(draft);
if (action === "draft") { if (action === "draft") {
if (!isWeatherCardAvailable(draft, offerId, cardId)) {
return;
}
const card = getWeatherCard(cardId); const card = getWeatherCard(cardId);
draft.drafted.push(cardId); draft.drafted.push(cardId);
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`); state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
if (otherCardId && !draft.locked.includes(otherCardId)) {
draft.locked.push(otherCardId);
}
} else { } else {
const card = getWeatherCard(cardId); offer.options.forEach((option) => {
draft.banned.push(cardId); if (!draft.banned.includes(option)) {
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`); draft.banned.push(option);
} }
});
if (otherCardId && !draft.locked.includes(otherCardId)) { state.history.unshift(`${state.players[playerId].name} banned both cards in an offer.`);
draft.locked.push(otherCardId);
} }
if (draft.draftIndex >= draft.playerOrder.length - 1) { if (draft.draftIndex >= draft.playerOrder.length - 1) {
@@ -736,6 +773,22 @@ async function endRound() {
player.roundScore = scores[index]; player.roundScore = scores[index];
player.totalScore += scores[index]; player.totalScore += scores[index];
player.bonusPoints = nextGrowth[index] - scores[index]; player.bonusPoints = nextGrowth[index] - scores[index];
// Apply banking effects from weather cards
const hasStorehouse = state.activeRoundEffects.includes("storehouse");
const hasCompoundInterest = state.activeRoundEffects.includes("compound_interest");
if (hasStorehouse) {
// Storehouse: Lose 1 banked energy (min 0)
player.bankedPoints = Math.max(0, player.bankedPoints - 1);
}
if (hasCompoundInterest) {
// Compound Interest: Gain 20% interest (rounded down)
const interest = Math.floor(player.bankedPoints * 0.2);
player.bankedPoints += interest;
}
player.growthPoints = player.bankedPoints; player.growthPoints = player.bankedPoints;
player.lifetimeGrowthIncome += nextGrowth[index]; player.lifetimeGrowthIncome += nextGrowth[index];
player.growthPoints += nextGrowth[index]; player.growthPoints += nextGrowth[index];
@@ -992,6 +1045,10 @@ function renderNewGameModal() {
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" /> <input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
</label> </label>
` : ""} ` : ""}
<label class="setup-field setup-field--checkbox">
<span class="setup-field__label">Enable Banking</span>
<input id="banking-toggle" type="checkbox" ${setup.bankingEnabled ? "checked" : ""} />
</label>
</div> </div>
</section> </section>
` : ""} ` : ""}
@@ -1021,7 +1078,7 @@ function renderNewGameModal() {
<div class="player-row"> <div class="player-row">
<div class="player-row__info"> <div class="player-row__info">
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span> <span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
<input class="player-name-input" data-player-id="${index}" type="text" value="${currentPlayer.name}" aria-label="${currentPlayer.name} name" /> <input class="player-name-input" data-player-id="${index}" type="text" value="${escapeHtml(currentPlayer.name)}" aria-label="${escapeHtml(currentPlayer.name)} name" />
</div> </div>
<div class="player-row__actions"> <div class="player-row__actions">
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button> <button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
@@ -1049,8 +1106,8 @@ function renderNewGameModal() {
<div class="player-list"> <div class="player-list">
${previewPlayers.map((currentPlayer, index) => ` ${previewPlayers.map((currentPlayer, index) => `
<label class="setup-field"> <label class="setup-field">
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span> <span class="setup-field__label" style="color: ${currentPlayer.color};">${escapeHtml(currentPlayer.name)}</span>
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" /> <input class="seed-input" data-player-id="${index}" type="text" value="${escapeHtml(setup.seedInputs[index] ?? "")}" placeholder="e.g. 2, 5" />
</label> </label>
`).join("")} `).join("")}
</div> </div>
@@ -1076,21 +1133,31 @@ function renderWeatherDraftModal() {
const currentPlayer = getCurrentWeatherDraftPlayer(); const currentPlayer = getCurrentWeatherDraftPlayer();
return ` return `
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title"> <section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="weather-title">
<div class="panel__title-row"> <div class="panel__title-row">
<div> <div>
<p class="eyebrow">Round ${state.round}</p> <p class="eyebrow">Round ${state.round}</p>
<h1 id="weather-title">Weather Draft</h1> <h1 id="weather-title">Weather Draft</h1>
</div> </div>
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
</div> </div>
<div class="seed-editor"> <div class="weather-draft-header">
<p class="seed-help">${currentPlayer?.name ?? "A player"} can draft or ban 1 card. Offered in pairs.</p> <p class="weather-draft-instructions">${escapeHtml(currentPlayer?.name ?? "A player")} can draft either card, or ban both cards in an offer.</p>
<div class="weather-key" aria-label="Weather action key"> <div class="weather-draft-actions">
<span><strong>☀ Draft</strong>: take that card for 1 round</span> <span class="weather-draft-action"><strong>☀ Draft</strong> - take that card for 1 round</span>
<span><strong>✕ Ban</strong>: remove just that card</span> <span class="weather-draft-action"><strong>✕ Ban Both</strong> - remove both cards in that offer</span>
</div> </div>
<div class="initiative-order-row"> <div class="weather-draft-order">
${getOrderedPlayers(draft.playerOrder).map((player, index) => `<span class="initiative-pill${index === draft.draftIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")} ${getOrderedPlayers(draft.playerOrder).map((player, index) => {
const isActive = index === draft.draftIndex;
const isNext = index === (draft.draftIndex + 1) % draft.playerOrder.length;
return `
<div class="weather-draft-player${isActive ? ' weather-draft-player--active' : ''}" style="--player-color: ${player.color};">
<span class="weather-draft-player__name">${escapeHtml(player.name)}</span>
${isNext ? '<span class="weather-draft-player__label">next draft</span>' : ''}
</div>
`;
}).join("")}
</div> </div>
</div> </div>
<div class="weather-grid"> <div class="weather-grid">
@@ -1100,7 +1167,8 @@ function renderWeatherDraftModal() {
<article class="weather-card${resolved ? " weather-card--resolved" : ""}"> <article class="weather-card${resolved ? " weather-card--resolved" : ""}">
<div> <div>
<p class="eyebrow">Offer</p> <p class="eyebrow">Offer</p>
<div class="weather-pair"> <div class="weather-offer-layout">
<div class="weather-pair">
${offer.options.map((cardId, optionIndex) => { ${offer.options.map((cardId, optionIndex) => {
const card = getWeatherCard(cardId); const card = getWeatherCard(cardId);
const drafted = draft.drafted.includes(cardId); const drafted = draft.drafted.includes(cardId);
@@ -1117,15 +1185,13 @@ function renderWeatherDraftModal() {
<span class="weather-action__icon" aria-hidden="true">☀</span> <span class="weather-action__icon" aria-hidden="true">☀</span>
<span><strong>Draft</strong></span> <span><strong>Draft</strong></span>
</button> </button>
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
<span class="weather-action__icon" aria-hidden="true">✕</span>
<span><strong>Ban</strong></span>
</button>
</div> </div>
` : `<p class="weather-card__status">${drafted ? "Drafted" : banned ? "Banned" : "Locked"}</p>`} ` : `<p class="weather-card__status">${drafted ? "Drafted" : banned ? "Banned" : "Locked"}</p>`}
</div> </div>
`; `;
}).join("")} }).join("")}
</div>
${!resolved ? `<button class="weather-action weather-action--ban-both" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${offer.options[0]}"><span class="weather-action__icon" aria-hidden="true">✕</span><span><strong>Ban Both</strong></span></button>` : ""}
</div> </div>
</div> </div>
</article> </article>
@@ -1147,22 +1213,23 @@ function renderInitiativeModal() {
const initiativeBonusStatus = getInitiativeBonusStatus(); const initiativeBonusStatus = getInitiativeBonusStatus();
return ` return `
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title"> <section class="draft-panel panel${isDraftPanelDocked ? " draft-panel--docked" : ""}" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
<div class="panel__title-row"> <div class="panel__title-row">
<div> <div>
<p class="eyebrow">Round ${state.round}</p> <p class="eyebrow">Round ${state.round}</p>
<h1 id="initiative-title">Initiative Draft</h1> <h1 id="initiative-title">Initiative Draft</h1>
</div> </div>
<button class="ghost-button draft-panel__toggle" id="draft-panel-toggle" aria-label="${isDraftPanelDocked ? "Expand draft panel" : "Dock draft panel"}">${isDraftPanelDocked ? "←" : "→"}</button>
</div> </div>
<div class="seed-editor"> <div class="seed-editor">
<p class="seed-help">${currentBidder?.name ?? "A player"} chooses a seat. Earlier seats gain extra growth before the round begins.</p> <p class="seed-help">${escapeHtml(currentBidder?.name ?? "A player")} chooses a seat. Earlier seats gain extra growth before the round begins.</p>
<p class="initiative-bonus-note ${initiativeBonusStatus.bonusActive ? "initiative-bonus-note--active" : ""}"> <p class="initiative-bonus-note ${initiativeBonusStatus.bonusActive ? "initiative-bonus-note--active" : ""}">
${initiativeBonusStatus.bonusActive ${initiativeBonusStatus.bonusActive
? "Seat bonuses are active: Seat 1 gains +1 growth this round." ? "Seat bonuses are active: Seat 1 gains +1 growth this round."
: `Seat bonuses start in ${initiativeBonusStatus.roundsRemaining} round${initiativeBonusStatus.roundsRemaining === 1 ? "" : "s"}.`} : `Seat bonuses start in ${initiativeBonusStatus.roundsRemaining} round${initiativeBonusStatus.roundsRemaining === 1 ? "" : "s"}.`}
</p> </p>
<div class="initiative-order-row"> <div class="initiative-order-row">
${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${player.name}</span>`).join("")} ${orderedBidders.map((player, index) => `<span class="initiative-pill${index === draft.biddingIndex ? " initiative-pill--active" : ""}" style="--player-color: ${player.color};">${escapeHtml(player.name)}</span>`).join("")}
</div> </div>
</div> </div>
<div class="initiative-seat-grid"> <div class="initiative-seat-grid">
@@ -1174,7 +1241,7 @@ function renderInitiativeModal() {
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}> <button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
<strong>Seat ${seatIndex + 1}</strong> <strong>Seat ${seatIndex + 1}</strong>
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span> <span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span> <span>${assignedPlayer ? escapeHtml(assignedPlayer.name) : "Open"}</span>
</button> </button>
`; `;
}).join("")} }).join("")}
@@ -1197,7 +1264,7 @@ function renderScoreboard() {
<div class="score-card__head"> <div class="score-card__head">
<div class="score-card__identity"> <div class="score-card__identity">
<span class="player-dot"></span> <span class="player-dot"></span>
<h2>${player.name}</h2> <h2>${escapeHtml(player.name)}</h2>
</div> </div>
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span> <span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
</div> </div>
@@ -1208,7 +1275,7 @@ function renderScoreboard() {
</div> </div>
<div> <div>
<span>Energy</span> <span>Energy</span>
<strong class="${growthChanged ? "score-value changed" : "score-value"}">${player.growthPoints}</strong> <strong class="${growthChanged ? "score-value changed" : "score-value"}" data-energy-score="${player.id}">${player.growthPoints}</strong>
</div> </div>
<div> <div>
<span>Bank</span> <span>Bank</span>
@@ -1262,14 +1329,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
}); });
}).join("") : ""; }).join("") : "";
const roots = state.animation.rootBursts.map((burst) => {
const player = state.players[burst.playerId];
const root = parseKey(burst.key);
const x = ((root.column + 0.5) / columns) * 100;
const y = ((root.row + 0.5) / rows) * 100;
return `<g class="board__root-burst" style="--trace-delay: 300ms; --burst-color: ${player.color};" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+${burst.count}</text></g>`;
}).join("");
const disease = state.animation.diseaseKeys.map((key, index) => { const disease = state.animation.diseaseKeys.map((key, index) => {
const node = parseKey(key); const node = parseKey(key);
const x = ((node.column + 0.5) / columns) * 100; const x = ((node.column + 0.5) / columns) * 100;
@@ -1298,15 +1357,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`; return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
}).join(""); }).join("");
const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined
? ""
: (() => {
const root = parseKey(state.animation?.bonusBurst?.key as string);
const x = ((root.column + 0.5) / columns) * 100;
const y = ((root.row + 0.5) / rows) * 100;
return `<g class="board__sunbeam-burst" transform="translate(${x} ${y})"><circle r="2.5"></circle><text text-anchor="middle" dominant-baseline="central">+1</text></g>`;
})();
return ` return `
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true"> <div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
${state.animation.phase === "bonus" ? bonusSunbeam : ""} ${state.animation.phase === "bonus" ? bonusSunbeam : ""}
@@ -1317,13 +1367,103 @@ function renderAnimationOverlay(columns: number, rows: number) {
${state.animation.phase === "bonus" ? bonusFlashes : ""} ${state.animation.phase === "bonus" ? bonusFlashes : ""}
</div> </div>
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"> <svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
${roots}
${disease} ${disease}
${sunbeam}
</svg> </svg>
`; `;
} }
function renderScoreFlightOverlay() {
if (!state.animation || (state.animation.phase !== "branches" && state.animation.phase !== "bonus")) {
return "";
}
const rootBursts = state.animation.phase === "branches"
? state.animation.rootBursts.map((burst, index) => `
<div
class="score-flight-badge"
data-root-key="${burst.key}"
data-player-id="${burst.playerId}"
data-flight-order="${index}"
style="--burst-color: ${state.players[burst.playerId].color};"
>
+${burst.displayCount}
</div>
`).join("")
: "";
const bonusBurst = state.animation.phase === "bonus" && state.animation.bonusBurst
? `
<div
class="score-flight-badge score-flight-badge--bonus"
data-root-key="${state.animation.bonusBurst.key}"
data-player-id="${state.animation.bonusBurst.playerId}"
data-flight-order="${state.animation.rootBursts.length}"
style="--burst-color: #ffd85e;"
>
+1
</div>
`
: "";
return `<div class="score-flight-layer" aria-hidden="true">${rootBursts}${bonusBurst}</div>`;
}
function pulseEnergyScore(playerId: number, delayMs: number) {
window.setTimeout(() => {
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
if (!targetValue) {
return;
}
targetValue.classList.remove("score-value--landing");
void targetValue.offsetWidth;
targetValue.classList.add("score-value--landing");
}, delayMs);
}
function positionScoreFlightBadges() {
const flightLayer = document.querySelector<HTMLElement>(".score-flight-layer");
if (!flightLayer) {
return;
}
const layerRect = flightLayer.getBoundingClientRect();
const badges = Array.from(flightLayer.querySelectorAll<HTMLElement>(".score-flight-badge"));
badges.forEach((badge, index) => {
const rootKey = badge.dataset.rootKey;
const playerId = Number(badge.dataset.playerId);
if (!rootKey || Number.isNaN(playerId)) {
return;
}
const { row, column } = parseKey(rootKey);
const sourceCell = document.querySelector<HTMLElement>(`.cell[data-row="${row}"][data-column="${column}"]`);
const targetValue = document.querySelector<HTMLElement>(`[data-energy-score="${playerId}"]`);
if (!sourceCell || !targetValue) {
return;
}
const sourceRect = sourceCell.getBoundingClientRect();
const targetRect = targetValue.getBoundingClientRect();
const startX = sourceRect.left + sourceRect.width / 2;
const startY = sourceRect.top + sourceRect.height / 2;
const endX = targetRect.left + targetRect.width / 2;
const endY = targetRect.top + targetRect.height / 2;
const deltaX = endX - startX;
const deltaY = endY - startY;
badge.style.left = `${startX}px`;
badge.style.top = `${startY}px`;
badge.style.setProperty("--flight-x", `${deltaX}px`);
badge.style.setProperty("--flight-y", `${deltaY}px`);
badge.style.setProperty("--flight-mid-x", `${deltaX * 0.55}px`);
badge.style.setProperty("--flight-mid-y", `${deltaY - 42}px`);
badge.style.setProperty("--flight-delay", `${index * 260}ms`);
pulseEnergyScore(playerId, index * 260 + 2200);
});
}
function renderBoard() { function renderBoard() {
const columns = state.config.columns; const columns = state.config.columns;
const rows = state.config.rows; const rows = state.config.rows;
@@ -1331,12 +1471,13 @@ function renderBoard() {
const parentMap = buildParentMap(); const parentMap = buildParentMap();
const lines = state.edges.map((edge) => { const lines = state.edges.map((edge) => {
const player = state.players[edge.ownerId]; const player = state.players[edge.ownerId];
const opacity = getTreeOpacity(edge.ownerId);
const x1 = ((edge.from.column + 0.5) / columns) * 100; const x1 = ((edge.from.column + 0.5) / columns) * 100;
const y1 = ((edge.from.row + 0.5) / rows) * 100; const y1 = ((edge.from.row + 0.5) / rows) * 100;
const x2 = ((edge.to.column + 0.5) / columns) * 100; const x2 = ((edge.to.column + 0.5) / columns) * 100;
const y2 = ((edge.to.row + 0.5) / rows) * 100; const y2 = ((edge.to.row + 0.5) / rows) * 100;
return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-width="0.9" stroke-linecap="round" />`; return `<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%" stroke="${player.color}" stroke-opacity="${opacity}" stroke-width="0.9" stroke-linecap="round" />`;
}).join(""); }).join("");
const cells = Array.from({ length: rows }, (_, row) => { const cells = Array.from({ length: rows }, (_, row) => {
@@ -1351,13 +1492,14 @@ function renderBoard() {
const background = columnLeader.ownerId === null || columnLeader.tied const background = columnLeader.ownerId === null || columnLeader.tied
? "transparent" ? "transparent"
: tint(state.players[columnLeader.ownerId].color); : tint(state.players[columnLeader.ownerId].color);
const treeOpacity = player ? getTreeOpacity(player.id) : 1;
return ` return `
<button <button
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}" class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
data-row="${row}" data-row="${row}"
data-column="${column}" data-column="${column}"
style="--column-tint: ${background}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}" style="--column-tint: ${background}; --tree-opacity: ${treeOpacity}; ${player ? `--node-color: ${player.color}; --node-glow: ${player.glow};` : ""}"
${isInteractionLocked() ? "disabled" : ""} ${isInteractionLocked() ? "disabled" : ""}
> >
<span class="cell__shade"></span> <span class="cell__shade"></span>
@@ -1386,18 +1528,18 @@ function renderSidebar() {
const player = getCurrentPlayer(); const player = getCurrentPlayer();
const rootShiftMoves = getSelectedRootShiftMoves(); const rootShiftMoves = getSelectedRootShiftMoves();
const boardLocked = isInteractionLocked(); const boardLocked = isInteractionLocked();
const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${orderedPlayer.name}`).join(" | "); const turnOrderSummary = getOrderedPlayers(state.turnOrder).map((orderedPlayer, index) => `${index + 1}. ${escapeHtml(orderedPlayer.name)}`).join(" | ");
const activeEffectsMarkup = state.activeRoundEffects.length > 0 const activeEffectsMarkup = state.activeRoundEffects.length > 0
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => { ? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
const card = getWeatherCard(cardId); const card = getWeatherCard(cardId);
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span><span class="effect-chip__rule">${card?.description ?? ""}</span></div>`; return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span><span class="effect-chip__rule">${card?.description ?? ""}</span></div>`;
}).join("")}</div>` }).join("")}</div>`
: `<p class="effect-empty">No weather effects active.</p>`; : `<p class="effect-empty">No weather effects active.</p>`;
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${state.players[index].name}: ${score}`).join(" | "); const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${escapeHtml(state.players[index].name)}: ${score}`).join(" | ");
const phaseHint = state.phase === "initiative" const phaseHint = state.phase === "initiative"
? "Choose a seat for this round." ? "Choose a seat for this round."
: state.phase === "weather" : state.phase === "weather"
? "Draft one card or ban one card." ? "Draft one card or ban both cards in an offer."
: state.gameOver : state.gameOver
? "Final totals are locked." ? "Final totals are locked."
: `${player.growthPoints} energy. Click a selected pending node again to undo.`; : `${player.growthPoints} energy. Click a selected pending node again to undo.`;
@@ -1440,7 +1582,7 @@ function renderSidebar() {
<p>Vertical growth costs 1. Diagonal growth costs 2.</p> <p>Vertical growth costs 1. Diagonal growth costs 2.</p>
<p>Click a selected pending node again to undo back through it.</p> <p>Click a selected pending node again to undo back through it.</p>
${state.round === 1 ? `<p>Roots can shift left or right for ${ROOT_SHIFT_COST} during round 1.</p>` : ""} ${state.round === 1 ? `<p>Roots can shift left or right for ${ROOT_SHIFT_COST} during round 1.</p>` : ""}
${isBankingEnabled() ? `<p>Storehouse is active, so banking is enabled this round.</p>` : `<p>Banking is disabled unless Storehouse is active.</p>`} ${isBankingEnabled() ? `<p>Banking is enabled. Bank your remaining energy to save it for next round.</p>` : `<p>Banking is disabled in this game.</p>`}
</div> </div>
</details> </details>
<details class="accordion"> <details class="accordion">
@@ -1465,7 +1607,7 @@ function renderSidebar() {
<details class="accordion"> <details class="accordion">
<summary>Round Log</summary> <summary>Round Log</summary>
<div class="accordion__content log-list"> <div class="accordion__content log-list">
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")} ${state.history.slice(0, 8).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("")}
</div> </div>
</details> </details>
</section> </section>
@@ -1528,6 +1670,10 @@ function attachEvents() {
document.querySelector("#end-turn")?.addEventListener("click", endTurn); document.querySelector("#end-turn")?.addEventListener("click", endTurn);
document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn); document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
document.querySelector("#finish-game")?.addEventListener("click", finishGameNow); document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
document.querySelector("#draft-panel-toggle")?.addEventListener("click", () => {
isDraftPanelDocked = !isDraftPanelDocked;
render();
});
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => { document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) }); rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
render(); render();
@@ -1582,6 +1728,10 @@ function attachEvents() {
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked; setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
render(); render();
}); });
document.querySelector<HTMLInputElement>("#banking-toggle")?.addEventListener("change", (event) => {
setup.bankingEnabled = (event.currentTarget as HTMLInputElement).checked;
render();
});
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => { document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) }); rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
render(); render();
@@ -1679,11 +1829,15 @@ function render() {
${renderScoreboard()} ${renderScoreboard()}
</footer> </footer>
</main> </main>
${renderScoreFlightOverlay()}
${renderNewGameModal()} ${renderNewGameModal()}
${renderInitiativeModal()} ${renderInitiativeModal()}
${renderWeatherDraftModal()} ${renderWeatherDraftModal()}
`; `;
attachEvents(); attachEvents();
requestAnimationFrame(() => {
positionScoreFlightBadges();
});
previousScoreSnapshot = getScoreSnapshot(); previousScoreSnapshot = getScoreSnapshot();
} }

File diff suppressed because it is too large Load Diff