diff --git a/docker-compose.yaml b/docker-compose.yaml index 535040a..1a92bb1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,6 +2,6 @@ services: server: build: context: . - dockerfile: packages/example-server/Dockerfile + dockerfile: packages/wonders-server/Dockerfile ports: - "4000:80" diff --git a/packages/wonders-common/.eslintrc.json b/packages/wonders-common/.eslintrc.json new file mode 100644 index 0000000..7e12a23 --- /dev/null +++ b/packages/wonders-common/.eslintrc.json @@ -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" + } +} diff --git a/packages/wonders-common/index.ts b/packages/wonders-common/index.ts new file mode 100644 index 0000000..3cd08e8 --- /dev/null +++ b/packages/wonders-common/index.ts @@ -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 + freeWith?: string[] + paysFor?: string[] + + gold?: number + goldPer?: Map + goldPerNeighbor?: Map + points?: number + pointsPer?: Map + pointsPerNeighbor?: Map + sciences?: Set + resources?: Map + 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): number { + return sum([...map.entries()].map(([countable, number]) => number * sumCountable(player, countable))) +} +function scoreScience( + sciences: Set[], + counts: Map = 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) +} diff --git a/packages/wonders-common/package.json b/packages/wonders-common/package.json new file mode 100644 index 0000000..952c1e5 --- /dev/null +++ b/packages/wonders-common/package.json @@ -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" + } +} diff --git a/packages/wonders-common/tsconfig.json b/packages/wonders-common/tsconfig.json new file mode 100644 index 0000000..41c42e8 --- /dev/null +++ b/packages/wonders-common/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node14/tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + } +} diff --git a/packages/wonders-server/.eslintrc.json b/packages/wonders-server/.eslintrc.json new file mode 100644 index 0000000..c22f690 --- /dev/null +++ b/packages/wonders-server/.eslintrc.json @@ -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" + } +} diff --git a/packages/wonders-server/Dockerfile b/packages/wonders-server/Dockerfile new file mode 100644 index 0000000..6586501 --- /dev/null +++ b/packages/wonders-server/Dockerfile @@ -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 diff --git a/packages/wonders-server/index.ts b/packages/wonders-server/index.ts new file mode 100644 index 0000000..6985f4d --- /dev/null +++ b/packages/wonders-server/index.ts @@ -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) diff --git a/packages/wonders-server/package.json b/packages/wonders-server/package.json new file mode 100644 index 0000000..c4821bf --- /dev/null +++ b/packages/wonders-server/package.json @@ -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" + } +} diff --git a/packages/wonders-server/tsconfig.json b/packages/wonders-server/tsconfig.json new file mode 100644 index 0000000..41c42e8 --- /dev/null +++ b/packages/wonders-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node14/tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + } +}