Compare commits

..

4 Commits

Author SHA1 Message Date
c4da8c942e Fix weather draft and deploy setup 2026-04-29 12:00:56 -04:00
f071837ed6 more card rule tweaks 2026-04-10 16:13:45 -04:00
856f0049b7 fix draft ui 2026-04-10 16:12:41 -04:00
30e3f88b21 Refine setup and draft interactions 2026-04-10 13:36:43 -04:00
17 changed files with 3763 additions and 3028 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,8 +1,12 @@
import type { GameState, InitiativeDraftState, PlayerId } from "./types"; import type { GameState, InitiativeDraftState, PlayerId } from "./types";
export function getInitiativeGraceRounds(state: GameState) {
return Math.max(0, (state.config.columns / state.players.length) - state.players.length);
}
export function getSeatBonuses(state: GameState) { export function getSeatBonuses(state: GameState) {
const graceRounds = Math.max(0, Math.floor(state.config.columns / state.players.length) - state.players.length); const graceRounds = getInitiativeGraceRounds(state);
const firstSeatBonus = state.round <= graceRounds ? 0 : 1; const firstSeatBonus = state.round - 1 < graceRounds ? 0 : 1;
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0)); return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
} }

453
src/engine/rules-scoring.ts Normal file
View File

@@ -0,0 +1,453 @@
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] += 1;
});
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") {
// Find the player with the most leaves
let maxLeaves = 0;
let playerWithMostLeaves = -1;
leafCounts.forEach((count, playerId) => {
scores[playerId] += Math.max(0, count - 1);
if (count > maxLeaves) {
maxLeaves = count;
playerWithMostLeaves = playerId;
}
});
// Give the player with most leaves 50% more energy (rounded down)
if (playerWithMostLeaves !== -1 && maxLeaves > 1) {
const bonus = Math.floor((maxLeaves - 1) * 0.5);
scores[playerWithMostLeaves] += bonus;
}
return;
}
if (effectId === "tall_reward" || effectId === "deep_roots") {
const bestRow = tallestLeaves.reduce<number | null>(
(currentBest, row) => {
if (row === null) {
return currentBest;
}
if (
(effectId === "deep_roots" &&
(currentBest === null || row > currentBest)) ||
(effectId === "tall_reward" &&
(currentBest === null || row < currentBest))
) {
return row;
}
return currentBest;
},
null,
);
if (bestRow !== null) {
tallestLeaves.forEach((row, playerId) => {
if (row === bestRow) {
scores[playerId] += effectId === "deep_roots" ? 4 : 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,
displayCount: 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 });
const playerRootTotals = state.players.map(() => 0);
rootBursts.forEach((burst) => {
playerRootTotals[burst.playerId] += burst.count;
});
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(
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.`,
};
}

View File

@@ -0,0 +1,83 @@
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState, WeatherOfferPair } from "./types";
import { shuffleArray } from "./utils";
export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ 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. Player with most branches gets 50% more energy (rounded down)." },
{ 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: "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: "high_noon", title: "High Noon", description: "Center third energy gets +50%, rounded up." },
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
{ 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: "deep_roots", title: "Deep Roots", description: "Shortest plant receives +4 energy." },
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
{ 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>(
WEATHER_CARDS.map((card) => [card.id, card]),
);
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] },
{ id: "banking_mix", options: ["storehouse", "compound_interest"] },
{ id: "tempo", options: ["sun_ladder", "edge_bloom"] },
{ id: "side_bias", options: ["west_light", "east_light"] },
{ id: "shape", options: ["high_noon", "wide_reach"] },
{ id: "height", options: ["tall_reward", "deep_roots"] },
{ id: "contest_soft", options: ["stalemate", "split_light"] },
];
export function createWeatherDraft(state: GameState): WeatherDraftState {
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 {
playerOrder: rotatedOrder,
draftIndex: 0,
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
drafted: [],
banned: [],
locked: [],
};
}
export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
return draft.playerOrder[draft.draftIndex] as PlayerId;
}
export function isWeatherCardAvailable(draft: WeatherDraftState, offerId: string, cardId: WeatherCardId) {
const offer = draft.offers.find((entry) => entry.id === offerId);
if (!offer) {
return false;
}
return offer.options.includes(cardId)
&& !draft.banned.includes(cardId)
&& !draft.drafted.includes(cardId)
&& !draft.locked.includes(cardId);
}
export function isWeatherOfferResolved(draft: WeatherDraftState, offerId: string) {
const offer = draft.offers.find((entry) => entry.id === offerId);
if (!offer) {
return true;
}
return offer.options.some((cardId) => draft.banned.includes(cardId) || draft.drafted.includes(cardId));
}
export function getWeatherCard(cardId: WeatherCardId) {
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
}

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(", ");
}); });
} }
@@ -68,7 +99,8 @@ export function getMaxStartingNodesPerPlayer(playerCount: number, columns: numbe
} }
export function createSetupState( export function createSetupState(
playerCount = 3, playerCount = 2,
playerNames: string[] | null = null,
columns = 18, columns = 18,
rows = 16, rows = 16,
startingNodesPerPlayer = 1, startingNodesPerPlayer = 1,
@@ -79,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 {
console.log("[DEBUG] createSetupState started"); // Adjust columns to ensure even spacing between players and edges
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
const paletteDefaults = createDefaultPaletteOrder(playerCount); const paletteDefaults = createDefaultPaletteOrder(playerCount);
const result = { return {
playerCount, playerCount,
columns, playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
columns: adjustedColumns,
rows, rows,
startingNodesPerPlayer: clampedSeeds, startingNodesPerPlayer: clampedSeeds,
sunbeamChance, sunbeamChance,
@@ -102,20 +138,23 @@ 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)),
}; };
console.log("[DEBUG] createSetupState completed");
return result;
} }
export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] { export function createPlayers(
playerCount: number,
paletteOrder = createDefaultPaletteOrder(playerCount),
playerNames: string[] = Array.from({ length: playerCount }, (_, index) => `Player ${index + 1}`),
): Player[] {
return Array.from({ length: playerCount }, (_, index) => { return Array.from({ length: playerCount }, (_, index) => {
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length]; const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
return { return {
id: index, id: index,
name: `Player ${index + 1}`, name: playerNames[index] ?? `Player ${index + 1}`,
color: palette.primary, color: palette.primary,
glow: palette.glow, glow: palette.glow,
totalScore: 0, totalScore: 0,
@@ -167,9 +206,8 @@ export function normalizeSeedInputs(setup: SetupState) {
} }
export function createInitialState(setup: SetupState): GameState { export function createInitialState(setup: SetupState): GameState {
console.log("[DEBUG] createInitialState started");
const playerPaletteOrder = [...setup.paletteOrder]; const playerPaletteOrder = [...setup.paletteOrder];
const players = createPlayers(setup.playerCount, playerPaletteOrder); const players = createPlayers(setup.playerCount, playerPaletteOrder, setup.playerNames);
const turnOrder = players.map((player) => player.id); const turnOrder = players.map((player) => player.id);
const nodes = new Map(); const nodes = new Map();
const edges = []; const edges = [];
@@ -180,8 +218,6 @@ export function createInitialState(setup: SetupState): GameState {
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index }); nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
}); });
}); });
console.log("[DEBUG] createInitialState completed");
return { return {
config: { config: {
columns: setup.columns, columns: setup.columns,
@@ -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

@@ -14,6 +14,7 @@ export type Position = {
export type SetupState = { export type SetupState = {
playerCount: number; playerCount: number;
playerNames: string[];
columns: number; columns: number;
rows: number; rows: number;
startingNodesPerPlayer: number; startingNodesPerPlayer: number;
@@ -25,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;
@@ -54,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;
@@ -123,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 = {
@@ -164,7 +168,7 @@ export type RoundSummary = {
}; };
export type ScoreSnapshot = { export type ScoreSnapshot = {
currentExposure: number; projectedIncome: number;
growthPoints: number; growthPoints: number;
bankedPoints: number; bankedPoints: number;
lifetimeGrowthIncome: number; lifetimeGrowthIncome: number;
@@ -187,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"
@@ -194,9 +199,9 @@ export type WeatherCardId =
| "edge_bloom" | "edge_bloom"
| "wide_reach" | "wide_reach"
| "tall_reward" | "tall_reward"
| "deep_roots"
| "stalemate" | "stalemate"
| "split_light" | "split_light";
| "shared_light";
export type WeatherCardDefinition = { export type WeatherCardDefinition = {
id: WeatherCardId; id: WeatherCardId;
@@ -204,6 +209,11 @@ export type WeatherCardDefinition = {
description: string; description: string;
}; };
export type WeatherOfferPair = {
id: string;
options: [WeatherCardId, WeatherCardId];
};
export type InitiativeDraftState = { export type InitiativeDraftState = {
biddingOrder: PlayerId[]; biddingOrder: PlayerId[];
biddingIndex: number; biddingIndex: number;
@@ -214,9 +224,10 @@ export type InitiativeDraftState = {
export type WeatherDraftState = { export type WeatherDraftState = {
playerOrder: PlayerId[]; playerOrder: PlayerId[];
draftIndex: number; draftIndex: number;
row: WeatherCardId[]; offers: WeatherOfferPair[];
drafted: WeatherCardId[]; drafted: WeatherCardId[];
banned: WeatherCardId[]; banned: WeatherCardId[];
locked: WeatherCardId[];
}; };
export type GameState = { export type GameState = {

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import "./styles.css"; import "./styles/globals.css";
import { import {
ROOT_SHIFT_COST, ROOT_SHIFT_COST,
ROUND_ANIMATION_BONUS_MS, ROUND_ANIMATION_BONUS_MS,
ROUND_ANIMATION_BRANCH_MS, ROUND_ANIMATION_BRANCH_MS,
ROUND_ANIMATION_SUN_MS, ROUND_ANIMATION_SUN_MS,
} from "./constants"; } from "./engine/constants";
import { import {
buildChildrenMap as buildChildrenMapForState, buildChildrenMap as buildChildrenMapForState,
buildParentMap as buildParentMapForState, buildParentMap as buildParentMapForState,
@@ -14,31 +14,34 @@ import {
getNodeOwner as getNodeOwnerForState, getNodeOwner as getNodeOwnerForState,
getRootShiftMove as getRootShiftMoveForState, getRootShiftMove as getRootShiftMoveForState,
playerHasLegalMove as playerHasLegalMoveForState, playerHasLegalMove as playerHasLegalMoveForState,
} from "./rules-board"; } from "./engine/rules-board";
import { import {
buildEnergySimulation, buildEnergySimulation,
buildRoundAnimation as buildRoundAnimationForState, buildRoundAnimation as buildRoundAnimationForState,
maybeRollDisease as maybeRollDiseaseForState, maybeRollDisease as maybeRollDiseaseForState,
maybeRollSunbeam as maybeRollSunbeamForState, maybeRollSunbeam as maybeRollSunbeamForState,
scoreColumns as scoreColumnsForState, scoreColumns as scoreColumnsForState,
} from "./rules-scoring"; } from "./engine/rules-scoring";
import { import {
createInitiativeDraft, createInitiativeDraft,
} from "./rules-initiative"; getInitiativeGraceRounds,
} from "./engine/rules-initiative";
import { import {
WEATHER_CARDS, WEATHER_OFFER_PAIRS,
createWeatherDraft, createWeatherDraft,
getCurrentWeatherPlayerId, getCurrentWeatherPlayerId,
getWeatherCard, getWeatherCard,
isWeatherCardAvailable, isWeatherCardAvailable,
} from "./rules-weather"; isWeatherOfferResolved,
} from "./engine/rules-weather";
import { import {
createInitialState, createInitialState,
createPlayers, createPlayers,
createRandomizedSeedInputs, createRandomizedSeedInputs,
createSetupState, createSetupState,
getMaxStartingNodesPerPlayer, getMaxStartingNodesPerPlayer,
} from "./state"; normalizeSeedInputs,
} from "./engine/state";
import type { import type {
GameState, GameState,
GrowTarget, GrowTarget,
@@ -49,8 +52,8 @@ import type {
ShiftMove, ShiftMove,
TurnMove, TurnMove,
WeatherCardId, WeatherCardId,
} from "./types"; } from "./engine/types";
import { keyFor, parseKey, tint, wait } from "./utils"; import { keyFor, parseKey, tint, wait } from "./engine/utils";
const app = document.querySelector("#app"); const app = document.querySelector("#app");
@@ -64,10 +67,13 @@ let state: GameState = createInitialState(setup);
let isNewGameModalOpen = false; 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 isDraftPanelDocked = false;
function rebuildSetup(overrides: Partial<SetupState> = {}) { function rebuildSetup(overrides: Partial<SetupState> = {}) {
setup = createSetupState( setup = createSetupState(
overrides.playerCount ?? setup.playerCount, overrides.playerCount ?? setup.playerCount,
overrides.playerNames ?? setup.playerNames,
overrides.columns ?? setup.columns, overrides.columns ?? setup.columns,
overrides.rows ?? setup.rows, overrides.rows ?? setup.rows,
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer, overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
@@ -79,16 +85,40 @@ 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;
} }
function getProjectedIncomeScores() {
return getLiveExposureScores().map((score) => score + 1);
}
function getTopLeafCount() { function getTopLeafCount() {
const childrenMap = buildChildrenMap(); const childrenMap = buildChildrenMap();
let count = 0; let count = 0;
@@ -120,9 +150,9 @@ function getWinConditionSummary() {
} }
function getScoreSnapshot() { function getScoreSnapshot() {
const exposureScores = getLiveExposureScores(); const projectedIncomeScores = getProjectedIncomeScores();
return state.players.map((player, index) => ({ return state.players.map((player, index) => ({
currentExposure: exposureScores[index], projectedIncome: projectedIncomeScores[index],
growthPoints: player.growthPoints, growthPoints: player.growthPoints,
bankedPoints: player.bankedPoints, bankedPoints: player.bankedPoints,
lifetimeGrowthIncome: player.lifetimeGrowthIncome, lifetimeGrowthIncome: player.lifetimeGrowthIncome,
@@ -133,24 +163,37 @@ 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) {
return state.players.find((player) => player.id === playerId) ?? null;
}
function getOrderedPlayers(playerIds: number[]) { function getOrderedPlayers(playerIds: number[]) {
return playerIds.map((playerId) => state.players[playerId]); return playerIds.map((playerId) => state.players[playerId]);
} }
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) {
@@ -170,6 +213,17 @@ function getCurrentBiddingPlayer() {
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]]; return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
} }
function getInitiativeBonusStatus() {
const graceRounds = getInitiativeGraceRounds(state);
const roundsRemaining = Math.max(0, Math.ceil(graceRounds - (state.round - 1)));
const bonusActive = roundsRemaining === 0;
return {
bonusActive,
roundsRemaining,
};
}
function getCurrentWeatherDraftPlayer() { function getCurrentWeatherDraftPlayer() {
if (!state.weatherDraft) { if (!state.weatherDraft) {
return null; return null;
@@ -212,6 +266,13 @@ function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
return moves; return moves;
} }
const freeVerticalMovesUsed = state.turnMoves.filter((move) => move.type === "grow" && move.cost === 0).length;
const freeVerticalMovesRemaining = Math.max(0, 3 - freeVerticalMovesUsed);
if (freeVerticalMovesRemaining <= 0) {
return moves;
}
return moves.map((move) => move.direction === "vertical" return moves.map((move) => move.direction === "vertical"
? { ...move, cost: Math.max(0, move.cost - 1) } ? { ...move, cost: Math.max(0, move.cost - 1) }
: move); : move);
@@ -362,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);
@@ -371,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);
@@ -409,15 +472,17 @@ function finalizeInitiativeDraft() {
nextTurnOrder.forEach((playerId, seatIndex) => { nextTurnOrder.forEach((playerId, seatIndex) => {
const bonus = draft.seatBonuses[seatIndex] ?? 0; const bonus = draft.seatBonuses[seatIndex] ?? 0;
if (bonus > 0) { const player = getPlayerById(playerId);
awardGrowth(state.players[playerId], bonus); if (bonus > 0 && player) {
awardGrowth(player, bonus);
} }
}); });
const seatSummary = nextTurnOrder const seatSummary = nextTurnOrder
.map((playerId, seatIndex) => { .map((playerId, seatIndex) => {
const bonus = draft.seatBonuses[seatIndex] ?? 0; const bonus = draft.seatBonuses[seatIndex] ?? 0;
return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`; const player = getPlayerById(playerId);
return `${player?.name ?? `Player ${playerId + 1}`}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
}) })
.join(" | "); .join(" | ");
@@ -434,7 +499,7 @@ function chooseInitiativeSeat(seatIndex: number) {
const playerId = draft.biddingOrder[draft.biddingIndex]; const playerId = draft.biddingOrder[draft.biddingIndex];
draft.seatAssignments[seatIndex] = playerId; draft.seatAssignments[seatIndex] = playerId;
state.history.unshift(`${state.players[playerId].name} claimed seat ${seatIndex + 1} for round ${state.round}.`); state.history.unshift(`${getPlayerById(playerId)?.name ?? `Player ${playerId + 1}`} claimed seat ${seatIndex + 1} for round ${state.round}.`);
if (draft.biddingIndex >= draft.biddingOrder.length - 1) { if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
finalizeInitiativeDraft(); finalizeInitiativeDraft();
@@ -481,20 +546,33 @@ function finalizeWeatherDraft() {
moveToFirstPlayableTurn(); moveToFirstPlayableTurn();
} }
function chooseWeatherAction(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, cardId)) { const offer = draft?.offers.find((entry) => entry.id === offerId);
if (!draft || !offer) {
return;
}
const otherCardId = offer?.options.find((option) => option !== cardId) ?? null;
const playerId = getCurrentWeatherPlayerId(draft);
if (action === "draft") {
if (!isWeatherCardAvailable(draft, offerId, cardId)) {
return; return;
} }
const playerId = getCurrentWeatherPlayerId(draft);
const card = getWeatherCard(cardId); const card = getWeatherCard(cardId);
if (action === "draft") {
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 {
draft.banned.push(cardId); offer.options.forEach((option) => {
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`); if (!draft.banned.includes(option)) {
draft.banned.push(option);
}
});
state.history.unshift(`${state.players[playerId].name} banned both cards in an offer.`);
} }
if (draft.draftIndex >= draft.playerOrder.length - 1) { if (draft.draftIndex >= draft.playerOrder.length - 1) {
@@ -695,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];
@@ -790,6 +884,45 @@ function moveSetupPlayer(fromIndex: number, toIndex: number) {
[setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]]; [setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]];
[setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]]; [setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]];
[setup.playerNames[fromIndex], setup.playerNames[toIndex]] = [setup.playerNames[toIndex], setup.playerNames[fromIndex]];
render();
}
function getSetupSeedColumns() {
return normalizeSeedInputs(setup).map((columns) => [...columns]);
}
function setSetupSeedColumns(seedColumnsByPlayer: number[][]) {
setup.seedInputs = seedColumnsByPlayer.map((columns) => columns.join(", ") ? columns.map((column) => String(column + 1)).join(", ") : "");
}
function moveSetupSeed(playerId: number, seedIndex: number, targetColumn: number) {
const seedColumnsByPlayer = getSetupSeedColumns();
const originColumn = seedColumnsByPlayer[playerId]?.[seedIndex];
if (originColumn === undefined || originColumn === targetColumn) {
return;
}
let swapped = false;
seedColumnsByPlayer.forEach((columns, otherPlayerId) => {
columns.forEach((column, otherSeedIndex) => {
if (otherPlayerId === playerId && otherSeedIndex === seedIndex) {
return;
}
if (column === targetColumn) {
seedColumnsByPlayer[otherPlayerId][otherSeedIndex] = originColumn;
swapped = true;
}
});
});
seedColumnsByPlayer[playerId][seedIndex] = targetColumn;
if (!swapped) {
seedColumnsByPlayer[playerId] = [...seedColumnsByPlayer[playerId]];
}
setSetupSeedColumns(seedColumnsByPlayer);
render(); render();
} }
@@ -809,8 +942,15 @@ function renderNewGameModal() {
} }
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns); const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder); const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder, setup.playerNames);
const draftCountMax = WEATHER_CARDS.length; const draftCountMax = WEATHER_OFFER_PAIRS.length;
const previewSeedColumns = getSetupSeedColumns();
const seedMarkers = previewSeedColumns.flatMap((columns, playerId) => columns.map((column, seedIndex) => ({
playerId,
seedIndex,
column,
player: previewPlayers[playerId],
})));
return ` return `
<div class="modal-backdrop" id="new-game-modal-backdrop"> <div class="modal-backdrop" id="new-game-modal-backdrop">
@@ -820,7 +960,6 @@ function renderNewGameModal() {
<p class="eyebrow">New Game</p> <p class="eyebrow">New Game</p>
<h1 id="new-game-title">Configure the next canopy</h1> <h1 id="new-game-title">Configure the next canopy</h1>
</div> </div>
<button class="ghost-button" id="close-new-game">Close</button>
</header> </header>
<nav class="setup-tabs" aria-label="Setup categories"> <nav class="setup-tabs" aria-label="Setup categories">
<button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button> <button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button>
@@ -834,11 +973,12 @@ function renderNewGameModal() {
<section class="setup-section"> <section class="setup-section">
<h2 class="setup-section__title">Board Settings</h2> <h2 class="setup-section__title">Board Settings</h2>
<div class="setup-grid"> <div class="setup-grid">
<label class="setup-field setup-field--range"> <label class="setup-field">
<span class="setup-field__label">Players</span> <span class="setup-field__label">Players</span>
<div class="setup-field__input"> <div class="setup-stepper">
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" /> <button class="stepper-button" id="player-count-decrease" ${setup.playerCount <= 2 ? "disabled" : ""}>-</button>
<strong class="setup-field__value">${setup.playerCount}</strong> <strong class="setup-stepper__value">${setup.playerCount}</strong>
<button class="stepper-button" id="player-count-increase" ${setup.playerCount >= 8 ? "disabled" : ""}>+</button>
</div> </div>
</label> </label>
<label class="setup-field"> <label class="setup-field">
@@ -905,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>
` : ""} ` : ""}
@@ -934,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>
<span class="player-row__name">${currentPlayer.name}</span> <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>
@@ -946,13 +1090,24 @@ function renderNewGameModal() {
</section> </section>
<section class="setup-section"> <section class="setup-section">
<h2 class="setup-section__title">Starting columns</h2> <h2 class="setup-section__title">Starting columns</h2>
<p class="setup-section__help">Use 1-based column numbers. Duplicate or invalid picks are auto-corrected.</p> <p class="setup-section__help">Drag markers on the strip to move starting seeds. Text inputs remain available as a fallback.</p>
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button> <button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
<div class="start-strip" role="group" aria-label="Starting positions preview">
${Array.from({ length: setup.columns }, (_, column) => {
const marker = seedMarkers.find((entry) => entry.column === column);
return `
<div class="start-strip__slot" data-start-slot="${column}">
<span class="start-strip__label">${column + 1}</span>
${marker ? `<button class="start-marker" draggable="true" data-start-marker="${marker.playerId}:${marker.seedIndex}" style="--player-color: ${marker.player.color}; --player-glow: ${marker.player.glow};">${marker.player.name.slice(0, 1) || marker.playerId + 1}</button>` : ""}
</div>
`;
}).join("")}
</div>
<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>
@@ -962,7 +1117,7 @@ function renderNewGameModal() {
<footer class="modal-footer"> <footer class="modal-footer">
<button class="ghost-button" id="cancel-new-game">Cancel</button> <button class="ghost-button" id="cancel-new-game">Cancel</button>
<button id="start-new-game">Start New Game</button> <button class="primary-button" id="start-new-game">Start New Game</button>
</footer> </footer>
</section> </section>
</div> </div>
@@ -978,48 +1133,67 @@ 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"} must draft one card for this round or ban one to deny it.</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>: apply to the board for 1 round</span> <span class="weather-draft-action"><strong>☀ Draft</strong> - take that card for 1 round</span>
<span><strong>✕ Ban</strong>: remove it this round</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">
${draft.row.map((cardId) => { ${draft.offers.map((offer) => {
const resolved = isWeatherOfferResolved(draft, offer.id);
return `
<article class="weather-card${resolved ? " weather-card--resolved" : ""}">
<div>
<p class="eyebrow">Offer</p>
<div class="weather-offer-layout">
<div class="weather-pair">
${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);
const banned = draft.banned.includes(cardId); const banned = draft.banned.includes(cardId);
const available = !drafted && !banned; const locked = draft.locked.includes(cardId);
return ` const available = isWeatherCardAvailable(draft, offer.id, cardId);
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}"> return `${optionIndex === 1 ? `<div class="weather-pair__divider">-- OR --</div>` : ""}
<div> <div class="weather-pair__option${drafted ? " weather-pair__option--drafted" : ""}${banned ? " weather-pair__option--banned" : ""}${locked ? " weather-pair__option--locked" : ""}">
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
<h2>${card?.title ?? cardId}</h2> <h2>${card?.title ?? cardId}</h2>
<p>${card?.description ?? ""}</p> <p>${card?.description ?? ""}</p>
</div>
${available ? ` ${available ? `
<div class="weather-card__actions"> <div class="weather-card__actions">
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-card="${cardId}"> <button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
<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-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>`}
</div>
`;
}).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>
</article> </article>
`; `;
}).join("")} }).join("")}
@@ -1036,19 +1210,26 @@ function renderInitiativeModal() {
const draft = state.initiativeDraft; const draft = state.initiativeDraft;
const currentBidder = getCurrentBiddingPlayer(); const currentBidder = getCurrentBiddingPlayer();
const orderedBidders = getOrderedPlayers(draft.biddingOrder); const orderedBidders = getOrderedPlayers(draft.biddingOrder);
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" : ""}">
${initiativeBonusStatus.bonusActive
? "Seat bonuses are active: Seat 1 gains +1 growth this round."
: `Seat bonuses start in ${initiativeBonusStatus.roundsRemaining} round${initiativeBonusStatus.roundsRemaining === 1 ? "" : "s"}.`}
</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">
@@ -1060,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("")}
@@ -1070,12 +1251,12 @@ function renderInitiativeModal() {
} }
function renderScoreboard() { function renderScoreboard() {
const liveExposureScores = getLiveExposureScores(); const projectedIncomeScores = getProjectedIncomeScores();
return state.players.map((player, index) => { return state.players.map((player, index) => {
const isActive = player.id === state.activePlayerId && !state.gameOver; const isActive = player.id === state.activePlayerId && !state.gameOver;
const seatIndex = state.turnOrder.indexOf(player.id); const seatIndex = state.turnOrder.indexOf(player.id);
const previous = previousScoreSnapshot?.[index]; const previous = previousScoreSnapshot?.[index];
const sunlightChanged = previous && previous.currentExposure !== liveExposureScores[index]; const projectedIncomeChanged = previous && previous.projectedIncome !== projectedIncomeScores[index];
const growthChanged = previous && previous.growthPoints !== player.growthPoints; const growthChanged = previous && previous.growthPoints !== player.growthPoints;
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints; const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
return ` return `
@@ -1083,18 +1264,18 @@ 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>
<div class="score-card__numbers"> <div class="score-card__numbers">
<div> <div>
<span>Current</span> <span>Next</span>
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong> <strong class="${projectedIncomeChanged ? "score-value changed" : "score-value"}">${projectedIncomeScores[index]}</strong>
</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>
@@ -1148,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;
@@ -1184,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 : ""}
@@ -1203,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;
@@ -1217,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) => {
@@ -1237,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>
@@ -1272,59 +1528,95 @@ 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></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 nextGrowthText = state.roundSummary const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${escapeHtml(state.players[index].name)}: ${score}`).join(" | ");
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ") const phaseHint = state.phase === "initiative"
: "Next round growth = 1 + columns owned + any banked growth."; ? "Choose a seat for this round."
: state.phase === "weather"
? "Draft one card or ban both cards in an offer."
: state.gameOver
? "Final totals are locked."
: `${player.growthPoints} energy. Click a selected pending node again to undo.`;
return ` return `
<aside class="sidebar"> <aside class="sidebar">
<section class="panel accordion-panel accordion-panel--top">
<details class="accordion">
<summary>In Effect</summary>
<div class="accordion__content">
${activeEffectsMarkup}
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
</div>
</details>
</section>
<section class="panel controls-panel"> <section class="panel controls-panel">
<div class="panel__title-row"> <details class="accordion" open>
<div> <summary>End Turn</summary>
<p class="eyebrow">Canopy</p> <div class="accordion__content">
<h1>Sunlight decides the next round.</h1>
</div>
<button class="ghost-button" id="new-game">New Game</button>
</div>
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};"> <div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
<p class="eyebrow">Round ${state.round}</p> <p class="eyebrow">Round ${state.round}</p>
<h2>${getTurnLabel()}</h2> <h2>${getTurnLabel()}</h2>
<p>${state.phase === "initiative" ? "Choose a public seat for this round. Earlier seats gain more growth, later seats act later." : state.gameOver ? "Tallies are final." : "Spend growth by extending upward. Vertical costs 1. Diagonal costs 2. Click a glowing new node to undo back to that point before you commit the turn."}</p> <p>${phaseHint}</p>
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"} this turn. ${player.bankedPoints} banked for the next round.</p>` : ""} ${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"}. Energy ${player.growthPoints}. Bank ${player.bankedPoints}.</p>` : ""}
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
<p>Turn order: ${turnOrderSummary}</p>
<p>${getWinConditionSummary()}</p>
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""} ${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
</div> </div>
<div class="button-row"> <div class="button-row">
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button> <button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
<button id="bank-turn" class="ghost-button" ${boardLocked || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button> <button id="bank-turn" class="ghost-button" ${boardLocked || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button>
</div> </div>
</section>
<section class="panel status-panel">
<h2>Round economy</h2>
<p>${nextGrowthText}</p>
${activeEffectsMarkup}
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
</section>
<section class="panel log-panel">
<h2>Round log</h2>
<div class="log-list">
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
</div> </div>
</details>
</section>
<section class="panel accordion-panel">
<details class="accordion">
<summary>Turn Help</summary>
<div class="accordion__content">
<p>Vertical growth costs 1. Diagonal growth costs 2.</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>` : ""}
${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>
</details>
<details class="accordion">
<summary>Win Condition</summary>
<div class="accordion__content">
<p>${getWinConditionSummary()}</p>
<p>Turn order: ${turnOrderSummary}</p>
<p>Next income: ${projectedIncomeText}</p>
</div>
</details>
<details class="accordion">
<summary>Weather Details</summary>
<div class="accordion__content">
${state.activeRoundEffects.length > 0
? state.activeRoundEffects.map((cardId) => {
const card = getWeatherCard(cardId);
return `<p><strong>${card?.title ?? cardId}</strong>: ${card?.description ?? ""}</p>`;
}).join("")
: `<p>No weather effects active.</p>`}
</div>
</details>
<details class="accordion">
<summary>Round Log</summary>
<div class="accordion__content log-list">
${state.history.slice(0, 8).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("")}
</div>
</details>
</section> </section>
<section class="panel finish-panel"> <section class="panel finish-panel">
<div class="button-row finish-panel__actions">
<button id="new-game" class="ghost-button finish-game-button">New Game</button>
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button> <button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
</div>
</section> </section>
</aside> </aside>
`; `;
@@ -1368,7 +1660,6 @@ function attachEvents() {
}); });
document.querySelector("#new-game")?.addEventListener("click", openNewGameModal); document.querySelector("#new-game")?.addEventListener("click", openNewGameModal);
document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal);
document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal); document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal);
document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal); document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal);
document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => { document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => {
@@ -1379,13 +1670,16 @@ 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<HTMLInputElement>("#player-count")?.addEventListener("input", (event) => { document.querySelector("#draft-panel-toggle")?.addEventListener("click", () => {
const input = event.currentTarget as HTMLInputElement; isDraftPanelDocked = !isDraftPanelDocked;
const output = input.parentElement?.querySelector("strong"); render();
if (output) { });
output.textContent = input.value; document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
} rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
rebuildSetup({ playerCount: Number(input.value) }); render();
});
document.querySelector<HTMLElement>("#player-count-increase")?.addEventListener("click", () => {
rebuildSetup({ playerCount: Math.min(8, setup.playerCount + 1) });
render(); render();
}); });
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => { document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
@@ -1434,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();
@@ -1455,6 +1753,13 @@ function attachEvents() {
setup.seedInputs[playerId] = target.value; setup.seedInputs[playerId] = target.value;
}); });
}); });
document.querySelectorAll<HTMLInputElement>(".player-name-input").forEach((input) => {
input.addEventListener("input", (event) => {
const target = event.currentTarget as HTMLInputElement;
const playerId = Number(target.dataset.playerId);
setup.playerNames[playerId] = target.value;
});
});
document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations); document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations);
document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => { document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -1468,6 +1773,29 @@ function attachEvents() {
shiftSelectedRoot(Number(button.dataset.rootShift)); shiftSelectedRoot(Number(button.dataset.rootShift));
}); });
}); });
document.querySelectorAll<HTMLElement>("[data-start-marker]").forEach((marker) => {
marker.addEventListener("dragstart", () => {
const [playerId, seedIndex] = (marker.dataset.startMarker ?? "").split(":").map(Number);
draggedSetupSeed = { playerId, seedIndex };
});
marker.addEventListener("dragend", () => {
draggedSetupSeed = null;
});
});
document.querySelectorAll<HTMLElement>("[data-start-slot]").forEach((slot) => {
slot.addEventListener("dragover", (event) => {
event.preventDefault();
});
slot.addEventListener("drop", (event) => {
event.preventDefault();
if (!draggedSetupSeed) {
return;
}
moveSetupSeed(draggedSetupSeed.playerId, draggedSetupSeed.seedIndex, Number(slot.dataset.startSlot));
draggedSetupSeed = null;
});
});
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => { document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
chooseInitiativeSeat(Number(button.dataset.seatChoice)); chooseInitiativeSeat(Number(button.dataset.seatChoice));
@@ -1475,7 +1803,11 @@ function attachEvents() {
}); });
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => { document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban"); chooseWeatherAction(
button.dataset.weatherOffer as string,
button.dataset.weatherCard as WeatherCardId,
button.dataset.weatherAction as "draft" | "ban",
);
}); });
}); });
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => { document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {
@@ -1497,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();
} }

View File

@@ -1,334 +0,0 @@
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 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;
}
if (state.activeRoundEffects.includes("shared_light")) {
playersPresent.forEach((playerId) => {
scores[playerId] += 1;
});
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") {
tallestLeaves.forEach((row, playerId) => {
if (row !== null) {
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;
}
energySimulation.columns.forEach((column) => {
if (!column.intercepted || column.ownerId === null) {
return;
}
const region = getColumnRegion(state, column.column);
if (effectId === "west_light" && region === "left") {
scores[column.ownerId] += 1;
}
if (effectId === "east_light" && region === "right") {
scores[column.ownerId] += 1;
}
if (effectId === "high_noon" && region === "center") {
scores[column.ownerId] += 1;
}
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
scores[column.ownerId] += 1;
}
});
});
}
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.`,
};
}

View File

@@ -1,46 +0,0 @@
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } from "./types";
import { shuffleArray } from "./utils";
export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ 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: "storehouse", title: "Storehouse", description: "Banking is enabled this round." },
{ id: "sun_ladder", title: "Sun Ladder", description: "Straight-up growth costs 0." },
{ id: "west_light", title: "West Light", description: "Left third columns give +1." },
{ id: "east_light", title: "East Light", description: "Right third columns give +1." },
{ id: "high_noon", title: "High Noon", description: "Center third 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: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
{ 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: "shared_light", title: "Shared Light", description: "Contested columns give full energy to each player there." },
];
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
WEATHER_CARDS.map((card) => [card.id, card]),
);
export function createWeatherDraft(state: GameState): WeatherDraftState {
const rowSize = Math.min(WEATHER_CARDS.length, state.config.weatherDraftCount);
return {
playerOrder: [...state.turnOrder],
draftIndex: 0,
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
drafted: [],
banned: [],
};
}
export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
return draft.playerOrder[draft.draftIndex] as PlayerId;
}
export function isWeatherCardAvailable(draft: WeatherDraftState, cardId: WeatherCardId) {
return draft.row.includes(cardId) && !draft.drafted.includes(cardId) && !draft.banned.includes(cardId);
}
export function getWeatherCard(cardId: WeatherCardId) {
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
}

View File

@@ -1,964 +0,0 @@
/* Grid-based TV-Optimized Layout Framework */
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%),
linear-gradient(180deg, #0b1220 0%, #070b13 100%);
color: #f4f7fb;
/* Layout constants */
--bottom-bar-height: 100px;
--sidebar-min-width: 280px;
--sidebar-max-width: 380px;
--gap-size: 0.75rem;
--padding-size: 0.75rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
background: transparent;
}
/* Main App Container - fills viewport accounting for browser chrome */
#app {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
#app > * {
width: 100%;
height: calc(100vh - 100px);
max-width: 100%;
max-height: calc(100vh - 100px);
min-width: 0;
min-height: 0;
}
/* Main Layout Grid */
.layout {
width: 100%;
height: 100%;
display: grid;
grid-template-areas:
"main sidebar"
"bottom bottom";
grid-template-columns: 1fr minmax(var(--sidebar-min-width), var(--sidebar-max-width));
grid-template-rows: 1fr var(--bottom-bar-height);
gap: var(--gap-size);
padding: var(--padding-size);
overflow: hidden;
}
/* Game Area - Main left section */
.game-area {
grid-area: main;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* Board shell fills the game area */
.board-shell {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px);
padding: 0.5rem;
min-height: 0;
overflow: hidden;
}
/* Board - fits within shell */
.board {
position: relative;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
aspect-ratio: var(--board-columns) / var(--board-rows);
display: grid;
grid-template-columns: repeat(var(--board-columns), minmax(0, 1fr));
grid-template-rows: repeat(var(--board-rows), minmax(0, 1fr));
gap: clamp(2px, 0.3cqmin, 4px);
margin: 0 auto;
}
/* Sidebar - Right panel */
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
overflow: hidden;
}
/* Bottom bar - Fixed height player scores */
.scoreboard {
grid-area: bottom;
display: grid;
grid-template-columns: repeat(var(--player-count, 3), 1fr);
gap: 0.75rem;
height: 100%;
overflow: hidden;
}
.score-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1rem 1.25rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
}
.score-card.active {
border-color: color-mix(in srgb, var(--player-color) 55%, white);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 24px var(--player-glow);
}
/* Sidebar panels */
.panel {
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px);
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
}
.controls-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow: auto;
}
.log-panel {
max-height: 120px;
flex-shrink: 0;
}
/* Cell styling */
.cell {
position: relative;
background: rgba(255, 255, 255, 0.03);
border-radius: clamp(4px, 15%, 0.6rem);
border: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
}
.cell__shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), var(--column-tint));
}
.cell__root-ring {
position: absolute;
inset: 18% 18%;
border: 1px dashed rgba(255, 255, 255, 0.28);
border-radius: 999px;
}
.cell__node,
.cell__target-label {
position: absolute;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
}
.cell__node {
width: clamp(8px, 35%, 1.2rem);
height: clamp(8px, 35%, 1.2rem);
border-radius: 50%;
background: var(--node-color);
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 1rem var(--node-glow);
aspect-ratio: 1 / 1;
}
.cell.selected {
border-color: rgba(255, 255, 255, 0.55);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.cell.pending {
border-color: rgba(255, 255, 255, 0.28);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px rgba(255, 255, 255, 0.08);
}
.cell.pending .cell__node {
box-shadow: 0 0 0 0.18rem rgba(255, 255, 255, 0.08), 0 0 1.1rem var(--node-glow), 0 0 1.5rem rgba(255, 255, 255, 0.08);
}
.cell.target {
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.05);
}
.cell.target:hover {
transform: translateY(-1px);
}
.cell__target-label {
width: min(60%, 1.5rem);
height: min(60%, 1.5rem);
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.18);
font-weight: 700;
font-size: clamp(0.6rem, 2cqmin, 0.9rem);
}
/* Board overlays */
.board__lines {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.18));
z-index: 1;
}
.board__fx {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 4;
}
.board__drop-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 5;
}
.board__energy-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.board__energy-cell {
position: absolute;
border-radius: clamp(4px, 15%, 0.6rem);
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.95), color-mix(in srgb, var(--flash-color) 70%, #ffe08a) 34%, rgba(255, 224, 138, 0.18) 72%, transparent 100%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 0 18px color-mix(in srgb, var(--flash-color) 55%, #ffe08a);
opacity: 0;
}
.board__energy-cell--sunlight {
inset: 12%;
background:
linear-gradient(180deg, rgba(255, 244, 214, 0.05), rgba(255, 244, 214, 0.008)),
radial-gradient(circle at 50% 50%, rgba(255, 242, 196, 0.11), color-mix(in srgb, var(--flash-color) 10%, #ffe3a3) 42%, rgba(255, 221, 128, 0.03) 72%, transparent 100%);
box-shadow: inset 0 0 0 1px rgba(255, 245, 224, 0.02), 0 0 6px color-mix(in srgb, var(--flash-color) 8%, #ffe3a3);
}
.board--sunlight .board__energy-cell--sunlight,
.board--branches .board__energy-cell,
.board--bonus .board__energy-cell--bonus {
animation: energy-cell-flash 0.48s ease forwards;
animation-delay: var(--flash-delay, 0ms);
}
.board__energy-cell--bonus {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 0 22px rgba(255, 216, 94, 0.9);
}
.board--bonus .board__drop--bonus {
animation: sunlight-drop 0.85s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
.board__drop-core,
.board__drop-spark {
position: absolute;
inset: 0;
border-radius: 999px;
}
.board__drop-core {
background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.98), color-mix(in srgb, var(--drop-color) 72%, #ffe38a) 45%, rgba(255, 227, 138, 0.18) 100%);
box-shadow: 0 0 18px color-mix(in srgb, var(--drop-color) 40%, #ffe38a), 0 0 32px rgba(255, 236, 174, 0.65);
}
.board__drop-spark {
inset: 35%;
border: 1px solid rgba(255, 248, 220, 0.95);
opacity: 0.9;
}
.board__drop-spark--a { transform: translate(-0.8rem, -0.15rem) scale(0.55); }
.board__drop-spark--b { transform: translate(0.75rem, -0.1rem) scale(0.45); }
.board__drop-spark--c { transform: translate(0.1rem, -0.75rem) scale(0.35); }
.board__root-burst,
.board__disease-mark,
.board__sunbeam-burst {
opacity: 0;
}
.board__root-burst circle,
.board__sunbeam-burst {
fill: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white);
stroke: rgba(255, 255, 255, 0.65);
stroke-width: 0.35;
}
.board__root-burst text {
fill: #08111c;
font-size: 2.1px;
font-weight: 800;
}
.board__disease-mark circle {
fill: rgba(162, 255, 142, 0.2);
stroke: rgba(162, 255, 142, 0.9);
stroke-width: 0.35;
}
.board__disease-mark path {
stroke: rgba(162, 255, 142, 1);
stroke-width: 0.5;
stroke-linecap: round;
}
.board--branches .board__root-burst,
.board--events .board__root-burst,
.board--events .board__disease-mark,
.board--bonus .board__sunbeam-burst,
.board--events .board__sunbeam-burst {
animation: pop-fade 0.8s ease forwards;
animation-delay: var(--trace-delay, 0ms);
}
.board__sunbeam-burst text {
fill: #08111c;
font-size: 2.1px;
font-weight: 800;
}
/* Score card content */
.score-card__head,
.score-card__numbers,
.panel__title-row,
.button-row,
.setup-grid,
.toggle-row,
.active-turn {
display: flex;
align-items: center;
}
.score-card__head,
.panel__title-row,
.button-row,
.toggle-row {
justify-content: space-between;
}
.score-card__identity {
display: flex;
align-items: center;
gap: 0.5rem;
}
.score-card__head h2,
.panel h1,
.panel h2,
.active-turn h2 {
margin: 0;
font-size: clamp(0.9rem, 2.5cqmin, 1.2rem);
}
.score-card__numbers {
margin-top: 0.5rem;
gap: 0.75rem;
}
.score-card__footer {
margin-top: 0.5rem;
padding-top: 0.4rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(231, 238, 247, 0.72);
font-size: 0.75rem;
}
.score-card__numbers div {
display: grid;
gap: 0.1rem;
}
.score-card__numbers span,
.eyebrow,
label span,
.log-list p,
.status-panel p,
.active-turn p,
.effect-empty {
color: rgba(231, 238, 247, 0.72);
}
.score-card__meta {
font-size: 0.75rem;
color: rgba(231, 238, 247, 0.7);
}
.score-card__numbers strong {
font-size: 1.2rem;
}
.score-value {
display: inline-block;
}
.score-value.changed {
animation: score-pop 0.7s ease;
}
.player-dot {
width: 0.85rem;
height: 0.85rem;
border-radius: 999px;
background: var(--player-color);
box-shadow: 0 0 16px var(--player-glow);
}
/* Sidebar content */
.panel__actions {
display: flex;
gap: 0.5rem;
}
.eyebrow {
margin: 0 0 0.25rem;
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.button-row {
gap: 0.5rem;
}
.button-row button,
.ghost-button {
min-height: 2.4rem;
padding: 0.5rem 0.75rem;
border-radius: 0.85rem;
background: #f4f7fb;
color: #0a1020;
font-weight: 700;
font-size: 0.9rem;
border: none;
cursor: pointer;
}
.ghost-button,
#finish-game {
background: rgba(255, 255, 255, 0.08);
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.1);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.log-list {
display: grid;
gap: 0.4rem;
font-size: 0.8rem;
overflow: auto;
}
.log-list p,
.status-panel p,
.active-turn p {
margin: 0;
}
.event-note {
color: #ffd577;
}
.active-turn {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.6rem;
border-radius: 0.85rem;
background: linear-gradient(135deg, color-mix(in srgb, var(--player-color) 22%, rgba(255, 255, 255, 0.04)), rgba(255, 255, 255, 0.04));
border: 1px solid color-mix(in srgb, var(--player-color) 35%, rgba(255, 255, 255, 0.08));
}
/* Form elements */
button,
input,
select {
font: inherit;
}
input[type="number"],
input[type="text"],
select {
width: 100%;
min-height: 2.4rem;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #f4f7fb;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
}
/* Modal styles */
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(3, 8, 16, 0.72);
backdrop-filter: blur(14px);
z-index: 20;
}
.modal {
width: min(1180px, 100%);
max-height: min(92%, 980px);
overflow: auto;
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px);
padding: 1rem;
}
/* Setup Modal - Redesigned */
.setup-modal {
display: flex;
flex-direction: column;
max-width: 900px;
padding: 0;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modal-header__title h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.setup-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
padding: 0 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.setup-tab {
min-height: 3rem;
display: grid;
place-items: center;
gap: 0.15rem;
padding: 0.55rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04);
color: rgba(231, 238, 247, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.setup-tab--active {
background: rgba(255, 255, 255, 0.1);
color: #f4f7fb;
border-color: rgba(255, 255, 255, 0.16);
}
.setup-tab span:first-child {
font-size: 1rem;
}
.setup-tab span:last-child {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
}
/* Setup Sections */
.setup-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.setup-section__title {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.6);
margin: 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.setup-section__help {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
/* Setup Grid */
.setup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.setup-grid--2col {
grid-template-columns: repeat(2, 1fr);
}
.setup-grid--3col {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 600px) {
.setup-tabs {
grid-template-columns: repeat(2, 1fr);
}
.setup-grid,
.setup-grid--2col,
.setup-grid--3col {
grid-template-columns: 1fr;
}
}
/* Setup Field */
.setup-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.setup-field__label {
font-size: 0.8125rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.setup-field__input {
display: flex;
align-items: center;
gap: 0.75rem;
}
.setup-field__value {
font-size: 1.125rem;
font-weight: 700;
min-width: 1.5rem;
}
.setup-field--range input[type="range"] {
flex: 1;
}
.setup-field--checkbox {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.setup-field--checkbox .setup-field__label {
margin: 0;
}
.setup-field input[type="number"],
.setup-field input[type="text"],
.setup-field select {
width: 100%;
min-height: 2.25rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #f4f7fb;
font-size: 0.9375rem;
}
.setup-field input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
accent-color: #4a9eff;
}
/* Player List */
.player-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.player-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.625rem;
}
.player-row__info {
display: flex;
align-items: center;
gap: 0.625rem;
}
.player-row__name {
font-weight: 600;
}
.player-row__actions {
display: flex;
gap: 0.375rem;
}
.player-row__actions .mini-button {
min-height: 1.75rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Mini button for player reordering */
.mini-button {
min-height: 2rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.08);
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
}
.mini-button:hover {
background: rgba(255, 255, 255, 0.12);
}
.mini-button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.draft-panel {
position: fixed;
top: 1rem;
right: 1rem;
width: max(320px, 30%);
max-width: calc(100% - 2rem);
max-height: calc(100% - 9.5rem);
overflow: auto;
z-index: 24;
border-color: rgba(255, 255, 255, 0.12);
background: rgba(9, 16, 29, 0.5);
backdrop-filter: blur(18px);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
}
/* Animations */
@keyframes sunlight-drop {
0% {
opacity: 0;
top: -0.9rem;
transform: scale(0.65);
}
12% {
opacity: 1;
}
85% {
opacity: 1;
top: calc(var(--drop-end) - 0.55rem);
transform: scale(1);
}
100% {
opacity: 0;
top: calc(var(--drop-end) - 0.55rem);
transform: scale(1.25);
}
}
@keyframes pop-fade {
0% {
opacity: 0;
transform: scale(0.65);
}
25% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes energy-cell-flash {
0% {
opacity: 0;
}
20% {
opacity: 0.98;
}
100% {
opacity: 0;
}
}
@keyframes score-pop {
0% {
transform: scale(0.88);
color: #fff7d6;
text-shadow: 0 0 0 rgba(255, 235, 153, 0);
}
35% {
transform: scale(1.18);
color: #ffe480;
text-shadow: 0 0 16px rgba(255, 228, 128, 0.9);
}
100% {
transform: scale(1);
color: inherit;
text-shadow: 0 0 0 rgba(255, 235, 153, 0);
}
}
/* Fullscreen mode adjustments */
:fullscreen #app > *,
:-webkit-full-screen #app > *,
:-moz-full-screen #app > * {
max-height: 100vh;
padding: 0;
}
/* Responsive adjustments */
@media (max-width: 900px) {
:root {
--bottom-bar-height: 80px;
--sidebar-min-width: 240px;
}
.layout {
grid-template-columns: 1fr minmax(var(--sidebar-min-width), 320px);
gap: 0.5rem;
padding: 0.5rem;
}
.score-card {
padding: 1rem 1.25rem;
}
.score-card__head h2 {
font-size: 0.9rem;
}
.score-card__numbers strong {
font-size: 1rem;
}
}
@media (max-width: 700px) {
.layout {
grid-template-areas:
"main"
"sidebar"
"bottom";
grid-template-columns: 1fr;
grid-template-rows: 2fr auto var(--bottom-bar-height);
}
.sidebar {
flex-direction: row;
gap: 0.5rem;
}
.panel {
flex: 1;
min-width: 0;
}
}

1700
src/styles/globals.css Normal file

File diff suppressed because it is too large Load Diff