-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathgame.ts
502 lines (430 loc) · 13.9 KB
/
game.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
import _ from 'lodash'
import * as algo from 'src/core/server/algorithms'
import {
assertDefined,
getOppositePosition,
getPieceAt,
getPlayerPositionFromBoard,
isValidPlayerMove,
pushWithPiece,
} from 'src/core/server/board'
import {
createDeck as createCardDeck,
createInitialBoardPieces,
createPieceBag,
createPlayerColors,
} from 'src/core/server/pieces'
import * as t from 'src/gameTypes'
import { getLogger } from 'src/utils/logger'
import { format } from 'src/utils/utils'
const logger = getLogger('📓 SERVER:')
const PLAYER_DEFAULT_NAME = 'Player'
export type CreateGameOptions = {
onStateChange?: (game: t.Game) => void
cardsPerPlayer?: number
}
export type GameControl = ReturnType<typeof createGame>
export function createGame(opts: CreateGameOptions) {
const onStateChange = opts.onStateChange ?? (() => undefined)
// Note: Many other functions rely on object reference pointers.
// It is safe to do the shuffle before game starts, but after
// that it breaks the references.
const shuffleBoard = mutator((level?: t.ShuffleLevel) => {
const game = stageGuard(['setup'])
const shuffleFn = algo.systematicRandom
const { board, pieceBag } = shuffleFn({
logger,
level: level ?? game.settings.shuffleLevel,
})
game.board = board
if (pieceBag.length !== 1) {
throw new Error('Unexpected amount of pieces in bag')
}
game.pieceBag = pieceBag as [t.Piece]
})
const gameState = createInitialState(opts.cardsPerPlayer)
shuffleBoard()
/**
* Helper function to ensure a state mutation is reflected to callback.
* Proxy is another way to achieve the same effect, but ended up complicated
* for no obvious upside in this case.
*/
function mutator<ArgsT extends unknown[], ReturnT>(
fn: (...args: ArgsT) => Promise<ReturnT> | ReturnT
): (...args: ArgsT) => Promise<ReturnT> | ReturnT {
return (...args: ArgsT) => {
const val = fn(...args)
// TODO: How to prevent nested mutator calls to emit state change n times
onStateChange(gameState)
return val
}
}
function stageGuard<ValidStages extends t.Game['stage'][]>(
validStages: ValidStages
): t.GameByStages<ValidStages> {
if (!gameIsOneOfStages(gameState, validStages)) {
throw new Error(`Incorrect game stage: ${gameState.stage}`)
}
return gameState
}
const changeSettings = mutator((settings: Partial<t.GameSettings>) => {
const game = stageGuard(['setup'])
if (
settings.shuffleLevel &&
settings.shuffleLevel !== game.settings.shuffleLevel
) {
shuffleBoard(settings.shuffleLevel)
}
game.settings = { ...game.settings, ...settings }
})
const nextTurn = mutator(() => {
const game = stageGuard(['playing'])
if (getWinners().length > 0) {
// TODO: Allow even out turns?
finish()
return
}
game.playerTurn += 1
if (game.playerTurn >= game.players.length) {
game.playerTurn = 0
}
game.playerHasPushed = false
game.turnCounter += 1
})
const start = mutator(() => {
const game = stageGuard(['setup']) as t.Game
if (game.pieceBag.length !== 1) {
throw new Error(
`Game must have exactly one piece in the bag when starting`
)
}
if (game.players.length < 1) {
throw new Error(`Game must have at least one player`)
}
game.stage = 'playing'
// Choose random player to start
game.playerTurn = assertDefined(_.sample(_.times(game.players.length)))
logger.log('Random starting player is', game.players[game.playerTurn].name)
game.playerWhoStarted = game.playerTurn
const corners = _.shuffle<t.Position>([
{ x: 0, y: 0 },
{ x: game.board.pieces[0].length - 1, y: 0 },
{ x: game.board.pieces[0].length - 1, y: game.board.pieces.length - 1 },
{ x: 0, y: game.board.pieces.length - 1 },
])
game.players.forEach((player) => {
setPlayerPosition(player.id, assertDefined(corners.pop()))
// Deal cards
player.cards = _.times(game.settings.trophyCount).map(() =>
assertDefined(gameState.cards.pop())
)
})
})
const restart = mutator(() => {
const game = gameState as t.Game
const gameAny = game as any
// Reset all values to initial state
const {
players: _players,
playerColors: _playerColors,
settings: _settings,
...initial
} = createInitialState()
const keys = Object.keys(initial) as Array<keyof typeof initial>
keys.forEach((key) => {
gameAny[key] = initial[key]
})
shuffleBoard()
// Remove added state from players
game.players.forEach((player) => {
player.cards = []
})
})
const finish = mutator(() => {
const game = stageGuard(['playing']) as t.Game
game.stage = 'finished'
const winners = getWinners()
if (winners.length === 0) {
throw new Error(`Game cannot finish without winners`)
}
game.winners = winners as t.NonEmptyArray<t.Player>
})
const addPlayer = mutator(
(player: Pick<t.Player, 'id'> & { name?: string }) => {
const game = stageGuard(['setup'])
if (game.players.length >= 4 || gameState.playerColors.length === 0) {
throw new Error('Server is full')
}
const p = player as t.Player
p.cards = []
p.color = assertDefined(gameState.playerColors.pop())
p.originalName = player.name ?? PLAYER_DEFAULT_NAME
p.name = resolvePlayerName(p.originalName, game.players.length)
game.players.push(p)
return p.id
}
)
const removePlayer = mutator((id: string) => {
const game = stageGuard(['setup'])
const player = getPlayerById(id)
game.players = game.players.filter((p) => p.id !== id)
game.playerColors.push(player.color)
resetPlayerVisibleNames()
})
// Promotes player as the first player.
// This is a bit of an edge-case but if another player connects before
// the admin player, the setup stage is in a weird state.
// The first player has some special rights at the setup stage
// because playerTurn is set to 0.
const promotePlayer = mutator((id: string) => {
const game = stageGuard(['setup'])
const index = playerIndexById(id)
// Remove the promoted player
const [promotedPlayer] = game.players.splice(index, 1)
// Add promoted player as the first
game.players.unshift(promotedPlayer)
resetPlayerVisibleNames()
// Re-assign colors in the original order
game.playerColors = createPlayerColors()
game.players.forEach((player) => {
player.color = assertDefined(game.playerColors.pop())
})
})
const pushByPlayer = mutator((playerId: string, pushPos: t.PushPosition) => {
const game = stageGuard(['playing'])
if (!isPlayersTurn(playerId)) {
throw new Error(`It's not ${playerId}'s turn`)
}
if (game.playerHasPushed) {
throw new Error(`Player ${playerId} already pushed`)
}
if (game.previousPushPosition) {
const opposite = getOppositePosition(
game.board.pieces.length,
game.previousPushPosition
)
if (pushPos.x === opposite.x && pushPos.y === opposite.y) {
throw new Error(
`Illegal push position ${format.pos(
pushPos
)}. Cannot revert previous push.`
)
}
}
const extraPiece = assertDefined(game.pieceBag.pop())
const { piece: newExtraPiece, originalPiece } = pushWithPiece(
game.board,
pushPos,
extraPiece
)
// Transfer players to the other edge
const addedPiece = getPieceAt(game.board, pushPos)
addedPiece.players = originalPiece.players
game.pieceBag.push(newExtraPiece)
if (game.pieceBag.length !== 1) {
throw new Error(
`Unexpected amount of extra pieces: ${game.pieceBag.length}`
)
}
game.previousPushPosition = pushPos
game.playerHasPushed = true
})
const moveByPlayer = mutator((playerId: string, moveTo?: t.Position) => {
const game = stageGuard(['playing'])
if (!isPlayersTurn(playerId)) {
throw new Error(`It's not ${playerId}'s turn`)
}
if (!game.playerHasPushed) {
throw new Error(`Player must push first`)
}
if (moveTo) {
const playerPos = assertDefined(getPlayerPosition(playerId))
if (!isValidPlayerMove(game.board, playerPos, moveTo)) {
throw new Error(
`Not a valid player move for '${playerId}': ${format.pos(
playerPos
)} -> ${format.pos(moveTo)} `
)
}
setPlayerPosition(playerId, moveTo)
}
maybeUpdateCardFound(getPlayerById(playerId))
nextTurn()
})
const setExtraPieceRotationByPlayer = mutator(
(playerId: string, rotation: t.Rotation) => {
const game = stageGuard(['playing'])
if (![0, 90, 180, 270].includes(rotation)) {
throw new Error(`Invalid rotation: ${rotation}`)
}
if (!isPlayersTurn(playerId)) {
throw new Error(`It's not ${playerId}'s turn`)
}
game.pieceBag[0].rotation = rotation
}
)
const setNameByPlayer = mutator((playerId: string, name: string) => {
const index = playerIndexById(playerId)
gameState.players[index].originalName = name
gameState.players[index].name = resolvePlayerName(name, index)
})
const maybeUpdateCardFound = mutator((player: t.Player) => {
const game = stageGuard(['playing'])
const pos = getPlayerPosition(player.id)
const currentCards = getPlayersCurrentCards(player)
const piece = getPieceAt(game.board, pos)
const foundCard = currentCards.find((c) => c.trophy === piece.trophy)
if (piece.trophy && foundCard) {
foundCard.found = true
logger.log(`Player ${player.id} found trophy ${piece.trophy}`)
}
})
function getPlayerPosition(playerId: string): t.Position {
const game = stageGuard(['playing', 'finished'])
return getPlayerPositionFromBoard(game.board, playerId)
}
// XXX: does not validate move
function setPlayerPosition(playerId: string, newPos: t.Position) {
const game = stageGuard(['playing'])
const pieces = _.flatten(gameState.board.pieces)
const piece = _.find(pieces, (p) =>
(p?.players ?? []).some((player) => player.id === playerId)
)
if (piece) {
// Remove from old piece
piece.players = piece.players.filter((p) => p.id !== playerId)
}
const newPiece = getPieceAt(game.board, newPos)
newPiece.players.push(getPlayerById(playerId))
}
function resolvePlayerName(name: string, playerIndex: number): string {
const playersWithSameName = gameState.players.filter(
(p, index) =>
p.originalName.toLowerCase() === name.toLowerCase() &&
index < playerIndex
)
return `${name} ${playersWithSameName.length + 1}`
}
function resetPlayerVisibleNames() {
const game = stageGuard(['setup'])
game.players.forEach((player, index) => {
player.name = resolvePlayerName(player.originalName, index)
})
}
function playerIndexById(playerId: string): number {
return _.findIndex(gameState.players, (p) => p.id === playerId)
}
function getPlayerById(playerId: string): t.Player {
const found = _.find(gameState.players, (p) => p.id === playerId)
if (!found) {
throw new Error(`Player not found with id '${playerId}'`)
}
return found
}
function maybeGetPlayerById(playerId: string): t.Player | undefined {
return _.find(gameState.players, (p) => p.id === playerId)
}
function isPlayersTurn(playerId: string): boolean {
const idx = playerIndexById(playerId)
return gameState.playerTurn === idx
}
function getWinners() {
return gameState.players.filter((p) => {
const hasFoundAllCards = p.cards.every((c) => c.found)
return hasFoundAllCards
})
}
function getExtraPieceRotation(): t.Rotation {
return gameState.pieceBag[0].rotation
}
function whosTurn(): t.Player {
return gameState.players[gameState.playerTurn]
}
return {
getState: () => gameState as t.Game,
changeSettings,
restart,
start,
shuffleBoard,
addPlayer,
getPlayerById,
maybeGetPlayerById,
removePlayer,
promotePlayer,
pushByPlayer,
moveByPlayer,
nextTurn,
whosTurn,
isPlayersTurn,
getPlayerPosition,
getPlayersCurrentCards: (playerId: string) =>
getPlayersCurrentCards(getPlayerById(playerId)),
getExtraPieceRotation,
setExtraPieceRotationByPlayer,
setNameByPlayer,
}
}
export function createInitialState(cardsPerPlayer = 5) {
const deck = createCardDeck()
const initial: Readonly<t.Game> = {
stage: 'setup',
cards: deck,
pieceBag: createPieceBag(),
board: {
pieces: createInitialBoardPieces(),
},
playerColors: createPlayerColors(),
players: [],
playerWhoStarted: 0,
playerTurn: 0,
playerHasPushed: false,
winners: [],
previousPushPosition: undefined,
turnCounter: 0,
settings: {
trophyCount: cardsPerPlayer,
shuffleLevel: 'hard',
},
}
return initial
}
export function getPlayersBetweenCurrentAndPlayerWhoStarted(
players: t.Player[],
current: number,
whoStarted: number
): t.Player[] {
const between: t.Player[] = []
while (current !== whoStarted) {
current++
if (current >= players.length) {
current = 0
}
between.push(players[current])
}
return between
}
/**
* Regular rules only allow single card to be found at a time, but let's model
* the data so it would allow finding multiple cards at a time.
*/
function getPlayersCurrentCards(player: t.Player, max = 1): t.Card[] {
return getCurrentCards(player.cards, max)
}
export function getCurrentCards(playerCards: t.Card[], max = 1): t.Card[] {
const current: t.Card[] = []
for (let i = 0; i < playerCards.length; ++i) {
if (!playerCards[i].found) {
current.push(playerCards[i])
if (current.length >= max) {
return current
}
}
}
return current
}
function gameIsOneOfStages<ValidStages extends t.GameStage[]>(
game: t.Game,
valid: ValidStages
): game is t.GameByStages<ValidStages> {
return valid.includes(game.stage)
}