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
294 lines
8.0 KiB
1 year ago
|
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;
|
||
|
}
|