Fix weather draft and deploy setup

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

View File

@@ -2,7 +2,7 @@
cmds = ["npm run build"]
[start]
cmd = "npx serve dist -l 80 -s"
cmd = "serve dist -l 80 -s"
[healthcheck]
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",
"build": "vite build",
"preview": "vite preview",
"start": "npx serve dist -l 80 -s",
"start": "serve dist -l 80 -s",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^6.0.2",
"vite": "^5.4.19"
},
"dependencies": {
"serve": "^14.2.6"
}
}

View File

@@ -1,4 +1,11 @@
import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
import type {
EnergySimulation,
GameState,
NodeKey,
PlayerId,
RootBurst,
RoundAnimation,
} from "./types";
import { keyFor, parseKey, shuffleArray } from "./utils";
import { buildChildrenMap, buildParentMap } from "./rules-board";
@@ -20,7 +27,7 @@ function getLeafCounts(state: GameState) {
const counts = state.players.map(() => 0);
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
if (!(childrenMap.get(nodeKey)?.length)) {
if (!childrenMap.get(nodeKey)?.length) {
counts[node.ownerId] += 1;
}
});
@@ -49,7 +56,12 @@ function addRoundedHalfBonus(scores: number[], counts: number[]) {
});
}
function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: PlayerId, playersPresent: PlayerId[]) {
function applyColumnBaseEnergy(
state: GameState,
scores: number[],
ownerId: PlayerId,
playersPresent: PlayerId[],
) {
const contested = playersPresent.length > 1;
if (!contested) {
@@ -63,7 +75,7 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
if (state.activeRoundEffects.includes("split_light")) {
playersPresent.forEach((playerId) => {
scores[playerId] += 0.5;
scores[playerId] += 1;
});
return;
}
@@ -71,7 +83,11 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
scores[ownerId] += 1;
}
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
function applyWeatherEffects(
state: GameState,
scores: number[],
energySimulation: EnergySimulation,
) {
if (state.activeRoundEffects.length === 0) {
return;
}
@@ -106,29 +122,51 @@ function applyWeatherEffects(state: GameState, scores: number[], energySimulatio
}
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") {
const bestRow = tallestLeaves.reduce<number | null>((currentBest, row) => {
if (effectId === "tall_reward" || effectId === "deep_roots") {
const bestRow = tallestLeaves.reduce<number | null>(
(currentBest, row) => {
if (row === null) {
return currentBest;
}
if (currentBest === null || row < currentBest) {
if (
(effectId === "deep_roots" &&
(currentBest === null || row > currentBest)) ||
(effectId === "tall_reward" &&
(currentBest === null || row < currentBest))
) {
return row;
}
return currentBest;
}, null);
},
null,
);
if (bestRow !== null) {
tallestLeaves.forEach((row, playerId) => {
if (row === bestRow) {
scores[playerId] += 2;
scores[playerId] += effectId === "deep_roots" ? 4 : 2;
}
});
}
@@ -164,7 +202,10 @@ function applyWeatherEffects(state: GameState, scores: number[], energySimulatio
if (region === "center") {
highNoonCounts[column.ownerId] += 1;
}
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
if (
effectId === "edge_bloom" &&
(column.column === 0 || column.column === state.config.columns - 1)
) {
scores[column.ownerId] += 1;
}
});
@@ -245,7 +286,12 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
return map;
}
const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 };
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;
@@ -254,6 +300,33 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
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,
@@ -271,7 +344,10 @@ export function buildRoundAnimation(
.filter((column) => column.intercepted)
.map((column) => ({
playerId: column.ownerId as PlayerId,
verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })),
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,
@@ -280,8 +356,15 @@ export function buildRoundAnimation(
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;
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",
@@ -342,7 +425,11 @@ export function maybeRollDisease(state: GameState) {
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);
return (
row !== state.config.rows - 1 &&
!childrenMap.has(nodeKey) &&
parentMap.has(nodeKey)
);
});
if (twigKeys.length === 0) {
@@ -353,7 +440,10 @@ export function maybeRollDisease(state: GameState) {
}
const shuffled = shuffleArray(twigKeys);
const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)));
const killCount = Math.min(
shuffled.length,
1 + Math.floor(Math.random() * Math.min(3, shuffled.length)),
);
const killedKeys = shuffled.slice(0, killCount);
return {

View File

@@ -3,8 +3,9 @@ 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: "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." },
@@ -12,8 +13,9 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ 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 half to each player there." },
{ id: "split_light", title: "Split Light", description: "Contested columns give 1 energy to each player there." },
];
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
@@ -22,18 +24,27 @@ export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] },
{ id: "tempo_tools", options: ["storehouse", "sun_ladder"] },
{ id: "banking_mix", options: ["storehouse", "compound_interest"] },
{ id: "tempo", options: ["sun_ladder", "edge_bloom"] },
{ id: "side_bias", options: ["west_light", "east_light"] },
{ id: "shape_bias", options: ["high_noon", "edge_bloom"] },
{ id: "reward_shape", options: ["wide_reach", "tall_reward"] },
{ 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: [...state.turnOrder],
playerOrder: rotatedOrder,
draftIndex: 0,
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
drafted: [],

View File

@@ -6,16 +6,47 @@ export function createDefaultPaletteOrder(playerCount: number) {
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) {
const totalSeeds = playerCount * startingNodesPerPlayer;
const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
// Calculate spacing to place players equidistant from each other and edges
// 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) => {
const start = playerId * startingNodesPerPlayer;
return positions
.slice(start, start + startingNodesPerPlayer)
.map((column) => String(column + 1))
.join(", ");
const playerCenter = Math.round((playerId + 1) * spacing);
// For multiple seeds per player, spread them around the center
if (startingNodesPerPlayer === 1) {
return String(playerCenter + 1);
}
// For multiple seeds, alternate left and right from center
const positions = [];
for (let i = 0; i < startingNodesPerPlayer; i++) {
// Alternate: 0, +1, -1, +2, -2, etc.
const offset = i === 0 ? 0 : (i % 2 === 1 ? Math.ceil(i / 2) : -Math.ceil(i / 2));
const pos = Math.max(1, Math.min(columns, playerCenter + offset + 1));
positions.push(pos);
}
return positions.join(", ");
});
}
@@ -80,19 +111,23 @@ export function createSetupState(
initiativeMode: SetupState["initiativeMode"] = "fixed",
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
weatherDraftEnabled = true,
weatherDraftCount = playerCount + 2,
weatherDraftCount = playerCount,
bankingEnabled = true,
winCondition: SetupState["winCondition"] = "rounds",
maxRounds = 12,
topLeafTarget = 4,
): SetupState {
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
// Adjust columns to ensure even spacing between players and edges
const adjustedColumns = getMinimumColumnsForEvenSpacing(playerCount, columns);
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, adjustedColumns));
const defaults = createDefaultSeedInputs(playerCount, adjustedColumns, clampedSeeds);
const paletteDefaults = createDefaultPaletteOrder(playerCount);
return {
playerCount,
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
columns,
columns: adjustedColumns,
rows,
startingNodesPerPlayer: clampedSeeds,
sunbeamChance,
@@ -103,9 +138,10 @@ export function createSetupState(
biddingOrderRule,
weatherDraftEnabled,
weatherDraftCount: Math.max(1, weatherDraftCount),
bankingEnabled,
winCondition,
maxRounds,
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
topLeafTarget: Math.max(1, Math.min(adjustedColumns, topLeafTarget)),
};
}
@@ -193,6 +229,7 @@ export function createInitialState(setup: SetupState): GameState {
biddingOrderRule: setup.biddingOrderRule,
weatherDraftEnabled: setup.weatherDraftEnabled,
weatherDraftCount: setup.weatherDraftCount,
bankingEnabled: setup.bankingEnabled,
winCondition: setup.winCondition,
maxRounds: setup.maxRounds,
topLeafTarget: setup.topLeafTarget,

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@ let isNewGameModalOpen = false;
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
let setupTab: "board" | "rules" | "events" | "players" = "board";
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
let isDraftPanelDocked = false;
function rebuildSetup(overrides: Partial<SetupState> = {}) {
setup = createSetupState(
@@ -84,12 +85,32 @@ function rebuildSetup(overrides: Partial<SetupState> = {}) {
overrides.biddingOrderRule ?? setup.biddingOrderRule,
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
overrides.weatherDraftCount ?? setup.weatherDraftCount,
overrides.bankingEnabled ?? setup.bankingEnabled,
overrides.winCondition ?? setup.winCondition,
overrides.maxRounds ?? setup.maxRounds,
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() {
return buildEnergySimulation(state).scores;
}
@@ -142,6 +163,14 @@ function getCurrentPlayer() {
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;
}
@@ -152,18 +181,19 @@ function getOrderedPlayers(playerIds: number[]) {
function getTurnLabel() {
if (state.phase === "initiative" && state.initiativeDraft) {
return `${getCurrentPlayer().name} drafts initiative`;
return `${escapeHtml(getCurrentPlayer().name)} drafts initiative`;
}
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() {
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) {
@@ -393,6 +423,7 @@ function startWeatherDraft() {
}
state.phase = "weather";
isDraftPanelDocked = false;
state.turnMoves = [];
updateSelection(null);
state.weatherDraft = createWeatherDraft(state);
@@ -402,6 +433,7 @@ function startWeatherDraft() {
function startInitiativeDraft() {
state.phase = "initiative";
isDraftPanelDocked = false;
state.turnMoves = [];
updateSelection(null);
state.initiativeDraft = createInitiativeDraft(state);
@@ -516,27 +548,32 @@ function finalizeWeatherDraft() {
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
const draft = state.weatherDraft;
if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) {
const offer = draft?.offers.find((entry) => entry.id === offerId);
if (!draft || !offer) {
return;
}
const offer = draft.offers.find((entry) => entry.id === offerId);
const otherCardId = offer?.options.find((option) => option !== cardId) ?? null;
const playerId = getCurrentWeatherPlayerId(draft);
if (action === "draft") {
if (!isWeatherCardAvailable(draft, offerId, cardId)) {
return;
}
const card = getWeatherCard(cardId);
draft.drafted.push(cardId);
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
} else {
const card = getWeatherCard(cardId);
draft.banned.push(cardId);
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
}
if (otherCardId && !draft.locked.includes(otherCardId)) {
draft.locked.push(otherCardId);
}
} else {
offer.options.forEach((option) => {
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) {
finalizeWeatherDraft();
@@ -736,6 +773,22 @@ async function endRound() {
player.roundScore = scores[index];
player.totalScore += 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.lifetimeGrowthIncome += nextGrowth[index];
player.growthPoints += nextGrowth[index];
@@ -992,6 +1045,10 @@ function renderNewGameModal() {
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
</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>
</section>
` : ""}
@@ -1021,7 +1078,7 @@ function renderNewGameModal() {
<div class="player-row">
<div class="player-row__info">
<span class="player-dot" style="--player-color: ${currentPlayer.color}; --player-glow: ${currentPlayer.glow};"></span>
<input class="player-name-input" data-player-id="${index}" type="text" value="${currentPlayer.name}" aria-label="${currentPlayer.name} name" />
<input class="player-name-input" data-player-id="${index}" type="text" value="${escapeHtml(currentPlayer.name)}" aria-label="${escapeHtml(currentPlayer.name)} name" />
</div>
<div class="player-row__actions">
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
@@ -1049,8 +1106,8 @@ function renderNewGameModal() {
<div class="player-list">
${previewPlayers.map((currentPlayer, index) => `
<label class="setup-field">
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" />
<span class="setup-field__label" style="color: ${currentPlayer.color};">${escapeHtml(currentPlayer.name)}</span>
<input class="seed-input" data-player-id="${index}" type="text" value="${escapeHtml(setup.seedInputs[index] ?? "")}" placeholder="e.g. 2, 5" />
</label>
`).join("")}
</div>
@@ -1076,21 +1133,31 @@ function renderWeatherDraftModal() {
const currentPlayer = getCurrentWeatherDraftPlayer();
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>
<p class="eyebrow">Round ${state.round}</p>
<h1 id="weather-title">Weather Draft</h1>
</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 class="seed-editor">
<p class="seed-help">${currentPlayer?.name ?? "A player"} can draft or ban 1 card. Offered in pairs.</p>
<div class="weather-key" aria-label="Weather action key">
<span><strong>☀ Draft</strong>: take that card for 1 round</span>
<span><strong>✕ Ban</strong>: remove just that card</span>
<div class="weather-draft-header">
<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-draft-actions">
<span class="weather-draft-action"><strong>☀ Draft</strong> - take that card for 1 round</span>
<span class="weather-draft-action"><strong>✕ Ban Both</strong> - remove both cards in that offer</span>
</div>
<div class="initiative-order-row">
${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("")}
<div class="weather-draft-order">
${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 class="weather-grid">
@@ -1100,6 +1167,7 @@ function renderWeatherDraftModal() {
<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);
@@ -1117,16 +1185,14 @@ function renderWeatherDraftModal() {
<span class="weather-action__icon" aria-hidden="true">☀</span>
<span><strong>Draft</strong></span>
</button>
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
<span class="weather-action__icon" aria-hidden="true">✕</span>
<span><strong>Ban</strong></span>
</button>
</div>
` : `<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>
`;
@@ -1147,22 +1213,23 @@ function renderInitiativeModal() {
const initiativeBonusStatus = getInitiativeBonusStatus();
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>
<p class="eyebrow">Round ${state.round}</p>
<h1 id="initiative-title">Initiative Draft</h1>
</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 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">
${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 class="initiative-seat-grid">
@@ -1174,7 +1241,7 @@ function renderInitiativeModal() {
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
<strong>Seat ${seatIndex + 1}</strong>
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
<span>${assignedPlayer ? escapeHtml(assignedPlayer.name) : "Open"}</span>
</button>
`;
}).join("")}
@@ -1197,7 +1264,7 @@ function renderScoreboard() {
<div class="score-card__head">
<div class="score-card__identity">
<span class="player-dot"></span>
<h2>${player.name}</h2>
<h2>${escapeHtml(player.name)}</h2>
</div>
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
</div>
@@ -1208,7 +1275,7 @@ function renderScoreboard() {
</div>
<div>
<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>
<span>Bank</span>
@@ -1262,14 +1329,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
});
}).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 node = parseKey(key);
const x = ((node.column + 0.5) / columns) * 100;
@@ -1298,15 +1357,6 @@ function renderAnimationOverlay(columns: number, rows: number) {
return `<div class="board__energy-cell board__energy-cell--bonus" style="--flash-delay: ${index * 175}ms; --flash-color: #ffd85e; left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
}).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 `
<div class="board__drop-layer board__drop-layer--${state.animation.phase}" aria-hidden="true">
${state.animation.phase === "bonus" ? bonusSunbeam : ""}
@@ -1317,13 +1367,103 @@ function renderAnimationOverlay(columns: number, rows: number) {
${state.animation.phase === "bonus" ? bonusFlashes : ""}
</div>
<svg class="board__fx board__fx--${state.animation.phase}" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
${roots}
${disease}
${sunbeam}
</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() {
const columns = state.config.columns;
const rows = state.config.rows;
@@ -1331,12 +1471,13 @@ function renderBoard() {
const parentMap = buildParentMap();
const lines = state.edges.map((edge) => {
const player = state.players[edge.ownerId];
const opacity = getTreeOpacity(edge.ownerId);
const x1 = ((edge.from.column + 0.5) / columns) * 100;
const y1 = ((edge.from.row + 0.5) / rows) * 100;
const x2 = ((edge.to.column + 0.5) / columns) * 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("");
const cells = Array.from({ length: rows }, (_, row) => {
@@ -1351,13 +1492,14 @@ function renderBoard() {
const background = columnLeader.ownerId === null || columnLeader.tied
? "transparent"
: tint(state.players[columnLeader.ownerId].color);
const treeOpacity = player ? getTreeOpacity(player.id) : 1;
return `
<button
class="cell${player ? " occupied" : ""}${target ? " target" : ""}${pending ? " pending" : ""}${isRoot ? " root" : ""}${state.selectedSource === nodeKey ? " selected" : ""}"
data-row="${row}"
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" : ""}
>
<span class="cell__shade"></span>
@@ -1386,18 +1528,18 @@ function renderSidebar() {
const player = getCurrentPlayer();
const rootShiftMoves = getSelectedRootShiftMoves();
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
? `<div class="active-effects">${state.activeRoundEffects.map((cardId) => {
const card = getWeatherCard(cardId);
return `<div class="effect-chip" title="${card?.description ?? ""}"><span class="effect-chip__title">${card?.title ?? cardId}</span><span class="effect-chip__rule">${card?.description ?? ""}</span></div>`;
}).join("")}</div>`
: `<p class="effect-empty">No weather effects active.</p>`;
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${state.players[index].name}: ${score}`).join(" | ");
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${escapeHtml(state.players[index].name)}: ${score}`).join(" | ");
const phaseHint = state.phase === "initiative"
? "Choose a seat for this round."
: state.phase === "weather"
? "Draft one card or ban one card."
? "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.`;
@@ -1440,7 +1582,7 @@ function renderSidebar() {
<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>Storehouse is active, so banking is enabled this round.</p>` : `<p>Banking is disabled unless Storehouse is active.</p>`}
${isBankingEnabled() ? `<p>Banking is enabled. Bank your remaining energy to save it for next round.</p>` : `<p>Banking is disabled in this game.</p>`}
</div>
</details>
<details class="accordion">
@@ -1465,7 +1607,7 @@ function renderSidebar() {
<details class="accordion">
<summary>Round Log</summary>
<div class="accordion__content log-list">
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
${state.history.slice(0, 8).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("")}
</div>
</details>
</section>
@@ -1528,6 +1670,10 @@ function attachEvents() {
document.querySelector("#end-turn")?.addEventListener("click", endTurn);
document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
document.querySelector("#draft-panel-toggle")?.addEventListener("click", () => {
isDraftPanelDocked = !isDraftPanelDocked;
render();
});
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
render();
@@ -1582,6 +1728,10 @@ function attachEvents() {
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
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) => {
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
render();
@@ -1679,11 +1829,15 @@ function render() {
${renderScoreboard()}
</footer>
</main>
${renderScoreFlightOverlay()}
${renderNewGameModal()}
${renderInitiativeModal()}
${renderWeatherDraftModal()}
`;
attachEvents();
requestAnimationFrame(() => {
positionScoreFlightBadges();
});
previousScoreSnapshot = getScoreSnapshot();
}

View File

@@ -2,7 +2,9 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
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%);
@@ -22,7 +24,8 @@
padding: 0;
}
html, body {
html,
body {
height: 100%;
width: 100%;
overflow: hidden;
@@ -56,7 +59,10 @@ html, body {
grid-template-areas:
"main sidebar"
"bottom bottom";
grid-template-columns: 1fr minmax(var(--sidebar-min-width), var(--sidebar-max-width));
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);
@@ -128,7 +134,11 @@ html, body {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1.2rem 1.25rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
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;
@@ -139,16 +149,18 @@ html, body {
.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);
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);
border-radius: 1.1rem;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px);
padding: 0.75rem;
padding: 2px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -205,7 +217,11 @@ html, body {
.cell__shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), var(--column-tint));
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.03),
var(--column-tint)
);
}
.cell__root-ring {
@@ -213,6 +229,7 @@ html, body {
inset: 18% 18%;
border: 1px dashed rgba(255, 255, 255, 0.28);
border-radius: 999px;
opacity: var(--tree-opacity, 1);
}
.cell__node,
@@ -227,8 +244,11 @@ html, body {
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);
box-shadow:
0 0 0 0.15rem rgba(255, 255, 255, 0.06),
0 0 1rem var(--node-glow);
aspect-ratio: 1 / 1;
opacity: var(--tree-opacity, 1);
}
.cell.selected {
@@ -238,11 +258,16 @@ html, body {
.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);
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);
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 {
@@ -305,18 +330,37 @@ html, body {
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);
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);
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,
@@ -327,7 +371,9 @@ html, body {
}
.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);
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 {
@@ -342,8 +388,15 @@ html, body {
}
.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);
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 {
@@ -352,27 +405,53 @@ html, body {
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__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 {
.board__disease-mark {
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;
.score-flight-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
isolation: isolate;
contain: strict;
transform: translateZ(0);
backface-visibility: hidden;
}
.board__root-burst text {
fill: #08111c;
font-size: 2.1px;
.score-flight-badge {
position: absolute;
transform: translate(-50%, -50%);
min-width: 2.8rem;
height: 2.8rem;
padding: 0 0.64rem;
display: grid;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white);
border: 1px solid rgba(255, 255, 255, 0.65);
color: #08111c;
font-size: 1.05rem;
font-weight: 800;
line-height: 1;
box-shadow: 0 0 20px
color-mix(in srgb, var(--burst-color, #ffd85e) 32%, transparent);
z-index: 100;
opacity: 0;
animation: score-flight 2.2s cubic-bezier(0.12, 0.8, 0.22, 1) forwards;
animation-delay: var(--flight-delay, 0ms);
will-change: transform;
}
.board__disease-mark circle {
@@ -387,12 +466,9 @@ html, body {
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;
.board--events .board__disease-mark {
animation: pop-fade 1.35s cubic-bezier(0.18, 0.9, 0.28, 1) forwards;
animation-delay: var(--trace-delay, 0ms);
}
@@ -486,6 +562,10 @@ label span,
animation: score-pop 0.7s ease;
}
.score-value--landing {
animation: score-land 0.5s cubic-bezier(0.18, 0.9, 0.24, 1);
}
.player-dot {
width: 0.85rem;
height: 0.85rem;
@@ -572,7 +652,11 @@ button:disabled {
min-height: 3.4rem;
padding: 0.75rem 0.9rem;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04));
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);
}
@@ -594,8 +678,13 @@ button:disabled {
gap: 0.15rem;
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));
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));
}
.economy-line {
@@ -742,7 +831,11 @@ input[type="range"] {
color: rgba(231, 238, 247, 0.68);
border: 1px solid transparent;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease,
transform 120ms ease;
}
.setup-tab--active {
@@ -934,17 +1027,34 @@ input[type="range"] {
}
.start-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(2.4rem, 1fr));
display: flex;
flex-wrap: nowrap;
gap: 0.35rem;
padding: 0.85rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.start-strip::-webkit-scrollbar {
height: 6px;
}
.start-strip::-webkit-scrollbar-track {
background: transparent;
}
.start-strip::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.start-strip__slot {
min-height: 4.2rem;
min-width: 1.8rem;
min-height: 3.5rem;
display: grid;
align-content: start;
justify-items: center;
@@ -952,6 +1062,7 @@ input[type="range"] {
padding: 0.45rem 0.2rem;
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.025);
flex-shrink: 0;
}
.start-strip__label {
@@ -961,15 +1072,19 @@ input[type="range"] {
.start-marker {
appearance: none;
width: 2rem;
height: 2rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--player-color) 55%, white);
background: var(--player-color);
color: #08111c;
font-weight: 800;
font-size: 0.7rem;
cursor: grab;
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 0.9rem var(--player-glow);
box-shadow:
0 0 0 0.15rem rgba(255, 255, 255, 0.06),
0 0 0.9rem var(--player-glow);
flex-shrink: 0;
}
.start-marker:active {
@@ -1040,15 +1155,29 @@ input[type="range"] {
position: fixed;
top: 1rem;
right: 1rem;
width: max(320px, 30%);
width: min(1100px, calc(100vw - 2rem));
max-width: calc(100% - 2rem);
max-height: calc(100% - 9.5rem);
max-height: calc(100% - 7.25rem);
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);
transition:
width 180ms ease,
max-height 180ms ease;
}
.draft-panel--docked {
width: max(320px, 30%);
}
.draft-panel__toggle {
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0;
font-size: 1.1rem;
}
.initiative-order-row {
@@ -1119,13 +1248,92 @@ input[type="range"] {
font-size: 0.86rem;
}
.weather-draft-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.weather-draft-instructions {
font-size: 0.9rem;
color: rgba(231, 238, 247, 0.9);
margin: 0;
}
.weather-draft-actions {
display: flex;
gap: 1.5rem;
padding: 0.5rem 0;
}
.weather-draft-action {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: rgba(231, 238, 247, 0.8);
}
.weather-draft-action strong {
color: #f4f7fb;
font-weight: 700;
}
.weather-draft-order {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
align-items: flex-start;
}
.weather-draft-player {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 0.4rem 0.6rem;
border-radius: 0.6rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
min-width: 4rem;
}
.weather-draft-player--active {
border-color: color-mix(in srgb, var(--player-color) 55%, white);
background: color-mix(
in srgb,
var(--player-color) 15%,
rgba(255, 255, 255, 0.08)
);
box-shadow: 0 0 12px color-mix(in srgb, var(--player-color) 30%, transparent);
}
.weather-draft-player__name {
font-weight: 600;
font-size: 0.85rem;
color: #f4f7fb;
}
.weather-draft-player__label {
font-size: 0.7rem;
color: rgba(231, 238, 247, 0.6);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.weather-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 0.9rem;
}
.draft-panel--docked .weather-grid {
grid-template-columns: 1fr;
}
.weather-card {
display: grid;
gap: 0.8rem;
@@ -1145,6 +1353,13 @@ input[type="range"] {
gap: 0.7rem;
}
.weather-offer-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.2rem;
gap: 0.7rem;
align-items: stretch;
}
.weather-pair__divider {
font-size: 0.7rem;
letter-spacing: 0.08em;
@@ -1158,7 +1373,10 @@ input[type="range"] {
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: transform 120ms ease, opacity 120ms ease, padding 120ms ease;
transition:
transform 120ms ease,
opacity 120ms ease,
padding 120ms ease;
}
.weather-pair__option--drafted {
@@ -1246,7 +1464,11 @@ input[type="range"] {
}
.weather-action--draft {
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
background: linear-gradient(
180deg,
rgba(255, 208, 96, 0.2),
rgba(255, 208, 96, 0.08)
);
border-color: rgba(255, 208, 96, 0.4);
}
@@ -1255,7 +1477,11 @@ input[type="range"] {
}
.weather-action--ban {
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
background: linear-gradient(
180deg,
rgba(255, 110, 110, 0.16),
rgba(255, 110, 110, 0.06)
);
border-color: rgba(255, 110, 110, 0.32);
}
@@ -1263,6 +1489,26 @@ input[type="range"] {
background: rgba(255, 110, 110, 0.18);
}
.weather-action--ban-both {
align-self: stretch;
justify-content: center;
text-align: center;
background: linear-gradient(
180deg,
rgba(255, 110, 110, 0.16),
rgba(255, 110, 110, 0.06)
);
border-color: rgba(255, 110, 110, 0.32);
flex-direction: column;
gap: 0.5rem;
}
.weather-action--ban-both .weather-action__icon {
width: 2rem;
height: 2rem;
background: rgba(255, 110, 110, 0.18);
}
.weather-action:hover,
.initiative-seat:hover {
transform: translateY(-1px);
@@ -1293,15 +1539,19 @@ input[type="range"] {
@keyframes pop-fade {
0% {
opacity: 0;
transform: scale(0.65);
transform: translate(-50%, -50%) scale(0.4);
}
25% {
18% {
opacity: 1;
transform: scale(1);
transform: translate(-50%, -50%) scale(1.12);
}
40% {
opacity: 0.95;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 1;
transform: scale(1);
opacity: 0;
transform: translate(-50%, -50%) scale(1);
}
}
@@ -1335,6 +1585,55 @@ input[type="range"] {
}
}
@keyframes score-flight {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.35);
}
10% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.16);
}
48% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
72% {
opacity: 0.96;
transform: translate(
calc(-50% + var(--flight-mid-x)),
calc(-50% + var(--flight-mid-y))
)
scale(1);
}
100% {
opacity: 0;
transform: translate(
calc(-50% + var(--flight-x)),
calc(-50% + var(--flight-y))
)
scale(0.86);
}
}
@keyframes score-land {
0% {
transform: scale(1);
color: inherit;
text-shadow: 0 0 0 rgba(255, 230, 153, 0);
}
40% {
transform: scale(1.22);
color: #fff2b0;
text-shadow: 0 0 16px rgba(255, 226, 120, 0.85);
}
100% {
transform: scale(1);
color: inherit;
text-shadow: 0 0 0 rgba(255, 230, 153, 0);
}
}
/* Fullscreen mode adjustments */
:fullscreen #app > *,
:-webkit-full-screen #app > *,
@@ -1367,6 +1666,16 @@ input[type="range"] {
.score-card__numbers strong {
font-size: 1rem;
}
.draft-panel,
.draft-panel--docked {
width: calc(100vw - 2rem);
}
.weather-grid,
.draft-panel--docked .weather-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {