Partially implement 7 Wonders
This commit is contained in:
parent
3e692c0996
commit
da9306223f
@ -2,6 +2,6 @@ services:
|
|||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: packages/example-server/Dockerfile
|
dockerfile: packages/wonders-server/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "4000:80"
|
- "4000:80"
|
||||||
|
|||||||
24
packages/wonders-common/.eslintrc.json
Normal file
24
packages/wonders-common/.eslintrc.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"es2021": true,
|
||||||
|
"node": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 12,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"comma-dangle": ["error", "only-multiline"],
|
||||||
|
"padded-blocks": "warn",
|
||||||
|
"no-multiple-empty-lines": "warn",
|
||||||
|
"space-before-function-paren": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
428
packages/wonders-common/index.ts
Normal file
428
packages/wonders-common/index.ts
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import min from 'lodash/min'
|
||||||
|
import chunk from 'lodash/chunk'
|
||||||
|
import isInteger from 'lodash/isInteger'
|
||||||
|
import sampleSize from 'lodash/sampleSize'
|
||||||
|
import shuffle from 'lodash/shuffle'
|
||||||
|
import sum from 'lodash/sum'
|
||||||
|
import max from 'lodash/max'
|
||||||
|
import head from 'lodash/head'
|
||||||
|
import tail from 'lodash/tail'
|
||||||
|
|
||||||
|
export const minAge = 1
|
||||||
|
export const maxAge = 3
|
||||||
|
export const minPlayers = 3
|
||||||
|
export const maxPlayers = 7
|
||||||
|
export const startingGold = 3
|
||||||
|
export const discardGold = 3
|
||||||
|
export const victoryPoints = [1, 3, 5]
|
||||||
|
export const defeatPoints = -1
|
||||||
|
export const goldPerPoint = 3
|
||||||
|
export const scienceSetPoints = 7
|
||||||
|
|
||||||
|
type Resource = 'wood' | 'stone' | 'ore' | 'brick' | 'paper' | 'cloth' | 'glass'
|
||||||
|
type Science = 'engineering' | 'research' | 'education'
|
||||||
|
type StructureType = 'commerce' | 'culture' | 'science' | 'basic industry' | 'advanced industry' | 'guild' | 'military'
|
||||||
|
type Countable = StructureType | 'wonder stage' | 'defeat'
|
||||||
|
|
||||||
|
export interface Structure {
|
||||||
|
type: StructureType
|
||||||
|
name: string
|
||||||
|
appears?: (undefined | (3 | 4 | 5 | 6 | 7)[])[]
|
||||||
|
|
||||||
|
cost?: Map<Resource | 'gold', number>
|
||||||
|
freeWith?: string[]
|
||||||
|
paysFor?: string[]
|
||||||
|
|
||||||
|
gold?: number
|
||||||
|
goldPer?: Map<Countable, number>
|
||||||
|
goldPerNeighbor?: Map<Countable, number>
|
||||||
|
points?: number
|
||||||
|
pointsPer?: Map<Countable, number>
|
||||||
|
pointsPerNeighbor?: Map<Countable, number>
|
||||||
|
sciences?: Set<Science>
|
||||||
|
resources?: Map<Resource, number>
|
||||||
|
shields?: number
|
||||||
|
discountTrade?: {
|
||||||
|
resourceType: 'basic' | 'advanced'
|
||||||
|
direction: ('left' | 'right')[]
|
||||||
|
amount: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuresAge1: Structure[] = [
|
||||||
|
{ type: 'advanced industry', name: 'Glassworks', appears: [[3, 6], [3, 5]], resources: new Map([['glass', 1]]) },
|
||||||
|
{ type: 'advanced industry', name: 'Loom', appears: [[3, 6], [3, 5]], resources: new Map([['cloth', 1]]) },
|
||||||
|
{ type: 'advanced industry', name: 'Press', appears: [[3, 6], [3, 5]], resources: new Map([['paper', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Clay Pit', appears: [[3]], cost: new Map([['gold', 1]]), resources: new Map([['brick', 1], ['ore', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Clay Pool', appears: [[3, 5]], resources: new Map([['brick', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Excavation', appears: [[4]], cost: new Map([['gold', 1]]), resources: new Map([['stone', 1], ['brick', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Forest Cave', appears: [[5]], cost: new Map([['gold', 1]]), resources: new Map([['wood', 1], ['ore', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Lumber Yard', appears: [[3, 4]], resources: new Map([['wood', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Mine', appears: [[6]], cost: new Map([['gold', 1]]), resources: new Map([['stone', 1], ['ore', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Ore Vein', appears: [[3, 4]], resources: new Map([['ore', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Stone Pit', appears: [[3, 5]], resources: new Map([['stone', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Timber Yard', appears: [[3]], cost: new Map([['gold', 1]]), resources: new Map([['stone', 1], ['wood', 1]]) },
|
||||||
|
{ type: 'basic industry', name: 'Tree Farm', appears: [[6]], cost: new Map([['gold', 1]]), resources: new Map([['wood', 1], ['brick', 1]]) },
|
||||||
|
{ type: 'commerce', name: 'East Trading Post', appears: [[3, 7]], discountTrade: [{ resourceType: 'basic', direction: ['right'], amount: 1 }] },
|
||||||
|
{ type: 'commerce', name: 'Marketplace', appears: [[3, 6]], discountTrade: [{ resourceType: 'advanced', direction: ['left', 'right'], amount: 1 }] },
|
||||||
|
{ type: 'commerce', name: 'Tavern', appears: [[4, 5, 7]], gold: 5 },
|
||||||
|
{ type: 'commerce', name: 'West Trading Post', appears: [[3, 7]], discountTrade: [{ resourceType: 'basic', direction: ['left'], amount: 1 }] },
|
||||||
|
{ type: 'culture', name: 'Altar', appears: [[3, 5]], points: 2 },
|
||||||
|
{ type: 'culture', name: 'Baths', appears: [[3, 7]], cost: new Map([['stone', 1]]), points: 3 },
|
||||||
|
{ type: 'culture', name: 'Pawnshop', appears: [[4, 7]], points: 3 },
|
||||||
|
{ type: 'culture', name: 'Theater', appears: [[3, 6]], points: 2 },
|
||||||
|
{ type: 'military', name: 'Barracks', appears: [[3, 5]], cost: new Map([['ore', 1]]), shields: 1 },
|
||||||
|
{ type: 'military', name: 'Guard Tower', appears: [[3, 4]], cost: new Map([['brick', 1]]), shields: 1 },
|
||||||
|
{ type: 'military', name: 'Stockade', appears: [[3, 7]], cost: new Map([['wood', 1]]), shields: 1 },
|
||||||
|
{ type: 'science', name: 'Apothecary', appears: [[3, 5]], cost: new Map([['cloth', 1]]), sciences: new Set(['research']) },
|
||||||
|
{ type: 'science', name: 'Scriptorium', appears: [[3, 4]], cost: new Map([['glass', 1]]), sciences: new Set(['education']) },
|
||||||
|
{ type: 'science', name: 'Workshop', appears: [[3, 7]], cost: new Map([['glass', 1]]), sciences: new Set(['engineering']) },
|
||||||
|
]
|
||||||
|
const structuresAge2: Structure[] = [
|
||||||
|
{ type: 'basic industry', name: 'Brickyard', appears: [, [3, 4]], cost: new Map([['gold', 1]]), resources: new Map([['ore', 2]]) },
|
||||||
|
{ type: 'basic industry', name: 'Foundry', appears: [, [3, 4]], cost: new Map([['gold', 1]]), resources: new Map([['ore', 2]]) },
|
||||||
|
{ type: 'basic industry', name: 'Sawmill', appears: [, [3, 4]], cost: new Map([['gold', 1]]), resources: new Map([['wood', 2]]) },
|
||||||
|
{ type: 'basic industry', name: 'Quarry', appears: [, [3, 4]], cost: new Map([['gold', 1]]), resources: new Map([['stone', 2]]) },
|
||||||
|
{ type: 'commerce', name: 'Bazar', appears: [, [4, 7]], goldPer: new Map([['advanced industry', 2]]), goldPerNeighbor: new Map([['advanced industry', 2]]) },
|
||||||
|
{ type: 'commerce', name: 'Caravansery', appears: [, [3, 5, 6]], cost: new Map([['wood', 2]]), freeWith: ['Marketplace'], resources: new Map([['wood', 1], ['stone', 1], ['ore', 1], ['brick', 1]]) },
|
||||||
|
{ type: 'commerce', name: 'Forum', appears: [, [3, 6, 7]], cost: new Map([['brick', 2]]), freeWith: ['East Trading Post', 'West Trading Post'], resources: new Map([['glass', 1], ['cloth', 1], ['paper', 1]]) },
|
||||||
|
{ type: 'commerce', name: 'Vineyard', appears: [, [3, 6]], goldPer: new Map([['basic industry', 1]]), goldPerNeighbor: new Map([['basic industry', 1]]) },
|
||||||
|
{ type: 'culture', name: 'Aqueduct', appears: [, [3, 7]], cost: new Map([['stone', 3]]), freeWith: ['Baths'], points: 5 },
|
||||||
|
{ type: 'culture', name: 'Courthouse', appears: [, [3, 5]], cost: new Map([['brick', 2], ['cloth', 1]]), freeWith: ['Scriptorium'], points: 4 },
|
||||||
|
{ type: 'culture', name: 'Statue', appears: [, [3, 7]], cost: new Map([['ore', 2], ['wood', 1]]), freeWith: ['Theater'], points: 4 },
|
||||||
|
{ type: 'culture', name: 'Temple', appears: [, [3, 6]], cost: new Map([['brick', 1], ['wood', 1], ['glass', 1]]), freeWith: ['Altar'], points: 3 },
|
||||||
|
{ type: 'military', name: 'Archery Range', appears: [, [3, 6]], cost: new Map([['wood', 2], ['ore', 1]]), freeWith: ['Workshop'], shields: 2 },
|
||||||
|
{ type: 'military', name: 'Stables', appears: [, [3, 5]], cost: new Map([['wood', 1], ['ore', 1], ['brick', 1]]), freeWith: ['Apothecary'], shields: 2 },
|
||||||
|
{ type: 'military', name: 'Training Ground', appears: [, [4, 6, 7]], cost: new Map([['ore', 2], ['wood', 1]]), shields: 2 },
|
||||||
|
{ type: 'military', name: 'Walls', appears: [, [3, 7]], cost: new Map([['stone', 3]]), shields: 2 },
|
||||||
|
{ type: 'science', name: 'Dispensary', appears: [, [3, 4]], cost: new Map([['glass', 1], ['ore', 2]]), freeWith: ['Apothecary'], sciences: new Set(['research']) },
|
||||||
|
{ type: 'science', name: 'Laboratory', appears: [, [3, 5]], cost: new Map([['paper', 1], ['brick', 2]]), freeWith: ['Workshop'], sciences: new Set(['engineering']) },
|
||||||
|
{ type: 'science', name: 'Libary', appears: [, [3, 6]], cost: new Map([['cloth', 1], ['stone', 2]]), freeWith: ['Scriptorium'], sciences: new Set(['education']) },
|
||||||
|
{ type: 'science', name: 'School', appears: [, [3, 7]], cost: new Map([['paper', 1], ['wood', 1]]), sciences: new Set(['education']) },
|
||||||
|
]
|
||||||
|
const structuresAge3: Structure[] = [
|
||||||
|
{ type: 'commerce', name: 'Arena', appears: [, , [3, 3, 5, 7]], cost: new Map([['stone', 2], ['ore', 1]]), freeWith: ['Dispensary'], goldPer: new Map([['wonder stage', 3]]), pointsPer: new Map([['wonder stage', 1]]) },
|
||||||
|
{ type: 'commerce', name: 'Chamber of Commerce', appears: [, , [4, 6]], cost: new Map([['brick', 2], ['paper', 1]]), goldPer: new Map([['advanced industry', 2]]), pointsPer: new Map([['advanced industry', 2]]) },
|
||||||
|
{ type: 'commerce', name: 'Haven', appears: [, , [4]], cost: new Map([['wood', 1], ['ore', 1], ['paper', 1]]), goldPer: new Map([['basic industry', 1]]), pointsPer: new Map([['basic industry', 1]]) },
|
||||||
|
{ type: 'commerce', name: 'Lighthouse', appears: [, , [3, 6]], cost: new Map([['stone', 1], ['glass', 1]]), freeWith: ['Caravansery'], goldPer: new Map([['commerce', 1]]), pointsPer: new Map([['commerce', 1]]) },
|
||||||
|
{ type: 'culture', name: 'Gardens', appears: [, , [3, 4]], cost: new Map([['brick', 2], ['wood', 1]]), points: 5 },
|
||||||
|
{ type: 'culture', name: 'Palace', appears: [, , [3, 7]], cost: new Map([['wood', 1], ['brick', 1], ['stone', 1], ['ore', 1], ['glass', 1], ['cloth', 1], ['paper', 1]]), points: 8 },
|
||||||
|
{ type: 'culture', name: 'Pantheon', appears: [, , [3, 6]], cost: new Map([['brick', 2], ['ore', 1], ['glass', 1], ['paper', 1], ['cloth', 1]]), freeWith: ['Temple'], points: 7 },
|
||||||
|
{ type: 'culture', name: 'Senate', appears: [, , [3, 5]], cost: new Map([['wood', 2], ['ore', 1], ['stone', 1]]), freeWith: ['Library'], points: 6 },
|
||||||
|
{ type: 'culture', name: 'Town Hall', appears: [, , [3, 5, 6]], cost: new Map([['stone', 2], ['ore', 1], ['glass', 1]]), points: 6 },
|
||||||
|
{ type: 'military', name: 'Arsenal', appears: [, , [3, 4, 7]], cost: new Map([['wood', 2], ['ore', 1], ['cloth', 1]]), shields: 3 },
|
||||||
|
{ type: 'military', name: 'Circus', appears: [, , [4, 5, 6]], cost: new Map([['stone', 3], ['ore', 1]]), freeWith: ['Training Ground'], shields: 3 },
|
||||||
|
{ type: 'military', name: 'Fortifications', appears: [, , [3, 7]], cost: new Map([['ore', 3], ['stone', 1]]), freeWith: ['Walls'], shields: 3 },
|
||||||
|
{ type: 'military', name: 'Siege Workshop', appears: [, , [3, 5]], cost: new Map([['brick', 3], ['wood', 1]]), freeWith: ['Laboratory'], shields: 3 },
|
||||||
|
{ type: 'science', name: 'Academy', appears: [, , [3, 7]], cost: new Map([['stone', 3], ['glass', 1]]), freeWith: ['School'], sciences: new Set(['research']) },
|
||||||
|
{ type: 'science', name: 'Lodge', appears: [, , [3, 6]], cost: new Map([['brick', 2], ['paper', 1], ['cloth', 1]]), freeWith: ['Dispensary'], sciences: new Set(['research']) },
|
||||||
|
{ type: 'science', name: 'Observatory', appears: [, , [3, 7]], cost: new Map([['ore', 2], ['glass', 1], ['cloth', 1]]), freeWith: ['Laboratory'], sciences: new Set(['engineering']) },
|
||||||
|
{ type: 'science', name: 'Study', appears: [, , [3, 5]], cost: new Map([['wood', 1], ['paper', 1], ['cloth', 1]]), freeWith: ['School'], sciences: new Set(['engineering']) },
|
||||||
|
{ type: 'science', name: 'University', appears: [, , [3, 4]], cost: new Map([['wood', 2], ['paper', 1], ['glass', 1]]), freeWith: ['Library'], sciences: new Set(['education']) },
|
||||||
|
]
|
||||||
|
const structuresGuilds: Structure[] = [
|
||||||
|
{ type: 'guild', name: 'Builders Guild', cost: new Map([['stone', 2], ['brick', 2], ['glass', 1]]), pointsPer: new Map([['wonder stage', 1]]), pointsPerNeighbor: new Map([['wonder stage', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Craftsmens Guild', cost: new Map([['ore', 2], ['stone', 2]]), pointsPerNeighbor: new Map([['advanced industry', 2]]) },
|
||||||
|
{ type: 'guild', name: 'Magistrates Guild', cost: new Map([['wood', 3], ['stone', 1], ['cloth', 1]]), pointsPerNeighbor: new Map([['culture', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Philosophers Guild', cost: new Map([['brick', 3], ['paper', 1], ['cloth', 1]]), pointsPerNeighbor: new Map([['science', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Scientists Guild', cost: new Map([['ore', 2], ['wood', 2], ['paper', 1]]), sciences: new Set(['engineering', 'research', 'education']) },
|
||||||
|
{ type: 'guild', name: 'Shipowners Guild', cost: new Map([['wood', 3], ['glass', 1], ['paper', 1]]), pointsPer: new Map([['basic industry', 1], ['advanced industry', 1], ['guild', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Spies Guild', cost: new Map([['brick', 3], ['glass', 1]]), pointsPerNeighbor: new Map([['military', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Strategists Guild', cost: new Map([['ore', 2], ['stone', 1], ['cloth', 1]]), pointsPerNeighbor: new Map([['defeat', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Traders Guild', cost: new Map([['glass', 1], ['paper', 1], ['cloth', 1]]), pointsPerNeighbor: new Map([['commerce', 1]]) },
|
||||||
|
{ type: 'guild', name: 'Workers Guild', cost: new Map([['ore', 2], ['brick', 1], ['stone', 1], ['wood', 1]]), pointsPerNeighbor: new Map([['basic industry', 2]]) },
|
||||||
|
]
|
||||||
|
export const structures = new Map([structuresAge1, structuresAge2, structuresAge3, structuresGuilds].flat().map(c => ([c.name, c])))
|
||||||
|
// Initialize 'freeWith' properties
|
||||||
|
for (const structure of structures.values()) {
|
||||||
|
for (const precursorName of structure.freeWith ?? []) {
|
||||||
|
const precursor = structures.get(precursorName)!
|
||||||
|
if (precursor.paysFor === undefined) {
|
||||||
|
precursor.paysFor = [structure.name]
|
||||||
|
} else {
|
||||||
|
precursor.paysFor.push(structure.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeck(age: number, numPlayers: number) {
|
||||||
|
if (age < minAge || age > maxAge || !isInteger(age)) {
|
||||||
|
throw new Error(`Unsupported age ${age}`)
|
||||||
|
}
|
||||||
|
if (numPlayers < minPlayers || numPlayers > maxPlayers || !isInteger(numPlayers)) {
|
||||||
|
throw new Error(`Unsupported player count ${numPlayers}`)
|
||||||
|
}
|
||||||
|
const cards: Structure[] = [...structures.values()].flatMap(structure =>
|
||||||
|
structure.appears?.[age - 1]
|
||||||
|
?.filter(minPlayers => numPlayers <= minPlayers)
|
||||||
|
?.map(() => structure)
|
||||||
|
?? []
|
||||||
|
)
|
||||||
|
if (age === maxAge) {
|
||||||
|
cards.push(...sampleSize(structuresGuilds, numPlayers + 2))
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wonder {
|
||||||
|
name: string
|
||||||
|
side: 'A' | 'B'
|
||||||
|
innateResource: Resource
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wonders: Wonder[] = [
|
||||||
|
{ name: 'Great Pyramid of Giza', side: 'A', innateResource: 'stone' },
|
||||||
|
{ name: 'Great Pyramid of Giza', side: 'B', innateResource: 'stone' },
|
||||||
|
{ name: 'Hanging Gardens of Babylon', side: 'A', innateResource: 'brick' },
|
||||||
|
{ name: 'Hanging Gardens of Babylon', side: 'B', innateResource: 'brick' },
|
||||||
|
{ name: 'Temple of Artemis at Ephesus', side: 'A', innateResource: 'paper' },
|
||||||
|
{ name: 'Temple of Artemis at Ephesus', side: 'B', innateResource: 'paper' },
|
||||||
|
{ name: 'State of Zeus at Olympia', side: 'A', innateResource: 'wood' },
|
||||||
|
{ name: 'State of Zeus at Olympia', side: 'B', innateResource: 'wood' },
|
||||||
|
{ name: 'Mausoleum at Halicarnassus', side: 'A', innateResource: 'cloth' },
|
||||||
|
{ name: 'Mausoleum at Halicarnassus', side: 'B', innateResource: 'cloth' },
|
||||||
|
{ name: 'Colossus of Rhodes', side: 'A', innateResource: 'ore' },
|
||||||
|
{ name: 'Colossus of Rhodes', side: 'B', innateResource: 'ore' },
|
||||||
|
{ name: 'Lighthouse of Alexandria', side: 'A', innateResource: 'glass' },
|
||||||
|
{ name: 'Lighthouse of Alexandria', side: 'B', innateResource: 'glass' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface TurnPlan {
|
||||||
|
action: 'structure' | 'discard'
|
||||||
|
cardIdx: number
|
||||||
|
goldLeft: number
|
||||||
|
goldRight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
name: string
|
||||||
|
wonder?: Wonder
|
||||||
|
gold: number
|
||||||
|
structures: {
|
||||||
|
age: number
|
||||||
|
structure: string
|
||||||
|
}[]
|
||||||
|
|
||||||
|
hand: string[]
|
||||||
|
turnPlan?: TurnPlan
|
||||||
|
|
||||||
|
leftBattles: number[]
|
||||||
|
rightBattles: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
stage: 'starting' | 'play' | 'finished'
|
||||||
|
players: Player[]
|
||||||
|
|
||||||
|
age?: number
|
||||||
|
turnsRemaining?: number
|
||||||
|
discard: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initial: State = {
|
||||||
|
stage: 'starting',
|
||||||
|
players: [],
|
||||||
|
discard: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseAction {
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
export interface AddPlayerAction extends BaseAction {
|
||||||
|
type: 'add player'
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
export interface StartGameAction extends BaseAction {
|
||||||
|
type: 'start game'
|
||||||
|
}
|
||||||
|
export interface PlanTurnAction extends BaseAction {
|
||||||
|
type: 'plan turn'
|
||||||
|
playerIdx: number
|
||||||
|
turnPlan?: TurnPlan
|
||||||
|
}
|
||||||
|
export interface ResetAction extends BaseAction {
|
||||||
|
type: 'reset'
|
||||||
|
}
|
||||||
|
export type Action = AddPlayerAction | StartGameAction
|
||||||
|
| PlanTurnAction
|
||||||
|
| ResetAction
|
||||||
|
|
||||||
|
function sumCountable(player: Player, countable: Countable): number {
|
||||||
|
switch (countable) {
|
||||||
|
case 'wonder stage':
|
||||||
|
return 0 // TODO
|
||||||
|
case 'defeat':
|
||||||
|
return [...player.leftBattles, ...player.rightBattles].filter(b => b === defeatPoints).length
|
||||||
|
default:
|
||||||
|
return player.structures.filter(({ structure }) => structures.get(structure)?.type === countable).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sumCountableMap(player: Player, map: Map<Countable, number>): number {
|
||||||
|
return sum([...map.entries()].map(([countable, number]) => number * sumCountable(player, countable)))
|
||||||
|
}
|
||||||
|
function scoreScience(
|
||||||
|
sciences: Set<Science>[],
|
||||||
|
counts: Map<Science, number> = new Map([['engineering', 0], ['research', 0], ['education', 0]]),
|
||||||
|
): number {
|
||||||
|
if (sciences.length === 0) {
|
||||||
|
return (scienceSetPoints * (min([...counts.values()]) ?? 0))
|
||||||
|
+ sum([...counts.values()].map(c => c * c))
|
||||||
|
} else {
|
||||||
|
return max([...head(sciences)!.values()].map(science =>
|
||||||
|
scoreScience(tail(sciences), new Map([...counts, [science, (counts.get(science) ?? 0) + 1]]))
|
||||||
|
)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function countPoints(state: State, playerIdx: number): number {
|
||||||
|
const player = state.players[playerIdx]
|
||||||
|
const left = state.players[(playerIdx + 1) % state.players.length]
|
||||||
|
const right = state.players[(playerIdx - 1 + state.players.length) % state.players.length]
|
||||||
|
return sum(player.leftBattles) + sum(player.rightBattles)
|
||||||
|
+ sum(player.structures.map(({ structure }) => {
|
||||||
|
const card = structures.get(structure)!
|
||||||
|
return (card.points ?? 0)
|
||||||
|
+ sumCountableMap(player, card.pointsPer ?? new Map())
|
||||||
|
+ sumCountableMap(left, card.pointsPerNeighbor ?? new Map())
|
||||||
|
+ sumCountableMap(right, card.pointsPerNeighbor ?? new Map())
|
||||||
|
}))
|
||||||
|
+ Math.floor(player.gold / goldPerPoint)
|
||||||
|
+ scoreScience(player.structures.flatMap(({ structure }) => {
|
||||||
|
const sciences = structures.get(structure)?.sciences
|
||||||
|
return sciences === undefined ? [] : [sciences]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
function beginAge(state: State): State {
|
||||||
|
const age = (state.age ?? minAge - 1) + 1
|
||||||
|
const deck = shuffle(buildDeck(age, state.players.length).map(s => s.name))
|
||||||
|
const handSize = Math.ceil(deck.length / state.players.length)
|
||||||
|
const hands = chunk(deck, handSize)
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
age,
|
||||||
|
turnsRemaining: handSize - 1,
|
||||||
|
players: state.players.map((p, i) => ({ ...p, hand: hands[i] })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function countShields(player: Player): number {
|
||||||
|
return sum(player.structures.map(({ structure }) => structures.get(structure)?.shields))
|
||||||
|
}
|
||||||
|
function battleResult(state: State, playerIdx: number, delta: number): number | undefined {
|
||||||
|
const playerShields = countShields(state.players[playerIdx])
|
||||||
|
const otherShields = countShields(state.players[(playerIdx + delta + state.players.length) % state.players.length])
|
||||||
|
if (playerShields > otherShields) {
|
||||||
|
return victoryPoints[state.age!]
|
||||||
|
} else if (playerShields < otherShields) {
|
||||||
|
return defeatPoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function endAge(state: State): State {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
turnsRemaining: undefined,
|
||||||
|
discard: [...state.discard, ...state.players.flatMap(p => p.hand)],
|
||||||
|
players: state.players.map((p, i) => {
|
||||||
|
const left = battleResult(state, i, 1)
|
||||||
|
const right = battleResult(state, i, -1)
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
hand: [],
|
||||||
|
leftBattles: [...p.leftBattles, ...(left === undefined ? [] : [left])],
|
||||||
|
rightBattles: [...p.rightBattles, ...(right === undefined ? [] : [right])],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function doTurn(state: State): State {
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
players: state.players.map((player, playerIdx) => {
|
||||||
|
const turnPlan = player.turnPlan!
|
||||||
|
const left = state.players[(playerIdx + 1) % state.players.length]
|
||||||
|
const right = state.players[(playerIdx - 1 + state.players.length) % state.players.length]
|
||||||
|
const card = structures.get(player.hand[turnPlan.cardIdx])!
|
||||||
|
|
||||||
|
return {
|
||||||
|
...player,
|
||||||
|
gold: player.gold
|
||||||
|
+ left.turnPlan!.goldRight + right.turnPlan!.goldLeft
|
||||||
|
+ (turnPlan.action === 'discard' ? 3 : 0)
|
||||||
|
+ (turnPlan.action !== 'structure' ? 0 :
|
||||||
|
(card.gold ?? 0)
|
||||||
|
+ sumCountableMap(player, card.goldPer ?? new Map())
|
||||||
|
+ sumCountableMap(left, card.goldPerNeighbor ?? new Map())
|
||||||
|
+ sumCountableMap(right, card.goldPerNeighbor ?? new Map())
|
||||||
|
),
|
||||||
|
hand: [
|
||||||
|
...player.hand.slice(0, turnPlan.cardIdx),
|
||||||
|
...player.hand.slice(turnPlan.cardIdx + 1)
|
||||||
|
],
|
||||||
|
structures: [
|
||||||
|
...player.structures,
|
||||||
|
...(turnPlan.action !== 'structure' ? [] : [
|
||||||
|
{ age: state.age!, structure: player.hand[turnPlan.cardIdx] }
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
turnsRemaining: state.turnsRemaining! - 1,
|
||||||
|
}
|
||||||
|
if (state.turnsRemaining === 0) {
|
||||||
|
state = endAge(state)
|
||||||
|
if (state.age === maxAge) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
stage: 'finished',
|
||||||
|
age: undefined,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return beginAge(state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const delta = state.age! % 2 === 1 ? -1 : 1
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: state.players.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
hand: state.players[(i + delta + state.players.length) % state.players.length].hand,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reducers: { [type: string]: (state: State, action: any) => State } = {
|
||||||
|
'add player': (state, action: AddPlayerAction) => {
|
||||||
|
if (state.stage !== 'starting') { return state }
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
players: [...state.players, {
|
||||||
|
name: action.name,
|
||||||
|
gold: startingGold,
|
||||||
|
structures: [],
|
||||||
|
hand: [],
|
||||||
|
leftBattles: [],
|
||||||
|
rightBattles: [],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'start game': state => {
|
||||||
|
if (state.stage !== 'starting' || state.players.length < minPlayers || state.players.length > maxPlayers) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
return beginAge({ ...state, stage: 'play' })
|
||||||
|
},
|
||||||
|
'plan turn': (state, action: PlanTurnAction) => {
|
||||||
|
if (state.stage !== 'play') {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
players: state.players.map(
|
||||||
|
(p, i) => i !== action.playerIdx ? p : { ...p, turnPlan: action.turnPlan }
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return newState.players.every(p => p.turnPlan !== undefined) ? doTurn(newState) : newState
|
||||||
|
},
|
||||||
|
reset: () => initial,
|
||||||
|
}
|
||||||
|
export function reducer(state: State, action: Action): State {
|
||||||
|
return reducers[action.type](state, action)
|
||||||
|
}
|
||||||
27
packages/wonders-common/package.json
Normal file
27
packages/wonders-common/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "wonders-common",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"description": "WebSocket-based rollback state synchronization server.",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ketchup-common": "^0.1.1",
|
||||||
|
"lodash": "^4.17.20"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@types/jest": "^26.0.20",
|
||||||
|
"@types/lodash": "^4.14.168",
|
||||||
|
"@types/node": "^14.14.22",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.14.1",
|
||||||
|
"@typescript-eslint/parser": "^4.14.1",
|
||||||
|
"eslint": "^7.18.0",
|
||||||
|
"jest": "^26.6.3",
|
||||||
|
"ts-jest": "^26.5.0",
|
||||||
|
"typescript": "^4.1.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/wonders-common/tsconfig.json
Normal file
7
packages/wonders-common/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node14/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/wonders-server/.eslintrc.json
Normal file
23
packages/wonders-server/.eslintrc.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"standard"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 12,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"comma-dangle": ["error", "only-multiline"],
|
||||||
|
"padded-blocks": "warn",
|
||||||
|
"no-multiple-empty-lines": "warn",
|
||||||
|
"space-before-function-paren": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/wonders-server/Dockerfile
Normal file
20
packages/wonders-server/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
FROM node:16-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache tini
|
||||||
|
|
||||||
|
RUN yarn global add lerna
|
||||||
|
|
||||||
|
WORKDIR /opt/example-server
|
||||||
|
COPY packages/ketchup-common/package.json packages/ketchup-common/
|
||||||
|
COPY packages/ketchup-server/package.json packages/ketchup-server/
|
||||||
|
COPY packages/example-common/package.json packages/example-common/
|
||||||
|
COPY packages/example-server/package.json packages/example-server/
|
||||||
|
COPY package.json yarn.lock lerna.json ./
|
||||||
|
RUN lerna bootstrap
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
RUN lerna run build
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
CMD ["node", "packages/example-server"]
|
||||||
|
EXPOSE 80
|
||||||
22
packages/wonders-server/index.ts
Normal file
22
packages/wonders-server/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import WebSocket from 'ws'
|
||||||
|
import KetchupServer from 'ketchup-server'
|
||||||
|
import { initial, reducer } from 'wonders-common'
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT ?? '80')
|
||||||
|
|
||||||
|
const wss = new WebSocket.Server({ port })
|
||||||
|
|
||||||
|
const ks = new KetchupServer(initial, reducer)
|
||||||
|
|
||||||
|
wss.on('connection', ws => {
|
||||||
|
ks.addRemoteClient(ws)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Listening on ${port}`)
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
console.log('Shutting down')
|
||||||
|
wss.close()
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
36
packages/wonders-server/package.json
Normal file
36
packages/wonders-server/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "wonders-server",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"description": "WebSocket-based rollback state synchronization server.",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ketchup-server": "^0.1.1",
|
||||||
|
"lodash": "^4.17.20",
|
||||||
|
"wonders-common": "^0.1.1",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"ws": "^7.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@types/jest": "^26.0.20",
|
||||||
|
"@types/lodash": "^4.14.168",
|
||||||
|
"@types/node": "^14.14.22",
|
||||||
|
"@types/ws": "^7.4.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.14.1",
|
||||||
|
"@typescript-eslint/parser": "^4.14.1",
|
||||||
|
"eslint": "^7.18.0",
|
||||||
|
"jest": "^26.6.3",
|
||||||
|
"ts-jest": "^26.5.0",
|
||||||
|
"typescript": "^4.1.3"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"bufferutil": "^4.0.3",
|
||||||
|
"utf-8-validate": "^5.0.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/wonders-server/tsconfig.json
Normal file
7
packages/wonders-server/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node14/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user