Refine setup and draft interactions

This commit is contained in:
2026-04-10 13:36:43 -04:00
parent e11264168c
commit 30e3f88b21
7 changed files with 718 additions and 167 deletions

View File

@@ -24,13 +24,15 @@ import {
} from "./rules-scoring";
import {
createInitiativeDraft,
getInitiativeGraceRounds,
} from "./rules-initiative";
import {
WEATHER_CARDS,
WEATHER_OFFER_PAIRS,
createWeatherDraft,
getCurrentWeatherPlayerId,
getWeatherCard,
isWeatherCardAvailable,
isWeatherOfferResolved,
} from "./rules-weather";
import {
createInitialState,
@@ -38,6 +40,7 @@ import {
createRandomizedSeedInputs,
createSetupState,
getMaxStartingNodesPerPlayer,
normalizeSeedInputs,
} from "./state";
import type {
GameState,
@@ -64,10 +67,12 @@ let state: GameState = createInitialState(setup);
let isNewGameModalOpen = false;
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
let setupTab: "board" | "rules" | "events" | "players" = "board";
let draggedSetupSeed: { playerId: number; seedIndex: number } | null = null;
function rebuildSetup(overrides: Partial<SetupState> = {}) {
setup = createSetupState(
overrides.playerCount ?? setup.playerCount,
overrides.playerNames ?? setup.playerNames,
overrides.columns ?? setup.columns,
overrides.rows ?? setup.rows,
overrides.startingNodesPerPlayer ?? setup.startingNodesPerPlayer,
@@ -89,6 +94,10 @@ function getLiveExposureScores() {
return buildEnergySimulation(state).scores;
}
function getProjectedIncomeScores() {
return getLiveExposureScores().map((score) => score + 1);
}
function getTopLeafCount() {
const childrenMap = buildChildrenMap();
let count = 0;
@@ -120,9 +129,9 @@ function getWinConditionSummary() {
}
function getScoreSnapshot() {
const exposureScores = getLiveExposureScores();
const projectedIncomeScores = getProjectedIncomeScores();
return state.players.map((player, index) => ({
currentExposure: exposureScores[index],
projectedIncome: projectedIncomeScores[index],
growthPoints: player.growthPoints,
bankedPoints: player.bankedPoints,
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
@@ -133,6 +142,10 @@ function getCurrentPlayer() {
return state.players[state.activePlayerId];
}
function getPlayerById(playerId: number) {
return state.players.find((player) => player.id === playerId) ?? null;
}
function getOrderedPlayers(playerIds: number[]) {
return playerIds.map((playerId) => state.players[playerId]);
}
@@ -170,6 +183,17 @@ function getCurrentBiddingPlayer() {
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
}
function getInitiativeBonusStatus() {
const graceRounds = getInitiativeGraceRounds(state);
const roundsRemaining = Math.max(0, Math.ceil(graceRounds - (state.round - 1)));
const bonusActive = roundsRemaining === 0;
return {
bonusActive,
roundsRemaining,
};
}
function getCurrentWeatherDraftPlayer() {
if (!state.weatherDraft) {
return null;
@@ -212,6 +236,13 @@ function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
return moves;
}
const freeVerticalMovesUsed = state.turnMoves.filter((move) => move.type === "grow" && move.cost === 0).length;
const freeVerticalMovesRemaining = Math.max(0, 3 - freeVerticalMovesUsed);
if (freeVerticalMovesRemaining <= 0) {
return moves;
}
return moves.map((move) => move.direction === "vertical"
? { ...move, cost: Math.max(0, move.cost - 1) }
: move);
@@ -409,15 +440,17 @@ function finalizeInitiativeDraft() {
nextTurnOrder.forEach((playerId, seatIndex) => {
const bonus = draft.seatBonuses[seatIndex] ?? 0;
if (bonus > 0) {
awardGrowth(state.players[playerId], bonus);
const player = getPlayerById(playerId);
if (bonus > 0 && player) {
awardGrowth(player, bonus);
}
});
const seatSummary = nextTurnOrder
.map((playerId, seatIndex) => {
const bonus = draft.seatBonuses[seatIndex] ?? 0;
return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
const player = getPlayerById(playerId);
return `${player?.name ?? `Player ${playerId + 1}`}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
})
.join(" | ");
@@ -434,7 +467,7 @@ function chooseInitiativeSeat(seatIndex: number) {
const playerId = draft.biddingOrder[draft.biddingIndex];
draft.seatAssignments[seatIndex] = playerId;
state.history.unshift(`${state.players[playerId].name} claimed seat ${seatIndex + 1} for round ${state.round}.`);
state.history.unshift(`${getPlayerById(playerId)?.name ?? `Player ${playerId + 1}`} claimed seat ${seatIndex + 1} for round ${state.round}.`);
if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
finalizeInitiativeDraft();
@@ -481,18 +514,19 @@ function finalizeWeatherDraft() {
moveToFirstPlayableTurn();
}
function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") {
function chooseWeatherAction(offerId: string, cardId: WeatherCardId, action: "draft" | "ban") {
const draft = state.weatherDraft;
if (!draft || !isWeatherCardAvailable(draft, cardId)) {
if (!draft || !isWeatherCardAvailable(draft, offerId, cardId)) {
return;
}
const playerId = getCurrentWeatherPlayerId(draft);
const card = getWeatherCard(cardId);
if (action === "draft") {
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}.`);
}
@@ -790,6 +824,45 @@ function moveSetupPlayer(fromIndex: number, toIndex: number) {
[setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]];
[setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]];
[setup.playerNames[fromIndex], setup.playerNames[toIndex]] = [setup.playerNames[toIndex], setup.playerNames[fromIndex]];
render();
}
function getSetupSeedColumns() {
return normalizeSeedInputs(setup).map((columns) => [...columns]);
}
function setSetupSeedColumns(seedColumnsByPlayer: number[][]) {
setup.seedInputs = seedColumnsByPlayer.map((columns) => columns.join(", ") ? columns.map((column) => String(column + 1)).join(", ") : "");
}
function moveSetupSeed(playerId: number, seedIndex: number, targetColumn: number) {
const seedColumnsByPlayer = getSetupSeedColumns();
const originColumn = seedColumnsByPlayer[playerId]?.[seedIndex];
if (originColumn === undefined || originColumn === targetColumn) {
return;
}
let swapped = false;
seedColumnsByPlayer.forEach((columns, otherPlayerId) => {
columns.forEach((column, otherSeedIndex) => {
if (otherPlayerId === playerId && otherSeedIndex === seedIndex) {
return;
}
if (column === targetColumn) {
seedColumnsByPlayer[otherPlayerId][otherSeedIndex] = originColumn;
swapped = true;
}
});
});
seedColumnsByPlayer[playerId][seedIndex] = targetColumn;
if (!swapped) {
seedColumnsByPlayer[playerId] = [...seedColumnsByPlayer[playerId]];
}
setSetupSeedColumns(seedColumnsByPlayer);
render();
}
@@ -809,8 +882,15 @@ function renderNewGameModal() {
}
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder);
const draftCountMax = WEATHER_CARDS.length;
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder, setup.playerNames);
const draftCountMax = WEATHER_OFFER_PAIRS.length;
const previewSeedColumns = getSetupSeedColumns();
const seedMarkers = previewSeedColumns.flatMap((columns, playerId) => columns.map((column, seedIndex) => ({
playerId,
seedIndex,
column,
player: previewPlayers[playerId],
})));
return `
<div class="modal-backdrop" id="new-game-modal-backdrop">
@@ -820,7 +900,6 @@ function renderNewGameModal() {
<p class="eyebrow">New Game</p>
<h1 id="new-game-title">Configure the next canopy</h1>
</div>
<button class="ghost-button" id="close-new-game">Close</button>
</header>
<nav class="setup-tabs" aria-label="Setup categories">
<button class="setup-tab${setupTab === "board" ? " setup-tab--active" : ""}" data-setup-tab="board"><span aria-hidden="true">▦</span><span>Board</span></button>
@@ -834,11 +913,12 @@ function renderNewGameModal() {
<section class="setup-section">
<h2 class="setup-section__title">Board Settings</h2>
<div class="setup-grid">
<label class="setup-field setup-field--range">
<label class="setup-field">
<span class="setup-field__label">Players</span>
<div class="setup-field__input">
<input id="player-count" type="range" min="2" max="6" step="1" value="${setup.playerCount}" />
<strong class="setup-field__value">${setup.playerCount}</strong>
<div class="setup-stepper">
<button class="stepper-button" id="player-count-decrease" ${setup.playerCount <= 2 ? "disabled" : ""}>-</button>
<strong class="setup-stepper__value">${setup.playerCount}</strong>
<button class="stepper-button" id="player-count-increase" ${setup.playerCount >= 8 ? "disabled" : ""}>+</button>
</div>
</label>
<label class="setup-field">
@@ -934,7 +1014,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>
<span class="player-row__name">${currentPlayer.name}</span>
<input class="player-name-input" data-player-id="${index}" type="text" value="${currentPlayer.name}" aria-label="${currentPlayer.name} name" />
</div>
<div class="player-row__actions">
<button class="mini-button" data-move-player="${index}" data-direction="up" ${index === 0 ? "disabled" : ""}>↑</button>
@@ -946,8 +1026,19 @@ function renderNewGameModal() {
</section>
<section class="setup-section">
<h2 class="setup-section__title">Starting columns</h2>
<p class="setup-section__help">Use 1-based column numbers. Duplicate or invalid picks are auto-corrected.</p>
<p class="setup-section__help">Drag markers on the strip to move starting seeds. Text inputs remain available as a fallback.</p>
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
<div class="start-strip" role="group" aria-label="Starting positions preview">
${Array.from({ length: setup.columns }, (_, column) => {
const marker = seedMarkers.find((entry) => entry.column === column);
return `
<div class="start-strip__slot" data-start-slot="${column}">
<span class="start-strip__label">${column + 1}</span>
${marker ? `<button class="start-marker" draggable="true" data-start-marker="${marker.playerId}:${marker.seedIndex}" style="--player-color: ${marker.player.color}; --player-glow: ${marker.player.glow};">${marker.player.name.slice(0, 1) || marker.playerId + 1}</button>` : ""}
</div>
`;
}).join("")}
</div>
<div class="player-list">
${previewPlayers.map((currentPlayer, index) => `
<label class="setup-field">
@@ -962,7 +1053,7 @@ function renderNewGameModal() {
<footer class="modal-footer">
<button class="ghost-button" id="cancel-new-game">Cancel</button>
<button id="start-new-game">Start New Game</button>
<button class="primary-button" id="start-new-game">Start New Game</button>
</footer>
</section>
</div>
@@ -986,40 +1077,49 @@ function renderWeatherDraftModal() {
</div>
</div>
<div class="seed-editor">
<p class="seed-help">${currentPlayer?.name ?? "A player"} must draft one card for this round or ban one to deny it.</p>
<p class="seed-help">${currentPlayer?.name ?? "A player"} can draft or ban either card in each offer.</p>
<div class="weather-key" aria-label="Weather action key">
<span><strong>☀ Draft</strong>: apply to the board for 1 round</span>
<span><strong>✕ Ban</strong>: remove it this round</span>
<span><strong>☀ Draft</strong>: take that card for 1 round</span>
<span><strong>✕ Ban</strong>: remove just that card</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>
</div>
<div class="weather-grid">
${draft.row.map((cardId) => {
${draft.offers.map((offer) => {
const resolved = isWeatherOfferResolved(draft, offer.id);
return `
<article class="weather-card${resolved ? " weather-card--resolved" : ""}">
<div>
<p class="eyebrow">Offer</p>
<div class="weather-pair">
${offer.options.map((cardId) => {
const card = getWeatherCard(cardId);
const drafted = draft.drafted.includes(cardId);
const banned = draft.banned.includes(cardId);
const available = !drafted && !banned;
const available = isWeatherCardAvailable(draft, offer.id, cardId);
return `
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
<div>
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
<div class="weather-pair__option${drafted ? " weather-pair__option--drafted" : ""}${banned ? " weather-pair__option--banned" : ""}">
<h2>${card?.title ?? cardId}</h2>
<p>${card?.description ?? ""}</p>
</div>
${available ? `
<div class="weather-card__actions">
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-card="${cardId}">
<button class="weather-action weather-action--draft" data-weather-action="draft" data-weather-offer="${offer.id}" data-weather-card="${cardId}">
<span class="weather-action__icon" aria-hidden="true">☀</span>
<span><strong>Draft</strong></span>
</button>
<button class="weather-action weather-action--ban" data-weather-action="ban" data-weather-card="${cardId}">
<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"}</p>`}
</div>
`;
}).join("")}
</div>
</div>
</article>
`;
}).join("")}
@@ -1036,6 +1136,7 @@ function renderInitiativeModal() {
const draft = state.initiativeDraft;
const currentBidder = getCurrentBiddingPlayer();
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
const initiativeBonusStatus = getInitiativeBonusStatus();
return `
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
@@ -1047,6 +1148,11 @@ function renderInitiativeModal() {
</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="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("")}
</div>
@@ -1070,12 +1176,12 @@ function renderInitiativeModal() {
}
function renderScoreboard() {
const liveExposureScores = getLiveExposureScores();
const projectedIncomeScores = getProjectedIncomeScores();
return state.players.map((player, index) => {
const isActive = player.id === state.activePlayerId && !state.gameOver;
const seatIndex = state.turnOrder.indexOf(player.id);
const previous = previousScoreSnapshot?.[index];
const sunlightChanged = previous && previous.currentExposure !== liveExposureScores[index];
const projectedIncomeChanged = previous && previous.projectedIncome !== projectedIncomeScores[index];
const growthChanged = previous && previous.growthPoints !== player.growthPoints;
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
return `
@@ -1089,8 +1195,8 @@ function renderScoreboard() {
</div>
<div class="score-card__numbers">
<div>
<span>Current</span>
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
<span>Next</span>
<strong class="${projectedIncomeChanged ? "score-value changed" : "score-value"}">${projectedIncomeScores[index]}</strong>
</div>
<div>
<span>Energy</span>
@@ -1276,31 +1382,33 @@ function renderSidebar() {
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></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>`
: `<p class="effect-empty">No weather effects active.</p>`;
const nextGrowthText = state.roundSummary
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
: "Next round growth = 1 + columns owned + any banked growth.";
const projectedIncomeText = getProjectedIncomeScores().map((score, index) => `${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."
: state.gameOver
? "Final totals are locked."
: `${player.growthPoints} energy. Click a selected pending node again to undo.`;
return `
<aside class="sidebar">
<button class="ghost-button new-game-launch" id="new-game">New Game</button>
<section class="panel status-panel status-panel--weather">
<h2>In Effect</h2>
${activeEffectsMarkup}
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
</section>
<section class="panel controls-panel">
<div class="panel__title-row">
<div>
<p class="eyebrow">Canopy</p>
<h1>Sunlight decides the next round.</h1>
</div>
<button class="ghost-button" id="new-game">New Game</button>
</div>
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
<p class="eyebrow">Round ${state.round}</p>
<h2>${getTurnLabel()}</h2>
<p>${state.phase === "initiative" ? "Choose a public seat for this round. Earlier seats gain more growth, later seats act later." : state.gameOver ? "Tallies are final." : "Spend growth by extending upward. Vertical costs 1. Diagonal costs 2. Click a glowing new node to undo back to that point before you commit the turn."}</p>
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"} this turn. ${player.bankedPoints} banked for the next round.</p>` : ""}
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
<p>Turn order: ${turnOrderSummary}</p>
<p>${getWinConditionSummary()}</p>
<p>${phaseHint}</p>
${!state.gameOver ? `<p>${state.turnMoves.length} pending move${state.turnMoves.length === 1 ? "" : "s"}. Energy ${player.growthPoints}. Bank ${player.bankedPoints}.</p>` : ""}
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
</div>
<div class="button-row">
@@ -1309,18 +1417,41 @@ function renderSidebar() {
</div>
</section>
<section class="panel status-panel">
<h2>Round economy</h2>
<p>${nextGrowthText}</p>
${activeEffectsMarkup}
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
</section>
<section class="panel log-panel">
<h2>Round log</h2>
<div class="log-list">
<section class="panel accordion-panel">
<details class="accordion">
<summary>Turn Help</summary>
<div class="accordion__content">
<p>Vertical growth costs 1. Diagonal growth costs 2.</p>
<p>Click a selected pending node again to undo back through it.</p>
${state.round === 1 ? `<p>Roots can shift left or right for ${ROOT_SHIFT_COST} during round 1.</p>` : ""}
${isBankingEnabled() ? `<p>Storehouse is active, so banking is enabled this round.</p>` : `<p>Banking is disabled unless Storehouse is active.</p>`}
</div>
</details>
<details class="accordion">
<summary>Win Condition</summary>
<div class="accordion__content">
<p>${getWinConditionSummary()}</p>
<p>Turn order: ${turnOrderSummary}</p>
<p>Next income: ${projectedIncomeText}</p>
</div>
</details>
<details class="accordion">
<summary>Weather Details</summary>
<div class="accordion__content">
${state.activeRoundEffects.length > 0
? state.activeRoundEffects.map((cardId) => {
const card = getWeatherCard(cardId);
return `<p><strong>${card?.title ?? cardId}</strong>: ${card?.description ?? ""}</p>`;
}).join("")
: `<p>No weather effects active.</p>`}
</div>
</details>
<details class="accordion">
<summary>Round Log</summary>
<div class="accordion__content log-list">
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
</div>
</details>
</section>
<section class="panel finish-panel">
@@ -1368,7 +1499,6 @@ function attachEvents() {
});
document.querySelector("#new-game")?.addEventListener("click", openNewGameModal);
document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal);
document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal);
document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal);
document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => {
@@ -1379,13 +1509,12 @@ 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<HTMLInputElement>("#player-count")?.addEventListener("input", (event) => {
const input = event.currentTarget as HTMLInputElement;
const output = input.parentElement?.querySelector("strong");
if (output) {
output.textContent = input.value;
}
rebuildSetup({ playerCount: Number(input.value) });
document.querySelector<HTMLElement>("#player-count-decrease")?.addEventListener("click", () => {
rebuildSetup({ playerCount: Math.max(2, setup.playerCount - 1) });
render();
});
document.querySelector<HTMLElement>("#player-count-increase")?.addEventListener("click", () => {
rebuildSetup({ playerCount: Math.min(8, setup.playerCount + 1) });
render();
});
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
@@ -1455,6 +1584,13 @@ function attachEvents() {
setup.seedInputs[playerId] = target.value;
});
});
document.querySelectorAll<HTMLInputElement>(".player-name-input").forEach((input) => {
input.addEventListener("input", (event) => {
const target = event.currentTarget as HTMLInputElement;
const playerId = Number(target.dataset.playerId);
setup.playerNames[playerId] = target.value;
});
});
document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations);
document.querySelectorAll<HTMLElement>("[data-move-player]").forEach((button) => {
button.addEventListener("click", () => {
@@ -1468,6 +1604,29 @@ function attachEvents() {
shiftSelectedRoot(Number(button.dataset.rootShift));
});
});
document.querySelectorAll<HTMLElement>("[data-start-marker]").forEach((marker) => {
marker.addEventListener("dragstart", () => {
const [playerId, seedIndex] = (marker.dataset.startMarker ?? "").split(":").map(Number);
draggedSetupSeed = { playerId, seedIndex };
});
marker.addEventListener("dragend", () => {
draggedSetupSeed = null;
});
});
document.querySelectorAll<HTMLElement>("[data-start-slot]").forEach((slot) => {
slot.addEventListener("dragover", (event) => {
event.preventDefault();
});
slot.addEventListener("drop", (event) => {
event.preventDefault();
if (!draggedSetupSeed) {
return;
}
moveSetupSeed(draggedSetupSeed.playerId, draggedSetupSeed.seedIndex, Number(slot.dataset.startSlot));
draggedSetupSeed = null;
});
});
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
button.addEventListener("click", () => {
chooseInitiativeSeat(Number(button.dataset.seatChoice));
@@ -1475,7 +1634,11 @@ function attachEvents() {
});
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
button.addEventListener("click", () => {
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
chooseWeatherAction(
button.dataset.weatherOffer as string,
button.dataset.weatherCard as WeatherCardId,
button.dataset.weatherAction as "draft" | "ban",
);
});
});
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {

View File

@@ -1,8 +1,12 @@
import type { GameState, InitiativeDraftState, PlayerId } from "./types";
export function getInitiativeGraceRounds(state: GameState) {
return Math.max(0, (state.config.columns / state.players.length) - state.players.length);
}
export function getSeatBonuses(state: GameState) {
const graceRounds = Math.max(0, Math.floor(state.config.columns / state.players.length) - state.players.length);
const firstSeatBonus = state.round <= graceRounds ? 0 : 1;
const graceRounds = getInitiativeGraceRounds(state);
const firstSeatBonus = state.round - 1 < graceRounds ? 0 : 1;
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
}

View File

@@ -60,13 +60,6 @@ function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: Play
return;
}
if (state.activeRoundEffects.includes("shared_light")) {
playersPresent.forEach((playerId) => {
scores[playerId] += 1;
});
return;
}
scores[ownerId] += 1;
}

View File

@@ -1,11 +1,11 @@
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } from "./types";
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState, WeatherOfferPair } from "./types";
import { shuffleArray } from "./utils";
export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." },
{ id: "storehouse", title: "Storehouse", description: "Banking is enabled this round." },
{ id: "sun_ladder", title: "Sun Ladder", description: "Straight-up growth costs 0." },
{ id: "sun_ladder", title: "Sun Ladder", description: "Your first 3 vertical growths cost 0." },
{ id: "west_light", title: "West Light", description: "Left third columns give +1." },
{ id: "east_light", title: "East Light", description: "Right third columns give +1." },
{ id: "high_noon", title: "High Noon", description: "Center third columns give +1." },
@@ -14,20 +14,28 @@ export const WEATHER_CARDS: WeatherCardDefinition[] = [
{ id: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
{ id: "stalemate", title: "Stalemate", description: "Contested columns give no energy." },
{ id: "split_light", title: "Split Light", description: "Contested columns give half to each player there." },
{ id: "shared_light", title: "Shared Light", description: "Contested columns give full energy to each player there." },
];
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
WEATHER_CARDS.map((card) => [card.id, card]),
);
export const WEATHER_OFFER_PAIRS: WeatherOfferPair[] = [
{ id: "growth_mix", options: ["leaf_surge", "branching_season"] },
{ id: "tempo_tools", options: ["storehouse", "sun_ladder"] },
{ 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: "contest_soft", options: ["stalemate", "split_light"] },
];
export function createWeatherDraft(state: GameState): WeatherDraftState {
const rowSize = Math.min(WEATHER_CARDS.length, state.config.weatherDraftCount);
const rowSize = Math.min(WEATHER_OFFER_PAIRS.length, state.config.weatherDraftCount);
return {
playerOrder: [...state.turnOrder],
draftIndex: 0,
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
offers: shuffleArray(WEATHER_OFFER_PAIRS).slice(0, rowSize),
drafted: [],
banned: [],
};
@@ -37,8 +45,22 @@ export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
return draft.playerOrder[draft.draftIndex] as PlayerId;
}
export function isWeatherCardAvailable(draft: WeatherDraftState, cardId: WeatherCardId) {
return draft.row.includes(cardId) && !draft.drafted.includes(cardId) && !draft.banned.includes(cardId);
export function isWeatherCardAvailable(draft: WeatherDraftState, offerId: string, cardId: WeatherCardId) {
const offer = draft.offers.find((entry) => entry.id === offerId);
if (!offer) {
return false;
}
return offer.options.includes(cardId) && !draft.banned.includes(cardId) && !draft.drafted.includes(cardId);
}
export function isWeatherOfferResolved(draft: WeatherDraftState, offerId: string) {
const offer = draft.offers.find((entry) => entry.id === offerId);
if (!offer) {
return true;
}
return offer.options.every((cardId) => draft.banned.includes(cardId) || draft.drafted.includes(cardId));
}
export function getWeatherCard(cardId: WeatherCardId) {

View File

@@ -68,7 +68,8 @@ export function getMaxStartingNodesPerPlayer(playerCount: number, columns: numbe
}
export function createSetupState(
playerCount = 3,
playerCount = 2,
playerNames: string[] | null = null,
columns = 18,
rows = 16,
startingNodesPerPlayer = 1,
@@ -84,13 +85,13 @@ export function createSetupState(
maxRounds = 12,
topLeafTarget = 4,
): SetupState {
console.log("[DEBUG] createSetupState started");
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
const paletteDefaults = createDefaultPaletteOrder(playerCount);
const result = {
return {
playerCount,
playerNames: Array.from({ length: playerCount }, (_, index) => playerNames?.[index] ?? `Player ${index + 1}`),
columns,
rows,
startingNodesPerPlayer: clampedSeeds,
@@ -106,16 +107,18 @@ export function createSetupState(
maxRounds,
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
};
console.log("[DEBUG] createSetupState completed");
return result;
}
export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] {
export function createPlayers(
playerCount: number,
paletteOrder = createDefaultPaletteOrder(playerCount),
playerNames: string[] = Array.from({ length: playerCount }, (_, index) => `Player ${index + 1}`),
): Player[] {
return Array.from({ length: playerCount }, (_, index) => {
const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
return {
id: index,
name: `Player ${index + 1}`,
name: playerNames[index] ?? `Player ${index + 1}`,
color: palette.primary,
glow: palette.glow,
totalScore: 0,
@@ -167,9 +170,8 @@ export function normalizeSeedInputs(setup: SetupState) {
}
export function createInitialState(setup: SetupState): GameState {
console.log("[DEBUG] createInitialState started");
const playerPaletteOrder = [...setup.paletteOrder];
const players = createPlayers(setup.playerCount, playerPaletteOrder);
const players = createPlayers(setup.playerCount, playerPaletteOrder, setup.playerNames);
const turnOrder = players.map((player) => player.id);
const nodes = new Map();
const edges = [];
@@ -180,8 +182,6 @@ export function createInitialState(setup: SetupState): GameState {
nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
});
});
console.log("[DEBUG] createInitialState completed");
return {
config: {
columns: setup.columns,

View File

@@ -39,7 +39,7 @@ html, body {
padding: 0.5rem;
}
#app > * {
#app > .layout {
width: 100%;
height: calc(100vh - 100px);
max-width: 100%;
@@ -92,7 +92,7 @@ html, body {
.board {
position: relative;
width: auto;
height: auto;
height: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: var(--board-columns) / var(--board-rows);
@@ -113,6 +113,10 @@ html, body {
overflow: hidden;
}
.new-game-launch {
align-self: flex-end;
}
/* Bottom bar - Fixed height player scores */
.scoreboard {
grid-area: bottom;
@@ -126,12 +130,13 @@ html, body {
.score-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1rem;
padding: 1rem 1.25rem;
padding: 1.2rem 1.25rem;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
gap: 0.2rem;
min-height: 0;
}
@@ -153,15 +158,14 @@ html, body {
}
.controls-panel {
flex: 1;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow: auto;
gap: 0.5rem;
}
.log-panel {
max-height: 120px;
max-height: 88px;
flex-shrink: 0;
}
@@ -447,6 +451,12 @@ label span,
font-size: 1.2rem;
}
.score-card__numbers span {
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
}
.score-value {
display: inline-block;
}
@@ -481,6 +491,7 @@ label span,
}
.button-row button,
.primary-button,
.ghost-button {
min-height: 2.4rem;
padding: 0.5rem 0.75rem;
@@ -493,6 +504,12 @@ label span,
cursor: pointer;
}
.primary-button {
background: #f4f7fb;
color: #0a1020;
border: none;
}
.ghost-button,
#finish-game {
background: rgba(255, 255, 255, 0.08);
@@ -522,16 +539,79 @@ button:disabled {
color: #ffd577;
}
.active-effects {
display: grid;
gap: 0.55rem;
margin-top: 0.4rem;
}
.effect-chip {
display: grid;
gap: 0.18rem;
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));
border: 1px solid rgba(255, 208, 96, 0.2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.effect-chip__title {
font-size: 1rem;
font-weight: 700;
color: #f4f7fb;
}
.effect-chip__rule {
font-size: 0.78rem;
color: rgba(231, 238, 247, 0.76);
}
.active-turn {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
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));
}
.economy-line {
margin-top: 0.4rem;
}
.accordion {
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.accordion summary {
list-style: none;
cursor: pointer;
padding: 0.7rem 0.85rem;
font-weight: 700;
color: #f4f7fb;
}
.accordion summary::-webkit-details-marker {
display: none;
}
.accordion__content {
display: grid;
gap: 0.45rem;
padding: 0 0.85rem 0.8rem;
}
.accordion__content p {
margin: 0;
color: rgba(231, 238, 247, 0.76);
font-size: 0.84rem;
}
/* Form elements */
button,
input,
@@ -583,7 +663,7 @@ input[type="range"] {
.setup-modal {
display: flex;
flex-direction: column;
max-width: 900px;
max-width: 960px;
padding: 0;
}
@@ -592,8 +672,7 @@ input[type="range"] {
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 1.6rem 1.75rem 1rem;
}
.modal-header__title h1 {
@@ -603,65 +682,75 @@ input[type="range"] {
}
.setup-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
padding: 0 1.5rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
justify-content: center;
gap: 0.65rem;
padding: 0 1.75rem 1.1rem;
}
.setup-tab {
min-height: 3rem;
appearance: none;
width: 4.25rem;
min-width: 4.25rem;
min-height: 4.25rem;
display: grid;
place-items: center;
gap: 0.15rem;
gap: 0.2rem;
padding: 0.55rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04);
color: rgba(231, 238, 247, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.025);
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;
}
.setup-tab--active {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.08);
color: #f4f7fb;
border-color: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 -2px 0 rgba(255, 208, 96, 0.75);
}
.setup-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: rgba(244, 247, 251, 0.9);
}
.setup-tab span:first-child {
font-size: 1rem;
font-size: 1.05rem;
}
.setup-tab span:last-child {
font-size: 0.78rem;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.1;
text-align: center;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 1.9rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
padding: 1.1rem 1.75rem 1.5rem;
}
/* Setup Sections */
.setup-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 1rem;
}
.setup-section__title {
@@ -671,8 +760,6 @@ input[type="range"] {
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.6);
margin: 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.setup-section__help {
@@ -684,8 +771,8 @@ input[type="range"] {
/* Setup Grid */
.setup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.15rem;
}
.setup-grid--2col {
@@ -698,7 +785,7 @@ input[type="range"] {
@media (max-width: 600px) {
.setup-tabs {
grid-template-columns: repeat(2, 1fr);
flex-wrap: wrap;
}
.setup-grid,
@@ -712,13 +799,13 @@ input[type="range"] {
.setup-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.55rem;
}
.setup-field__label {
font-size: 0.8125rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
font-size: 0.86rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.76);
}
.setup-field__input {
@@ -733,6 +820,39 @@ input[type="range"] {
min-width: 1.5rem;
}
.setup-stepper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.45rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.setup-stepper__value {
flex: 1;
text-align: center;
font-size: 1.35rem;
font-weight: 800;
}
.stepper-button {
appearance: none;
width: 3.25rem;
min-width: 3.25rem;
min-height: 3.25rem;
border-radius: 0.95rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: #f4f7fb;
font-size: 1.4rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
}
.setup-field--range input[type="range"] {
flex: 1;
}
@@ -751,13 +871,13 @@ input[type="range"] {
.setup-field input[type="text"],
.setup-field select {
width: 100%;
min-height: 2.25rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
min-height: 3.25rem;
padding: 0.8rem 0.95rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: #f4f7fb;
font-size: 0.9375rem;
font-size: 1rem;
}
.setup-field input[type="checkbox"] {
@@ -773,14 +893,57 @@ input[type="range"] {
gap: 0.5rem;
}
.start-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(2.4rem, 1fr));
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);
}
.start-strip__slot {
min-height: 4.2rem;
display: grid;
align-content: start;
justify-items: center;
gap: 0.4rem;
padding: 0.45rem 0.2rem;
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.025);
}
.start-strip__label {
font-size: 0.7rem;
color: rgba(231, 238, 247, 0.58);
}
.start-marker {
appearance: none;
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--player-color) 55%, white);
background: var(--player-color);
color: #08111c;
font-weight: 800;
cursor: grab;
box-shadow: 0 0 0 0.15rem rgba(255, 255, 255, 0.06), 0 0 0.9rem var(--player-glow);
}
.start-marker:active {
cursor: grabbing;
}
.player-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
padding: 0.8rem 1rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.625rem;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 0.9rem;
}
.player-row__info {
@@ -793,6 +956,12 @@ input[type="range"] {
font-weight: 600;
}
.player-name-input {
min-width: 0;
max-width: 100%;
min-height: 3.25rem;
}
.player-row__actions {
display: flex;
gap: 0.375rem;
@@ -806,6 +975,7 @@ input[type="range"] {
/* Mini button for player reordering */
.mini-button {
appearance: none;
min-height: 2rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
@@ -841,6 +1011,200 @@ input[type="range"] {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
}
.initiative-order-row {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.initiative-pill {
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(231, 238, 247, 0.76);
}
.initiative-pill--active {
border-color: color-mix(in srgb, var(--player-color) 62%, white);
box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent);
color: #f4f7fb;
}
.initiative-bonus-note {
margin: 0.2rem 0 0;
padding: 0.55rem 0.7rem;
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(231, 238, 247, 0.8);
font-size: 0.82rem;
}
.initiative-bonus-note--active {
border-color: rgba(255, 208, 96, 0.28);
background: rgba(255, 208, 96, 0.09);
color: #f4f7fb;
}
.initiative-seat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
margin-top: 0.9rem;
}
.initiative-seat {
appearance: none;
min-height: 8rem;
padding: 0.9rem;
border-radius: 1rem;
display: grid;
gap: 0.35rem;
text-align: left;
background: rgba(255, 255, 255, 0.06);
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
}
.initiative-seat--taken {
opacity: 0.68;
}
.weather-key {
display: grid;
gap: 0.25rem;
color: rgba(231, 238, 247, 0.78);
font-size: 0.86rem;
}
.weather-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.9rem;
margin-top: 0.9rem;
}
.weather-card {
display: grid;
gap: 0.8rem;
padding: 0.95rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.weather-card h2,
.weather-card p {
margin: 0;
}
.weather-pair {
display: grid;
gap: 0.7rem;
}
.weather-pair__option {
padding: 0.75rem 0.8rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.weather-pair__option--drafted {
border-color: rgba(130, 224, 182, 0.55);
}
.weather-pair__option--banned {
border-color: rgba(255, 128, 128, 0.45);
opacity: 0.72;
}
.weather-card--drafted {
border-color: rgba(130, 224, 182, 0.55);
}
.weather-card--banned {
border-color: rgba(255, 128, 128, 0.45);
opacity: 0.72;
}
.weather-card__actions {
display: grid;
grid-template-columns: 1fr;
gap: 0.45rem;
margin-top: 0.75rem;
}
.weather-card__status {
margin-top: 0.75rem;
font-weight: 700;
color: rgba(231, 238, 247, 0.8);
}
.weather-action {
appearance: none;
min-width: 0;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.58rem 0.72rem;
border-radius: 0.95rem;
color: #f4f7fb;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
text-align: left;
cursor: pointer;
}
.weather-action span {
display: grid;
gap: 0.12rem;
min-width: 0;
}
.weather-action strong {
display: block;
overflow-wrap: anywhere;
font-size: 0.9rem;
}
.weather-action__icon {
width: 1.75rem;
height: 1.75rem;
display: grid;
place-items: center;
border-radius: 999px;
font-size: 0.9rem;
line-height: 1;
background: rgba(255, 255, 255, 0.12);
flex: 0 0 auto;
}
.weather-action--draft {
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
border-color: rgba(255, 208, 96, 0.4);
}
.weather-action--draft .weather-action__icon {
background: rgba(255, 208, 96, 0.22);
}
.weather-action--ban {
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
border-color: rgba(255, 110, 110, 0.32);
}
.weather-action--ban .weather-action__icon {
background: rgba(255, 110, 110, 0.18);
}
.weather-action:hover,
.initiative-seat:hover {
transform: translateY(-1px);
}
/* Animations */
@keyframes sunlight-drop {
0% {

View File

@@ -14,6 +14,7 @@ export type Position = {
export type SetupState = {
playerCount: number;
playerNames: string[];
columns: number;
rows: number;
startingNodesPerPlayer: number;
@@ -164,7 +165,7 @@ export type RoundSummary = {
};
export type ScoreSnapshot = {
currentExposure: number;
projectedIncome: number;
growthPoints: number;
bankedPoints: number;
lifetimeGrowthIncome: number;
@@ -195,8 +196,7 @@ export type WeatherCardId =
| "wide_reach"
| "tall_reward"
| "stalemate"
| "split_light"
| "shared_light";
| "split_light";
export type WeatherCardDefinition = {
id: WeatherCardId;
@@ -204,6 +204,11 @@ export type WeatherCardDefinition = {
description: string;
};
export type WeatherOfferPair = {
id: string;
options: [WeatherCardId, WeatherCardId];
};
export type InitiativeDraftState = {
biddingOrder: PlayerId[];
biddingIndex: number;
@@ -214,7 +219,7 @@ export type InitiativeDraftState = {
export type WeatherDraftState = {
playerOrder: PlayerId[];
draftIndex: number;
row: WeatherCardId[];
offers: WeatherOfferPair[];
drafted: WeatherCardId[];
banned: WeatherCardId[];
};