Fix weather draft and deploy setup

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

View File

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

960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,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 { keyFor, parseKey, shuffleArray } from "./utils";
import { buildChildrenMap, buildParentMap } from "./rules-board"; import { buildChildrenMap, buildParentMap } from "./rules-board";
@@ -20,7 +27,7 @@ function getLeafCounts(state: GameState) {
const counts = state.players.map(() => 0); const counts = state.players.map(() => 0);
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => { Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
if (!(childrenMap.get(nodeKey)?.length)) { if (!childrenMap.get(nodeKey)?.length) {
counts[node.ownerId] += 1; counts[node.ownerId] += 1;
} }
}); });
@@ -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; const contested = playersPresent.length > 1;
if (!contested) { if (!contested) {
@@ -63,7 +75,7 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
if (state.activeRoundEffects.includes("split_light")) { if (state.activeRoundEffects.includes("split_light")) {
playersPresent.forEach((playerId) => { playersPresent.forEach((playerId) => {
scores[playerId] += 0.5; scores[playerId] += 1;
}); });
return; return;
} }
@@ -71,7 +83,11 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
scores[ownerId] += 1; scores[ownerId] += 1;
} }
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) { function applyWeatherEffects(
state: GameState,
scores: number[],
energySimulation: EnergySimulation,
) {
if (state.activeRoundEffects.length === 0) { if (state.activeRoundEffects.length === 0) {
return; return;
} }
@@ -106,29 +122,51 @@ function applyWeatherEffects(state: GameState, scores: number[], energySimulatio
} }
if (effectId === "branching_season") { if (effectId === "branching_season") {
// Find the player with the most leaves
let maxLeaves = 0;
let playerWithMostLeaves = -1;
leafCounts.forEach((count, playerId) => { leafCounts.forEach((count, playerId) => {
scores[playerId] += Math.max(0, count - 1); 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; return;
} }
if (effectId === "tall_reward") { if (effectId === "tall_reward" || effectId === "deep_roots") {
const bestRow = tallestLeaves.reduce<number | null>((currentBest, row) => { const bestRow = tallestLeaves.reduce<number | null>(
(currentBest, row) => {
if (row === null) { if (row === null) {
return currentBest; 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 row;
} }
return currentBest; return currentBest;
}, null); },
null,
);
if (bestRow !== null) { if (bestRow !== null) {
tallestLeaves.forEach((row, playerId) => { tallestLeaves.forEach((row, playerId) => {
if (row === bestRow) { 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") { if (region === "center") {
highNoonCounts[column.ownerId] += 1; 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; scores[column.ownerId] += 1;
} }
}); });
@@ -245,7 +286,12 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
return map; return map;
} }
const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 }; const entry = map.get(column.rootKey) ?? {
key: column.rootKey,
playerId: column.ownerId as PlayerId,
count: 0,
displayCount: 0,
};
entry.count += 1; entry.count += 1;
map.set(column.rootKey, entry); map.set(column.rootKey, entry);
return map; return map;
@@ -254,6 +300,33 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
applyWeatherEffects(state, scores, { scores, columns, rootBursts }); 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 { return {
scores, scores,
columns, columns,
@@ -271,7 +344,10 @@ export function buildRoundAnimation(
.filter((column) => column.intercepted) .filter((column) => column.intercepted)
.map((column) => ({ .map((column) => ({
playerId: column.ownerId as PlayerId, playerId: column.ownerId as PlayerId,
verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })), verticalCells: Array.from(
{ length: column.terminalRow + 1 },
(_, row) => ({ row, column: column.column }),
),
ray: { ray: {
x: ((column.column + 0.5) / state.config.columns) * 100, x: ((column.column + 0.5) / state.config.columns) * 100,
y: ((column.terminalRow + 0.5) / state.config.rows) * 100, y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
@@ -280,8 +356,15 @@ export function buildRoundAnimation(
branchNodes: column.branchNodes, branchNodes: column.branchNodes,
})); }));
const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null; const bonusTrace =
const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null; sunbeamPlayerId === null
? null
: (traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null);
const bonusBurst = bonusTrace
? (energySimulation.rootBursts.find(
(burst) => burst.key === bonusTrace.rootKey,
) ?? null)
: null;
return { return {
phase: "sunlight", phase: "sunlight",
@@ -342,7 +425,11 @@ export function maybeRollDisease(state: GameState) {
const parentMap = buildParentMap(state); const parentMap = buildParentMap(state);
const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => { const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
const { row } = parseKey(nodeKey); const { row } = parseKey(nodeKey);
return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey); return (
row !== state.config.rows - 1 &&
!childrenMap.has(nodeKey) &&
parentMap.has(nodeKey)
);
}); });
if (twigKeys.length === 0) { if (twigKeys.length === 0) {
@@ -353,7 +440,10 @@ export function maybeRollDisease(state: GameState) {
} }
const shuffled = shuffleArray(twigKeys); const shuffled = shuffleArray(twigKeys);
const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length))); const killCount = Math.min(
shuffled.length,
1 + Math.floor(Math.random() * Math.min(3, shuffled.length)),
);
const killedKeys = shuffled.slice(0, killCount); const killedKeys = shuffled.slice(0, killCount);
return { return {

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,7 +2,9 @@
:root { :root {
color-scheme: dark; 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: background:
radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%), radial-gradient(circle at top, rgba(48, 72, 104, 0.35), transparent 42%),
linear-gradient(180deg, #0b1220 0%, #070b13 100%); linear-gradient(180deg, #0b1220 0%, #070b13 100%);
@@ -22,7 +24,8 @@
padding: 0; padding: 0;
} }
html, body { html,
body {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -56,7 +59,10 @@ html, body {
grid-template-areas: grid-template-areas:
"main sidebar" "main sidebar"
"bottom bottom"; "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); grid-template-rows: 1fr var(--bottom-bar-height);
gap: var(--gap-size); gap: var(--gap-size);
padding: var(--padding-size); padding: var(--padding-size);
@@ -128,7 +134,11 @@ html, body {
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem; border-radius: 1rem;
padding: 1.2rem 1.25rem; 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); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -139,16 +149,18 @@ html, body {
.score-card.active { .score-card.active {
border-color: color-mix(in srgb, var(--player-color) 55%, white); 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 */ /* Sidebar panels */
.panel { .panel {
border-radius: 1.25rem; border-radius: 1.1rem;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(9, 16, 29, 0.72); background: rgba(9, 16, 29, 0.72);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
padding: 0.75rem; padding: 2px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -205,7 +217,11 @@ html, body {
.cell__shade { .cell__shade {
position: absolute; position: absolute;
inset: 0; 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 { .cell__root-ring {
@@ -213,6 +229,7 @@ html, body {
inset: 18% 18%; inset: 18% 18%;
border: 1px dashed rgba(255, 255, 255, 0.28); border: 1px dashed rgba(255, 255, 255, 0.28);
border-radius: 999px; border-radius: 999px;
opacity: var(--tree-opacity, 1);
} }
.cell__node, .cell__node,
@@ -227,8 +244,11 @@ html, body {
height: clamp(8px, 35%, 1.2rem); height: clamp(8px, 35%, 1.2rem);
border-radius: 50%; border-radius: 50%;
background: var(--node-color); 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; aspect-ratio: 1 / 1;
opacity: var(--tree-opacity, 1);
} }
.cell.selected { .cell.selected {
@@ -238,11 +258,16 @@ html, body {
.cell.pending { .cell.pending {
border-color: rgba(255, 255, 255, 0.28); 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 { .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 { .cell.target {
@@ -305,18 +330,37 @@ html, body {
position: absolute; position: absolute;
border-radius: clamp(4px, 15%, 0.6rem); border-radius: clamp(4px, 15%, 0.6rem);
background: 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%), radial-gradient(
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent); circle at 50% 50%,
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); 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; opacity: 0;
} }
.board__energy-cell--sunlight { .board__energy-cell--sunlight {
inset: 12%; inset: 12%;
background: background:
linear-gradient(180deg, rgba(255, 244, 214, 0.05), rgba(255, 244, 214, 0.008)), linear-gradient(
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%); 180deg,
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); 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--sunlight .board__energy-cell--sunlight,
@@ -327,7 +371,9 @@ html, body {
} }
.board__energy-cell--bonus { .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 { .board--bonus .board__drop--bonus {
@@ -342,8 +388,15 @@ html, body {
} }
.board__drop-core { .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%); background: radial-gradient(
box-shadow: 0 0 18px color-mix(in srgb, var(--drop-color) 40%, #ffe38a), 0 0 32px rgba(255, 236, 174, 0.65); 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 { .board__drop-spark {
@@ -352,27 +405,53 @@ html, body {
opacity: 0.9; opacity: 0.9;
} }
.board__drop-spark--a { transform: translate(-0.8rem, -0.15rem) scale(0.55); } .board__drop-spark--a {
.board__drop-spark--b { transform: translate(0.75rem, -0.1rem) scale(0.45); } transform: translate(-0.8rem, -0.15rem) scale(0.55);
.board__drop-spark--c { transform: translate(0.1rem, -0.75rem) scale(0.35); } }
.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__disease-mark,
.board__sunbeam-burst {
opacity: 0; opacity: 0;
} }
.board__root-burst circle, .score-flight-layer {
.board__sunbeam-burst { position: fixed;
fill: color-mix(in srgb, var(--burst-color, #ffd85e) 70%, white); inset: 0;
stroke: rgba(255, 255, 255, 0.65); pointer-events: none;
stroke-width: 0.35; z-index: 9999;
isolation: isolate;
contain: strict;
transform: translateZ(0);
backface-visibility: hidden;
} }
.board__root-burst text { .score-flight-badge {
fill: #08111c; position: absolute;
font-size: 2.1px; 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; 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 { .board__disease-mark circle {
@@ -387,12 +466,9 @@ html, body {
stroke-linecap: round; stroke-linecap: round;
} }
.board--branches .board__root-burst,
.board--events .board__root-burst,
.board--events .board__disease-mark, .board--events .board__disease-mark,
.board--bonus .board__sunbeam-burst, .board--events .board__disease-mark {
.board--events .board__sunbeam-burst { animation: pop-fade 1.35s cubic-bezier(0.18, 0.9, 0.28, 1) forwards;
animation: pop-fade 0.8s ease forwards;
animation-delay: var(--trace-delay, 0ms); animation-delay: var(--trace-delay, 0ms);
} }
@@ -486,6 +562,10 @@ label span,
animation: score-pop 0.7s ease; 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 { .player-dot {
width: 0.85rem; width: 0.85rem;
height: 0.85rem; height: 0.85rem;
@@ -572,7 +652,11 @@ button:disabled {
min-height: 3.4rem; min-height: 3.4rem;
padding: 0.75rem 0.9rem; padding: 0.75rem 0.9rem;
border-radius: 1rem; 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); border: 1px solid rgba(255, 208, 96, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
} }
@@ -594,8 +678,13 @@ button:disabled {
gap: 0.15rem; gap: 0.15rem;
padding: 0.6rem; padding: 0.6rem;
border-radius: 0.85rem; 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)); background: linear-gradient(
border: 1px solid color-mix(in srgb, var(--player-color) 35%, rgba(255, 255, 255, 0.08)); 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 { .economy-line {
@@ -742,7 +831,11 @@ input[type="range"] {
color: rgba(231, 238, 247, 0.68); color: rgba(231, 238, 247, 0.68);
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; 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 { .setup-tab--active {
@@ -934,17 +1027,34 @@ input[type="range"] {
} }
.start-strip { .start-strip {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(2.4rem, 1fr)); flex-wrap: nowrap;
gap: 0.35rem; gap: 0.35rem;
padding: 0.85rem; padding: 0.85rem;
border-radius: 1rem; border-radius: 1rem;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06); 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 { .start-strip__slot {
min-height: 4.2rem; min-width: 1.8rem;
min-height: 3.5rem;
display: grid; display: grid;
align-content: start; align-content: start;
justify-items: center; justify-items: center;
@@ -952,6 +1062,7 @@ input[type="range"] {
padding: 0.45rem 0.2rem; padding: 0.45rem 0.2rem;
border-radius: 0.8rem; border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.025); background: rgba(255, 255, 255, 0.025);
flex-shrink: 0;
} }
.start-strip__label { .start-strip__label {
@@ -961,15 +1072,19 @@ input[type="range"] {
.start-marker { .start-marker {
appearance: none; appearance: none;
width: 2rem; width: 1.5rem;
height: 2rem; height: 1.5rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--player-color) 55%, white); border: 1px solid color-mix(in srgb, var(--player-color) 55%, white);
background: var(--player-color); background: var(--player-color);
color: #08111c; color: #08111c;
font-weight: 800; font-weight: 800;
font-size: 0.7rem;
cursor: grab; 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 { .start-marker:active {
@@ -1040,15 +1155,29 @@ input[type="range"] {
position: fixed; position: fixed;
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
width: max(320px, 30%); width: min(1100px, calc(100vw - 2rem));
max-width: calc(100% - 2rem); max-width: calc(100% - 2rem);
max-height: calc(100% - 9.5rem); max-height: calc(100% - 7.25rem);
overflow: auto; overflow: auto;
z-index: 24; z-index: 24;
border-color: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.12);
background: rgba(9, 16, 29, 0.5); background: rgba(9, 16, 29, 0.5);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32); 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 { .initiative-order-row {
@@ -1119,13 +1248,92 @@ input[type="range"] {
font-size: 0.86rem; 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 { .weather-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem; gap: 0.9rem;
margin-top: 0.9rem; margin-top: 0.9rem;
} }
.draft-panel--docked .weather-grid {
grid-template-columns: 1fr;
}
.weather-card { .weather-card {
display: grid; display: grid;
gap: 0.8rem; gap: 0.8rem;
@@ -1145,6 +1353,13 @@ input[type="range"] {
gap: 0.7rem; 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 { .weather-pair__divider {
font-size: 0.7rem; font-size: 0.7rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -1158,7 +1373,10 @@ input[type="range"] {
border-radius: 0.85rem; border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08); 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 { .weather-pair__option--drafted {
@@ -1246,7 +1464,11 @@ input[type="range"] {
} }
.weather-action--draft { .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); border-color: rgba(255, 208, 96, 0.4);
} }
@@ -1255,7 +1477,11 @@ input[type="range"] {
} }
.weather-action--ban { .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); border-color: rgba(255, 110, 110, 0.32);
} }
@@ -1263,6 +1489,26 @@ input[type="range"] {
background: rgba(255, 110, 110, 0.18); 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, .weather-action:hover,
.initiative-seat:hover { .initiative-seat:hover {
transform: translateY(-1px); transform: translateY(-1px);
@@ -1293,15 +1539,19 @@ input[type="range"] {
@keyframes pop-fade { @keyframes pop-fade {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.65); transform: translate(-50%, -50%) scale(0.4);
} }
25% { 18% {
opacity: 1; opacity: 1;
transform: scale(1); transform: translate(-50%, -50%) scale(1.12);
}
40% {
opacity: 0.95;
transform: translate(-50%, -50%) scale(1);
} }
100% { 100% {
opacity: 1; opacity: 0;
transform: scale(1); 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 mode adjustments */
:fullscreen #app > *, :fullscreen #app > *,
:-webkit-full-screen #app > *, :-webkit-full-screen #app > *,
@@ -1367,6 +1666,16 @@ input[type="range"] {
.score-card__numbers strong { .score-card__numbers strong {
font-size: 1rem; 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) { @media (max-width: 700px) {