Compare commits

..

2 Commits

6 changed files with 166 additions and 55 deletions

View File

@@ -1,7 +1,5 @@
import "./styles.css";
console.log("[DEBUG] Starting main.ts import...");
import {
ROOT_SHIFT_COST,
ROUND_ANIMATION_BONUS_MS,
@@ -28,6 +26,7 @@ import {
createInitiativeDraft,
} from "./rules-initiative";
import {
WEATHER_CARDS,
createWeatherDraft,
getCurrentWeatherPlayerId,
getWeatherCard,
@@ -54,22 +53,17 @@ import type {
import { keyFor, parseKey, tint, wait } from "./utils";
const app = document.querySelector("#app");
console.log("[DEBUG] App element found:", app);
if (!(app instanceof HTMLElement)) {
console.error("[DEBUG] #app container not found or not HTMLElement");
throw new Error("#app container not found");
}
console.log("[DEBUG] Initializing state...");
let roundAnimationToken = 0;
let setup: SetupState = createSetupState();
console.log("[DEBUG] Setup created:", setup);
let state: GameState = createInitialState(setup);
console.log("[DEBUG] Initial state created:", state);
let isNewGameModalOpen = false;
let previousScoreSnapshot: ScoreSnapshot[] | null = null;
console.log("[DEBUG] State initialized, proceeding to render...");
let setupTab: "board" | "rules" | "events" | "players" = "board";
function rebuildSetup(overrides: Partial<SetupState> = {}) {
setup = createSetupState(
@@ -84,6 +78,7 @@ function rebuildSetup(overrides: Partial<SetupState> = {}) {
overrides.initiativeMode ?? setup.initiativeMode,
overrides.biddingOrderRule ?? setup.biddingOrderRule,
overrides.weatherDraftEnabled ?? setup.weatherDraftEnabled,
overrides.weatherDraftCount ?? setup.weatherDraftCount,
overrides.winCondition ?? setup.winCondition,
overrides.maxRounds ?? setup.maxRounds,
overrides.topLeafTarget ?? setup.topLeafTarget,
@@ -154,6 +149,10 @@ function getTurnLabel() {
return state.gameOver ? "Game Over" : `${getCurrentPlayer().name}'s turn`;
}
function isBankingEnabled() {
return state.activeRoundEffects.includes("storehouse");
}
function awardGrowth(player: Player, amount: number) {
if (amount <= 0) {
return;
@@ -207,7 +206,15 @@ function getSelectedRootShiftMoves() {
}
function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
return getLegalMovesForSourceForState(state, sourceKey, player);
const moves = getLegalMovesForSourceForState(state, sourceKey, player);
if (!state.activeRoundEffects.includes("sun_ladder")) {
return moves;
}
return moves.map((move) => move.direction === "vertical"
? { ...move, cost: Math.max(0, move.cost - 1) }
: move);
}
function playerHasLegalMove(player: Player) {
@@ -621,6 +628,10 @@ function endTurn() {
}
function bankGrowthAndEndTurn() {
if (!isBankingEnabled()) {
return;
}
const player = getCurrentPlayer();
if (player.growthPoints <= 0) {
endTurn();
@@ -787,6 +798,11 @@ function randomizeStartingLocations() {
render();
}
function setSetupTab(tab: typeof setupTab) {
setupTab = tab;
render();
}
function renderNewGameModal() {
if (!isNewGameModalOpen) {
return "";
@@ -794,6 +810,7 @@ function renderNewGameModal() {
const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder);
const draftCountMax = WEATHER_CARDS.length;
return `
<div class="modal-backdrop" id="new-game-modal-backdrop">
@@ -805,9 +822,15 @@ function renderNewGameModal() {
</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>
<button class="setup-tab${setupTab === "rules" ? " setup-tab--active" : ""}" data-setup-tab="rules"><span aria-hidden="true">⚖</span><span>Rules</span></button>
<button class="setup-tab${setupTab === "events" ? " setup-tab--active" : ""}" data-setup-tab="events"><span aria-hidden="true">☀</span><span>Events</span></button>
<button class="setup-tab${setupTab === "players" ? " setup-tab--active" : ""}" data-setup-tab="players"><span aria-hidden="true">◉</span><span>Players</span></button>
</nav>
<div class="modal-body">
<!-- Board Settings Section -->
${setupTab === "board" ? `
<section class="setup-section">
<h2 class="setup-section__title">Board Settings</h2>
<div class="setup-grid">
@@ -832,8 +855,9 @@ function renderNewGameModal() {
</label>
</div>
</section>
` : ""}
<!-- Game Rules Section -->
${setupTab === "rules" ? `
<section class="setup-section">
<h2 class="setup-section__title">Game Rules</h2>
<div class="setup-grid setup-grid--2col">
@@ -871,10 +895,21 @@ function renderNewGameModal() {
</select>
</label>
` : ""}
<label class="setup-field setup-field--checkbox">
<span class="setup-field__label">Weather Draft</span>
<input id="weather-draft-toggle" type="checkbox" ${setup.weatherDraftEnabled ? "checked" : ""} />
</label>
${setup.weatherDraftEnabled ? `
<label class="setup-field">
<span class="setup-field__label">Weather cards in draft</span>
<input id="weather-draft-count" type="number" min="1" max="${draftCountMax}" step="1" value="${setup.weatherDraftCount}" />
</label>
` : ""}
</div>
</section>
` : ""}
<!-- Random Events Section -->
${setupTab === "events" ? `
<section class="setup-section">
<h2 class="setup-section__title">Random Events</h2>
<div class="setup-grid setup-grid--3col">
@@ -886,14 +921,11 @@ function renderNewGameModal() {
<span class="setup-field__label">Disease %</span>
<input id="disease-chance" type="number" min="0" max="100" step="5" value="${setup.diseaseChance}" />
</label>
<label class="setup-field setup-field--checkbox">
<span class="setup-field__label">Weather Draft</span>
<input id="weather-draft-toggle" type="checkbox" ${setup.weatherDraftEnabled ? "checked" : ""} />
</label>
</div>
</section>
` : ""}
<!-- Players Section -->
${setupTab === "players" ? `
<section class="setup-section">
<h2 class="setup-section__title">Players & Colors</h2>
<p class="setup-section__help">Reorder players to set turn order. Starting positions are auto-assigned.</p>
@@ -912,6 +944,20 @@ function renderNewGameModal() {
`).join("")}
</div>
</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>
<button class="ghost-button randomize-button" id="randomize-starting-locations">Randomize Starting Locations</button>
<div class="player-list">
${previewPlayers.map((currentPlayer, index) => `
<label class="setup-field">
<span class="setup-field__label" style="color: ${currentPlayer.color};">${currentPlayer.name}</span>
<input class="seed-input" data-player-id="${index}" type="text" value="${setup.seedInputs[index] ?? ""}" placeholder="e.g. 2, 5" />
</label>
`).join("")}
</div>
</section>
` : ""}
</div>
<footer class="modal-footer">
@@ -1259,7 +1305,7 @@ function renderSidebar() {
</div>
<div class="button-row">
<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 || !isBankingEnabled() ? "disabled" : ""}>Bank Remaining</button>
</div>
</section>
@@ -1386,6 +1432,11 @@ function attachEvents() {
});
document.querySelector<HTMLInputElement>("#weather-draft-toggle")?.addEventListener("change", (event) => {
setup.weatherDraftEnabled = (event.currentTarget as HTMLInputElement).checked;
render();
});
document.querySelector<HTMLInputElement>("#weather-draft-count")?.addEventListener("change", (event) => {
rebuildSetup({ weatherDraftCount: Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1) });
render();
});
document.querySelector<HTMLSelectElement>("#win-condition")?.addEventListener("change", (event) => {
setup.winCondition = (event.currentTarget as HTMLSelectElement).value as SetupState["winCondition"];
@@ -1427,38 +1478,31 @@ function attachEvents() {
chooseWeatherAction(button.dataset.weatherCard as WeatherCardId, button.dataset.weatherAction as "draft" | "ban");
});
});
document.querySelectorAll<HTMLElement>("[data-setup-tab]").forEach((button) => {
button.addEventListener("click", () => {
setSetupTab(button.dataset.setupTab as typeof setupTab);
});
});
}
function render() {
console.log("[DEBUG] render() called, playerCount:", state?.players?.length);
try {
const playerCount = state.players.length;
console.log("[DEBUG] Building HTML...");
app.innerHTML = `
<main class="layout" style="--player-count: ${playerCount}">
<section class="game-area">
${renderBoard()}
</section>
${renderSidebar()}
<footer class="scoreboard">
${renderScoreboard()}
</footer>
</main>
${renderNewGameModal()}
${renderInitiativeModal()}
${renderWeatherDraftModal()}
`;
console.log("[DEBUG] HTML rendered, attaching events...");
attachEvents();
console.log("[DEBUG] Events attached, getting score snapshot...");
previousScoreSnapshot = getScoreSnapshot();
console.log("[DEBUG] Render complete!");
} catch (error) {
console.error("[DEBUG] Error in render():", error);
throw error;
}
const playerCount = state.players.length;
app.innerHTML = `
<main class="layout" style="--player-count: ${playerCount}">
<section class="game-area">
${renderBoard()}
</section>
${renderSidebar()}
<footer class="scoreboard">
${renderScoreboard()}
</footer>
</main>
${renderNewGameModal()}
${renderInitiativeModal()}
${renderWeatherDraftModal()}
`;
attachEvents();
previousScoreSnapshot = getScoreSnapshot();
}
console.log("[DEBUG] About to call render()...");
render();
console.log("[DEBUG] render() executed");

View File

@@ -28,6 +28,48 @@ function getLeafCounts(state: GameState) {
return counts;
}
function getColumnPresence(state: GameState, column: number) {
const owners = new Set<PlayerId>();
for (let row = 0; row < state.config.rows; row += 1) {
const ownerId = state.nodes.get(keyFor(row, column))?.ownerId;
if (ownerId !== undefined) {
owners.add(ownerId);
}
}
return [...owners];
}
function applyColumnBaseEnergy(state: GameState, scores: number[], ownerId: PlayerId, playersPresent: PlayerId[]) {
const contested = playersPresent.length > 1;
if (!contested) {
scores[ownerId] += 1;
return;
}
if (state.activeRoundEffects.includes("stalemate")) {
return;
}
if (state.activeRoundEffects.includes("split_light")) {
playersPresent.forEach((playerId) => {
scores[playerId] += 0.5;
});
return;
}
if (state.activeRoundEffects.includes("shared_light")) {
playersPresent.forEach((playerId) => {
scores[playerId] += 1;
});
return;
}
scores[ownerId] += 1;
}
function applyWeatherEffects(state: GameState, scores: number[], energySimulation: EnergySimulation) {
if (state.activeRoundEffects.length === 0) {
return;
@@ -132,6 +174,7 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
terminalRow: state.config.rows - 1,
intercepted: false,
ownerId: null,
playersPresent: [],
hitNode: null,
rootKey: null,
branchNodes: [],
@@ -142,6 +185,7 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
const hitNode = parseKey(hitNodeKey);
const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
const playersPresent = getColumnPresence(state, column);
const branchNodes = [hitNode];
const branchEdges = [];
let cursor = hitNodeKey;
@@ -153,12 +197,13 @@ export function buildEnergySimulation(state: GameState): EnergySimulation {
cursor = parentKey;
}
scores[ownerId] += 1;
applyColumnBaseEnergy(state, scores, ownerId, playersPresent);
columns.push({
column,
terminalRow: hitNode.row,
intercepted: true,
ownerId,
playersPresent,
hitNode,
rootKey: cursor,
branchNodes,

View File

@@ -4,12 +4,17 @@ 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: "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." },
{ 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>(
@@ -17,7 +22,7 @@ export const WEATHER_CARD_LOOKUP = new Map<WeatherCardId, WeatherCardDefinition>
);
export function createWeatherDraft(state: GameState): WeatherDraftState {
const rowSize = Math.min(WEATHER_CARDS.length, state.players.length + 2);
const rowSize = Math.min(WEATHER_CARDS.length, state.config.weatherDraftCount);
return {
playerOrder: [...state.turnOrder],

View File

@@ -79,15 +79,17 @@ export function createSetupState(
initiativeMode: SetupState["initiativeMode"] = "fixed",
biddingOrderRule: SetupState["biddingOrderRule"] = "rotating",
weatherDraftEnabled = true,
weatherDraftCount = playerCount + 2,
winCondition: SetupState["winCondition"] = "rounds",
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);
return {
const result = {
playerCount,
columns,
rows,
@@ -99,10 +101,13 @@ export function createSetupState(
initiativeMode,
biddingOrderRule,
weatherDraftEnabled,
weatherDraftCount: Math.max(1, weatherDraftCount),
winCondition,
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[] {
@@ -162,6 +167,7 @@ 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 turnOrder = players.map((player) => player.id);
@@ -175,6 +181,7 @@ export function createInitialState(setup: SetupState): GameState {
});
});
console.log("[DEBUG] createInitialState completed");
return {
config: {
columns: setup.columns,
@@ -185,6 +192,7 @@ export function createInitialState(setup: SetupState): GameState {
initiativeMode: setup.initiativeMode,
biddingOrderRule: setup.biddingOrderRule,
weatherDraftEnabled: setup.weatherDraftEnabled,
weatherDraftCount: setup.weatherDraftCount,
winCondition: setup.winCondition,
maxRounds: setup.maxRounds,
topLeafTarget: setup.topLeafTarget,

View File

@@ -91,14 +91,14 @@ html, body {
/* Board - fits within shell */
.board {
position: relative;
width: 100%;
height: 100%;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
aspect-ratio: var(--board-columns) / var(--board-rows);
display: grid;
grid-template-columns: repeat(var(--board-columns), 1fr);
grid-template-rows: repeat(var(--board-rows), 1fr);
grid-template-columns: repeat(var(--board-columns), minmax(0, 1fr));
grid-template-rows: repeat(var(--board-rows), minmax(0, 1fr));
gap: clamp(2px, 0.3cqmin, 4px);
margin: 0 auto;
}
@@ -174,6 +174,7 @@ html, body {
overflow: hidden;
min-width: 0;
min-height: 0;
aspect-ratio: 1 / 1;
}
.cell__shade {

View File

@@ -24,6 +24,7 @@ export type SetupState = {
initiativeMode: "fixed" | "bid";
biddingOrderRule: "rotating" | "lowest_growth_income";
weatherDraftEnabled: boolean;
weatherDraftCount: number;
winCondition: "rounds" | "top_leaves";
maxRounds: number;
topLeafTarget: number;
@@ -52,6 +53,7 @@ export type GameConfig = {
initiativeMode: SetupState["initiativeMode"];
biddingOrderRule: SetupState["biddingOrderRule"];
weatherDraftEnabled: boolean;
weatherDraftCount: number;
winCondition: SetupState["winCondition"];
maxRounds: number;
topLeafTarget: number;
@@ -110,6 +112,7 @@ export type ColumnEnergy = {
terminalRow: number;
intercepted: boolean;
ownerId: PlayerId | null;
playersPresent: PlayerId[];
hitNode: Position | null;
rootKey: NodeKey | null;
branchNodes: Position[];
@@ -183,12 +186,17 @@ export type GamePhase = "initiative" | "turn" | "round_end" | "game_over";
export type WeatherCardId =
| "leaf_surge"
| "branching_season"
| "storehouse"
| "sun_ladder"
| "west_light"
| "east_light"
| "high_noon"
| "edge_bloom"
| "wide_reach"
| "tall_reward";
| "tall_reward"
| "stalemate"
| "split_light"
| "shared_light";
export type WeatherCardDefinition = {
id: WeatherCardId;