Compare commits

..

22 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
e11264168c Fix: ensure board cells stay square so circles don't become ovals 2026-04-09 17:45:09 -04:00
1cc85397bd Add weather rules and streamline setup 2026-04-09 17:42:19 -04:00
8b50482621 Fix score-card padding in responsive media query 2026-04-09 17:42:14 -04:00
f866ce02cb Fix: increase player card padding and ensure cell nodes stay circular 2026-04-09 14:56:18 -04:00
ad055d3ae4 Restore missing layout CSS that was accidentally deleted 2026-04-09 14:23:09 -04:00
f5ffb211cf Add debug console logs to find startup issues 2026-04-09 14:07:34 -04:00
6c47b3e288 Redesign setup modal with organized sections and improved layout 2026-04-09 13:33:57 -04:00
b4de9d112b Remove 16:9 aspect ratio constraint - fill available viewport space instead 2026-04-09 13:24:43 -04:00
94c619ce7c Rewrite layout with CSS Grid framework - predictable areas for gameboard, sidebar, and bottom bar 2026-04-09 13:20:20 -04:00
977481936c Fix board sizing to fill available width instead of being height-constrained 2026-04-09 13:11:34 -04:00
c85d7a8fc1 Optimize board sizing for constrained 16:9 container - reduce gaps and padding 2026-04-09 13:01:45 -04:00
170db88c5b Fix CSS to work within 16:9 constrained container - replace viewport units with relative units 2026-04-09 12:03:45 -04:00
2e08b6e66a Add health check to ensure Docker Swarm stops old containers after new one is healthy 2026-04-09 09:44:56 -04:00
8c302ed55a Add 16:9 aspect ratio container optimized for TV fullscreen with browser chrome compensation 2026-04-09 09:31:42 -04:00
98c19ca262 Add explicit start command to nixpacks.toml to override nginx default 2026-04-09 09:19:04 -04:00
7ef736be64 Fix: use port 80 instead of 3000 for dokploy static site 2026-04-09 09:06:55 -04:00
d61b8de6b7 refactor 2026-04-09 09:00:25 -04:00
5b44762fbb Fix nixpacks build: add start script and configure build phase 2026-04-09 08:50:24 -04:00
17 changed files with 3971 additions and 3008 deletions

View File

@@ -1,5 +1,11 @@
[phases.build] [phases.build]
cmds = ["npm ci", "npm run build"] cmds = ["npm run build"]
[start] [start]
cmd = "npx serve dist -l 3000 -s" cmd = "serve dist -l 80 -s"
[healthcheck]
cmd = "curl -f http://localhost:80/ || exit 1"
interval = "10s"
timeout = "5s"
retries = 3

960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,14 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"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

