UNPKG

hathora-et-labora-game

Version:

Plays Uwe Rosenberg's Ora et Labora for the Hathora engine. It reduces a list of moves into a board game state.

549 lines (501 loc) 17.8 kB
import { addIndex, any, append, curry, equals, filter, find, lensPath, map, pipe, range, reduce, reduced, reject, set, sum, } from 'ramda' import { match } from 'ts-pattern' import { LandEnum, PlayerColor, Tile, BuildingEnum, Clergy, GameCommandConfigParams, ErectionEnum, NextUseClergy, StateReducer, Tableau, } from '../types' import { pointsForBuilding, pointsForDwelling, terrainForErection } from './erections' import { isPrior, withActivePlayer, withPlayerIndex } from './player' import { isCloisterBuilding, isSettlement } from './buildings' export const districtPrices = (config: GameCommandConfigParams): number[] => match(config) .with({ players: 1 }, () => [8, 7, 6, 5, 5, 4, 4, 3, 2]) .otherwise(() => [2, 3, 4, 4, 5, 5, 6, 7, 8]) export const plotPrices = (config: GameCommandConfigParams): number[] => match(config) .with({ players: 1 }, () => [7, 6, 6, 5, 5, 5, 4, 4, 3]) .otherwise(() => [3, 4, 4, 5, 5, 5, 6, 6, 7]) // TODO: combine findBuilding with findBuildingOffset somehow export const findBuildingWithoutOffset = (building: BuildingEnum) => (landscape: Tile[][]): [number, number] | undefined => { let row let col landscape.forEach((landRow, r) => { landRow.forEach(([_l, b, _c], c) => { if (building === b) { row = r col = c } }) }) if (row === undefined || col === undefined) return undefined return [row, col] } export const findBuilding = ( landscape: Tile[][], landscapeOffset: number, building: BuildingEnum ): { row?: number; col?: number } => { let row let col landscape.forEach((landRow, r) => { landRow.forEach(([_l, b, _c], c) => { if (building === b) { row = r - landscapeOffset col = c } }) }) return { row, col } } export const findClergy = (clergy: Clergy[]) => (landscape: Tile[][]): [number, number, Tile][] => { const locationsFound: [number, number, Tile][] = [] landscape.forEach((landRow, r) => { landRow.forEach((land, c) => { const [_l, _b, clergyHere] = land if (clergyHere && clergy.includes(clergyHere)) { locationsFound.push([r, c, land]) } }) }) return locationsFound } const PP: Tile = [LandEnum.Plains, BuildingEnum.Moor] const PF: Tile = [LandEnum.Plains, BuildingEnum.Forest] const P: Tile = [LandEnum.Plains] const startBuilding = { [PlayerColor.Red]: [BuildingEnum.ClayMoundR, BuildingEnum.FarmYardR, BuildingEnum.CloisterOfficeR], [PlayerColor.Green]: [BuildingEnum.ClayMoundG, BuildingEnum.FarmYardG, BuildingEnum.CloisterOfficeG], [PlayerColor.Blue]: [BuildingEnum.ClayMoundB, BuildingEnum.FarmYardB, BuildingEnum.CloisterOfficeB], [PlayerColor.White]: [BuildingEnum.ClayMoundW, BuildingEnum.FarmYardW, BuildingEnum.CloisterOfficeW], } export const makeLandscape = (color: PlayerColor): Tile[][] => { const cm: Tile = [LandEnum.Hillside, startBuilding[color][0]] const fy: Tile = [LandEnum.Plains, startBuilding[color][1]] const co: Tile = [LandEnum.Plains, startBuilding[color][2]] return [ [[], [], PP, PF, PF, P, cm, [], []], [[], [], PP, PF, fy, P, co, [], []], ] } const checkLandTypeMatchesPlayer = (row: number, col: number, erection: ErectionEnum) => (player: Tableau) => { const tile = player.landscape[row + player.landscapeOffset][col + 2] const [landAtSpot] = tile const eligibleTerrain = terrainForErection(erection) if (landAtSpot && !eligibleTerrain.includes(landAtSpot)) return undefined return player } export const checkLandTypeMatches = (row: number, col: number, erection: ErectionEnum): StateReducer => (state) => { if (state?.frame.neutralBuildingPhase) return state return withActivePlayer(checkLandTypeMatchesPlayer(row, col, erection))(state) } export const checkLandscapeFree = (row: number, col: number, toBuild: ErectionEnum): StateReducer => (state) => { if (state?.frame.neutralBuildingPhase && isSettlement(toBuild) === false) { return withPlayerIndex(1)((player) => { const [, existing] = player.landscape?.[row + player.landscapeOffset]?.[col + 2] ?? [] if (existing === undefined) return player if (existing && isCloisterBuilding(existing) === isCloisterBuilding(toBuild)) return player return undefined })(state) } return withActivePlayer((player) => { const [land, erection] = player.landscape?.[row + player.landscapeOffset]?.[col + 2] ?? [] if (land === undefined) return undefined if (erection !== undefined) return undefined return player })(state) } export const getAdjacentOffsets = (col: number): [number, number][] => match<number, [number, number][]>(col) .with(5, () => [ [1, 0], // south [-1, 0], // north [0, -1], // west [-1, 1], // northeast [0, 1], // southeast ]) .with(6, () => [ [0, -1], // northwest [1, -1], // southwest ]) .otherwise(() => [ [1, 0], // south [-1, 0], // north [0, 1], // east [0, -1], // west ]) export const checkCloisterAdjacencyPlayer = (row: number, col: number, building: ErectionEnum) => (player: Tableau) => { if (isCloisterBuilding(building) === false) return player return pipe<[number], [number, number][], (ErectionEnum | undefined)[], boolean>( getAdjacentOffsets, map( ([rowOffset, colOffset]) => player?.landscape[row + rowOffset + (player.landscapeOffset ?? 0)]?.[col + 2 + colOffset]?.[1] ), any(isCloisterBuilding) )(col) ? player : undefined } export const checkCloisterAdjacency = (row: number, col: number, building: ErectionEnum): StateReducer => (state) => { if (state?.frame.neutralBuildingPhase) { return withPlayerIndex(1)(checkCloisterAdjacencyPlayer(row, col, building))(state) } return withActivePlayer(checkCloisterAdjacencyPlayer(row, col, building))(state) } export const moveClergyToOwnBuilding = (building: BuildingEnum): StateReducer => (state) => { if (state === undefined) return undefined if (state.frame.nextUse === NextUseClergy.Free) return state const player = state.players[state.frame.activePlayerIndex] const matrixLocation = findBuildingWithoutOffset(building)(player.landscape) if (matrixLocation === undefined) return undefined const [row, col] = matrixLocation const [land, ,] = player.landscape[row][col] const priors = player.clergy.filter(isPrior) if (state.frame.nextUse === NextUseClergy.OnlyPrior && priors.length === 0) return undefined // ^this line unnecessary const nextClergy = match(state.frame.nextUse) .with(NextUseClergy.Any, () => player.clergy[0]) .with(NextUseClergy.OnlyPrior, () => priors[0]) .exhaustive() if (nextClergy === undefined) return undefined return withActivePlayer( pipe( // set<Tableau, Tile>(lensPath(['landscape', row, col]), [land, building, nextClergy]), set<Tableau, Clergy[]>(lensPath(['clergy']), filter((c) => c !== nextClergy)(player.clergy)) ) )(state) } export const moveClergyToNeutralBuilding = (building: BuildingEnum, withPrior: boolean): StateReducer => (state) => { if (state === undefined) return undefined const neutralPlayer = state.players[1] const matrixLocation = findBuildingWithoutOffset(building)(neutralPlayer.landscape) if (matrixLocation === undefined) return undefined const [row, col] = matrixLocation const [land, ,] = neutralPlayer.landscape[row][col] const nextClergy = withPrior || state.frame.nextUse === NextUseClergy.OnlyPrior ? find(isPrior, neutralPlayer.clergy) : neutralPlayer.clergy[0] if (nextClergy === undefined) return undefined return withPlayerIndex(1)( pipe( // set<Tableau, Tile>(lensPath(['landscape', row, col]), [land, building, nextClergy]), set<Tableau, Clergy[]>(lensPath(['clergy']), filter((c) => c !== nextClergy)(neutralPlayer.clergy)) ) )(state) } const removeClergyFromActivePlayer = (clergy: Clergy): StateReducer => withActivePlayer((player) => { return { ...player, clergy: reject(equals(clergy), player.clergy), } }) const addClergyToTile = (clergy: Clergy) => (playerIndex: number, row: number, col: number): StateReducer => withPlayerIndex(playerIndex)((player) => { if (player === undefined) return undefined const [land, building, _] = player.landscape[row][col] return { ...player, landscape: [ ...player.landscape.slice(0, row), [ ...player.landscape[row].slice(0, col), // what to do about existingClergy? Just overwrite it for now, but // it might be nice to make it stack when multiple players go there [land, building, clergy], ...player.landscape[row].slice(col + 1), ], ...player.landscape.slice(row + 1), ], } }) const clearBonusRoundPlacement: StateReducer = (state) => { if (state === undefined) return undefined return { ...state, frame: { ...state.frame, bonusRoundPlacement: false, }, } } export const moveClergyInBonusRoundTo = (building: BuildingEnum): StateReducer => (state) => { if (state === undefined) return undefined const playerIndexes = range(0, state.config.players) const foundWithPlayer = reduce( (accum: [number, number, number] | undefined, elem: number) => { const searchResult = findBuildingWithoutOffset(building)(state.players[elem].landscape) if (searchResult === undefined) return accum const [searchRow, searchCol] = searchResult const result: [number, number, number] = [elem, searchRow, searchCol] return reduced(result) }, undefined as [number, number, number] | undefined, playerIndexes ) if (foundWithPlayer === undefined) return undefined const [p, r, c] = foundWithPlayer const [prior] = state.players[state.frame.activePlayerIndex].clergy.filter(isPrior) return pipe( // removeClergyFromActivePlayer(prior), addClergyToTile(prior)(p, r, c), clearBonusRoundPlacement )(state) } // given a row of tiles, return all of the buildings where there is a building AND a clergy const occupiedBuildingsInRow = (row: Tile[]) => reduce<Tile, BuildingEnum[]>( (rAccum, [_, building, clergy]: Tile) => { if (building !== undefined && clergy !== undefined && !isSettlement(building)) rAccum.push(building as BuildingEnum) return rAccum }, [] as BuildingEnum[], row ) // given a list of rows, do the thing const occupiedBuildingsForLandscape = (land: Tile[][]) => reduce<Tile[], BuildingEnum[]>( (rAccum, landRow: Tile[]) => { const occupied = occupiedBuildingsInRow(landRow) rAccum.push(...occupied) return rAccum }, [] as BuildingEnum[], land ) export const occupiedBuildingsForPlayers = reduce<Tableau, BuildingEnum[]>((pAccum, { landscape }) => { const occupied = occupiedBuildingsForLandscape(landscape) pAccum.push(...occupied) return pAccum }, [] as BuildingEnum[]) export const forestLocationsForCol = (rawCol: string, player: Tableau): string[] => { const col = Number.parseInt(rawCol, 10) + 2 const colsAtRow = map((row: Tile[]) => row[col], player.landscape) return addIndex<Tile, string[]>(reduce<Tile, string[]>)( (accum: string[], tile: Tile, rowIndex: number) => { if (tile[1] === BuildingEnum.Forest) accum.push(`${rowIndex - player.landscapeOffset}`) return accum }, [] as string[], colsAtRow ) } export const forestLocations = (player: Tableau): string[] => addIndex(reduce<Tile[], string[]>)( (accum: string[], row: Tile[], rowIndex: number) => addIndex(reduce<Tile, string[]>)( (innerAccum: string[], tile: Tile, colIndex: number) => { if (tile[1] === BuildingEnum.Forest) innerAccum.push(`${colIndex - 2} ${rowIndex - player.landscapeOffset}`) return innerAccum }, accum, row ), [] as string[], player.landscape ) export const moorLocationsForCol = (rawCol: string, player: Tableau): string[] => { const col = Number.parseInt(rawCol, 10) + 2 const colsAtRow = map((row: Tile[]) => row[col], player.landscape) return addIndex<Tile, string[]>(reduce<Tile, string[]>)( (accum: string[], tile: Tile, rowIndex: number) => { if (tile[1] === BuildingEnum.Moor) accum.push(`${rowIndex - player.landscapeOffset}`) return accum }, [] as string[], colsAtRow ) } export const moorLocations = (player: Tableau): string[] => addIndex(reduce<Tile[], string[]>)( (accum: string[], row: Tile[], rowIndex: number) => addIndex(reduce<Tile, string[]>)( (innerAccum: string[], tile: Tile, colIndex: number) => { if (tile[1] === BuildingEnum.Moor) innerAccum.push(`${colIndex - 2} ${rowIndex - player.landscapeOffset}`) return innerAccum }, accum, row ), [] as string[], player.landscape ) export const erectableLocationsCol = (erection: ErectionEnum, rawCol: string, player: Tableau): string[] => { const col = Number.parseInt(rawCol, 10) + 2 const colsAtRow = map((row: Tile[]) => row[col], player.landscape) return addIndex<Tile, string[]>(reduce<Tile, string[]>)( (accum: string[], tile: Tile, rowIndex: number) => { const [land, building] = tile if ( land && !building && !!checkLandTypeMatchesPlayer(rowIndex - player.landscapeOffset, col - 2, erection)(player) && !!checkCloisterAdjacencyPlayer(rowIndex - player.landscapeOffset, col - 2, erection)(player) ) accum.push(`${rowIndex - player.landscapeOffset}`) return accum }, [] as string[], colsAtRow ) } export const erectableLocations = curry((erection: ErectionEnum, player: Tableau): string[] => addIndex(reduce<Tile[], string[]>)( (accum: string[], row: Tile[], rowIndex: number) => addIndex(reduce<Tile, string[]>)( (innerAccum: string[], tile: Tile, colIndex: number) => { const [land, building] = tile if ( land && !building && !!checkLandTypeMatchesPlayer(rowIndex - player.landscapeOffset, colIndex - 2, erection)(player) && !!checkCloisterAdjacencyPlayer(rowIndex - player.landscapeOffset, colIndex - 2, erection)(player) ) innerAccum.push(`${colIndex - 2} ${rowIndex - player.landscapeOffset}`) return innerAccum }, accum, row ), [] as string[], player.landscape ) ) export const LANDSCAPES: ErectionEnum[] = [BuildingEnum.Forest, BuildingEnum.Moor] export const allVacantUsableBuildings = (landscape: Tile[][]): BuildingEnum[] => reduce( (accum, row) => reduce( (accum, tile) => { const [, building, clergy] = tile if ( building !== undefined && clergy === undefined && LANDSCAPES.includes(building) === false && isSettlement(building) === false ) accum.push(building as BuildingEnum) return accum }, accum, row ), [] as BuildingEnum[], landscape ) export const allBuiltBuildings = (landscape: readonly Tile[][]): BuildingEnum[] => reduce( (accum, row) => reduce( (accum, tile) => { const [, building] = tile if (building !== undefined && LANDSCAPES.includes(building) === false && isSettlement(building) === false) accum.push(building as BuildingEnum) return accum }, accum, row ), [] as BuildingEnum[], landscape ) export const allBuildingPoints = (landscape: Tile[][]): number => reduce( (accum: number, row: Tile[]) => reduce( (accum: number, tile: Tile) => { const [, building] = tile if (building !== undefined && LANDSCAPES.includes(building) === false) return accum + pointsForBuilding(building) return accum }, accum, row ), 0, landscape ) export const allDwellingPoints = (landscape: Tile[][]): number[] => pipe( // first get the coordinates of all of the settlements - column offset by 2, but dont worry about row offset (weird) (landscape: Tile[][]): [number, number][] => { if (landscape === undefined) return [] const locations: [number, number][] = [] landscape.forEach((landRow: Tile[], r: number) => { landRow.forEach(([_l, b, _c]: Tile, c: number) => { if (b && isSettlement(b)) { locations.push([r, c - 2]) } }) }) return locations }, map(([row, col]) => pipe( // get all of the offsets for the given column getAdjacentOffsets, // and also consider your own tile append<[number, number]>([0, 0]), // grab each of those tiles map<[number, number], Tile>(([rowOff, colOff]) => landscape?.[row + rowOff]?.[col + colOff + 2]), // and for each tile, calculate how much dwelling it adds map<Tile, number>((tile) => { if (tile === undefined) return 0 const [land, building] = tile if (building !== undefined) return pointsForDwelling(building) if (land === LandEnum.Water) return 3 return 0 }), // and sum that all together sum )(col) ) )(landscape)