@ -3,18 +3,33 @@ import { INVALID_MOVE } from "boardgame.io/core";
// TODO: partial information
export type PlaceableBuildings = {
[ K in keyof typeof Buildings ] : typeof Buildings [ K ] extends { cost : number } ? K : never
} [ keyof typeof Buildings ] ;
export type Cell = {
owner : null
owner : null ,
attacked? : number ,
} | {
owner : string ,
building : keyof typeof Buildings ,
hp : number ,
ghost? : boolean ,
attacked? : number ,
} ;
export type Move = {
type : "placeBuilding" ,
x : number ,
y : number ,
building : PlaceableBuildings ,
} | {
type : "attack" ,
building : keyof typeof Buildings ,
x : number ,
y : number ,
targetX : number ,
targetY : number ,
} ;
export type State = {
@ -25,27 +40,86 @@ export type State = {
export const Buildings = {
base : {
cost : Infinity ,
name : "base" ,
description : "Your HQ, if you lose it, you lose the game!" ,
humanName : "" ,
gain : 3 ,
hp : 5 ,
} ,
road : {
name : "road" ,
description : "The most basic tile, can be placed on any empty tile adjacent to one of your tiles. Allows buildings to be constructed on it." ,
humanName : "Road" ,
cost : 1 ,
hp : 1 ,
} ,
pawn : {
defender : {
name : "defender" ,
description : "Your first line of defense: has good HP and can attack a single adjacent tile to prevent enemy expansion to the border of your territory." ,
humanName : "Defender" ,
cost : 2 ,
placedOn : [ "road" ]
hp : 2 ,
placedOn : [ "road" ] ,
attack : {
power : 1 ,
cost : 0 ,
maxMoves : 1 ,
targets ( cells , x , y ) {
const currentPlayer = getCell ( cells , x , y ) ? . owner ;
if ( ! currentPlayer ) return [ ] ;
return [ . . . adjacentCells ( x , y ) ] . filter ( ( [ x , y ] ) = > {
const cell = getCell ( cells , x , y ) ;
if ( cell ? . owner ) {
return cell . owner !== currentPlayer ;
} else {
return true ;
}
} ) ;
} ,
damageZone ( _cells , _x , _y , targetX , targetY ) {
return [ [ targetX , targetY ] ] ;
}
}
} ,
factory : {
name : "factory" ,
description : "Gives a steady income of resources, but will explode the adjacent tiles if destroyed." ,
humanName : "Factory" ,
cost : 3 ,
gain : 2 ,
hp : 2 ,
placedOn : [ "road" ]
}
} as const satisfies Readonly < Record < string , Readonly < {
cost : number ,
name : string ,
description : string ,
humanName : string ,
cost? : number ,
hp : number ,
gain? : number ,
placedOn? : readonly string [ ]
placedOn? : readonly string [ ] ,
attack ? : {
power : number ,
cost : number ,
maxMoves : number ,
targets : ( cells : Record < string , Cell > , x : number , y : number ) = > [ x : number , y : number ] [ ] ,
damageZone : ( cells : Record < string , Cell > , x : number , y : number , targetX : number , targetY : number ) = > [ x : number , y : number ] [ ] ,
}
} >>> ;
export type Building = typeof Buildings [ keyof typeof Buildings ] ;
type MapMove < T > = T extends ( state : never , . . . args : infer Args ) = > infer Return ? ( . . . args : Args ) = > Exclude < Return , typeof INVALID_MOVE > : never ;
type MapMoves < T extends Record < string , ( ...args : never [ ] ) = > unknown >> = {
[ K in keyof T ] : MapMove < T [ K ] >
}
export type Moves = Partial < MapMoves < {
attack : typeof attack ,
placeBuilding : typeof placeBuilding ,
setReady : ( state : { } ) = > void ,
} >> ;
export const AcrossTheHex : Game < State , Record < string , unknown > , {
size? : number ,
initialResources? : number
@ -56,12 +130,14 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
setCell ( cells , 0 , 0 , {
owner : "0" ,
building : "base"
building : "base" ,
hp : Buildings.base.hp
} ) ;
setCell ( cells , size * 2 - 2 , size * 2 - 2 , {
owner : "1" ,
building : "base"
building : "base" ,
hp : Buildings.base.hp
} ) ;
return {
@ -97,6 +173,7 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
ctx . events . endStage ( ) ;
} ,
placeBuilding ,
attack ,
} ,
} ,
ready : {
@ -124,6 +201,8 @@ export function canPlaceBuilding(
if ( existingCell . owner && existingCell . owner !== playerID ) return false ;
const buildingRules = Buildings [ building ] ;
if ( ! ( "cost" in buildingRules ) ) return false ;
if ( ! ( "placedOn" in buildingRules ) ) {
// Cannot place a building on an existing building without a placedOn property
if ( existingCell . owner ) return false ;
@ -158,7 +237,8 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
type : "placeBuilding" ,
x ,
y ,
building ,
// SAFETY: guaranteed by canPlaceBuilding
building : building as PlaceableBuildings ,
} ) ;
// G.cells[`${y}:${x}`] = {
@ -169,12 +249,99 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
return ;
}
export function canAttackFrom (
cells : Record < string , Cell > ,
resources : number ,
playerID : string ,
x : number ,
y : number ,
) : boolean {
// Can only attack from owned, attack-ready cells
const building = getBuilding ( cells , x , y , playerID ) ;
if ( ! building || ! ( "attack" in building ) ) return false ;
// Cannot attack without the required resources
if ( resources < building . attack . cost ) return false ;
return true ;
}
export function canAttack (
cells : Record < string , Cell > ,
resources : number ,
playerID : string ,
x : number ,
y : number ,
targetX : number ,
targetY : number ,
) : boolean {
// Can only attack from owned, attack-ready cells
const building = getBuilding ( cells , x , y , playerID ) ;
if ( ! building || ! ( "attack" in building ) ) return false ;
// Cannot attack without the required resources
if ( resources < building . attack . cost ) return false ;
// Can only attack to one of the listed target cells
const targets = building . attack . targets ( cells , x , y ) ;
if ( ! targets . find ( ( [ tx , ty ] ) = > tx === targetX && ty === targetY ) ) return false ;
return true ;
}
function attack ( { G , playerID } : { G : State , playerID : string } , x : number , y : number , targetX : number , targetY : number ) {
if ( ! canAttack (
G . cells ,
remainingResources ( G . resources , G . moves , playerID ) ,
playerID ,
x ,
y ,
targetX ,
targetY ,
) ) return INVALID_MOVE ;
const building = getBuilding ( G . cells , x , y ) ;
// The type checker isn't happy with us otherwise
if ( ! building || ! ( "attack" in building ) ) return INVALID_MOVE ;
// Cannot attack more than the listed amount of attacks per turn
const attackingMoves = ( G . moves [ playerID ] ? ? [ ] ) . filter ( ( move ) = > {
return move . type === "attack" && move . x === x && move . y === y
} ) ;
if ( attackingMoves . length >= building . attack . maxMoves ) return INVALID_MOVE ;
G . moves [ playerID ] ? . push ( {
type : "attack" ,
x ,
y ,
targetX ,
targetY ,
building : building.name
} ) ;
return ;
}
export function getBuilding ( cells : Record < string , Cell > , x : number , y : number , expectedPlayer? : string ) : Building | null {
const cell = getCell ( cells , x , y ) ;
if ( ! cell ? . owner ) return null ;
if ( expectedPlayer && cell . owner !== expectedPlayer ) return null ;
return Buildings [ cell . building ] ;
}
export function remainingResources ( resources : Record < string , number > , moves : Record < string , Move [ ] > , playerID : string ) : number {
let result = resources [ playerID ] ? ? 0 ;
for ( const move of moves [ playerID ] ? ? [ ] ) {
if ( move . type === "placeBuilding" ) {
result -= Buildings [ move . building ] . cost ;
} else if ( move . type === "attack" ) {
const building = Buildings [ move . building ] ;
if ( "attack" in building ) {
result -= building . attack . cost ;
}
}
}
@ -224,13 +391,13 @@ export function cellIn(grid: Record<string, Cell>, x: number, y: number): boolea
return ` ${ y } : ${ x } ` in grid ;
}
function getCell ( grid : Record < string , Cell > , x : number , y : number ) : Cell | null {
export 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 ) {
export function setCell ( grid : Record < string , Cell > , x : number , y : number , cell : Cell ) {
if ( ! cellIn ( grid , x , y ) ) return ;
grid [ ` ${ y } : ${ x } ` ] = cell ;
@ -289,7 +456,10 @@ export function* iterateCells(grid: Record<string, Cell>) {
function applyMoves ( state : State ) {
const players = Object . keys ( state . moves ) ;
const previousCells = { . . . state . cells } ;
const moves = state . moves ;
const moves = Object . entries ( state . moves )
. flatMap ( ( [ player , moves ] ) : [ string , Move ] [ ] = >
moves . map ( ( move ) = > [ player , move ] )
) ;
state . moves = emptyMoves ( players ) ;
const extractedResources = initResources ( players , 0 ) ;
@ -305,9 +475,20 @@ function applyMoves(state: State) {
}
// Building placement step
const placeBuildingMoves = Object . entries ( moves ) . flatMap ( ( [ player , moves ] ) = > moves . map ( ( move ) = > [ player , move ] as const ) ) ;
const placeBuildingMoves : [ player : string , move : Move & { type : "placeBuilding" } ] [ ] =
moves . flatMap ( ( [ player , move ] ) : [ string , Move & { type : "placeBuilding" } ] [ ] = > move . type === "placeBuilding" ? [ [ player , move ] ] : [ ] ) ;
for ( const [ x , y , _cell ] of iterateCells ( state . cells ) ) {
const orders = placeBuildingMoves . filter ( ( [ , move ] ) = > move . x === x && move . y === y ) ;
const orders = placeBuildingMoves
. filter ( ( [ , move ] ) = > move . x === x && move . y === y )
. filter ( ( [ player , move ] ) = > canPlaceBuilding (
previousCells ,
state . resources [ player ] ? ? 0 ,
player ,
move . x ,
move . y ,
move . building
) ) ;
if ( orders . length === 0 ) continue ;
@ -319,23 +500,53 @@ function applyMoves(state: State) {
const order = orders [ 0 ] ! ;
const cost = Buildings [ order [ 1 ] . building ] . cost ;
if ( ! canPlaceBuilding (
previousCells ,
state . resources [ order [ 0 ] ] ! ,
order [ 0 ] ,
x ,
y ,
order [ 1 ] . building
) ) continue ;
setCell ( state . cells , x , y , {
owner : order [ 0 ] ,
building : order [ 1 ] . building
building : order [ 1 ] . building ,
hp : Buildings [ order [ 1 ] . building ] . hp
} ) ;
state . resources [ order [ 0 ] ] -= cost ;
}
// Attacking step
const attackingMoves = moves
. flatMap ( ( [ player , move ] ) : [ string , Move & { type : "attack" } ] [ ] = > move . type === "attack" ? [ [ player , move ] ] : [ ] ) ;
for ( const [ player , move ] of attackingMoves ) {
if ( ! canAttack (
previousCells ,
state . resources [ player ] ? ? 0 ,
player ,
move . x ,
move . y ,
move . targetX ,
move . targetY
) ) {
continue ;
}
const building = Buildings [ move . building ] ;
if ( ! ( "attack" in building ) ) continue ;
const attackedSquares = building . attack . damageZone ( previousCells , move . x , move . y , move . targetX , move . targetY ) ;
for ( const [ x , y ] of attackedSquares ) {
const attackedBuilding = getCell ( state . cells , x , y ) ;
if ( ! attackedBuilding ? . owner ) continue ;
const hp = attackedBuilding . hp - building . attack . power ;
if ( hp <= 0 ) {
setCell ( state . cells , x , y , {
owner : null
} ) ;
} else {
setCell ( state . cells , x , y , {
. . . attackedBuilding ,
hp
} ) ;
}
}
}
// Resource gathering step
for ( const player of players ) {
state . resources [ player ] += extractedResources [ player ] ! ;
@ -349,12 +560,34 @@ export function getLocalState(state: State, playerID: string): State["cells"] {
for ( const move of state . moves [ playerID ] ? ? [ ] ) {
if ( move . type === "placeBuilding" ) {
res[ ` ${ move . y } : ${ move . x } ` ] = {
setCell( res , move . x , move . y , {
owner : playerID ,
building : move.building
} ;
building : move.building ,
hp : Buildings [ move . building ] . hp ,
ghost : true ,
} ) ;
}
}
// Register attack values
for ( const move of state . moves [ playerID ] ? ? [ ] ) {
if ( move . type !== "attack" ) continue ;
const building = getBuilding ( state . cells , move . x , move . y , playerID ) ;
if ( ! building || ! ( "attack" in building ) ) continue ;
const attacks = building . attack . damageZone ( state . cells , move . x , move . y , move . targetX , move . targetY ) ;
for ( const [ x , y ] of attacks ) {
const cell = getCell ( res , x , y ) ;
if ( ! cell ) continue ;
setCell ( res , x , y , {
. . . cell ,
attacked : ( cell . attacked ? ? 0 ) + building . attack . power
} ) ;
}
}
console . log ( res ) ;
return res ;
}