| | import { v4 as uuidv4 } from 'uuid'; |
| | import type { |
| | CardId, |
| | CardInstance, |
| | Player, |
| | GameState, |
| | PlayerHand, |
| | CARD_DATABASE, |
| | PendingAction, |
| | } from '../../../shared/types.js'; |
| | import { CARD_DATABASE as CardDB } from '../../../shared/types.js'; |
| |
|
| | |
| | function shuffleArray<T>(array: T[]): T[] { |
| | const shuffled = [...array]; |
| | for (let i = shuffled.length - 1; i > 0; i--) { |
| | const j = Math.floor(Math.random() * (i + 1)); |
| | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; |
| | } |
| | return shuffled; |
| | } |
| |
|
| | |
| | function createCardInstances(cardId: CardId, count: number): CardInstance[] { |
| | return Array.from({ length: count }, () => ({ |
| | instanceId: uuidv4(), |
| | cardId, |
| | })); |
| | } |
| |
|
| | export class GameEngine { |
| | private deck: CardInstance[] = []; |
| | private discardPile: CardInstance[] = []; |
| | private playerHands: Map<string, CardInstance[]> = new Map(); |
| | private players: Player[] = []; |
| | private currentPlayerIndex: number = 0; |
| | private turnsRemaining: number = 1; |
| | private phase: 'waiting' | 'playing' | 'game_over' = 'waiting'; |
| | private winnerId: string | null = null; |
| | |
| | |
| | public pendingAction: PendingAction | null = null; |
| | |
| | |
| | public onStateChange?: (state: GameState) => void; |
| | public onHandChange?: (playerId: string, hand: CardInstance[]) => void; |
| | public onNotification?: (playerId: string | null, message: string, type: 'info' | 'warning' | 'success' | 'danger') => void; |
| |
|
| | constructor(players: Player[]) { |
| | this.players = players.map(p => ({ ...p, isAlive: true, cardCount: 0 })); |
| | } |
| |
|
| | |
| | initializeGame(): void { |
| | const playerCount = this.players.length; |
| | |
| | |
| | this.deck = []; |
| | |
| | |
| | const normalCards: CardId[] = [ |
| | 'sharp_eye', 'wait_a_sec', 'me_or_you', 'spellbound', |
| | 'criminal_mummy', 'shuffle_it', 'king_ra_says_no', 'safe_travels' |
| | ]; |
| | |
| | for (const cardId of normalCards) { |
| | this.deck.push(...createCardInstances(cardId, playerCount)); |
| | } |
| | |
| | |
| | const halfCards: CardId[] = [ |
| | 'give_and_take', 'all_or_nothing', 'flip_the_table', 'this_is_on_you' |
| | ]; |
| | |
| | for (const cardId of halfCards) { |
| | this.deck.push(...createCardInstances(cardId, playerCount)); |
| | } |
| | |
| | |
| | const defuseCards = createCardInstances('take_a_lap', playerCount + 1); |
| | |
| | |
| | const mummyCards = createCardInstances('mummified', playerCount - 1); |
| | |
| | |
| | this.deck = shuffleArray(this.deck); |
| | |
| | |
| | for (const player of this.players) { |
| | const hand: CardInstance[] = []; |
| | |
| | |
| | hand.push(defuseCards.pop()!); |
| | |
| | |
| | for (let i = 0; i < 4 && this.deck.length > 0; i++) { |
| | hand.push(this.deck.pop()!); |
| | } |
| | |
| | this.playerHands.set(player.id, shuffleArray(hand)); |
| | player.cardCount = hand.length; |
| | } |
| | |
| | |
| | if (defuseCards.length > 0) { |
| | this.deck.push(...defuseCards); |
| | } |
| | |
| | |
| | this.deck.push(...mummyCards); |
| | |
| | |
| | this.deck = shuffleArray(this.deck); |
| | |
| | |
| | this.phase = 'playing'; |
| | this.currentPlayerIndex = 0; |
| | this.turnsRemaining = 1; |
| | } |
| |
|
| | |
| | getGameState(): GameState { |
| | return { |
| | phase: this.phase, |
| | players: this.players.map(p => ({ |
| | ...p, |
| | cardCount: this.playerHands.get(p.id)?.length ?? 0, |
| | })), |
| | currentPlayerIndex: this.currentPlayerIndex, |
| | turnsRemaining: this.turnsRemaining, |
| | deckCount: this.deck.length, |
| | discardPile: this.discardPile.map(c => c.cardId), |
| | winnerId: this.winnerId, |
| | }; |
| | } |
| |
|
| | |
| | getPlayerHand(playerId: string): CardInstance[] { |
| | return this.playerHands.get(playerId) ?? []; |
| | } |
| |
|
| | |
| | getCurrentPlayer(): Player | null { |
| | return this.players[this.currentPlayerIndex] ?? null; |
| | } |
| |
|
| | |
| | isPlayerTurn(playerId: string): boolean { |
| | const current = this.getCurrentPlayer(); |
| | return current?.id === playerId && this.phase === 'playing'; |
| | } |
| |
|
| | |
| | hasCard(playerId: string, cardId: CardId): boolean { |
| | const hand = this.playerHands.get(playerId); |
| | return hand?.some(c => c.cardId === cardId) ?? false; |
| | } |
| |
|
| | |
| | countCards(playerId: string, cardId: CardId): number { |
| | const hand = this.playerHands.get(playerId); |
| | return hand?.filter(c => c.cardId === cardId).length ?? 0; |
| | } |
| |
|
| | |
| | findCardInstance(playerId: string, instanceId: string): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | return hand?.find(c => c.instanceId === instanceId) ?? null; |
| | } |
| |
|
| | |
| | removeCardFromHand(playerId: string, instanceId: string): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand) return null; |
| | |
| | const index = hand.findIndex(c => c.instanceId === instanceId); |
| | if (index === -1) return null; |
| | |
| | const [card] = hand.splice(index, 1); |
| | this.updatePlayerCardCount(playerId); |
| | return card; |
| | } |
| |
|
| | |
| | addCardToHand(playerId: string, card: CardInstance): void { |
| | const hand = this.playerHands.get(playerId); |
| | if (hand) { |
| | hand.push(card); |
| | this.updatePlayerCardCount(playerId); |
| | } |
| | } |
| |
|
| | |
| | private updatePlayerCardCount(playerId: string): void { |
| | const player = this.players.find(p => p.id === playerId); |
| | if (player) { |
| | player.cardCount = this.playerHands.get(playerId)?.length ?? 0; |
| | } |
| | } |
| |
|
| | |
| | discardCard(card: CardInstance): void { |
| | this.discardPile.push(card); |
| | } |
| |
|
| | |
| | drawTopCard(): CardInstance | null { |
| | return this.deck.pop() ?? null; |
| | } |
| |
|
| | |
| | peekTopCards(count: number): CardInstance[] { |
| | const startIndex = Math.max(0, this.deck.length - count); |
| | return this.deck.slice(startIndex).reverse(); |
| | } |
| |
|
| | |
| | insertCardInDeck(card: CardInstance, position: number): void { |
| | const actualPosition = this.deck.length - position; |
| | this.deck.splice(Math.max(0, actualPosition), 0, card); |
| | } |
| |
|
| | |
| | rearrangeTopCards(newOrder: string[]): void { |
| | const count = newOrder.length; |
| | const topCards = this.deck.splice(-count); |
| | |
| | |
| | const orderedCards = newOrder.map(instanceId => |
| | topCards.find(c => c.instanceId === instanceId)! |
| | ).filter(Boolean); |
| | |
| | |
| | this.deck.push(...orderedCards.reverse()); |
| | } |
| |
|
| | |
| | shuffleDeck(): void { |
| | this.deck = shuffleArray(this.deck); |
| | } |
| |
|
| | |
| | getDeckSize(): number { |
| | return this.deck.length; |
| | } |
| |
|
| | |
| | endTurn(skipDraw: boolean = false): void { |
| | this.turnsRemaining--; |
| | |
| | if (this.turnsRemaining <= 0) { |
| | this.moveToNextPlayer(); |
| | } |
| | } |
| |
|
| | |
| | private moveToNextPlayer(): void { |
| | const alivePlayers = this.players.filter(p => p.isAlive); |
| | |
| | if (alivePlayers.length <= 1) { |
| | this.endGame(alivePlayers[0]?.id ?? null); |
| | return; |
| | } |
| | |
| | |
| | let nextIndex = (this.currentPlayerIndex + 1) % this.players.length; |
| | while (!this.players[nextIndex].isAlive) { |
| | nextIndex = (nextIndex + 1) % this.players.length; |
| | } |
| | |
| | this.currentPlayerIndex = nextIndex; |
| | this.turnsRemaining = 1; |
| | } |
| |
|
| | |
| | setNextPlayerTurns(turns: number): void { |
| | |
| | const alivePlayers = this.players.filter(p => p.isAlive); |
| | if (alivePlayers.length <= 1) return; |
| | |
| | let nextIndex = (this.currentPlayerIndex + 1) % this.players.length; |
| | while (!this.players[nextIndex].isAlive) { |
| | nextIndex = (nextIndex + 1) % this.players.length; |
| | } |
| | |
| | this.currentPlayerIndex = nextIndex; |
| | this.turnsRemaining = turns; |
| | } |
| |
|
| | |
| | eliminatePlayer(playerId: string): void { |
| | const player = this.players.find(p => p.id === playerId); |
| | if (!player) return; |
| | |
| | player.isAlive = false; |
| | |
| | |
| | const hand = this.playerHands.get(playerId) ?? []; |
| | for (const card of hand) { |
| | this.discardCard(card); |
| | } |
| | this.playerHands.set(playerId, []); |
| | player.cardCount = 0; |
| | |
| | |
| | const alivePlayers = this.players.filter(p => p.isAlive); |
| | if (alivePlayers.length === 1) { |
| | this.endGame(alivePlayers[0].id); |
| | } else if (this.getCurrentPlayer()?.id === playerId) { |
| | |
| | this.moveToNextPlayer(); |
| | } |
| | } |
| |
|
| | |
| | private endGame(winnerId: string | null): void { |
| | this.phase = 'game_over'; |
| | this.winnerId = winnerId; |
| | } |
| |
|
| | |
| | canDefuse(playerId: string): boolean { |
| | return this.hasCard(playerId, 'take_a_lap'); |
| | } |
| |
|
| | |
| | useDefuse(playerId: string): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand) return null; |
| | |
| | const defuseIndex = hand.findIndex(c => c.cardId === 'take_a_lap'); |
| | if (defuseIndex === -1) return null; |
| | |
| | const [defuseCard] = hand.splice(defuseIndex, 1); |
| | this.discardCard(defuseCard); |
| | this.updatePlayerCardCount(playerId); |
| | |
| | return defuseCard; |
| | } |
| |
|
| | |
| | getOtherAlivePlayers(excludeId: string): Player[] { |
| | return this.players.filter(p => p.isAlive && p.id !== excludeId); |
| | } |
| |
|
| | |
| | getPlayer(playerId: string): Player | null { |
| | return this.players.find(p => p.id === playerId) ?? null; |
| | } |
| |
|
| | |
| | getLowestValueCard(playerId: string): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand || hand.length === 0) return null; |
| | |
| | const playableCards = hand.filter(c => |
| | c.cardId !== 'take_a_lap' && c.cardId !== 'mummified' |
| | ); |
| | |
| | if (playableCards.length === 0) return null; |
| | |
| | return playableCards.reduce((lowest, card) => { |
| | const lowestValue = CardDB[lowest.cardId].value; |
| | const cardValue = CardDB[card.cardId].value; |
| | return cardValue < lowestValue ? card : lowest; |
| | }); |
| | } |
| |
|
| | |
| | getHighestValueCard(playerId: string): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand || hand.length === 0) return null; |
| | |
| | const playableCards = hand.filter(c => c.cardId !== 'mummified'); |
| | |
| | if (playableCards.length === 0) return null; |
| | |
| | return playableCards.reduce((highest, card) => { |
| | const highestValue = CardDB[highest.cardId].value; |
| | const cardValue = CardDB[card.cardId].value; |
| | return cardValue > highestValue ? card : highest; |
| | }); |
| | } |
| |
|
| | |
| | getRandomCard(playerId: string, position: number): CardInstance | null { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand || position < 0 || position >= hand.length) return null; |
| | return hand[position]; |
| | } |
| |
|
| | |
| | rearrangeHand(playerId: string, newOrder: string[]): void { |
| | const hand = this.playerHands.get(playerId); |
| | if (!hand) return; |
| | |
| | const newHand = newOrder |
| | .map(instanceId => hand.find(c => c.instanceId === instanceId)) |
| | .filter((c): c is CardInstance => c !== undefined); |
| | |
| | this.playerHands.set(playerId, newHand); |
| | } |
| |
|
| | |
| | isGameOver(): boolean { |
| | return this.phase === 'game_over'; |
| | } |
| |
|
| | |
| | getPlayersWithKingRa(excludeId: string): string[] { |
| | return this.players |
| | .filter(p => p.isAlive && p.id !== excludeId && this.hasCard(p.id, 'king_ra_says_no')) |
| | .map(p => p.id); |
| | } |
| | } |
| |
|