big version with draft order and weather cards
This commit is contained in:
5
nixpacks.toml
Normal file
5
nixpacks.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[phases.build]
|
||||||
|
cmds = ["npm ci", "npm run build"]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "npx serve dist -l 3000 -s"
|
||||||
518
src/main.ts
518
src/main.ts
@@ -22,6 +22,15 @@ import {
|
|||||||
maybeRollSunbeam as maybeRollSunbeamForState,
|
maybeRollSunbeam as maybeRollSunbeamForState,
|
||||||
scoreColumns as scoreColumnsForState,
|
scoreColumns as scoreColumnsForState,
|
||||||
} from "./rules-scoring";
|
} from "./rules-scoring";
|
||||||
|
import {
|
||||||
|
createInitiativeDraft,
|
||||||
|
} from "./rules-initiative";
|
||||||
|
import {
|
||||||
|
createWeatherDraft,
|
||||||
|
getCurrentWeatherPlayerId,
|
||||||
|
getWeatherCard,
|
||||||
|
isWeatherCardAvailable,
|
||||||
|
} from "./rules-weather";
|
||||||
import {
|
import {
|
||||||
createInitialState,
|
createInitialState,
|
||||||
createPlayers,
|
createPlayers,
|
||||||
@@ -38,6 +47,7 @@ import type {
|
|||||||
SetupState,
|
SetupState,
|
||||||
ShiftMove,
|
ShiftMove,
|
||||||
TurnMove,
|
TurnMove,
|
||||||
|
WeatherCardId,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { keyFor, parseKey, tint, wait } from "./utils";
|
import { keyFor, parseKey, tint, wait } from "./utils";
|
||||||
|
|
||||||
@@ -53,12 +63,47 @@ let state: GameState = createInitialState(setup);
|
|||||||
let isNewGameModalOpen = false;
|
let isNewGameModalOpen = false;
|
||||||
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
|
||||||
|
|
||||||
|
function getLiveExposureScores() {
|
||||||
|
return buildEnergySimulation(state).scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopLeafCount() {
|
||||||
|
const childrenMap = buildChildrenMap();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
state.nodes.forEach((_, nodeKey) => {
|
||||||
|
const { row } = parseKey(nodeKey);
|
||||||
|
if (row === 0 && !(childrenMap.get(nodeKey)?.length)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReachedWinCondition() {
|
||||||
|
if (state.config.winCondition === "rounds") {
|
||||||
|
return state.round >= state.config.maxRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getTopLeafCount() >= state.config.topLeafTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWinConditionSummary() {
|
||||||
|
if (state.config.winCondition === "rounds") {
|
||||||
|
return `Game ends after round ${state.config.maxRounds}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Game ends when ${state.config.topLeafTarget} top-row leaf${state.config.topLeafTarget === 1 ? " is" : "s are"} occupied.`;
|
||||||
|
}
|
||||||
|
|
||||||
function getScoreSnapshot() {
|
function getScoreSnapshot() {
|
||||||
return state.players.map((player) => ({
|
const exposureScores = getLiveExposureScores();
|
||||||
totalScore: player.totalScore,
|
return state.players.map((player, index) => ({
|
||||||
roundScore: player.roundScore,
|
currentExposure: exposureScores[index],
|
||||||
growthPoints: player.growthPoints,
|
growthPoints: player.growthPoints,
|
||||||
bankedPoints: player.bankedPoints,
|
bankedPoints: player.bankedPoints,
|
||||||
|
lifetimeGrowthIncome: player.lifetimeGrowthIncome,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +111,47 @@ function getCurrentPlayer() {
|
|||||||
return state.players[state.activePlayerId];
|
return state.players[state.activePlayerId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrderedPlayers(playerIds: number[]) {
|
||||||
|
return playerIds.map((playerId) => state.players[playerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTurnLabel() {
|
||||||
|
if (state.phase === "initiative" && state.initiativeDraft) {
|
||||||
|
return `${getCurrentPlayer().name} drafts initiative`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase === "weather" && state.weatherDraft) {
|
||||||
|
return `${getCurrentPlayer().name} drafts weather`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardGrowth(player: Player, amount: number) {
|
||||||
|
if (amount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.growthPoints += amount;
|
||||||
|
player.lifetimeGrowthIncome += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentBiddingPlayer() {
|
||||||
|
if (!state.initiativeDraft) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.players[state.initiativeDraft.biddingOrder[state.initiativeDraft.biddingIndex]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentWeatherDraftPlayer() {
|
||||||
|
if (!state.weatherDraft) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.players[getCurrentWeatherPlayerId(state.weatherDraft)];
|
||||||
|
}
|
||||||
|
|
||||||
function getNodeOwner(row: number, column: number) {
|
function getNodeOwner(row: number, column: number) {
|
||||||
return getNodeOwnerForState(state, row, column);
|
return getNodeOwnerForState(state, row, column);
|
||||||
}
|
}
|
||||||
@@ -232,7 +318,160 @@ function updateSelection(sourceKey: NodeKey | null = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInteractionLocked() {
|
function isInteractionLocked() {
|
||||||
return state.gameOver || Boolean(state.animation);
|
return state.gameOver || state.phase !== "turn" || Boolean(state.animation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWeatherDraft() {
|
||||||
|
if (!state.config.weatherDraftEnabled) {
|
||||||
|
moveToFirstPlayableTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.phase = "weather";
|
||||||
|
state.turnMoves = [];
|
||||||
|
updateSelection(null);
|
||||||
|
state.weatherDraft = createWeatherDraft(state);
|
||||||
|
state.activePlayerId = state.weatherDraft.playerOrder[0];
|
||||||
|
state.history.unshift(`Round ${state.round} weather draft begins.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInitiativeDraft() {
|
||||||
|
state.phase = "initiative";
|
||||||
|
state.turnMoves = [];
|
||||||
|
updateSelection(null);
|
||||||
|
state.initiativeDraft = createInitiativeDraft(state);
|
||||||
|
state.activePlayerId = state.initiativeDraft.biddingOrder[0];
|
||||||
|
state.history.unshift(`Round ${state.round} initiative draft begins.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToFirstPlayableTurn() {
|
||||||
|
const nextPlayerId = state.turnOrder.find((playerId) => playerHasLegalMove(state.players[playerId]));
|
||||||
|
|
||||||
|
if (nextPlayerId === undefined) {
|
||||||
|
state.gameOver = true;
|
||||||
|
state.phase = "game_over";
|
||||||
|
state.history.unshift("No player can grow further. Final totals are locked.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.phase = "turn";
|
||||||
|
state.activePlayerId = nextPlayerId;
|
||||||
|
state.turnMoves = [];
|
||||||
|
updateSelection(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeInitiativeDraft() {
|
||||||
|
const draft = state.initiativeDraft;
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTurnOrder = draft.seatAssignments.filter((playerId): playerId is number => playerId !== null);
|
||||||
|
state.turnOrder = nextTurnOrder;
|
||||||
|
state.players.forEach((player) => {
|
||||||
|
player.passed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
nextTurnOrder.forEach((playerId, seatIndex) => {
|
||||||
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||||
|
if (bonus > 0) {
|
||||||
|
awardGrowth(state.players[playerId], bonus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const seatSummary = nextTurnOrder
|
||||||
|
.map((playerId, seatIndex) => {
|
||||||
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||||
|
return `${state.players[playerId].name}: Seat ${seatIndex + 1}${bonus > 0 ? ` (+${bonus})` : ""}`;
|
||||||
|
})
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
state.initiativeDraft = null;
|
||||||
|
state.history.unshift(`Round ${state.round} initiative set. ${seatSummary}`);
|
||||||
|
startWeatherDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseInitiativeSeat(seatIndex: number) {
|
||||||
|
const draft = state.initiativeDraft;
|
||||||
|
if (!draft || seatIndex < 0 || seatIndex >= draft.seatAssignments.length || draft.seatAssignments[seatIndex] !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}.`);
|
||||||
|
|
||||||
|
if (draft.biddingIndex >= draft.biddingOrder.length - 1) {
|
||||||
|
finalizeInitiativeDraft();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.biddingIndex += 1;
|
||||||
|
state.activePlayerId = draft.biddingOrder[draft.biddingIndex];
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginRound() {
|
||||||
|
if (state.gameOver) {
|
||||||
|
state.phase = "game_over";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.players.forEach((player) => {
|
||||||
|
player.passed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.config.initiativeMode === "bid") {
|
||||||
|
startInitiativeDraft();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startWeatherDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeWeatherDraft() {
|
||||||
|
if (!state.weatherDraft) {
|
||||||
|
moveToFirstPlayableTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activeRoundEffects = [...state.weatherDraft.drafted];
|
||||||
|
const draftedSummary = state.activeRoundEffects.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", ");
|
||||||
|
const bannedSummary = state.weatherDraft.banned.map((cardId) => getWeatherCard(cardId)?.title ?? cardId).join(", ");
|
||||||
|
state.history.unshift(
|
||||||
|
`Round ${state.round} weather set.${draftedSummary ? ` Active: ${draftedSummary}.` : ""}${bannedSummary ? ` Banned: ${bannedSummary}.` : ""}`
|
||||||
|
);
|
||||||
|
state.weatherDraft = null;
|
||||||
|
moveToFirstPlayableTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseWeatherAction(cardId: WeatherCardId, action: "draft" | "ban") {
|
||||||
|
const draft = state.weatherDraft;
|
||||||
|
if (!draft || !isWeatherCardAvailable(draft, cardId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = getCurrentWeatherPlayerId(draft);
|
||||||
|
const card = getWeatherCard(cardId);
|
||||||
|
if (action === "draft") {
|
||||||
|
draft.drafted.push(cardId);
|
||||||
|
state.history.unshift(`${state.players[playerId].name} drafted ${card?.title ?? cardId}.`);
|
||||||
|
} else {
|
||||||
|
draft.banned.push(cardId);
|
||||||
|
state.history.unshift(`${state.players[playerId].name} banned ${card?.title ?? cardId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.draftIndex >= draft.playerOrder.length - 1) {
|
||||||
|
finalizeWeatherDraft();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.draftIndex += 1;
|
||||||
|
state.activePlayerId = draft.playerOrder[draft.draftIndex];
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceTurn() {
|
function advanceTurn() {
|
||||||
@@ -241,9 +480,10 @@ function advanceTurn() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentOrderIndex = Math.max(0, state.turnOrder.indexOf(state.activePlayerId));
|
||||||
let nextPlayerId = state.activePlayerId;
|
let nextPlayerId = state.activePlayerId;
|
||||||
for (let step = 0; step < state.players.length; step += 1) {
|
for (let step = 1; step <= state.turnOrder.length; step += 1) {
|
||||||
nextPlayerId = (nextPlayerId + 1) % state.players.length;
|
nextPlayerId = state.turnOrder[(currentOrderIndex + step) % state.turnOrder.length];
|
||||||
const candidate = state.players[nextPlayerId];
|
const candidate = state.players[nextPlayerId];
|
||||||
|
|
||||||
if (!candidate.passed && playerHasLegalMove(candidate)) {
|
if (!candidate.passed && playerHasLegalMove(candidate)) {
|
||||||
@@ -258,21 +498,6 @@ function advanceTurn() {
|
|||||||
endRound();
|
endRound();
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveToFirstPlayableTurn() {
|
|
||||||
const nextPlayerId = state.players.findIndex((player) => playerHasLegalMove(player));
|
|
||||||
|
|
||||||
if (nextPlayerId === -1) {
|
|
||||||
state.gameOver = true;
|
|
||||||
state.history.unshift("No player can grow further. Final totals are locked.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.activePlayerId = nextPlayerId;
|
|
||||||
state.turnMoves = [];
|
|
||||||
updateSelection(null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function undoMovesThrough(targetKey: NodeKey) {
|
function undoMovesThrough(targetKey: NodeKey) {
|
||||||
const player = getCurrentPlayer();
|
const player = getCurrentPlayer();
|
||||||
const moveIndex = findTurnMoveIndex(targetKey);
|
const moveIndex = findTurnMoveIndex(targetKey);
|
||||||
@@ -386,6 +611,7 @@ function bankGrowthAndEndTurn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function endRound() {
|
async function endRound() {
|
||||||
|
state.phase = "round_end";
|
||||||
const { scores, columnResults, energySimulation } = scoreColumns();
|
const { scores, columnResults, energySimulation } = scoreColumns();
|
||||||
const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores);
|
const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores);
|
||||||
const { killedKeys, event: diseaseEvent } = maybeRollDisease();
|
const { killedKeys, event: diseaseEvent } = maybeRollDisease();
|
||||||
@@ -431,7 +657,9 @@ 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];
|
||||||
player.growthPoints = nextGrowth[index] + player.bankedPoints;
|
player.growthPoints = player.bankedPoints;
|
||||||
|
player.lifetimeGrowthIncome += nextGrowth[index];
|
||||||
|
player.growthPoints += nextGrowth[index];
|
||||||
player.bankedPoints = 0;
|
player.bankedPoints = 0;
|
||||||
player.passed = false;
|
player.passed = false;
|
||||||
});
|
});
|
||||||
@@ -450,25 +678,27 @@ async function endRound() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.animation = null;
|
state.animation = null;
|
||||||
|
state.activeRoundEffects = [];
|
||||||
|
|
||||||
const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player));
|
const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player));
|
||||||
if (boardFull) {
|
if (boardFull || hasReachedWinCondition()) {
|
||||||
state.gameOver = true;
|
state.gameOver = true;
|
||||||
state.history.unshift("The canopy is complete. Final totals are locked.");
|
state.phase = "game_over";
|
||||||
|
state.history.unshift(boardFull ? "The canopy is complete. Final totals are locked." : `Win condition reached. ${getWinConditionSummary()}`);
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.round += 1;
|
state.round += 1;
|
||||||
state.history.unshift(`Round ${state.round} begins.`);
|
state.history.unshift(`Round ${state.round} begins.`);
|
||||||
moveToFirstPlayableTurn();
|
beginRound();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetGame() {
|
function resetGame() {
|
||||||
roundAnimationToken += 1;
|
roundAnimationToken += 1;
|
||||||
state = createInitialState(setup);
|
state = createInitialState(setup);
|
||||||
moveToFirstPlayableTurn();
|
beginRound();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +724,7 @@ function finishGameNow() {
|
|||||||
|
|
||||||
roundAnimationToken += 1;
|
roundAnimationToken += 1;
|
||||||
state.animation = null;
|
state.animation = null;
|
||||||
|
state.phase = "game_over";
|
||||||
const { scores, columnResults } = scoreColumns();
|
const { scores, columnResults } = scoreColumns();
|
||||||
state.players.forEach((player, index) => {
|
state.players.forEach((player, index) => {
|
||||||
player.roundScore = scores[index];
|
player.roundScore = scores[index];
|
||||||
@@ -573,10 +804,44 @@ function renderNewGameModal() {
|
|||||||
<span>Disease %</span>
|
<span>Disease %</span>
|
||||||
<input id="disease-chance" type="number" min="0" max="100" step="5" value="${setup.diseaseChance}" />
|
<input id="disease-chance" type="number" min="0" max="100" step="5" value="${setup.diseaseChance}" />
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label>
|
||||||
<span>Shuffle turn order</span>
|
<span>Initiative</span>
|
||||||
<input id="shuffle-order-toggle" type="checkbox" ${setup.shuffleTurnOrder ? "checked" : ""} />
|
<select id="initiative-mode">
|
||||||
|
<option value="fixed" ${setup.initiativeMode === "fixed" ? "selected" : ""}>Fixed Order</option>
|
||||||
|
<option value="bid" ${setup.initiativeMode === "bid" ? "selected" : ""}>Seat Draft</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
${setup.initiativeMode === "bid" ? `
|
||||||
|
<label>
|
||||||
|
<span>Bid Order</span>
|
||||||
|
<select id="bidding-order-rule">
|
||||||
|
<option value="rotating" ${setup.biddingOrderRule === "rotating" ? "selected" : ""}>Rotating</option>
|
||||||
|
<option value="lowest_growth_income" ${setup.biddingOrderRule === "lowest_growth_income" ? "selected" : ""}>Lowest Growth</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
` : ""}
|
||||||
|
<label class="toggle-row">
|
||||||
|
<span>Weather Draft</span>
|
||||||
|
<input id="weather-draft-toggle" type="checkbox" ${setup.weatherDraftEnabled ? "checked" : ""} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Win Condition</span>
|
||||||
|
<select id="win-condition">
|
||||||
|
<option value="rounds" ${setup.winCondition === "rounds" ? "selected" : ""}>Round Limit</option>
|
||||||
|
<option value="top_leaves" ${setup.winCondition === "top_leaves" ? "selected" : ""}>Top Leaves</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
${setup.winCondition === "rounds" ? `
|
||||||
|
<label>
|
||||||
|
<span>Max rounds</span>
|
||||||
|
<input id="max-rounds" type="number" min="1" max="99" step="1" value="${setup.maxRounds}" />
|
||||||
|
</label>
|
||||||
|
` : `
|
||||||
|
<label>
|
||||||
|
<span>Top leaf target</span>
|
||||||
|
<input id="top-leaf-target" type="number" min="1" max="${setup.columns}" step="1" value="${setup.topLeafTarget}" />
|
||||||
|
</label>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-grid">
|
<div class="modal-grid">
|
||||||
<div class="seed-editor">
|
<div class="seed-editor">
|
||||||
@@ -626,28 +891,128 @@ function renderNewGameModal() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWeatherDraftModal() {
|
||||||
|
if (state.phase !== "weather" || !state.weatherDraft) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = state.weatherDraft;
|
||||||
|
const currentPlayer = getCurrentWeatherDraftPlayer();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="weather-title">
|
||||||
|
<div class="panel__title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
|
<h1 id="weather-title">Weather Draft</h1>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</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) => {
|
||||||
|
const card = getWeatherCard(cardId);
|
||||||
|
const drafted = draft.drafted.includes(cardId);
|
||||||
|
const banned = draft.banned.includes(cardId);
|
||||||
|
const available = !drafted && !banned;
|
||||||
|
return `
|
||||||
|
<article class="weather-card${drafted ? " weather-card--drafted" : ""}${banned ? " weather-card--banned" : ""}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">${drafted ? "Drafted" : banned ? "Banned" : "Open"}</p>
|
||||||
|
<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}">
|
||||||
|
<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}">
|
||||||
|
<span class="weather-action__icon" aria-hidden="true">✕</span>
|
||||||
|
<span><strong>Ban</strong></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ""}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInitiativeModal() {
|
||||||
|
if (state.phase !== "initiative" || !state.initiativeDraft) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = state.initiativeDraft;
|
||||||
|
const currentBidder = getCurrentBiddingPlayer();
|
||||||
|
const orderedBidders = getOrderedPlayers(draft.biddingOrder);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="draft-panel panel" role="dialog" aria-modal="true" aria-labelledby="initiative-title">
|
||||||
|
<div class="panel__title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
|
<h1 id="initiative-title">Initiative Draft</h1>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="initiative-seat-grid">
|
||||||
|
${draft.seatAssignments.map((playerId, seatIndex) => {
|
||||||
|
const assignedPlayer = playerId === null ? null : state.players[playerId];
|
||||||
|
const bonus = draft.seatBonuses[seatIndex] ?? 0;
|
||||||
|
const disabled = playerId !== null ? "disabled" : "";
|
||||||
|
return `
|
||||||
|
<button class="initiative-seat${playerId === null ? "" : " initiative-seat--taken"}" data-seat-choice="${seatIndex}" ${disabled}>
|
||||||
|
<strong>Seat ${seatIndex + 1}</strong>
|
||||||
|
<span>${bonus > 0 ? `+${bonus} growth` : "+0 growth"}</span>
|
||||||
|
<span>${assignedPlayer ? assignedPlayer.name : "Open"}</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderScoreboard() {
|
function renderScoreboard() {
|
||||||
|
const liveExposureScores = getLiveExposureScores();
|
||||||
return state.players.map((player, index) => {
|
return state.players.map((player, index) => {
|
||||||
const isActive = index === state.activePlayerId && !state.gameOver;
|
const isActive = player.id === state.activePlayerId && !state.gameOver;
|
||||||
|
const seatIndex = state.turnOrder.indexOf(player.id);
|
||||||
const previous = previousScoreSnapshot?.[index];
|
const previous = previousScoreSnapshot?.[index];
|
||||||
const totalChanged = previous && previous.totalScore !== player.totalScore;
|
const sunlightChanged = previous && previous.currentExposure !== liveExposureScores[index];
|
||||||
const sunlightChanged = previous && previous.roundScore !== player.roundScore;
|
|
||||||
const growthChanged = previous && previous.growthPoints !== player.growthPoints;
|
const growthChanged = previous && previous.growthPoints !== player.growthPoints;
|
||||||
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
|
||||||
return `
|
return `
|
||||||
<article class="score-card${isActive ? " active" : ""}" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
<article class="score-card${isActive ? " active" : ""}" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
||||||
<div class="score-card__head">
|
<div class="score-card__head">
|
||||||
<span class="player-dot"></span>
|
<div class="score-card__identity">
|
||||||
<h2>${player.name}</h2>
|
<span class="player-dot"></span>
|
||||||
|
<h2>${player.name}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="score-card__meta">Lifetime ${player.lifetimeGrowthIncome}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="score-card__numbers">
|
<div class="score-card__numbers">
|
||||||
<div>
|
<div>
|
||||||
<span>Total</span>
|
<span>Current</span>
|
||||||
<strong class="${totalChanged ? "score-value changed" : "score-value"}">${player.totalScore}</strong>
|
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${liveExposureScores[index]}</strong>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Sunlight</span>
|
|
||||||
<strong class="${sunlightChanged ? "score-value changed" : "score-value"}">${player.roundScore}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Energy</span>
|
<span>Energy</span>
|
||||||
@@ -658,6 +1023,9 @@ function renderScoreboard() {
|
|||||||
<strong class="${bankChanged ? "score-value changed" : "score-value"}">${player.bankedPoints}</strong>
|
<strong class="${bankChanged ? "score-value changed" : "score-value"}">${player.bankedPoints}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="score-card__footer">
|
||||||
|
<span>Seat ${seatIndex === -1 ? "-" : seatIndex + 1}</span>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
@@ -826,6 +1194,13 @@ 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 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>`;
|
||||||
|
}).join("")}</div>`
|
||||||
|
: `<p class="effect-empty">No weather effects active.</p>`;
|
||||||
const nextGrowthText = state.roundSummary
|
const nextGrowthText = state.roundSummary
|
||||||
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
|
? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
|
||||||
: "Next round growth = 1 + columns owned + any banked growth.";
|
: "Next round growth = 1 + columns owned + any banked growth.";
|
||||||
@@ -842,22 +1217,24 @@ function renderSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
<div class="active-turn" style="--player-color: ${player.color}; --player-glow: ${player.glow};">
|
||||||
<p class="eyebrow">Round ${state.round}</p>
|
<p class="eyebrow">Round ${state.round}</p>
|
||||||
<h2>${state.gameOver ? "Game Over" : `${player.name}'s turn`}</h2>
|
<h2>${getTurnLabel()}</h2>
|
||||||
<p>${state.gameOver ? "Tallies are final." : "Spend growth by extending upward. Vertical costs 1. Diagonal costs 2. Click a glowing new node to undo back to that point before you commit the turn."}</p>
|
<p>${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.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>` : ""}
|
${state.round === 1 ? `<p>Select a root on the bottom row to shift it left or right for ${ROOT_SHIFT_COST} point during round 1.</p>` : ""}
|
||||||
|
<p>Turn order: ${turnOrderSummary}</p>
|
||||||
|
<p>${getWinConditionSummary()}</p>
|
||||||
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
|
${rootShiftMoves.length > 0 ? `<div class="root-shift-row">${rootShiftMoves.map((move) => `<button class="ghost-button root-shift-button" data-root-shift="${move.direction === "left" ? -1 : 1}" ${boardLocked ? "disabled" : ""}>${move.direction === "left" ? "<- Root" : "Root ->"}</button>`).join("")}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
|
<button id="end-turn" ${boardLocked ? "disabled" : ""}>End Turn</button>
|
||||||
<button id="bank-turn" class="ghost-button" ${boardLocked ? "disabled" : ""}>Bank Remaining</button>
|
<button id="bank-turn" class="ghost-button" ${boardLocked ? "disabled" : ""}>Bank Remaining</button>
|
||||||
<button id="finish-game" class="ghost-button" ${boardLocked ? "disabled" : ""}>Score Now</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel status-panel">
|
<section class="panel status-panel">
|
||||||
<h2>Round economy</h2>
|
<h2>Round economy</h2>
|
||||||
<p>${nextGrowthText}</p>
|
<p>${nextGrowthText}</p>
|
||||||
|
${activeEffectsMarkup}
|
||||||
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
${state.roundSummary?.event ? `<p class="event-note">${state.roundSummary.event}</p>` : ""}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -867,6 +1244,10 @@ function renderSidebar() {
|
|||||||
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
${state.history.slice(0, 8).map((entry) => `<p>${entry}</p>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel finish-panel">
|
||||||
|
<button id="finish-game" class="ghost-button finish-game-button" ${boardLocked ? "disabled" : ""}>End Game / Score Now</button>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -890,13 +1271,15 @@ function attachEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownerId === currentPlayer.id && undoMovesThrough(nodeKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownerId === currentPlayer.id) {
|
if (ownerId === currentPlayer.id) {
|
||||||
const sourceKey = nodeKey;
|
const sourceKey = nodeKey;
|
||||||
|
const isPendingNode = isPendingTurnNode(row, column);
|
||||||
|
|
||||||
if (state.selectedSource === sourceKey) {
|
if (state.selectedSource === sourceKey) {
|
||||||
|
if (isPendingNode && undoMovesThrough(nodeKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateSelection(null);
|
updateSelection(null);
|
||||||
} else {
|
} else {
|
||||||
updateSelection(sourceKey);
|
updateSelection(sourceKey);
|
||||||
@@ -924,7 +1307,7 @@ function attachEvents() {
|
|||||||
if (output) {
|
if (output) {
|
||||||
output.textContent = input.value;
|
output.textContent = input.value;
|
||||||
}
|
}
|
||||||
setup = createSetupState(Number(input.value), setup.columns, setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
|
setup = createSetupState(Number(input.value), setup.columns, setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder, setup.initiativeMode, setup.biddingOrderRule, setup.weatherDraftEnabled, setup.winCondition, setup.maxRounds, setup.topLeafTarget);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#column-count")?.addEventListener("change", (event) => {
|
||||||
@@ -933,7 +1316,7 @@ function attachEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setup = createSetupState(setup.playerCount, Math.max(6, Math.min(24, columns)), setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
|
setup = createSetupState(setup.playerCount, Math.max(6, Math.min(24, columns)), setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder, setup.initiativeMode, setup.biddingOrderRule, setup.weatherDraftEnabled, setup.winCondition, setup.maxRounds, setup.topLeafTarget);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#row-count")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#row-count")?.addEventListener("change", (event) => {
|
||||||
@@ -942,7 +1325,7 @@ function attachEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setup = createSetupState(setup.playerCount, setup.columns, Math.max(6, Math.min(24, rows)), setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
|
setup = createSetupState(setup.playerCount, setup.columns, Math.max(6, Math.min(24, rows)), setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder, setup.initiativeMode, setup.biddingOrderRule, setup.weatherDraftEnabled, setup.winCondition, setup.maxRounds, setup.topLeafTarget);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#starting-nodes")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#starting-nodes")?.addEventListener("change", (event) => {
|
||||||
@@ -951,7 +1334,7 @@ function attachEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setup = createSetupState(setup.playerCount, setup.columns, setup.rows, Math.max(1, nextValue), setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
|
setup = createSetupState(setup.playerCount, setup.columns, setup.rows, Math.max(1, nextValue), setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder, setup.initiativeMode, setup.biddingOrderRule, setup.weatherDraftEnabled, setup.winCondition, setup.maxRounds, setup.topLeafTarget);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#sunbeam-chance")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLInputElement>("#sunbeam-chance")?.addEventListener("change", (event) => {
|
||||||
@@ -962,8 +1345,25 @@ function attachEvents() {
|
|||||||
setup.diseaseChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0));
|
setup.diseaseChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0));
|
||||||
state.randomEffects.diseaseChance = setup.diseaseChance;
|
state.randomEffects.diseaseChance = setup.diseaseChance;
|
||||||
});
|
});
|
||||||
document.querySelector<HTMLInputElement>("#shuffle-order-toggle")?.addEventListener("change", (event) => {
|
document.querySelector<HTMLSelectElement>("#initiative-mode")?.addEventListener("change", (event) => {
|
||||||
setup.shuffleTurnOrder = (event.currentTarget as HTMLInputElement).checked;
|
setup.initiativeMode = (event.currentTarget as HTMLSelectElement).value as SetupState["initiativeMode"];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLSelectElement>("#bidding-order-rule")?.addEventListener("change", (event) => {
|
||||||
|
setup.biddingOrderRule = (event.currentTarget as HTMLSelectElement).value as SetupState["biddingOrderRule"];
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLInputElement>("#weather-draft-toggle")?.addEventListener("change", (event) => {
|
||||||
|
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLSelectElement>("#win-condition")?.addEventListener("change", (event) => {
|
||||||
|
setup.winCondition = (event.currentTarget as HTMLSelectElement).value as SetupState["winCondition"];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLInputElement>("#max-rounds")?.addEventListener("change", (event) => {
|
||||||
|
setup.maxRounds = Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1);
|
||||||
|
});
|
||||||
|
document.querySelector<HTMLInputElement>("#top-leaf-target")?.addEventListener("change", (event) => {
|
||||||
|
setup.topLeafTarget = Math.max(1, Math.min(setup.columns, Number((event.currentTarget as HTMLInputElement).value) || 1));
|
||||||
});
|
});
|
||||||
document.querySelectorAll<HTMLInputElement>(".seed-input").forEach((input) => {
|
document.querySelectorAll<HTMLInputElement>(".seed-input").forEach((input) => {
|
||||||
input.addEventListener("input", (event) => {
|
input.addEventListener("input", (event) => {
|
||||||
@@ -985,6 +1385,16 @@ function attachEvents() {
|
|||||||
shiftSelectedRoot(Number(button.dataset.rootShift));
|
shiftSelectedRoot(Number(button.dataset.rootShift));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-seat-choice]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
chooseInitiativeSeat(Number(button.dataset.seatChoice));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-weather-card]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -999,6 +1409,8 @@ function render() {
|
|||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
${renderNewGameModal()}
|
${renderNewGameModal()}
|
||||||
|
${renderInitiativeModal()}
|
||||||
|
${renderWeatherDraftModal()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
attachEvents();
|
attachEvents();
|
||||||
|
|||||||
51
src/rules-initiative.ts
Normal file
51
src/rules-initiative.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { GameState, InitiativeDraftState, PlayerId } from "./types";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return Array.from({ length: state.players.length }, (_, index) => (index === 0 ? firstSeatBonus : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRotatingBiddingOrder(state: GameState) {
|
||||||
|
const start = (state.initiativeAnchorPlayerId + state.round - 1) % state.players.length;
|
||||||
|
return Array.from({ length: state.players.length }, (_, index) => (start + index) % state.players.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLowestGrowthBiddingOrder(state: GameState) {
|
||||||
|
const orderRank = new Map<PlayerId, number>(state.turnOrder.map((playerId, index) => [playerId, index]));
|
||||||
|
|
||||||
|
return [...state.players]
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.lifetimeGrowthIncome !== right.lifetimeGrowthIncome) {
|
||||||
|
return left.lifetimeGrowthIncome - right.lifetimeGrowthIncome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftRank = orderRank.get(left.id) ?? left.id;
|
||||||
|
const rightRank = orderRank.get(right.id) ?? right.id;
|
||||||
|
if (leftRank !== rightRank) {
|
||||||
|
return leftRank - rightRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id - right.id;
|
||||||
|
})
|
||||||
|
.map((player) => player.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitiativeDraft(state: GameState): InitiativeDraftState {
|
||||||
|
const biddingOrder = state.config.biddingOrderRule === "lowest_growth_income"
|
||||||
|
? getLowestGrowthBiddingOrder(state)
|
||||||
|
: getRotatingBiddingOrder(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
biddingOrder,
|
||||||
|
biddingIndex: 0,
|
||||||
|
seatAssignments: Array.from({ length: state.players.length }, () => null),
|
||||||
|
seatBonuses: getSeatBonuses(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeatNumberForPlayer(draft: InitiativeDraftState, playerId: PlayerId) {
|
||||||
|
const seatIndex = draft.seatAssignments.findIndex((assignedPlayerId) => assignedPlayerId === playerId);
|
||||||
|
return seatIndex === -1 ? null : seatIndex + 1;
|
||||||
|
}
|
||||||
@@ -2,6 +2,114 @@ import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAn
|
|||||||
import { keyFor, parseKey, shuffleArray } from "./utils";
|
import { keyFor, parseKey, shuffleArray } from "./utils";
|
||||||
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
import { buildChildrenMap, buildParentMap } from "./rules-board";
|
||||||
|
|
||||||
|
function getColumnRegion(state: GameState, column: number) {
|
||||||
|
const third = state.config.columns / 3;
|
||||||
|
if (column < third) {
|
||||||
|
return "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column >= state.config.columns - third) {
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLeafCounts(state: GameState) {
|
||||||
|
const childrenMap = buildChildrenMap(state);
|
||||||
|
const counts = state.players.map(() => 0);
|
||||||
|
|
||||||
|
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||||
|
if (!(childrenMap.get(nodeKey)?.length)) {
|
||||||
|
counts[node.ownerId] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
|
||||||
|
if (state.activeRoundEffects.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leafCounts = getLeafCounts(state);
|
||||||
|
const childrenMap = buildChildrenMap(state);
|
||||||
|
const tallestLeaves = state.players.map(() => null as number | null);
|
||||||
|
|
||||||
|
Array.from(state.nodes.entries()).forEach(([nodeKey, node]) => {
|
||||||
|
const leafCount = leafCounts[node.ownerId];
|
||||||
|
if (leafCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childrenMap.get(nodeKey)?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { row } = parseKey(nodeKey);
|
||||||
|
const currentTallest = tallestLeaves[node.ownerId];
|
||||||
|
if (currentTallest === null || row < currentTallest) {
|
||||||
|
tallestLeaves[node.ownerId] = row;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.activeRoundEffects.forEach((effectId) => {
|
||||||
|
if (effectId === "leaf_surge") {
|
||||||
|
leafCounts.forEach((count, playerId) => {
|
||||||
|
scores[playerId] += count;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "branching_season") {
|
||||||
|
leafCounts.forEach((count, playerId) => {
|
||||||
|
scores[playerId] += Math.max(0, count - 1);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "tall_reward") {
|
||||||
|
tallestLeaves.forEach((row, playerId) => {
|
||||||
|
if (row !== null) {
|
||||||
|
scores[playerId] += 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectId === "wide_reach") {
|
||||||
|
const maxScore = Math.max(...energySimulation.scores);
|
||||||
|
energySimulation.scores.forEach((score, playerId) => {
|
||||||
|
if (score === maxScore && maxScore > 0) {
|
||||||
|
scores[playerId] += 2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
energySimulation.columns.forEach((column) => {
|
||||||
|
if (!column.intercepted || column.ownerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getColumnRegion(state, column.column);
|
||||||
|
if (effectId === "west_light" && region === "left") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "east_light" && region === "right") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "high_noon" && region === "center") {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
if (effectId === "edge_bloom" && (column.column === 0 || column.column === state.config.columns - 1)) {
|
||||||
|
scores[column.ownerId] += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
export function buildEnergySimulation(state: GameState): EnergySimulation {
|
||||||
const parentMap = buildParentMap(state);
|
const parentMap = buildParentMap(state);
|
||||||
const columns = [];
|
const columns = [];
|
||||||
@@ -70,6 +178,8 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
|
|||||||
}, new Map<NodeKey, RootBurst>());
|
}, new Map<NodeKey, RootBurst>());
|
||||||
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
const rootBursts: RootBurst[] = [...rootBurstMap.values()];
|
||||||
|
|
||||||
|
applyWeatherEffects(state, scores, { scores, columns, rootBursts });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scores,
|
scores,
|
||||||
columns,
|
columns,
|
||||||
|
|||||||
41
src/rules-weather.ts
Normal file
41
src/rules-weather.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { GameState, PlayerId, WeatherCardDefinition, WeatherCardId, WeatherDraftState } from "./types";
|
||||||
|
import { shuffleArray } from "./utils";
|
||||||
|
|
||||||
|
export const WEATHER_CARDS: WeatherCardDefinition[] = [
|
||||||
|
{ id: "leaf_surge", title: "Leaf Surge", description: "Each leaf gives +1." },
|
||||||
|
{ id: "branching_season", title: "Branching Season", description: "Each leaf after your first gives +1." },
|
||||||
|
{ id: "west_light", title: "West Light", description: "Left third columns give +1." },
|
||||||
|
{ id: "east_light", title: "East Light", description: "Right third columns give +1." },
|
||||||
|
{ id: "high_noon", title: "High Noon", description: "Center third columns give +1." },
|
||||||
|
{ id: "edge_bloom", title: "Edge Bloom", description: "Edge columns give +1." },
|
||||||
|
{ id: "wide_reach", title: "Wide Reach", description: "Most columns gets +2." },
|
||||||
|
{ id: "tall_reward", title: "Tall Reward", description: "Your tallest leaf gives +2." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>(
|
||||||
|
WEATHER_CARDS.map((card) => [card.id, card]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function createWeatherDraft(state: GameState): WeatherDraftState {
|
||||||
|
const rowSize = Math.min(WEATHER_CARDS.length, state.players.length + 2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerOrder: [...state.turnOrder],
|
||||||
|
draftIndex: 0,
|
||||||
|
row: shuffleArray(WEATHER_CARDS.map((card) => card.id)).slice(0, rowSize),
|
||||||
|
drafted: [],
|
||||||
|
banned: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentWeatherPlayerId(draft: WeatherDraftState) {
|
||||||
|
return draft.playerOrder[draft.draftIndex] as PlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeatherCardAvailable(draft: WeatherDraftState, cardId: WeatherCardId) {
|
||||||
|
return draft.row.includes(cardId) && !draft.drafted.includes(cardId) && !draft.banned.includes(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherCard(cardId: WeatherCardId) {
|
||||||
|
return WEATHER_CARD_LOOKUP.get(cardId) ?? null;
|
||||||
|
}
|
||||||
28
src/state.ts
28
src/state.ts
@@ -77,6 +77,12 @@ export function createSetupState(
|
|||||||
seedInputs: string[] | null = null,
|
seedInputs: string[] | null = null,
|
||||||
paletteOrder: number[] | null = null,
|
paletteOrder: number[] | null = null,
|
||||||
shuffleTurnOrder = true,
|
shuffleTurnOrder = true,
|
||||||
|
initiativeMode: SetupState["initiativeMode"] = "fixed",
|
||||||
|
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
|
||||||
|
weatherDraftEnabled = true,
|
||||||
|
winCondition: SetupState["winCondition"] = "rounds",
|
||||||
|
maxRounds = 12,
|
||||||
|
topLeafTarget = 4,
|
||||||
): SetupState {
|
): SetupState {
|
||||||
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
|
||||||
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
|
||||||
@@ -92,6 +98,12 @@ export function createSetupState(
|
|||||||
seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]),
|
seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]),
|
||||||
paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]),
|
paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]),
|
||||||
shuffleTurnOrder,
|
shuffleTurnOrder,
|
||||||
|
initiativeMode,
|
||||||
|
biddingOrderRule,
|
||||||
|
weatherDraftEnabled,
|
||||||
|
winCondition,
|
||||||
|
maxRounds,
|
||||||
|
topLeafTarget: Math.max(1, Math.min(columns, topLeafTarget)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +120,7 @@ export function createPlayers(playerCount: number, paletteOrder = createDefaultP
|
|||||||
growthPoints: STARTING_POINTS,
|
growthPoints: STARTING_POINTS,
|
||||||
bankedPoints: 0,
|
bankedPoints: 0,
|
||||||
bonusPoints: 0,
|
bonusPoints: 0,
|
||||||
|
lifetimeGrowthIncome: STARTING_POINTS,
|
||||||
passed: false,
|
passed: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -153,6 +166,7 @@ export function normalizeSeedInputs(setup: SetupState) {
|
|||||||
export function createInitialState(setup: SetupState): GameState {
|
export function createInitialState(setup: SetupState): GameState {
|
||||||
const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...setup.paletteOrder];
|
const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...setup.paletteOrder];
|
||||||
const players = createPlayers(setup.playerCount, playerPaletteOrder);
|
const players = createPlayers(setup.playerCount, playerPaletteOrder);
|
||||||
|
const turnOrder = players.map((player) => player.id);
|
||||||
const nodes = new Map();
|
const nodes = new Map();
|
||||||
const edges = [];
|
const edges = [];
|
||||||
const seedColumnsByPlayer = normalizeSeedInputs(setup);
|
const seedColumnsByPlayer = normalizeSeedInputs(setup);
|
||||||
@@ -170,16 +184,28 @@ export function createInitialState(setup: SetupState): GameState {
|
|||||||
playerCount: setup.playerCount,
|
playerCount: setup.playerCount,
|
||||||
startingNodesPerPlayer: setup.startingNodesPerPlayer,
|
startingNodesPerPlayer: setup.startingNodesPerPlayer,
|
||||||
playerPaletteOrder,
|
playerPaletteOrder,
|
||||||
|
initiativeMode: setup.initiativeMode,
|
||||||
|
biddingOrderRule: setup.biddingOrderRule,
|
||||||
|
weatherDraftEnabled: setup.weatherDraftEnabled,
|
||||||
|
winCondition: setup.winCondition,
|
||||||
|
maxRounds: setup.maxRounds,
|
||||||
|
topLeafTarget: setup.topLeafTarget,
|
||||||
},
|
},
|
||||||
players,
|
players,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
round: 1,
|
round: 1,
|
||||||
activePlayerId: 0,
|
activePlayerId: turnOrder[0],
|
||||||
|
turnOrder,
|
||||||
|
phase: setup.initiativeMode === "bid" ? "initiative" : "turn",
|
||||||
turnMoves: [],
|
turnMoves: [],
|
||||||
selectedSource: null,
|
selectedSource: null,
|
||||||
availableTargets: [],
|
availableTargets: [],
|
||||||
animation: null,
|
animation: null,
|
||||||
|
initiativeAnchorPlayerId: Math.floor(Math.random() * players.length),
|
||||||
|
initiativeDraft: null,
|
||||||
|
weatherDraft: null,
|
||||||
|
activeRoundEffects: [],
|
||||||
randomEffects: {
|
randomEffects: {
|
||||||
sunbeamChance: setup.sunbeamChance,
|
sunbeamChance: setup.sunbeamChance,
|
||||||
diseaseChance: setup.diseaseChance,
|
diseaseChance: setup.diseaseChance,
|
||||||
|
|||||||
216
src/styles.css
216
src/styles.css
@@ -18,12 +18,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input {
|
input,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 2.8rem;
|
min-height: 2.8rem;
|
||||||
padding: 0.7rem 0.85rem;
|
padding: 0.7rem 0.85rem;
|
||||||
@@ -58,6 +60,8 @@ button {
|
|||||||
|
|
||||||
.scoreboard--bottom {
|
.scoreboard--bottom {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-card {
|
.score-card {
|
||||||
@@ -91,6 +95,12 @@ button {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__head h2,
|
.score-card__head h2,
|
||||||
.panel h1,
|
.panel h1,
|
||||||
.panel h2,
|
.panel h2,
|
||||||
@@ -103,6 +113,14 @@ button {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__footer {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(231, 238, 247, 0.72);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__numbers div {
|
.score-card__numbers div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -113,10 +131,16 @@ button {
|
|||||||
label span,
|
label span,
|
||||||
.log-list p,
|
.log-list p,
|
||||||
.status-panel p,
|
.status-panel p,
|
||||||
.active-turn p {
|
.active-turn p,
|
||||||
|
.effect-empty {
|
||||||
color: rgba(231, 238, 247, 0.72);
|
color: rgba(231, 238, 247, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-card__meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(231, 238, 247, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
.score-card__numbers strong {
|
.score-card__numbers strong {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
}
|
}
|
||||||
@@ -139,6 +163,7 @@ label span,
|
|||||||
|
|
||||||
.game-area {
|
.game-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr);
|
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 0.85fr);
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
@@ -415,6 +440,21 @@ label span,
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draft-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: max(320px, calc(((100vw - 2rem) - 0.85rem) * 0.3091));
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 9.5rem);
|
||||||
|
overflow: auto;
|
||||||
|
z-index: 24;
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(9, 16, 29, 0.5);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-setup-grid,
|
.modal-setup-grid,
|
||||||
.modal-grid {
|
.modal-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -503,10 +543,180 @@ label span,
|
|||||||
color: rgba(231, 238, 247, 0.72);
|
color: rgba(231, 238, 247, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.initiative-order-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-pill {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(231, 238, 247, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-pill--active {
|
||||||
|
border-color: color-mix(in srgb, var(--player-color) 62%, white);
|
||||||
|
box-shadow: 0 0 18px color-mix(in srgb, var(--player-color) 40%, transparent);
|
||||||
|
color: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat {
|
||||||
|
min-height: 8rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
text-align: left;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #f4f7fb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.initiative-seat--taken {
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 0.95rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card h2,
|
||||||
|
.weather-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card--drafted {
|
||||||
|
border-color: rgba(130, 224, 182, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card--banned {
|
||||||
|
border-color: rgba(255, 128, 128, 0.45);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-key {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: rgba(231, 238, 247, 0.78);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-effects {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 208, 96, 0.14), rgba(255, 208, 96, 0.04));
|
||||||
|
border: 1px solid rgba(255, 208, 96, 0.2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-chip__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
color: #f4f7fb;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--draft {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 208, 96, 0.2), rgba(255, 208, 96, 0.08));
|
||||||
|
border-color: rgba(255, 208, 96, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--draft .weather-action__icon {
|
||||||
|
background: rgba(255, 208, 96, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--ban {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 110, 110, 0.16), rgba(255, 110, 110, 0.06));
|
||||||
|
border-color: rgba(255, 110, 110, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action--ban .weather-action__icon {
|
||||||
|
background: rgba(255, 110, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-action:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.randomize-button {
|
.randomize-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finish-game-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="range"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/types.ts
56
src/types.ts
@@ -22,6 +22,12 @@ export type SetupState = {
|
|||||||
seedInputs: string[];
|
seedInputs: string[];
|
||||||
paletteOrder: number[];
|
paletteOrder: number[];
|
||||||
shuffleTurnOrder: boolean;
|
shuffleTurnOrder: boolean;
|
||||||
|
initiativeMode: "fixed" | "bid";
|
||||||
|
biddingOrderRule: "rotating" | "lowest_growth_income";
|
||||||
|
weatherDraftEnabled: boolean;
|
||||||
|
winCondition: "rounds" | "top_leaves";
|
||||||
|
maxRounds: number;
|
||||||
|
topLeafTarget: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Player = {
|
export type Player = {
|
||||||
@@ -34,6 +40,7 @@ export type Player = {
|
|||||||
growthPoints: number;
|
growthPoints: number;
|
||||||
bankedPoints: number;
|
bankedPoints: number;
|
||||||
bonusPoints: number;
|
bonusPoints: number;
|
||||||
|
lifetimeGrowthIncome: number;
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +50,12 @@ export type GameConfig = {
|
|||||||
playerCount: number;
|
playerCount: number;
|
||||||
startingNodesPerPlayer: number;
|
startingNodesPerPlayer: number;
|
||||||
playerPaletteOrder: number[];
|
playerPaletteOrder: number[];
|
||||||
|
initiativeMode: SetupState["initiativeMode"];
|
||||||
|
biddingOrderRule: SetupState["biddingOrderRule"];
|
||||||
|
weatherDraftEnabled: boolean;
|
||||||
|
winCondition: SetupState["winCondition"];
|
||||||
|
maxRounds: number;
|
||||||
|
topLeafTarget: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodeState = {
|
export type NodeState = {
|
||||||
@@ -149,10 +162,10 @@ export type RoundSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ScoreSnapshot = {
|
export type ScoreSnapshot = {
|
||||||
totalScore: number;
|
currentExposure: number;
|
||||||
roundScore: number;
|
|
||||||
growthPoints: number;
|
growthPoints: number;
|
||||||
bankedPoints: number;
|
bankedPoints: number;
|
||||||
|
lifetimeGrowthIncome: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ColumnLeader = {
|
export type ColumnLeader = {
|
||||||
@@ -166,6 +179,39 @@ export type RandomEffects = {
|
|||||||
diseaseChance: number;
|
diseaseChance: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GamePhase = "initiative" | "turn" | "round_end" | "game_over";
|
||||||
|
|
||||||
|
export type WeatherCardId =
|
||||||
|
| "leaf_surge"
|
||||||
|
| "branching_season"
|
||||||
|
| "west_light"
|
||||||
|
| "east_light"
|
||||||
|
| "high_noon"
|
||||||
|
| "edge_bloom"
|
||||||
|
| "wide_reach"
|
||||||
|
| "tall_reward";
|
||||||
|
|
||||||
|
export type WeatherCardDefinition = {
|
||||||
|
id: WeatherCardId;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InitiativeDraftState = {
|
||||||
|
biddingOrder: PlayerId[];
|
||||||
|
biddingIndex: number;
|
||||||
|
seatAssignments: Array<PlayerId | null>;
|
||||||
|
seatBonuses: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherDraftState = {
|
||||||
|
playerOrder: PlayerId[];
|
||||||
|
draftIndex: number;
|
||||||
|
row: WeatherCardId[];
|
||||||
|
drafted: WeatherCardId[];
|
||||||
|
banned: WeatherCardId[];
|
||||||
|
};
|
||||||
|
|
||||||
export type GameState = {
|
export type GameState = {
|
||||||
config: GameConfig;
|
config: GameConfig;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
@@ -173,10 +219,16 @@ export type GameState = {
|
|||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
round: number;
|
round: number;
|
||||||
activePlayerId: PlayerId;
|
activePlayerId: PlayerId;
|
||||||
|
turnOrder: PlayerId[];
|
||||||
|
phase: GamePhase | "weather";
|
||||||
turnMoves: TurnMove[];
|
turnMoves: TurnMove[];
|
||||||
selectedSource: NodeKey | null;
|
selectedSource: NodeKey | null;
|
||||||
availableTargets: GrowTarget[];
|
availableTargets: GrowTarget[];
|
||||||
animation: RoundAnimation | null;
|
animation: RoundAnimation | null;
|
||||||
|
initiativeAnchorPlayerId: PlayerId;
|
||||||
|
initiativeDraft: InitiativeDraftState | null;
|
||||||
|
weatherDraft: WeatherDraftState | null;
|
||||||
|
activeRoundEffects: WeatherCardId[];
|
||||||
randomEffects: RandomEffects;
|
randomEffects: RandomEffects;
|
||||||
gameOver: boolean;
|
gameOver: boolean;
|
||||||
history: string[];
|
history: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user