You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

294 lines
8.0 KiB

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<string, Cell>,
resources: Record<string, number>,
moves: Record<string, Move[]>
}
export const Buildings = {
base: {
cost: Infinity,
gain: 3,
},
pawn: {
cost: 1,
}
} as const;
export const AcrossTheHex: Game<State, Record<string, unknown>, {
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<string, number>, moves: Record<string, Move[]>): 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<string, Cell> {
const res: Record<string, Cell> = {};
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<string, Move[]> = {};
for (const player of players) {
res[player] = [];
}
return res;
}
function initResources(players: string[], resources: number = 0): Record<string, number> {
return Object.fromEntries(players.map((id) => [id, resources] as const));
}
export function cellIn(grid: Record<string, Cell>, 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<string, Cell>, x: number, y: number): Cell | null {
if (!cellIn(grid, x, y)) return null;
return grid[`${y}:${x}`]!;
}
function setCell(grid: Record<string, Cell>, x: number, y: number, cell: Cell) {
if (!cellIn(grid, x, y)) return;
grid[`${y}:${x}`] = cell;
}
function hasAdjacentBuilding(grid: Record<string, Cell>, 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<string, string>): 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<string, Cell>) {
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;
}