import type { Game } from "boardgame.io"; import { INVALID_MOVE } from "boardgame.io/core"; // TODO: partial information export type Cell = { owner: null } | { owner: string, building: keyof typeof Buildings, }; export type Move = { type: "placeBuilding", x: number, y: number, building: keyof typeof Buildings, }; export type State = { cells: Record, resources: Record, moves: Record } export const Buildings = { base: { cost: Infinity, gain: 3, }, pawn: { cost: 1, } } as const; export const AcrossTheHex: Game, { size?: number, initialResources?: number }> = { setup({ ctx }, { size = 3, initialResources = 1 } = {}) { const cells = initGrid(size); const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString()); setCell(cells, 0, 0, { owner: "0", building: "base" }); setCell(cells, size * 2 - 2, size * 2 - 2, { owner: "1", building: "base" }); return { cells, resources: initResources(players, initialResources), moves: emptyMoves(players), }; }, turn: { onBegin(ctx) { // Dispatch all players into the prepare stage ctx.events.setActivePlayers({ all: "prepare" }); }, onEnd(ctx) { if (!ctx.ctx.activePlayers || !areAllPlayersReady(ctx.ctx.activePlayers)) { throw new Error("Assertion error: not all players were ready when turn ended"); } applyMoves(ctx.G); }, endIf(ctx) { if (!ctx.ctx.activePlayers) return false; return areAllPlayersReady(ctx.ctx.activePlayers); }, stages: { prepare: { next: "ready", moves: { setReady(ctx) { ctx.events.endStage(); }, placeBuilding, }, }, ready: { moves: {} } } } } as const; function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) { // Cannot place a building outside the field if (!cellIn(G.cells, x, y)) return INVALID_MOVE; // Must place a building next to an already-established friendly building if (!hasAdjacentBuilding(G.cells, x, y, playerID)) return INVALID_MOVE; // Cannot place a building on an existing one const targetOwner = getCell(G.cells, x, y)?.owner; if (targetOwner && targetOwner !== playerID) return INVALID_MOVE; // Cannot place a building without enough resources if (remainingResources(playerID, G.resources, G.moves) < Buildings[building].cost) return INVALID_MOVE; // Cannot place two buildings at the same place if (G.moves[playerID]!.find((move) => { return move.type === "placeBuilding" && move.x === x && move.y === y; })) return INVALID_MOVE; G.moves[playerID]!.push({ type: "placeBuilding", x, y, building, }); // G.cells[`${y}:${x}`] = { // owner: playerID // }; // G.resources[playerID] -= Buildings[building].cost; return; } function remainingResources(playerID: string, resources: Record, moves: Record): number { let result = resources[playerID] ?? 0; for (const move of moves[playerID] ?? []) { if (move.type === "placeBuilding") { result -= Buildings[move.building].cost; } } return result; } function initGrid(size: number): Record { const res: Record = {}; for (let y = 0; y < size; y++) { for (let x = 0; x < size + y; x++) { res[`${y}:${x}`] = emptyCell(); } } for (let y = size; y < size * 2 - 1; y++) { for (let x = y - size + 1; x < size * 2 - 1; x++) { res[`${y}:${x}`] = emptyCell(); } } return res; } function emptyCell(): Cell { return { owner: null }; } function emptyMoves(players: string[]) { let res: Record = {}; for (const player of players) { res[player] = []; } return res; } function initResources(players: string[], resources: number = 0): Record { return Object.fromEntries(players.map((id) => [id, resources] as const)); } export function cellIn(grid: Record, x: number, y: number): boolean { if (x < 0 || y < 0 || !Number.isInteger(x) || !Number.isInteger(y)) return false; return `${y}:${x}` in grid; } function getCell(grid: Record, x: number, y: number): Cell | null { if (!cellIn(grid, x, y)) return null; return grid[`${y}:${x}`]!; } function setCell(grid: Record, x: number, y: number, cell: Cell) { if (!cellIn(grid, x, y)) return; grid[`${y}:${x}`] = cell; } function hasAdjacentBuilding(grid: Record, x: number, y: number, playerID: string): boolean { for (const [x2, y2] of adjacentCells(x, y)) { const cell = getCell(grid, x2, y2); if (cell?.owner === playerID) return true; } return false; } function areAllPlayersReady(activePlayers: Record): boolean { for (const player in activePlayers) { if (activePlayers[player] !== "ready") return false; } return true; } export function* adjacentCells(x: number, y: number): Iterable<[x: number, y: number]> { yield [x - 1, y - 1]; yield [x, y - 1]; yield [x - 1, y]; yield [x + 1, y]; yield [x, y + 1]; yield [x + 1, y + 1]; } export function* iterateCells(grid: Record) { const regexp = /^(\d+):(\d+)$/; for (let key in grid) { const match = regexp.exec(key); if (!match) continue; yield [+match[2]!, +match[1]!, grid[key]!] as const; } } function applyMoves(state: State) { const players = Object.keys(state.moves); const moves = state.moves; state.moves = emptyMoves(players); const extractedResources = initResources(players, 0); // Resource extracting step for (const [_x, _y, cell] of iterateCells(state.cells)) { if (cell.owner !== null && cell.owner in state.resources) { const building = Buildings[cell.building]; if ("gain" in building) { extractedResources[cell.owner] += building.gain; } } } // Building placement step const placeBuildingMoves = Object.entries(moves).flatMap(([player, moves]) => moves.map((move) => [player, move] as const)); for (const [x, y, _cell] of iterateCells(state.cells)) { const orders = placeBuildingMoves.filter(([, move]) => move.x === x && move.y === y); if (orders.length === 0) continue; // If two players try to build on the same tile, then nothing happens if (orders.length > 1) { continue; } const order = orders[0]!; const cost = Buildings[order[1].building].cost; if (state.resources[order[0]]! < cost) continue; // TODO: fully check that the building can be placed setCell(state.cells, x, y, { owner: order[0], building: order[1].building }); state.resources[order[0]] -= cost; } // Resource gathering step for (const player of players) { state.resources[player] += extractedResources[player]!; } } export function getLocalState(state: State, playerID: string): State["cells"] { const res: State["cells"] = { ...state.cells }; for (const move of state.moves[playerID] ?? []) { if (move.type === "placeBuilding") { res[`${move.y}:${move.x}`] = { owner: playerID, building: move.building }; } } return res; }