@@ -1,21 +1,52 @@
import { PLAYER_PALETTE, STARTING_POINTS } from "./constants"; import { PLAYER_PALETTE, STARTING_POINTS } from "./constants";
import type { GameState, Player, SetupState } from "./types"; import type { GameState, Player, SetupState } from "./types";
import { keyFor, shuffleArray } from "./utils"; import { keyFor } from "./utils";
export function createDefaultPaletteOrder(playerCount: number) { 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,
@@ -76,43 +108,53 @@ export function createSetupState(
diseaseChance = 0, diseaseChance = 0,
seedInputs: string[] | null = null, seedInputs: string[] | null = null,
paletteOrder: number[] | null = null, paletteOrder: number[] | null = null,
shuffleTurnOrder = true,
initiativeMode: SetupState["initiativeMode"] = "fixed", initiativeMode: SetupState["initiativeMode"] = "fixed",
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating", biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
weatherDraftEnabled = true, weatherDraftEnabled = true,
weatherDraftCount = playerCount,
bankingEnabled = true,
winCondition: SetupState["winCondition"] = "rounds", winCondition: SetupState["winCondition"] = "rounds",
maxRounds = 12, maxRounds = 12,
topLeafTarget = 4, topLeafTarget = 4,
): SetupState { ): SetupState {
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns)); // Adjust columns to ensure even spacing between players and edges
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds); const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
const paletteDefaults = createDefaultPaletteOrder(playerCount); const paletteDefaults = createDefaultPaletteOrder(playerCount);
return { return {
playerCount, playerCount,
columns, playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
columns: adjustedColumns,
rows, rows,
startingNodesPerPlayer: clampedSeeds, startingNodesPerPlayer: clampedSeeds,
sunbeamChance, sunbeamChance,
diseaseChance, diseaseChance,
seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]), seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]),
paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]), paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]),
shuffleTurnOrder,
initiativeMode, initiativeMode,
biddingOrderRule, biddingOrderRule,
weatherDraftEnabled, weatherDraftEnabled,
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)),
}; };
} }
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,
@@ -164,8 +206,8 @@ export function normalizeSeedInputs(setup: SetupState) {
} }
export function createInitialState(setup: SetupState): GameState { export function createInitialState(setup: SetupState): GameState {
const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...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 = [];
@@ -176,7 +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 });
}); });
}); });
return { return {
config: { config: {
columns: setup.columns, columns: setup.columns,
@@ -187,6 +228,8 @@ export function createInitialState(setup: SetupState): GameState {
initiativeMode: setup.initiativeMode, initiativeMode: setup.initiativeMode,
biddingOrderRule: setup.biddingOrderRule, biddingOrderRule: setup.biddingOrderRule,
weatherDraftEnabled: setup.weatherDraftEnabled, weatherDraftEnabled: setup.weatherDraftEnabled,
weatherDraftCount: setup.weatherDraftCount,
bankingEnabled: setup.bankingEnabled,
winCondition: setup.winCondition, winCondition: setup.winCondition,
maxRounds: setup.maxRounds, maxRounds: setup.maxRounds,
topLeafTarget: setup.topLeafTarget, topLeafTarget: setup.topLeafTarget,
@@ -213,7 +256,7 @@ export function createInitialState(setup: SetupState): GameState {
gameOver: false, gameOver: false,
history: [ history: [
`Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`, `Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`,
`${setup.shuffleTurnOrder ? "Turn order was randomized for this game." : "Turn order uses the setup order."}`, "Turn order uses the setup order unless changed by initiative drafting.",
], ],
roundSummary: null, roundSummary: null,
}; };

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;
@@ -21,10 +22,11 @@ export type SetupState = {
diseaseChance: number; diseaseChance: number;
seedInputs: string[]; seedInputs: string[];
paletteOrder: number[]; paletteOrder: number[];
shuffleTurnOrder: boolean;
initiativeMode: "fixed" | "bid"; initiativeMode: "fixed" | "bid";
biddingOrderRule: "rotating" | "lowest_growth_income"; biddingOrderRule: "rotating" | "lowest_growth_income";
weatherDraftEnabled: boolean; weatherDraftEnabled: boolean;
weatherDraftCount: number;
bankingEnabled: boolean;
winCondition: "rounds" | "top_leaves"; winCondition: "rounds" | "top_leaves";
maxRounds: number; maxRounds: number;
topLeafTarget: number; topLeafTarget: number;
@@ -53,6 +55,8 @@ export type GameConfig = {
initiativeMode: SetupState["initiativeMode"]; initiativeMode: SetupState["initiativeMode"];
biddingOrderRule: SetupState["biddingOrderRule"]; biddingOrderRule: SetupState["biddingOrderRule"];
weatherDraftEnabled: boolean; weatherDraftEnabled: boolean;
weatherDraftCount: number;
bankingEnabled: boolean;
winCondition: SetupState["winCondition"]; winCondition: SetupState["winCondition"];
maxRounds: number; maxRounds: number;
topLeafTarget: number; topLeafTarget: number;
@@ -111,6 +115,7 @@ export type ColumnEnergy = {
terminalRow: number; terminalRow: number;
intercepted: boolean; intercepted: boolean;
ownerId: PlayerId | null; ownerId: PlayerId | null;
playersPresent: PlayerId[];
hitNode: Position | null; hitNode: Position | null;
rootKey: NodeKey | null; rootKey: NodeKey | null;
branchNodes: Position[]; branchNodes: Position[];
@@ -121,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 = {
@@ -162,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;
@@ -184,12 +190,18 @@ export type GamePhase = "initiative" | "turn" | "round_end" | "game_over";
export type WeatherCardId = export type WeatherCardId =
| "leaf_surge" | "leaf_surge"
| "branching_season" | "branching_season"
| "storehouse"
| "compound_interest"
| "sun_ladder"
| "west_light" | "west_light"
| "east_light" | "east_light"
| "high_noon" | "high_noon"
| "edge_bloom" | "edge_bloom"
| "wide_reach" | "wide_reach"
| "tall_reward"; | "tall_reward"
| "deep_roots"
| "stalemate"
| "split_light";
export type WeatherCardDefinition = { export type WeatherCardDefinition = {
id: WeatherCardId; id: WeatherCardId;
@@ -197,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;
@@ -207,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

File diff suppressed because it is too large Load Diff

View File

@@ -1,289 +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 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,
hitNode: null,
rootKey: null,
branchNodes: [],
branchEdges: [],
});
continue;
}
const hitNode = parseKey(hitNodeKey);
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
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;
}
scores[ownerId] += 1;
columns.push({
column,
terminalRow: hitNode.row,
intercepted: true,
ownerId,
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,41 +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: "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." },
];
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.players.length + 2);
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,894 +0,0 @@
: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;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: transparent;
}
button,
input,
select {
font: inherit;
}
input[type="number"],
input[type="text"],
select {
width: 100%;
min-height: 2.8rem;
padding: 0.7rem 0.85rem;
border-radius: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #f4f7fb;
}
button {
border: 0;
cursor: pointer;
}
#app {
min-height: 100vh;
}
.layout {
min-height: 100vh;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.scoreboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.scoreboard--bottom {
align-items: end;
position: relative;
z-index: 25;
}
.score-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1.25rem;
padding: 0.8rem 1rem;
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);
}
.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);
}
.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.6rem;
}
.score-card__head h2,
.panel h1,
.panel h2,
.active-turn h2 {
margin: 0;
}
.score-card__numbers {
margin-top: 0.65rem;
gap: 1rem;
}
.score-card__footer {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(231, 238, 247, 0.72);
font-size: 0.82rem;
}
.score-card__numbers div {
display: grid;
gap: 0.15rem;
}
.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.82rem;
color: rgba(231, 238, 247, 0.7);
}
.score-card__numbers strong {
font-size: 1.35rem;
}
.score-value {
display: inline-block;
}
.score-value.changed {
animation: score-pop 0.7s ease;
}
.player-dot {
width: 0.95rem;
height: 0.95rem;
border-radius: 999px;
background: var(--player-color);
box-shadow: 0 0 18px var(--player-glow);
}
.game-area {
flex: 1;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr);
gap: 0.85rem;
min-height: 0;
align-items: start;
}
.board-shell,
.panel {
border-radius: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px);
}
.board-shell {
min-height: 0;
padding: 0.8rem;
}
.board {
position: relative;
width: 100%;
height: auto;
max-height: calc(100vh - 10.5rem);
aspect-ratio: var(--board-columns) / var(--board-rows);
display: grid;
gap: 0.32rem;
margin: 0 auto;
}
.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;
}
.cell {
position: relative;
background: rgba(255, 255, 255, 0.03);
border-radius: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
z-index: 2;
}
.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: min(2.5vw, 1.6rem);
height: min(2.5vw, 1.6rem);
min-width: 1rem;
min-height: 1rem;
border-radius: 50%;
background: var(--node-color);
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.06), 0 0 1.2rem var(--node-glow);
}
.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.22rem rgba(255, 255, 255, 0.08), 0 0 1.4rem var(--node-glow), 0 0 2rem rgba(255, 255, 255, 0.08);
}
.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: 0.8rem;
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;
}
.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: 2rem;
height: 2rem;
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;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel {
padding: 1rem;
}
.controls-panel {
display: grid;
gap: 0.8rem;
}
.panel__actions {
display: flex;
gap: 0.55rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 1.25rem;
background: rgba(3, 8, 16, 0.72);
backdrop-filter: blur(14px);
z-index: 20;
}
.modal {
width: min(1180px, 100%);
max-height: min(92vh, 980px);
overflow: auto;
}
.draft-panel {
position: fixed;
top: 1rem;
right: 1rem;
width: max(320px, calc(((100vw - 2rem) - 0.85rem) * 0.3091));
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 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);
}
.modal-setup-grid,
.modal-grid {
display: grid;
gap: 0.9rem;
}
.modal-setup-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.modal-grid {
margin-top: 0.9rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.modal-actions {
margin-top: 0.9rem;
justify-content: flex-end;
}
.eyebrow {
margin: 0 0 0.3rem;
font-size: 0.82rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.setup-grid {
gap: 1rem;
align-items: end;
}
.setup-grid label {
flex: 1;
display: grid;
gap: 0.35rem;
}
.seed-editor {
display: grid;
gap: 0.65rem;
padding: 0.8rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.seed-row {
display: grid;
gap: 0.35rem;
}
.order-row,
.order-row__label,
.order-row__actions {
display: flex;
align-items: center;
}
.order-row {
justify-content: space-between;
gap: 0.75rem;
}
.order-row__label {
gap: 0.6rem;
font-weight: 600;
}
.order-row__actions {
gap: 0.4rem;
}
.mini-button {
min-height: 2rem;
padding: 0.35rem 0.65rem;
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.08);
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 600;
}
.seed-help {
margin: 0;
color: rgba(231, 238, 247, 0.72);
}
.initiative-order-row {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.initiative-pill {
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(231, 238, 247, 0.76);
}
.initiative-pill--active {
border-color: color-mix(in srgb, var(--player-color) 62%, white);
box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent);
color: #f4f7fb;
}
.initiative-seat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
margin-top: 0.9rem;
}
.initiative-seat {
min-height: 8rem;
padding: 0.9rem;
border-radius: 1rem;
display: grid;
gap: 0.35rem;
text-align: left;
background: rgba(255, 255, 255, 0.06);
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.initiative-seat--taken {
opacity: 0.68;
}
.weather-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.9rem;
margin-top: 0.9rem;
}
.weather-card {
display: grid;
gap: 0.8rem;
padding: 0.95rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.weather-card h2,
.weather-card p {
margin: 0;
}
.weather-card--drafted {
border-color: rgba(130, 224, 182, 0.55);
}
.weather-card--banned {
border-color: rgba(255, 128, 128, 0.45);
opacity: 0.72;
}
.weather-card__actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.weather-key {
display: grid;
gap: 0.25rem;
color: rgba(231, 238, 247, 0.78);
font-size: 0.86rem;
}
.active-effects {
display: grid;
gap: 0.55rem;
margin-top: 0.65rem;
}
.effect-chip {
display: flex;
align-items: center;
min-height: 3rem;
padding: 0.75rem 0.9rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04));
border: 1px solid rgba(255, 208, 96, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.effect-chip__title {
font-size: 1rem;
font-weight: 700;
color: #f4f7fb;
}
.weather-action {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
border-radius: 0.95rem;
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
text-align: left;
}
.weather-action span {
display: grid;
gap: 0.12rem;
}
.weather-action strong {
display: block;
}
.weather-action__icon {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 1rem;
line-height: 1;
background: rgba(255, 255, 255, 0.12);
flex: 0 0 auto;
}
.weather-action--draft {
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
border-color: rgba(255, 208, 96, 0.4);
}
.weather-action--draft .weather-action__icon {
background: rgba(255, 208, 96, 0.22);
}
.weather-action--ban {
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
border-color: rgba(255, 110, 110, 0.32);
}
.weather-action--ban .weather-action__icon {
background: rgba(255, 110, 110, 0.18);
}
.weather-action:hover {
transform: translateY(-1px);
}
.randomize-button {
width: 100%;
}
.finish-game-button {
width: 100%;
}
input[type="range"] {
width: 100%;
}
.active-turn {
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 0.8rem;
border-radius: 1rem;
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));
}
.root-shift-row {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.root-shift-button {
min-height: 2.2rem;
}
.button-row {
gap: 0.75rem;
}
.button-row button,
.ghost-button {
min-height: 2.7rem;
padding: 0.65rem 0.9rem;
border-radius: 0.95rem;
background: #f4f7fb;
color: #0a1020;
font-weight: 700;
}
.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.55rem;
max-height: 18vh;
overflow: auto;
}
.log-list p,
.status-panel p,
.active-turn p {
margin: 0;
}
.event-note {
color: #ffd577;
}
@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);
}
}
@media (max-width: 1100px) {
.game-area {
grid-template-columns: 1fr;
}
.modal-grid {
grid-template-columns: 1fr;
}
.board {
max-height: none;
width: 100%;
}
}
@media (max-width: 720px) {
.layout {
padding: 1rem;
}
.scoreboard {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.score-card__numbers {
gap: 1rem;
}
.cell__node {
width: 1rem;
height: 1rem;
}
}

1700
src/styles/globals.css Normal file

File diff suppressed because it is too large Load Diff