diff --git a/Documentations/COMPLETE_GAME_WORKFLOW.md b/Documentations/COMPLETE_GAME_WORKFLOW.md new file mode 100644 index 00000000..ec8be442 --- /dev/null +++ b/Documentations/COMPLETE_GAME_WORKFLOW.md @@ -0,0 +1,1995 @@ +# Complete Game Workflow Documentation + +**Version**: 2.0 +**Last Updated**: October 30, 2025 +**Document Type**: Complete System Reference + +--- + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Game Flow Diagram](#game-flow-diagram) +3. [REST API Endpoints](#rest-api-endpoints) +4. [WebSocket Events](#websocket-events) +5. [Interfaces & Data Structures](#interfaces--data-structures) +6. [Domain Aggregates](#domain-aggregates) +7. [Complete Game Scenarios](#complete-game-scenarios) +8. [Turn Tracking System](#turn-tracking-system) +9. [Position Guessing Mechanic](#position-guessing-mechanic) +10. [Error Handling](#error-handling) + +--- + +## System Overview + +The SerpentRace game system uses a **hybrid architecture**: +- **REST API**: Game creation, joining, and initial setup +- **WebSocket (Socket.IO)**: Real-time gameplay, card interactions, and game state updates +- **Redis**: Session management, pending states, turn tracking +- **PostgreSQL**: Persistent game data, user profiles, decks + +### Technology Stack +- **Backend**: Node.js + TypeScript + Express +- **Real-time**: Socket.IO (WebSocket library) +- **Database**: TypeORM + PostgreSQL +- **Cache/Session**: Redis +- **Authentication**: JWT tokens (Access + Refresh) + +--- + +## Game Flow Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ GAME LIFECYCLE │ +└──────────────────────────────────────────────────────────────────────────┘ + +1. GAME CREATION (REST) + │ + ├─ POST /api/v1/game/start + │ ├─ Gamemaster creates game with deck selection + │ ├─ Game Code generated (6 characters) + │ └─ Game state: "waiting" + │ + ▼ + +2. PLAYER JOINING (REST + WebSocket) + │ + ├─ POST /api/v1/game/join + │ ├─ Validate game code + │ ├─ Check game type (PUBLIC/PRIVATE/ORGANIZATION) + │ ├─ Add player to game.players[] + │ └─ Return gameToken for WebSocket auth + │ + ├─ WebSocket Connection + │ ├─ Connect to /game namespace + │ ├─ Emit: game:join with gameToken + │ ├─ Server validates & joins rooms + │ └─ Broadcast: game:player-joined to all + │ + ├─ Emit: game:ready (player marks ready) + └─ Broadcast: game:player-ready to all + │ + ▼ + +3. GAME START (REST) + │ + ├─ POST /api/v1/game/:gameId/start + │ ├─ Only gamemaster can start + │ ├─ Check minimum players (2+) + │ ├─ Generate board (100 fields with pattern) + │ ├─ Shuffle and assign turn sequence + │ ├─ Initialize player positions (all at 0) + │ └─ Game state: "active" + │ + ├─ Broadcast: game:started + │ ├─ Board data sent to all players + │ ├─ Turn sequence revealed + │ └─ First player notified + │ + ▼ + +4. GAMEPLAY LOOP (WebSocket) + │ + ├─ DICE ROLL + │ ├─ Emit: game:dice-roll { diceValue: 1-6 } + │ ├─ Broadcast: game:dice-rolled + │ ├─ Calculate new position + │ ├─ Update player position + │ ├─ Broadcast: game:player-moved + │ └─ Check field type at new position + │ + ├─ SPECIAL FIELD LANDING (positive/negative/luck) + │ ├─ Wait 2 seconds (frontend animation) + │ ├─ Draw card from appropriate deck + │ ├─ Broadcast: game:card-drawn + │ └─ Branch based on card type: + │ + │ ┌─ QUESTION CARD (types 0-4) + │ │ ├─ Emit to player: game:card-drawn-self (60s) + │ │ ├─ Player answers + │ │ ├─ Emit: game:card-answer + │ │ ├─ Broadcast: game:answer-submitted + │ │ ├─ Validate answer + │ │ ├─ Broadcast: game:answer-validated + │ │ ├─ Determine if guess required: + │ │ │ ├─ Positive field + correct = GUESS + │ │ │ ├─ Negative field + wrong = GUESS + │ │ │ ├─ Positive field + wrong = NO MOVEMENT + │ │ │ └─ Negative field + correct = NO MOVEMENT + │ │ │ + │ │ └─ IF GUESS REQUIRED: + │ │ ├─ Emit: game:position-guess-request (30s) + │ │ ├─ Show pattern modifier, dice, stepValue + │ │ ├─ Player guesses position + │ │ ├─ Emit: game:position-guess + │ │ ├─ Broadcast: game:position-guess-broadcast + │ │ ├─ Calculate with BoardGenerationService + │ │ ├─ Apply -2 penalty if wrong + │ │ ├─ Broadcast: game:guess-result + │ │ ├─ Check win condition (position >= 100) + │ │ └─ Check secondary landing + │ + │ ┌─ LUCK CARD (type 6) + │ │ ├─ Process immediately (no answer) + │ │ ├─ Broadcast: game:card-result + │ │ ├─ Apply consequence: + │ │ │ ├─ MOVE_FORWARD (0): Move X steps + │ │ │ ├─ MOVE_BACKWARD (1): Move X steps back + │ │ │ ├─ LOSE_TURN (2): Store X turns to lose + │ │ │ ├─ EXTRA_TURN (3): Store X extra turns + │ │ │ └─ GO_TO_START (5): Return to position 1 + │ │ ├─ Broadcast: game:luck-consequence + │ │ └─ Advance turn (with turn tracking) + │ + │ └─ JOKER CARD (type 5) - Secondary Landing Only + │ ├─ Broadcast: game:joker-drawn + │ ├─ Request gamemaster decision + │ ├─ Emit to gamemaster: game:gamemaster-decision-request (120s) + │ ├─ Gamemaster decides (approve/reject) + │ ├─ Emit: game:gamemaster-decision + │ ├─ Broadcast: game:gamemaster-decision-result + │ ├─ Determine if guess required: + │ │ ├─ Positive field + approved = GUESS + │ │ ├─ Negative field + rejected = GUESS + │ │ ├─ Positive field + rejected = NO MOVEMENT + │ │ └─ Negative field + approved = NO MOVEMENT + │ │ + │ └─ IF GUESS REQUIRED: + │ ├─ Emit: game:joker-position-guess-request (30s) + │ ├─ Player guesses with dice=6 + │ ├─ Emit: game:joker-position-guess + │ ├─ Calculate with BoardGenerationService + │ ├─ Apply -2 penalty if wrong + │ ├─ Broadcast: game:joker-complete + │ └─ Check secondary landing + │ + ├─ TURN ADVANCEMENT + │ ├─ Check if current player has extra turns + │ │ └─ IF YES: Same player continues, decrement counter + │ ├─ Find next player in sequence + │ ├─ Skip players with turns to lose + │ │ └─ Broadcast: game:players-skipped + │ ├─ Broadcast: game:turn-changed + │ └─ Emit to next player: game:your-turn + │ + └─ WIN CONDITION + ├─ Player reaches position >= 100 + ├─ Broadcast: game:ended + ├─ Update database (finished=true, winner) + └─ Cleanup all Redis data and disconnect + │ + ▼ + +5. GAME END & CLEANUP + │ + ├─ Clear all Redis keys: + │ ├─ gameplay:{gameCode} + │ ├─ game_positions:{gameCode} + │ ├─ pending_card:{gameCode}:{playerId} + │ ├─ pending_decision:{gameCode}:{requestId} + │ ├─ player_extra_turns:{gameCode}:{playerId} + │ └─ player_turns_to_lose:{gameCode}:{playerId} + │ + ├─ Force disconnect all players from rooms + ├─ Emit: game:cleanup-complete + └─ Database updated with final state +``` + +--- + +## REST API Endpoints + +### Authentication Headers +All authenticated endpoints require: +``` +Authorization: Bearer +``` + +### 1. Create Game + +**Endpoint**: `POST /api/v1/game/start` +**Auth**: Required +**Description**: Create a new game session with selected decks + +**Request Body**: +```typescript +{ + deckids: string[]; // Array of deck UUIDs (at least 1) + maxplayers: number; // Maximum players (2-10) + logintype: number; // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION +} +``` + +**Response** (200 OK): +```typescript +{ + id: string; // Game UUID + gamecode: string; // 6-character game code + maxplayers: number; + logintype: number; + boardsize: number; // Default 100 + createdby: string; // Gamemaster user ID + orgid: string | null; // Organization ID (for ORG games) + gamedecks: GameDeck[]; // Array of decks with cards + players: string[]; // Empty array initially + startdate: Date | null; + enddate: Date | null; + finished: boolean; + winner: string | null; + totaltiles: number; +} +``` + +**Errors**: +- `400`: Invalid input (missing deckids, invalid maxplayers, etc.) +- `401`: Not authenticated +- `404`: Deck not found +- `500`: Internal server error + +--- + +### 2. Join Game + +**Endpoint**: `POST /api/v1/game/join` +**Auth**: Optional (depends on game type) +**Description**: Join an existing game using game code + +**Request Body**: +```typescript +{ + gameCode: string; // 6-character game code + playerName?: string; // Required for PUBLIC, optional for authenticated +} +``` + +**Authentication Requirements by Game Type**: +- **PUBLIC (0)**: No auth required, playerName mandatory +- **PRIVATE (1)**: Auth required +- **ORGANIZATION (2)**: Auth + organization membership required + +**Response** (200 OK): +```typescript +{ + id: string; // Game UUID + gamecode: string; // Game code + playerName: string; // Player's display name + playerCount: number; // Current number of players + maxPlayers: number; + gameType: string; // "PUBLIC" | "PRIVATE" | "ORGANIZATION" + isAuthenticated: boolean; + gameToken: string; // JWT token for WebSocket authentication +} +``` + +**Game Token Payload**: +```typescript +{ + gameId: string; + gameCode: string; + playerName: string; + playerId?: string; // Only if authenticated + iat: number; // Issued at + exp: number; // Expires in 24 hours +} +``` + +**Errors**: +- `400`: Invalid gameCode format or missing playerName +- `401`: Authentication required (PRIVATE/ORGANIZATION games) +- `403`: Organization membership required or mismatch +- `404`: Game not found +- `409`: Game full or already started +- `500`: Internal server error + +--- + +### 3. Start Game Play + +**Endpoint**: `POST /api/v1/game/:gameId/start` +**Auth**: Required (only gamemaster) +**Description**: Start the actual gameplay after all players are ready + +**Path Parameters**: +- `gameId`: UUID of the game + +**Request Body**: Empty `{}` + +**Response** (200 OK): +```typescript +{ + message: string; // "Game started successfully" + gameId: string; + playerCount: number; + game: GameAggregate; // Full game object + boardData: { + gameId: string; + fields: GameField[]; // 100 fields with positions, types, stepValues + generationComplete: boolean; + generatedAt: Date; + } +} +``` + +**Board Generation**: +- Generates 100 fields with pattern-based distribution +- Field types: `regular`, `positive`, `negative`, `luck` +- Each field has `stepValue` (0-3) for movement calculations +- Pattern modifiers by zones: + - Positions 1-20: +2 (easier start) + - Positions 21-40: -1 (early challenge) + - Positions 41-60: +1 (mid-game boost) + - Positions 61-80: -2 (late challenge) + - Positions 81-100: +3 (final stretch) + +**Game State Changes**: +- `game.startdate` set to current date +- `game.gamePhase` set to "active" +- Turn sequence randomized +- Player positions initialized to 0 + +**Errors**: +- `400`: Invalid gameId +- `403`: Only gamemaster can start game +- `404`: Game not found +- `409`: Game already started, not enough players, or players not ready +- `500`: Board generation failed or internal error + +--- + +## WebSocket Events + +### Connection & Authentication + +**Namespace**: `/game` + +**Connection**: +```typescript +const socket = io('http://localhost:3000/game', { + auth: { + token: gameToken // JWT token from /game/join endpoint + } +}); +``` + +**Authentication Flow**: +1. Client connects with `gameToken` in auth +2. Server validates token using `GameTokenService` +3. Server extracts: `gameId`, `gameCode`, `playerName`, `playerId` +4. Server joins socket to rooms: + - `game_{gameCode}` (all players) + - `game_{gameCode}:{playerName}` (individual player) +5. Server emits `authenticated` event + +**Events**: + +```typescript +// Client → Server: Initial join +socket.emit('game:join', { gameToken: string }); + +// Server → Client: Authentication success +socket.on('authenticated', { + gameCode: string; + playerName: string; + message: string; +}); + +// Server → All Players: Player joined +socket.on('game:player-joined', { + playerId: string; + playerName: string; + playerCount: number; + timestamp: string; +}); +``` + +--- + +### Pre-Game Events + +#### Ready System + +```typescript +// Client → Server: Mark player as ready +socket.emit('game:ready', { + gameCode: string; + ready: boolean; +}); + +// Server → All Players: Player ready status changed +socket.on('game:player-ready', { + playerId: string; + playerName: string; + ready: boolean; + readyCount: number; + totalPlayers: number; + allReady: boolean; + timestamp: string; +}); +``` + +#### Game Start Notification + +```typescript +// Server → All Players: Game started (after REST /start call) +socket.on('game:started', { + message: string; + boardData: BoardData; + turnSequence: string[]; // Array of player IDs in turn order + currentPlayer: string; // First player ID + currentPlayerName: string; + timestamp: string; +}); + +// Server → Current Player: Your turn notification +socket.on('game:your-turn', { + message: string; + canRoll: boolean; + isExtraTurn?: boolean; // True if using extra turn + timestamp: string; +}); +``` + +--- + +### Gameplay Events + +#### Dice Roll + +```typescript +// Client → Server: Roll dice +socket.emit('game:dice-roll', { + gameCode: string; + diceValue: number; // 1-6 +}); + +// Server → All Players: Dice rolled +socket.on('game:dice-rolled', { + playerId: string; + playerName: string; + diceValue: number; + timestamp: string; +}); + +// Server → All Players: Player moved +socket.on('game:player-moved', { + playerId: string; + playerName: string; + oldPosition: number; + newPosition: number; + diceValue: number; + timestamp: string; +}); +``` + +--- + +#### Card Drawing & Question Cards + +```typescript +// Server → All Players: Card drawn (everyone sees question) +socket.on('game:card-drawn', { + playerName: string; + playerId: string; + cardType: string; // "QUIZ", "SENTENCE_PAIRING", etc. + question: string; // The question text + fieldType: string; // "positive" | "negative" | "luck" + timestamp: string; +}); + +// Server → Drawing Player: Interactive card details +socket.on('game:card-drawn-self', { + cardData: { + cardid: string; + question: string; + type: CardType; + timeLimit: number; // 60 seconds + answerOptions?: QuizOption[]; // For QUIZ (multiple choice) + sentencePairs?: SentencePair[]; // For SENTENCE_PAIRING (left-right matching) + words?: string[]; // For SENTENCE_PAIRING (legacy scrambled words) + // ... type-specific data + }; + timestamp: string; +}); + +// SENTENCE_PAIRING Data Structures + +/** + * Sentence pair for left-right matching + */ +interface SentencePair { + id: string; // Unique identifier (e.g., "pair_0", "pair_1") + left: string; // Left part to match (shown in order) + right: string; // Right part (scrambled position) +} + +/** + * Player's answer for sentence pairing + */ +interface SentencePairingAnswer { + pairId: string; // ID of the pair being matched + leftText: string; // Left part text + rightText: string; // Player's chosen right part +} + +// Example: Matching fruits to colors +// Card data sent to player: +{ + sentencePairs: [ + { id: "pair_0", left: "Apple", right: "Yellow" }, // Right parts + { id: "pair_1", left: "Banana", right: "Orange" }, // are scrambled + { id: "pair_2", left: "Orange", right: "Red" } + ] +} + +// Player's answer: +{ + answer: [ + { pairId: "pair_0", leftText: "Apple", rightText: "Red" }, + { pairId: "pair_1", leftText: "Banana", rightText: "Yellow" }, + { pairId: "pair_2", leftText: "Orange", rightText: "Orange" } + ] +} + +// Client → Server: Submit answer +socket.emit('game:card-answer', { + gameCode: string; + answer: any; // Type depends on card type: + // - QUIZ: string (A/B/C/D) + // - SENTENCE_PAIRING: SentencePairingAnswer[] or string (legacy) + // - OWN_ANSWER: string + // - TRUE_FALSE: boolean or "true"/"false" + // - CLOSER: number +}); + +// Server → All Players: Answer submitted (before validation) +socket.on('game:answer-submitted', { + playerName: string; + playerId: string; + answer: any; + message: string; + timestamp: string; +}); + +// Server → All Players: Answer validated +socket.on('game:answer-validated', { + playerName: string; + playerId: string; + isCorrect: boolean; + correctAnswer: any; + message: string; + timestamp: string; +}); +``` + +--- + +#### Position Guessing (Question Cards) + +```typescript +// Server → Player: Request position guess +socket.on('game:position-guess-request', { + message: string; + currentPosition: number; // Starting position + diceRoll: number; // Original dice value + fieldStepValue: number; // Field's step value + patternModifier: number; // Zone-based modifier + timeLimit: number; // 30 seconds + timestamp: string; +}); + +// Client → Server: Submit position guess +socket.emit('game:position-guess', { + gameCode: string; + guessedPosition: number; +}); + +// Server → All Players: Player's guess broadcast +socket.on('game:position-guess-broadcast', { + playerId: string; + playerName: string; + guessedPosition: number; + message: string; + timestamp: string; +}); + +// Server → All Players: Guess result with full calculation +socket.on('game:guess-result', { + playerId: string; + playerName: string; + guessedPosition: number; + actualPosition: number; // Calculated position + finalPosition: number; // After penalty (if wrong) + guessCorrect: boolean; + penaltyApplied: boolean; // -2 if wrong + calculation: { + startPosition: number; + diceRoll: number; + stepValue: number; + patternModifier: number; + calculatedPosition: number; + penalty: number; // 0 or -2 + }; + message: string; + timestamp: string; +}); + +// Server → All Players: Player didn't move +socket.on('game:no-movement', { + playerId: string; + playerName: string; + reason: string; // "Wrong answer on positive field" + message: string; + timestamp: string; +}); + +// Server → All Players: Penalty avoided +socket.on('game:penalty-avoided', { + playerId: string; + playerName: string; + message: string; // "Avoided penalty on negative field" + timestamp: string; +}); +``` + +--- + +#### Luck Cards + +```typescript +// Server → All Players: Luck card consequence applied +socket.on('game:luck-consequence', { + playerId: string; + playerName: string; + consequenceType: string; // "MOVE_FORWARD" | "MOVE_BACKWARD" | etc. + value: number; // Magnitude of effect + newPosition?: number; // For movement consequences + turnsToLose?: number; // For LOSE_TURN + extraTurns?: number; // For EXTRA_TURN + message: string; + timestamp: string; +}); +``` + +**Consequence Types**: +- `MOVE_FORWARD` (0): Move forward X steps +- `MOVE_BACKWARD` (1): Move backward X steps +- `LOSE_TURN` (2): Lose X turns +- `EXTRA_TURN` (3): Gain X extra turns +- `GO_TO_START` (5): Return to position 1 + +--- + +#### Joker Cards & Gamemaster Decisions + +```typescript +// Server → All Players: Joker card drawn +socket.on('game:joker-drawn', { + playerName: string; + playerId: string; + jokerCard: { + question: string; + consequence: Consequence; + }; + waitingForGamemaster: boolean; + timestamp: string; +}); + +// Server → Gamemaster: Decision request +socket.on('game:gamemaster-decision-request', { + requestId: string; + playerName: string; + playerId: string; + jokerCard: { + question: string; + consequence: Consequence; + }; + timeLimit: number; // 120 seconds + timestamp: string; +}); + +// Client → Server: Gamemaster decision +socket.emit('game:gamemaster-decision', { + gameCode: string; + requestId: string; + decision: 'approve' | 'reject'; +}); + +// Server → All Players: Decision result +socket.on('game:gamemaster-decision-result', { + playerName: string; + playerId: string; + gamemasterName: string; + decision: string; // "approve" | "reject" + approved: boolean; + consequence: number | null; + description: string; + timestamp: string; +}); + +// Server → Player: Joker position guess request +socket.on('game:joker-position-guess-request', { + message: string; + currentPosition: number; + diceRoll: number; // Always 6 for jokers + fieldStepValue: number; + patternModifier: number; + timeLimit: number; // 30 seconds + timestamp: string; +}); + +// Client → Server: Joker position guess +socket.emit('game:joker-position-guess', { + gameCode: string; + guessedPosition: number; +}); + +// Server → All Players: Joker card complete +socket.on('game:joker-complete', { + playerId: string; + playerName: string; + guessedPosition: number; + actualPosition: number; + finalPosition: number; // Current position if didn't move + guessCorrect: boolean; + penaltyApplied: boolean; + moved: boolean; // Based on field type + decision + calculation: { + startPosition: number; + diceRoll: number; // 6 + stepValue: number; + patternModifier: number; + calculatedPosition: number; + penalty: number; + }; + message: string; + timestamp: string; +}); +``` + +**Joker Flow Logic**: +- **Positive field + approved**: Player moves (with guess) +- **Positive field + rejected**: Player doesn't move +- **Negative field + approved**: Player doesn't move (avoided penalty) +- **Negative field + rejected**: Player moves (with guess - penalty) + +--- + +#### Turn Advancement & Turn Tracking + +```typescript +// Server → All Players: Turn changed +socket.on('game:turn-changed', { + currentPlayer: string; // Player ID + currentPlayerName: string; + turnNumber: number; + message: string; + timestamp: string; +}); + +// Server → All Players: Extra turn remaining +socket.on('game:extra-turn-remaining', { + playerId: string; + playerName: string; + remainingExtraTurns: number; // How many left after this one + message: string; + timestamp: string; +}); + +// Server → All Players: Players skipped +socket.on('game:players-skipped', { + skippedPlayers: Array<{ + playerId: string; + playerName: string; + remainingTurnsToLose: number; + }>; + message: string; + timestamp: string; +}); +``` + +--- + +#### Game End & Cleanup + +```typescript +// Server → All Players: Game ended +socket.on('game:ended', { + winner: string; // Player ID + winnerName: string; + message: string; + finalPositions: PlayerPosition[]; + timestamp: string; +}); + +// Server → All Players: Cleanup complete +socket.on('game:cleanup-complete', { + gameCode: string; + message: string; + timestamp: string; +}); +``` + +--- + +#### Error Events + +```typescript +// Server → Client: Error occurred +socket.on('game:error', { + message: string; + code?: string; + timestamp: string; +}); + +// Server → All Players: Card error (no cards available) +socket.on('game:card-error', { + playerName: string; + playerId: string; + error: string; + timestamp: string; +}); + +// Server → All Players: Joker error +socket.on('game:joker-error', { + playerName: string; + playerId: string; + error: string; + timestamp: string; +}); +``` + +--- + +## Interfaces & Data Structures + +### WebSocket Interfaces + +Located in: `src/Application/Services/Interfaces/GameInterfaces.ts` + +```typescript +/** + * Join game room data + */ +export interface JoinGameData { + gameToken: string; // JWT token from REST /join endpoint +} + +/** + * Leave game data + */ +export interface LeaveGameData { + gameCode: string; +} + +/** + * Dice roll data + */ +export interface DiceRollData { + gameCode: string; + diceValue: number; // 1-6 +} + +/** + * Player position tracking + */ +export interface PlayerPosition { + playerId: string; + playerName: string; + boardPosition: number; + turnOrder: number; +} + +/** + * Game chat message + */ +export interface GameChatData { + gameCode: string; + message: string; +} + +/** + * Card answer data + */ +export interface CardAnswerData { + gameCode: string; + answer: any; // Type depends on card type +} + +/** + * Gamemaster decision data + */ +export interface GamemasterDecisionData { + gameCode: string; + requestId: string; + decision: 'approve' | 'reject'; +} + +/** + * Field effect calculation request + */ +export interface FieldEffectRequest { + gameId: string; + playerId: string; + playerName: string; + currentPosition: number; + card: GameCard; + field: GameField; + dice: number; + guessedPosition?: number; +} + +/** + * Field effect calculation result + */ +export interface FieldEffectResult { + finalPosition: number; + stepValue: number; + dice: number; + patternModifier: number; + consequenceModifier: number; + guessResult?: GuessResult; + gamemasterResult?: GamemasterDecisionResult; + description: string; + effects: string[]; + turnEffect?: TurnEffect; +} + +/** + * Turn effect (for multi-turn tracking) + */ +export interface TurnEffect { + type: 'LOSE_TURN' | 'EXTRA_TURN'; + playerId: string; + value: number; // Number of turns +} + +/** + * Guess result details + */ +export interface GuessResult { + guessedPosition: number; + actualPosition: number; + isCorrect: boolean; + penaltyApplied: boolean; + description: string; +} +``` + +--- + +### Pending State Interfaces + +Located in: `src/Application/Services/GameWebSocketService.ts` + +```typescript +/** + * Pending card state (stored in Redis) + * Used for question cards requiring answers + */ +interface PendingCardState { + playerId: string; + playerName: string; + card: GameCard; + field: GameField; // Field where card was drawn + dice: number; // Original dice roll + currentPosition: number; // Position before card effect + drawnAt: number; // Timestamp + answerGiven?: boolean; // Has player answered? + answerCorrect?: boolean; // Was answer correct? + requiresGuess?: boolean; // Does this require position guess? + guessedPosition?: number; // Player's guessed position +} + +/** + * Pending gamemaster decision state (stored in Redis) + * Used for joker cards + */ +interface PendingDecisionState { + playerId: string; + playerName: string; + card: GameCard; + field: GameField; // Field where joker was drawn + dice: number; // Always 6 for jokers + currentPosition: number; // Position before joker effect + drawnAt: number; // Timestamp + recursionDepth: number; // Prevent infinite secondary landings + gamemasterDecided?: boolean; // Has gamemaster decided? + gamemasterApproved?: boolean; // Was it approved? + guessedPosition?: number; // Player's guessed position (if required) +} +``` + +**Redis Keys**: +``` +pending_card:{gameCode}:{playerId} → PendingCardState (TTL: 60s) +pending_decision:{gameCode}:{requestId} → PendingDecisionState (TTL: 120s) +player_extra_turns:{gameCode}:{playerId} → number (extra turns remaining) +player_turns_to_lose:{gameCode}:{playerId} → number (turns to skip) +``` + +--- + +## Domain Aggregates + +### GameAggregate + +Located in: `src/Domain/Game/GameAggregate.ts` + +```typescript +/** + * Main game entity + */ +@Entity('Games') +export class GameAggregate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 10, unique: true }) + gamecode: string; // 6-character unique code + + @Column({ type: 'int' }) + maxplayers: number; + + @Column({ type: 'int', default: LoginType.PUBLIC }) + logintype: LoginType; // 0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION + + @Column({ type: 'int', default: 50 }) + boardsize: number; + + @Column({ type: 'uuid', nullable: false, name: 'createdBy' }) + createdby: string; // Gamemaster user ID + + @Column({ type: 'uuid', nullable: true, name: 'organizationid' }) + orgid: string | null; + + @Column({ type: 'jsonb', default: () => "'[]'", name: 'decks' }) + gamedecks: GameDeck[]; // Decks with cards + + @Column({ type: 'simple-array', default: '' }) + players: string[]; // Array of player IDs/names + + @Column({ type: 'timestamp', nullable: true }) + startdate: Date | null; + + @Column({ type: 'timestamp', nullable: true }) + enddate: Date | null; + + @Column({ type: 'boolean', default: false }) + finished: boolean; + + @Column({ type: 'varchar', nullable: true }) + winner: string | null; + + @Column({ type: 'int', default: 100 }) + totaltiles: number; +} + +/** + * Login type enum + */ +export enum LoginType { + PUBLIC = 0, // Anyone can join with playerName + PRIVATE = 1, // Requires authentication + ORGANIZATION = 2 // Requires auth + org membership +} +``` + +--- + +### GameField + +```typescript +/** + * Individual board field/tile + */ +export interface GameField { + position: number; // 0-100 + type: 'regular' | 'positive' | 'negative' | 'luck'; + stepValue?: number; // 0-3, used in movement calculation +} + +/** + * Complete board data + */ +export interface BoardData { + gameId?: string; + fields: GameField[]; // 100 fields + generationComplete?: boolean; + generatedAt?: Date; + error?: string; +} +``` + +**Field Type Distribution** (generated by `BoardGenerationService`): +- **Regular**: ~40% (no special card) +- **Positive**: ~25% (reward card) +- **Negative**: ~25% (penalty card) +- **Luck**: ~10% (instant consequence) + +**Important**: Fields have NO consequences. Consequences come only from **cards**. + +--- + +### GameCard + +```typescript +/** + * Card in a deck + */ +export interface GameCard { + cardid: string; + question?: string; + answer?: any; // Can be string, number, object, array + type?: CardType; + consequence?: Consequence | null; // Only for LUCK and JOKER cards + played?: boolean; + playerid?: string; +} + +/** + * Card types + */ +export enum CardType { + QUIZ = 0, // Multiple choice (A/B/C/D) + SENTENCE_PAIRING = 1, // Match left parts to right parts + OWN_ANSWER = 2, // Free text answer + TRUE_FALSE = 3, // Boolean answer + CLOSER = 4, // Closest number answer + JOKER = 5, // Gamemaster decision (secondary landing only) + LUCK = 6 // Instant consequence +} +``` + +**Card Answer Formats by Type:** + +| Card Type | Answer Format in Database | Player Answer Format | Description | +|-----------|---------------------------|----------------------|-------------| +| **QUIZ** | `[{answer:"A", text:"...", correct:true}, ...]` | `"A"` (string) | Multiple choice with 4 options | +| **SENTENCE_PAIRING** | `[{left:"Apple", right:"Red"}, ...]` | `[{pairId, leftText, rightText}, ...]` | Match left to right parts | +| **SENTENCE_PAIRING (legacy)** | `"word1 word2 word3"` (string) | `["word1", "word2", "word3"]` or `"word1 word2 word3"` | Reconstruct scrambled sentence | +| **OWN_ANSWER** | `["answer1", "answer2", ...]` (string[]) | `"player answer"` (string) | Free text with acceptable answers | +| **TRUE_FALSE** | `true` or `false` (boolean) | `true/false` or `"true"/"false"` | True/False question | +| **CLOSER** | `{correct: 42, percent: 10}` (object) | `40` (number) | Closest number within percentage | +| **JOKER** | Not applicable | Not applicable | Gamemaster decides | +| **LUCK** | Not applicable | Not applicable | Instant consequence | + +--- + +### Consequence + +Located in: `src/Domain/Deck/DeckAggregate.ts` + +```typescript +/** + * Card consequence structure + */ +export interface Consequence { + type: ConsequenceType; + value?: number; // Magnitude of effect (default: 1) +} + +/** + * Consequence types + */ +export enum ConsequenceType { + MOVE_FORWARD = 0, // Move forward X steps + MOVE_BACKWARD = 1, // Move backward X steps + LOSE_TURN = 2, // Skip X turns + EXTRA_TURN = 3, // Get X extra turns + GO_TO_START = 5 // Return to position 1 +} +``` + +**Multi-Turn Support**: +- `LOSE_TURN` with `value=3`: Player skips next 3 turns +- `EXTRA_TURN` with `value=2`: Player gets 2 additional turns after current + +--- + +### GameDeck + +```typescript +/** + * Deck of cards + */ +export interface GameDeck { + deckid: string; + decktype: DeckType; + cards: GameCard[]; +} + +/** + * Deck types + */ +export enum DeckType { + POSITIVE = 0, // For positive fields + NEGATIVE = 1, // For negative fields + LUCK = 2, // For luck fields + JOKER = 3 // For secondary landings +} +``` + +--- + +## Complete Game Scenarios + +### Scenario 1: Question Card on Positive Field (Correct Answer) + +``` +1. Player at position 20, rolls dice = 4 +2. Field at position 24 has stepValue = 2, type = "positive" +3. Player lands on position 24 +4. Server waits 2 seconds (frontend animation) +5. Server draws POSITIVE deck card (type = QUIZ) +6. Server broadcasts: game:card-drawn (everyone sees question) +7. Server emits: game:card-drawn-self to player (60s timer) +8. Player answers correctly +9. Server broadcasts: game:answer-submitted +10. Server broadcasts: game:answer-validated { isCorrect: true } +11. Server determines: positive field + correct = GUESS REQUIRED +12. Server emits: game:position-guess-request to player (30s timer) + - Shows: currentPosition=20, dice=4, stepValue=2, patternModifier=+2 +13. Player calculates: 20 + 4 + 2 + 2 = 28, guesses 28 +14. Server broadcasts: game:position-guess-broadcast { guessedPosition: 28 } +15. Server calculates: 20 + 4 + 2 + 2 = 28 (correct!) +16. Server broadcasts: game:guess-result { + guessCorrect: true, + finalPosition: 28, + penaltyApplied: false +} +17. Player position updated to 28 +18. Server checks: position 28 is not special field +19. Server calls: advanceTurn() → next player's turn +``` + +--- + +### Scenario 2: Question Card on Negative Field (Wrong Answer) + +``` +1. Player at position 50, rolls dice = 5 +2. Field at position 55 has stepValue = 1, type = "negative" +3. Player lands on position 55 +4. Server waits 2 seconds +5. Server draws NEGATIVE deck card (type = TRUE_FALSE) +6. Server broadcasts: game:card-drawn +7. Server emits: game:card-drawn-self to player +8. Player answers incorrectly +9. Server broadcasts: game:answer-validated { isCorrect: false } +10. Server determines: negative field + wrong = GUESS REQUIRED (penalty test) +11. Server emits: game:position-guess-request + - Shows: currentPosition=50, dice=5, stepValue=1, patternModifier=+1 +12. Player guesses: 57, but actual is: 50 + 5 + 1 + 1 = 57 +13. Server calculates actual: 57, player guessed: 57 (CORRECT!) +14. Server broadcasts: game:guess-result { + guessCorrect: true, + finalPosition: 57, + penaltyApplied: false +} +15. Player moves to 57 (avoided penalty despite wrong answer) +16. Server advances turn +``` + +--- + +### Scenario 3: Luck Card with Multi-Turn EXTRA_TURN + +``` +1. Player at position 30, rolls dice = 3 +2. Field at position 33 has type = "luck" +3. Player lands on position 33 +4. Server draws LUCK deck card +5. Card has: { type: EXTRA_TURN (3), value: 3 } +6. Server broadcasts: game:card-result +7. Server broadcasts: game:luck-consequence { + consequenceType: "EXTRA_TURN", + value: 3, + extraTurns: 3 +} +8. Server calls: setPlayerExtraTurns(gameCode, playerId, 3) + - Redis: player_extra_turns:{gameCode}:{playerId} = 3 +9. Server calls: advanceTurn() +10. advanceTurn() checks: getPlayerExtraTurns() = 3 > 0 +11. Server emits: game:extra-turn-remaining { remainingExtraTurns: 2 } +12. Same player continues (no turn advance) +13. Player rolls again... +14. After turn ends, advanceTurn() checks again: extraTurns = 2 > 0 +15. Process repeats until extraTurns = 0 +16. Player gets 4 total turns (1 original + 3 extra) +``` + +--- + +### Scenario 4: Luck Card with Multi-Turn LOSE_TURN + +``` +1. Player A draws luck card: { type: LOSE_TURN (2), value: 2 } +2. Server calls: setPlayerTurnsToLose(gameCode, playerA, 2) + - Redis: player_turns_to_lose:{gameCode}:{playerA} = 2 +3. Server broadcasts: game:luck-consequence { + consequenceType: "LOSE_TURN", + turnsToLose: 2 +} +4. Server calls: advanceTurn() → skip to Player B + +--- Later, when it's Player A's turn in sequence --- + +5. advanceTurn() reaches Player A in turn sequence +6. Checks: getPlayerTurnsToLose(playerA) = 2 > 0 +7. Decrements: 2 → 1 +8. Adds to skippedPlayers array +9. Continues to next player in sequence (Player B) +10. Server broadcasts: game:players-skipped { + skippedPlayers: [{ + playerName: "Player A", + remainingTurnsToLose: 1 + }] +} +11. Server broadcasts: game:turn-changed { currentPlayer: "Player B" } + +--- Next round --- + +12. advanceTurn() reaches Player A again +13. Checks: getPlayerTurnsToLose(playerA) = 1 > 0 +14. Decrements: 1 → 0, deletes Redis key +15. Skips Player A again +16. Player A skipped 2 times total +``` + +--- + +### Scenario 5: Joker Card on Positive Field (Approved by Gamemaster) + +``` +1. Player completes question card, lands on position 70 (positive field) +2. Server checks secondary landing +3. Server draws JOKER deck card +4. Server broadcasts: game:joker-drawn { waitingForGamemaster: true } +5. Server emits to gamemaster: game:gamemaster-decision-request (120s timer) +6. Gamemaster approves +7. Server broadcasts: game:gamemaster-decision-result { + decision: "approve", + approved: true +} +8. Server determines: positive field + approved = GUESS REQUIRED +9. Server emits: game:joker-position-guess-request + - Shows: currentPosition=70, dice=6, stepValue=1, patternModifier=-2 +10. Player guesses: 75, actual: 70 + 6 + 1 - 2 = 75 (CORRECT!) +11. Server broadcasts: game:joker-complete { + guessCorrect: true, + moved: true, + finalPosition: 75 +} +12. Player moves to 75 +13. Server checks for tertiary landing (not allowed - recursion depth) +14. Server advances turn +``` + +--- + +### Scenario 6: Multiple Players with Turn Tracking + +``` +Turn Sequence: [PlayerA, PlayerB, PlayerC] + +--- Turn 1 --- +PlayerA: Gets EXTRA_TURN (value=2) +- Redis: player_extra_turns:game:PlayerA = 2 +- PlayerA plays again + +--- Turn 2 (PlayerA extra turn 1) --- +PlayerA: Gets LOSE_TURN (value=1) +- Redis: player_turns_to_lose:game:PlayerA = 1 +- Redis: player_extra_turns:game:PlayerA = 1 (still has 1 left) +- advanceTurn() checks: extraTurns = 1 > 0 +- PlayerA plays AGAIN (extra turn takes priority) + +--- Turn 3 (PlayerA extra turn 2) --- +PlayerA: Normal turn +- Redis: player_extra_turns:game:PlayerA = 0 (deleted) +- advanceTurn() → next player + +--- Turn 4 --- +Sequence reaches PlayerA +- Checks: turnsToLose = 1 > 0 +- Skip PlayerA, decrement to 0 +- Server broadcasts: game:players-skipped +- PlayerB plays + +--- Turn 5 --- +PlayerB: Normal turn + +--- Turn 6 --- +PlayerC: Normal turn + +--- Turn 7 --- +Back to PlayerA (turnsToLose = 0) +- PlayerA plays normally +``` + +--- + +## Turn Tracking System + +### Redis Storage + +```typescript +// Extra turns storage +player_extra_turns:{gameCode}:{playerId} → "3" // Number as string +TTL: None (cleared on cleanup or use) + +// Turns to lose storage +player_turns_to_lose:{gameCode}:{playerId} → "2" // Number as string +TTL: None (cleared on cleanup or use) +``` + +### Turn Tracking Methods + +```typescript +class GameWebSocketService { + // Extra turns + private async setPlayerExtraTurns( + gameCode: string, + playerId: string, + count: number + ): Promise; + + private async getPlayerExtraTurns( + gameCode: string, + playerId: string + ): Promise; // Returns 0 if not found + + private async decrementPlayerExtraTurns( + gameCode: string, + playerId: string + ): Promise; // Decrements or deletes if reaches 0 + + // Turns to lose + private async setPlayerTurnsToLose( + gameCode: string, + playerId: string, + count: number + ): Promise; + + private async getPlayerTurnsToLose( + gameCode: string, + playerId: string + ): Promise; // Returns 0 if not found + + private async decrementPlayerTurnsToLose( + gameCode: string, + playerId: string + ): Promise; // Decrements or deletes if reaches 0 + + // Cleanup + private async clearPlayerTurnData( + gameCode: string, + playerId: string + ): Promise; // Deletes both keys +} +``` + +### Enhanced advanceTurn() Logic + +```typescript +private async advanceTurn(gameCode: string): Promise { + // PHASE 1: Check if current player has extra turns + const extraTurns = await this.getPlayerExtraTurns(gameCode, currentPlayerId); + if (extraTurns > 0) { + await this.decrementPlayerExtraTurns(gameCode, currentPlayerId); + emit('game:extra-turn-remaining', { remainingExtraTurns: extraTurns - 1 }); + emit('game:your-turn', { isExtraTurn: true }); + return; // Same player continues + } + + // PHASE 2: Find next player, skipping those with lost turns + let nextTurnIndex = (currentTurnIndex + 1) % turnSequence.length; + const skippedPlayers = []; + let loopGuard = 0; + + while (loopGuard < turnSequence.length) { + const candidatePlayerId = turnSequence[nextTurnIndex]; + const turnsToLose = await this.getPlayerTurnsToLose(gameCode, candidatePlayerId); + + if (turnsToLose > 0) { + await this.decrementPlayerTurnsToLose(gameCode, candidatePlayerId); + skippedPlayers.push({ + playerId: candidatePlayerId, + remainingTurnsToLose: turnsToLose - 1 + }); + nextTurnIndex = (nextTurnIndex + 1) % turnSequence.length; + loopGuard++; + } else { + break; // Found valid player + } + } + + // PHASE 3: Update game state + gameState.currentTurn = nextTurnIndex; + gameState.currentPlayer = turnSequence[nextTurnIndex]; + + // PHASE 4: Notify about skipped players + if (skippedPlayers.length > 0) { + emit('game:players-skipped', { skippedPlayers }); + } + + // PHASE 5: Notify about turn change + emit('game:turn-changed', { currentPlayer, currentPlayerName }); +} +``` + +--- + +## Position Guessing Mechanic + +### Pattern-Based Movement Calculation + +**Formula**: +``` +finalPosition = currentPosition + dice + stepValue + patternModifier +``` + +**Pattern Modifiers by Zone**: +```typescript +private getPatternModifier(position: number): number { + if (position <= 20) return 2; // Positions 1-20 + if (position <= 40) return -1; // Positions 21-40 + if (position <= 60) return 1; // Positions 41-60 + if (position <= 80) return -2; // Positions 61-80 + return 3; // Positions 81-100 +} +``` + +### Guess Requirement Logic + +```typescript +private determineGuessRequirement( + fieldType: 'regular' | 'positive' | 'negative' | 'luck', + answerCorrect: boolean +): boolean { + if (fieldType === 'positive') { + return answerCorrect; // Correct = guess for REWARD + } else if (fieldType === 'negative') { + return !answerCorrect; // Wrong = guess for PENALTY + } + return false; // Regular and luck fields never require guess +} +``` + +### Question Card Guess Flow + +**Matrix**: +| Field Type | Answer | Guess Required? | Reason | +|------------|---------|-----------------|---------------------------| +| Positive | Correct | **YES** | Reward scenario | +| Positive | Wrong | NO | No movement | +| Negative | Correct | NO | Avoided penalty | +| Negative | Wrong | **YES** | Penalty test | +| Regular | Any | NO | No special fields | +| Luck | N/A | NO | Instant consequence | + +### Joker Card Guess Flow + +**Matrix** (dice always = 6): +| Field Type | Gamemaster | Guess Required? | Movement | +|------------|------------|-----------------|---------------------| +| Positive | Approved | **YES** | Move if guess | +| Positive | Rejected | NO | No movement | +| Negative | Approved | NO | No movement (avoid) | +| Negative | Rejected | **YES** | Move if guess | + +### Penalty System + +- **Wrong guess**: -2 steps from calculated position +- **Minimum position**: 1 (can't go below start) +- **Applied after calculation**: `finalPosition = max(1, calculatedPosition - 2)` + +### Calculation Examples + +**Example 1**: Position 15, dice 4, stepValue 2 +``` +patternModifier = 2 (position 15 is in zone 1-20) +calculation = 15 + 4 + 2 + 2 = 23 +``` + +**Example 2**: Position 35, dice 6, stepValue 1 +``` +patternModifier = -1 (position 35 is in zone 21-40) +calculation = 35 + 6 + 1 - 1 = 41 +``` + +**Example 3**: Position 75 (joker), stepValue 3 +``` +dice = 6 (always for jokers) +patternModifier = -2 (position 75 is in zone 61-80) +calculation = 75 + 6 + 3 - 2 = 82 +if wrong guess: 82 - 2 = 80 +``` + +--- + +## Error Handling + +### REST API Error Responses + +**Format**: +```typescript +{ + error: string; // Human-readable error message + code?: string; // Optional error code +} +``` + +**Status Codes**: +- `400`: Bad Request (validation errors, missing fields) +- `401`: Unauthorized (authentication required) +- `403`: Forbidden (insufficient permissions, org mismatch) +- `404`: Not Found (game, deck, user not found) +- `409`: Conflict (game full, already started, player already in game) +- `500`: Internal Server Error + +### WebSocket Error Events + +```typescript +socket.on('game:error', { + message: string; + code?: string; + timestamp: string; +}); +``` + +**Common Errors**: +- Authentication failed +- Invalid game code +- Game already started +- Not your turn +- Invalid answer format +- Timeout expired +- No cards available +- Internal server error + +### Timeout Handling + +**Card Answer Timeout** (60 seconds): +```typescript +// Server auto-processes as wrong answer +socket.on('game:card-timeout', { + playerName: string; + message: string; + timestamp: string; +}); +``` + +**Gamemaster Decision Timeout** (120 seconds): +```typescript +// Server auto-rejects joker card +socket.on('game:gamemaster-timeout', { + requestId: string; + playerName: string; + message: string; + timestamp: string; +}); +``` + +**Position Guess Timeout** (30 seconds): +```typescript +// Server treats as no movement +socket.on('game:guess-timeout', { + playerName: string; + message: string; + timestamp: string; +}); +``` + +### Redis Cleanup + +**Automatic Cleanup**: +- Game end: All keys deleted +- Player disconnect: Pending states cleared +- Timeout: Keys expire naturally (TTL) + +**Manual Cleanup**: +```typescript +// Called on game:ended or disconnect +await cleanupGameData(gameCode, gameId); +``` + +**Keys Cleaned**: +``` +gameplay:{gameCode} +game_state:{gameCode} +game_board_{gameCode} +game_connections:{gameCode} +game_ready:{gameCode} +game_pending:{gameCode} +game_positions:{gameCode} +pending_card:{gameCode}:{playerId} +pending_decision:{gameCode}:{requestId} +player_extra_turns:{gameCode}:{playerId} +player_turns_to_lose:{gameCode}:{playerId} +``` + +--- + +## Card Type Implementation Details + +### SENTENCE_PAIRING Card Type + +**Purpose**: Test player's ability to match related items (left parts to right parts) + +**Use Cases**: +- Match words to definitions +- Match questions to answers +- Match countries to capitals +- Match names to descriptions +- Match terms to translations + +#### Database Format (NEW) + +```typescript +// In DeckAggregate Card.answer field +{ + text: "Match each fruit to its color", + type: CardType.SENTENCE_PAIRING, // 1 + answer: [ + { left: "Apple", right: "Red" }, + { left: "Banana", right: "Yellow" }, + { left: "Orange", right: "Orange" }, + { left: "Grape", right: "Purple" } + ] +} +``` + +#### Client Preparation + +Server scrambles the **right parts** while keeping left parts in order: + +```typescript +// Sent to client in game:card-drawn-self +{ + cardData: { + question: "Match each fruit to its color", + type: 1, // SENTENCE_PAIRING + sentencePairs: [ + { id: "pair_0", left: "Apple", right: "Purple" }, // Scrambled! + { id: "pair_1", left: "Banana", right: "Orange" }, + { id: "pair_2", left: "Orange", right: "Red" }, + { id: "pair_3", left: "Grape", right: "Yellow" } + ], + timeLimit: 60 + } +} +``` + +#### Player Answer Format + +```typescript +// Player submits array of matches +{ + answer: [ + { pairId: "pair_0", leftText: "Apple", rightText: "Red" }, + { pairId: "pair_1", leftText: "Banana", rightText: "Yellow" }, + { pairId: "pair_2", leftText: "Orange", rightText: "Orange" }, + { pairId: "pair_3", leftText: "Grape", rightText: "Purple" } + ] +} +``` + +#### Validation Logic + +```typescript +// For each correct pair in database: +1. Find player's match for the left part +2. Compare player's chosen right part to correct right part +3. Case-insensitive comparison with trimmed whitespace +4. ALL pairs must match correctly for answer to be correct + +// Example validation result: +{ + isCorrect: true, // Only if ALL pairs match + submittedAnswer: [...], + correctAnswer: [...], + explanation: "✅ Perfect! All 4 pairs matched correctly! + ✓ \"Apple\" → \"Red\" + ✓ \"Banana\" → \"Yellow\" + ✓ \"Orange\" → \"Orange\" + ✓ \"Grape\" → \"Purple\"" +} +``` + +#### Partial Match Example + +```typescript +// Player gets 2 out of 4 correct: +{ + isCorrect: false, + explanation: "❌ Only 2/4 pairs correct: + ✓ \"Apple\" → \"Red\" + ✗ \"Banana\" → \"Purple\" (should be \"Yellow\") + ✗ \"Orange\" → \"Yellow\" (should be \"Orange\") + ✓ \"Grape\" → \"Purple\"" +} +``` + +#### Legacy Format (Backward Compatibility) + +**Old Database Format**: +```typescript +{ + text: "The quick brown fox jumps over the lazy dog", + type: CardType.SENTENCE_PAIRING, + answer: "The quick brown fox jumps over the lazy dog" // String +} +``` + +**Client Preparation (Legacy)**: +```typescript +{ + cardData: { + question: "Arrange the words to form a sentence:", + type: 1, + words: ["lazy", "The", "dog", "fox", "over", "quick", "brown", "jumps", "the"], + timeLimit: 60 + } +} +``` + +**Player Answer (Legacy)**: +```typescript +{ + answer: ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"] + // OR + answer: "The quick brown fox jumps over the lazy dog" +} +``` + +#### Frontend Implementation Example + +```typescript +// React/Vue component for SENTENCE_PAIRING +interface Props { + sentencePairs: SentencePair[]; + onSubmit: (answer: SentencePairingAnswer[]) => void; +} + +function SentencePairingCard({ sentencePairs, onSubmit }: Props) { + const [matches, setMatches] = useState>(new Map()); + + const handleMatch = (pairId: string, leftText: string, rightText: string) => { + setMatches(prev => new Map(prev).set(pairId, rightText)); + }; + + const handleSubmit = () => { + const answer: SentencePairingAnswer[] = sentencePairs.map(pair => ({ + pairId: pair.id, + leftText: pair.left, + rightText: matches.get(pair.id) || '' + })); + onSubmit(answer); + }; + + return ( +
+ {sentencePairs.map(pair => ( +
+ {pair.left} + +
+ ))} + +
+ ); +} +``` + +#### Database Migration + +**No migration needed!** The implementation supports both formats: + +1. **New cards**: Use array of `{left, right}` objects +2. **Existing cards**: Continue working with string format +3. Detection is automatic based on `answer` field type + +**Creating New Sentence Pairing Cards**: +```sql +-- Example INSERT for new format +INSERT INTO "Cards" (deck_id, text, type, answer) +VALUES ( + 'deck-uuid', + 'Match programming languages to their creators', + 1, -- SENTENCE_PAIRING + '[ + {"left": "Python", "right": "Guido van Rossum"}, + {"left": "JavaScript", "right": "Brendan Eich"}, + {"left": "C++", "right": "Bjarne Stroustrup"}, + {"left": "Ruby", "right": "Yukihiro Matsumoto"} + ]'::jsonb +); +``` + +--- + +## Appendix: Complete Event Summary + +### Client → Server Events + +| Event | Data | Description | +|-------|------|-------------| +| `game:join` | `{ gameToken: string }` | Join game room with auth token | +| `game:ready` | `{ gameCode: string, ready: boolean }` | Mark player as ready/not ready | +| `game:dice-roll` | `{ gameCode: string, diceValue: number }` | Roll dice (1-6) | +| `game:card-answer` | `{ gameCode: string, answer: any }` | Submit card answer | +| `game:gamemaster-decision` | `{ gameCode: string, requestId: string, decision: string }` | Gamemaster decision on joker | +| `game:position-guess` | `{ gameCode: string, guessedPosition: number }` | Submit position guess (question) | +| `game:joker-position-guess` | `{ gameCode: string, guessedPosition: number }` | Submit position guess (joker) | +| `game:leave` | `{ gameCode: string }` | Leave game | + +### Server → Client Events + +| Event | Audience | Description | +|-------|----------|-------------| +| `authenticated` | Individual | Auth success, joined rooms | +| `game:player-joined` | All | Player joined game | +| `game:player-ready` | All | Player ready status changed | +| `game:started` | All | Game started, board generated | +| `game:turn-changed` | All | Turn advanced to next player | +| `game:your-turn` | Individual | Your turn notification | +| `game:dice-rolled` | All | Dice rolled by player | +| `game:player-moved` | All | Player moved to new position | +| `game:card-drawn` | All | Card drawn (question shown) | +| `game:card-drawn-self` | Individual | Interactive card data | +| `game:answer-submitted` | All | Answer submitted (pre-validation) | +| `game:answer-validated` | All | Answer validation result | +| `game:position-guess-request` | Individual | Request position guess | +| `game:position-guess-broadcast` | All | Player's guess shown | +| `game:guess-result` | All | Guess result with calculation | +| `game:no-movement` | All | Player didn't move | +| `game:penalty-avoided` | All | Player avoided penalty | +| `game:luck-consequence` | All | Luck card consequence applied | +| `game:joker-drawn` | All | Joker card drawn | +| `game:gamemaster-decision-request` | Gamemaster | Decision request | +| `game:gamemaster-decision-result` | All | Decision result | +| `game:joker-position-guess-request` | Individual | Joker position guess request | +| `game:joker-complete` | All | Joker card processing complete | +| `game:extra-turn-remaining` | All | Player using extra turn | +| `game:players-skipped` | All | Players skipped (lost turns) | +| `game:ended` | All | Game ended, winner declared | +| `game:cleanup-complete` | All | Cleanup finished | +| `game:error` | Individual | Error occurred | +| `game:card-error` | All | Card drawing error | + +--- + +**End of Documentation** + +For additional details, see: +- `FRONTEND_WEBSOCKET_EVENTS_REFERENCE.md` - Frontend event handling guide +- `DATABASE_MANAGEMENT_GUIDE.md` - Database schema and queries +- `BUILD.md` - Build and deployment instructions diff --git a/SerpentRace_Backend/src/Application/Services/CardProcessingService.ts b/SerpentRace_Backend/src/Application/Services/CardProcessingService.ts index ace3605a..c3ff639a 100644 --- a/SerpentRace_Backend/src/Application/Services/CardProcessingService.ts +++ b/SerpentRace_Backend/src/Application/Services/CardProcessingService.ts @@ -13,13 +13,33 @@ export interface CloserAnswer { percent: number; } +/** + * Sentence pair for matching left to right + */ +export interface SentencePair { + id: string; // Unique identifier for this pair + left: string; // Left part to match + right: string; // Right part (scrambled position) +} + +/** + * Player's answer for sentence pairing (array of matches) + */ +export interface SentencePairingAnswer { + pairId: string; // ID of the pair + leftText: string; // Left part + rightText: string; // Player's chosen right part +} + export interface CardClientData { cardid: string; question: string; type: CardType; + timeLimit: number; // Type-specific client data - options?: QuizOption[]; // For QUIZ - words?: string[]; // For SENTENCE_PAIRING (scrambled) + answerOptions?: QuizOption[]; // For QUIZ + words?: string[]; // For SENTENCE_PAIRING (legacy scrambled words) + sentencePairs?: SentencePair[]; // For SENTENCE_PAIRING (left-right matching) acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client) // CLOSER and TRUE_FALSE send only question } @@ -50,7 +70,8 @@ export class CardProcessingService { const baseData: CardClientData = { cardid: card.cardid, question: card.question, - type: card.type + type: card.type, + timeLimit: 60 // Default 60 seconds for question cards }; switch (card.type) { @@ -116,25 +137,60 @@ export class CardProcessingService { return { ...baseData, - options: card.answer as QuizOption[] + answerOptions: card.answer as QuizOption[] }; } /** - * Prepare SENTENCE_PAIRING card with scrambled words + * Prepare SENTENCE_PAIRING card with scrambled left/right pairs + * + * Expected card.answer format: + * [ + * { left: "Apple", right: "Red" }, + * { left: "Banana", right: "Yellow" }, + * { left: "Orange", right: "Orange color" } + * ] + * + * OR legacy string format: "word1 word2 word3" (will be split and scrambled) */ private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData { - if (typeof card.answer !== 'string') { - throw new Error('Sentence pairing card answer must be a string'); + // NEW FORMAT: Array of pairs (left-right matching) + if (Array.isArray(card.answer)) { + // Validate structure + const pairs = card.answer as Array<{ left: string; right: string }>; + if (!pairs.every(p => p.left && p.right)) { + throw new Error('Sentence pairing card answer must be array of {left, right} objects'); + } + + // Create pairs with IDs and scramble the right parts + const leftParts = pairs.map((p, idx) => ({ id: `pair_${idx}`, left: p.left, right: p.right })); + const rightParts = this.scrambleArray([...pairs.map(p => p.right)]); + + // Send left parts in order, right parts scrambled + const sentencePairs: SentencePair[] = leftParts.map((lp, idx) => ({ + id: lp.id, + left: lp.left, + right: rightParts[idx] // Scrambled position + })); + + return { + ...baseData, + sentencePairs + }; } - const words = card.answer.split(' ').filter(word => word.trim() !== ''); - const scrambledWords = this.scrambleArray([...words]); + // LEGACY FORMAT: Single sentence to reconstruct (backward compatibility) + if (typeof card.answer === 'string') { + const words = card.answer.split(' ').filter(word => word.trim() !== ''); + const scrambledWords = this.scrambleArray([...words]); - return { - ...baseData, - words: scrambledWords - }; + return { + ...baseData, + words: scrambledWords + }; + } + + throw new Error('Sentence pairing card answer must be array of pairs or string'); } /** @@ -187,29 +243,80 @@ export class CardProcessingService { } /** - * Validate SENTENCE_PAIRING answer (reconstructed sentence) + * Validate SENTENCE_PAIRING answer + * + * Supports two formats: + * 1. NEW: Array of { pairId, leftText, rightText } matches + * 2. LEGACY: Reconstructed sentence string or array of words */ - private validateSentencePairingAnswer(card: GameCard, playerAnswer: string[] | string): CardValidationResult { - if (typeof card.answer !== 'string') { - throw new Error('Sentence pairing card answer must be a string'); + private validateSentencePairingAnswer(card: GameCard, playerAnswer: any): CardValidationResult { + // NEW FORMAT: Array of pairs (left-right matching) + if (Array.isArray(card.answer) && card.answer.every((p: any) => p.left && p.right)) { + const correctPairs = card.answer as Array<{ left: string; right: string }>; + + // Player answer should be array of SentencePairingAnswer objects + if (!Array.isArray(playerAnswer)) { + throw new Error('Player answer must be array of pair matches'); + } + + const playerMatches = playerAnswer as SentencePairingAnswer[]; + + // Check if all pairs match correctly + let correctCount = 0; + const results: string[] = []; + + for (const correctPair of correctPairs) { + const playerMatch = playerMatches.find(pm => + pm.leftText.toLowerCase().trim() === correctPair.left.toLowerCase().trim() + ); + + if (playerMatch) { + const isMatch = playerMatch.rightText.toLowerCase().trim() === + correctPair.right.toLowerCase().trim(); + if (isMatch) { + correctCount++; + results.push(`✓ "${correctPair.left}" → "${correctPair.right}"`); + } else { + results.push(`✗ "${correctPair.left}" → "${playerMatch.rightText}" (should be "${correctPair.right}")`); + } + } else { + results.push(`✗ "${correctPair.left}" → (not matched)`); + } + } + + const isCorrect = correctCount === correctPairs.length; + + return { + isCorrect, + submittedAnswer: playerMatches, + correctAnswer: correctPairs, + explanation: isCorrect + ? `✅ Perfect! All ${correctCount} pairs matched correctly!\n${results.join('\n')}` + : `❌ Only ${correctCount}/${correctPairs.length} pairs correct:\n${results.join('\n')}` + }; } - // Handle both array of words and joined string - const reconstructed = Array.isArray(playerAnswer) - ? playerAnswer.join(' ').toLowerCase().trim() - : playerAnswer.toLowerCase().trim(); + // LEGACY FORMAT: Single sentence to reconstruct (backward compatibility) + if (typeof card.answer === 'string') { + // Handle both array of words and joined string + const reconstructed = Array.isArray(playerAnswer) + ? playerAnswer.join(' ').toLowerCase().trim() + : (typeof playerAnswer === 'string' ? playerAnswer.toLowerCase().trim() : ''); - const correctSentence = card.answer.toLowerCase().trim(); - const isCorrect = reconstructed === correctSentence; + const correctSentence = card.answer.toLowerCase().trim(); + const isCorrect = reconstructed === correctSentence; - return { - isCorrect, - submittedAnswer: reconstructed, - correctAnswer: card.answer, - explanation: isCorrect - ? '✅ Perfect! You arranged the sentence correctly!' - : `❌ Wrong order! Correct sentence: "${card.answer}"` - }; + return { + isCorrect, + submittedAnswer: reconstructed, + correctAnswer: card.answer, + explanation: isCorrect + ? '✅ Perfect! You arranged the sentence correctly!' + : `❌ Wrong order! Correct sentence: "${card.answer}"` + }; + } + + throw new Error('Sentence pairing card answer must be array of pairs or string'); } /** diff --git a/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts b/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts index 850bfed5..710e25c8 100644 --- a/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts +++ b/SerpentRace_Backend/src/Application/Services/GameWebSocketService.ts @@ -8,7 +8,7 @@ import { RedisService } from './RedisService'; import { FieldEffectService, CardProcessingResult } from './FieldEffectService'; import { CardDrawingService } from './CardDrawingService'; import { BoardGenerationService } from '../Game/BoardGenerationService'; -import { GamemasterService } from './GamemasterService'; +import { GamemasterService, GamemasterDecision } from './GamemasterService'; import { GameActionData, PlayerPosition, @@ -36,6 +36,45 @@ interface GameChatData { message: string; } +interface CardAnswerData { + gameCode: string; + answer: any; +} + +interface GamemasterDecisionData { + gameCode: string; + requestId: string; + decision: 'approve' | 'reject'; +} + +interface PendingCardState { + playerId: string; + playerName: string; + card: any; + field: GameField; // Field info + dice: number; // Dice roll + currentPosition: number; // Position before card + drawnAt: number; + answerGiven?: boolean; // Track if answer submitted + answerCorrect?: boolean; // Track if answer was correct + requiresGuess?: boolean; // Track if guess is required + guessedPosition?: number; // Store player's guess +} + +interface PendingDecisionState { + playerId: string; + playerName: string; + card: any; + field: GameField; // Field info + dice: number; // Dice roll (always 6 for jokers) + currentPosition: number; // Position before card + drawnAt: number; + recursionDepth: number; + gamemasterDecided?: boolean; // Track if gamemaster decided + gamemasterApproved?: boolean; // Track approval result + guessedPosition?: number; // Store player's guess +} + export class GameWebSocketService { private io: SocketIOServer; private gameTokenService: GameTokenService; @@ -130,6 +169,26 @@ export class GameWebSocketService { await this.handleDiceRoll(socket, data); }); + // Card answer from player + socket.on('game:card-answer', async (data: CardAnswerData) => { + await this.handleCardAnswer(socket, data); + }); + + // Gamemaster decision on joker card + socket.on('game:gamemaster-decision', async (data: GamemasterDecisionData) => { + await this.handleGamemasterDecision(socket, data); + }); + + // Position guess (for question cards) + socket.on('game:position-guess', async (data: { gameCode: string; guessedPosition: number }) => { + await this.handlePositionGuess(socket, data); + }); + + // Joker position guess (for joker cards) + socket.on('game:joker-position-guess', async (data: { gameCode: string; guessedPosition: number }) => { + await this.handleJokerPositionGuess(socket, data); + }); + // Disconnect handling socket.on('disconnect', async () => { await this.handleDisconnect(socket); @@ -617,120 +676,58 @@ export class GameWebSocketService { // Calculate new position after dice roll let newPosition = Math.min(currentPlayer.boardPosition + diceValue, 101); // Win at 101 - let cardProcessingResult: CardProcessingResult | null = null; - // Process card effects if player didn't win immediately and lands on special field - if (newPosition < 101 && newPosition > 0) { - // Get board data to check field type - const boardData = await this.getBoardData(gameCode); - if (boardData && boardData.fields) { - const landedField = boardData.fields.find((f: GameField) => f.position === newPosition); - - // Check if field requires card drawing (positive, negative, or luck fields) - if (landedField && this.isSpecialField(landedField)) { - try { - // Get game data for card drawing - const gameData = await this.gameRepository.findByGameCode(gameCode); - - if (gameData) { - // Draw a card based on field type - const cardDrawResult = await this.cardDrawingService.drawCard( - gameData, - landedField.type as 'positive' | 'negative' | 'luck', - socket.userId! - ); - - if (cardDrawResult.success && cardDrawResult.card) { - // Process the card through FieldEffectService - const fieldEffectRequest: FieldEffectRequest = { - gameId: gameCode, - playerId: socket.userId!, - playerName: socket.playerName || 'Player', - currentPosition: currentPlayer.boardPosition, - card: cardDrawResult.card, - field: landedField, - dice: diceValue, - guessedPosition: undefined // Will be set later for question/joker cards - }; - - // For now, process simple cards immediately (luck cards) - // Question and joker cards will need player interaction - if (this.isLuckCard(cardDrawResult.card.type)) { - cardProcessingResult = await this.fieldEffectService.processFieldEffect(fieldEffectRequest); - newPosition = cardProcessingResult.finalPosition; - } - // TODO: Handle question and joker cards with proper UI interaction - } - } - } catch (error) { - logError('Error processing card effect', error as Error); - } - } - } - } - - // Update player position + // Update player position immediately await this.updatePlayerPosition(gameCode, socket.userId!, newPosition); - // Check if player won (reached position 101) - const hasWon = newPosition >= 101; + const gameRoomName = `game_${gameCode}`; - // Prepare move data with card processing information - const moveData = { + // Broadcast move to all players + this.io.of('/game').to(gameRoomName).emit('game:player-moved', { playerId: socket.userId, playerName: socket.playerName, diceValue, oldPosition: currentPlayer.boardPosition, newPosition, - hasWon, - cardEffect: cardProcessingResult ? { - applied: true, - description: cardProcessingResult.description || 'Card effect applied', - positionChange: cardProcessingResult.consequenceModifier, - extraTurn: cardProcessingResult.turnEffect?.type === 'EXTRA_TURN', - turnEffect: cardProcessingResult.turnEffect?.type, - effects: cardProcessingResult.effects - } : null, + hasWon: newPosition >= 101, timestamp: new Date().toISOString() - }; + }); - // Broadcast move to all players - const gameRoomName = `game_${gameCode}`; - this.io.of('/game').to(gameRoomName).emit('game:player-moved', moveData); - - // Send card effect notification if there was one - if (cardProcessingResult) { - this.io.of('/game').to(gameRoomName).emit('game:field-effect', { - playerId: socket.userId, - playerName: socket.playerName, - description: cardProcessingResult.description, - positionChange: cardProcessingResult.consequenceModifier, - extraTurn: cardProcessingResult.turnEffect?.type === 'EXTRA_TURN', - turnEffect: cardProcessingResult.turnEffect, - effects: cardProcessingResult.effects, - timestamp: new Date().toISOString() - }); - } - - if (hasWon) { - // Player won - end game + // Check if player won (reached position 101) + if (newPosition >= 101) { await this.endGame(gameCode, socket.userId!, socket.playerName!); - } else if (cardProcessingResult?.turnEffect?.type === 'EXTRA_TURN') { - // Player gets extra turn - notify them - socket.emit('game:extra-turn', { - message: 'You get an extra turn!', - reason: cardProcessingResult.description - }); - } else { - // Advance to next player's turn - await this.advanceTurn(gameCode); + return; } + // Check if player landed on special field (positive, negative, or luck) + const boardData = await this.getBoardData(gameCode); + if (boardData && boardData.fields) { + const landedField = boardData.fields.find((f: GameField) => f.position === newPosition); + + if (landedField && this.isSpecialField(landedField)) { + // Wait 2 seconds for frontend animation to complete + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Process special field - draw card + await this.handleSpecialFieldLanding( + gameCode, + socket.userId!, + socket.playerName!, + landedField, + newPosition, + diceValue, + currentPlayer.boardPosition + ); + return; // Don't advance turn yet, waiting for card answer + } + } + + // If no special field, advance to next player's turn + await this.advanceTurn(gameCode); + logOther(`Player ${socket.playerName} rolled ${diceValue}, moved from ${currentPlayer.boardPosition} to ${newPosition}`, { gameCode, - playerId: socket.userId, - hasWon, - cardEffect: cardProcessingResult ? cardProcessingResult.description : 'none' + playerId: socket.userId }); } catch (error) { @@ -739,12 +736,428 @@ export class GameWebSocketService { } } + private async handleCardAnswer(socket: AuthenticatedSocket, data: CardAnswerData): Promise { + try { + const { gameCode, answer } = JSON.parse(data as any); + + // Validate input + if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) { + socket.emit('game:error', { message: 'You must be in the game to answer cards' }); + return; + } + + if (!socket.userId) { + socket.emit('game:error', { message: 'Player not authenticated' }); + return; + } + + // Get pending card from Redis + const pendingCard = await this.getPendingCard(gameCode, socket.userId); + if (!pendingCard) { + socket.emit('game:error', { message: 'No pending card answer found' }); + return; + } + + const pendingState = pendingCard as PendingCardState; + + // Clear the timeout by clearing from CardDrawingService + const answerKey = `${gameCode}:${socket.userId}`; + this.cardDrawingService.clearAnswerTimeout(answerKey); + + const gameRoomName = `game_${gameCode}`; + + // Broadcast player's answer to all players BEFORE validation + this.io.of('/game').to(gameRoomName).emit('game:answer-submitted', { + playerName: socket.playerName, + playerId: socket.userId, + answer: answer, + message: `${socket.playerName} answered: ${JSON.stringify(answer)}`, + timestamp: new Date().toISOString() + }); + + // Add dramatic pause before showing result + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Process the answer + const result = this.cardDrawingService.processAnswer(pendingState.card, answer); + + // Update pending state + pendingState.answerGiven = true; + pendingState.answerCorrect = result.correct; + + // Broadcast validation result + this.io.of('/game').to(gameRoomName).emit('game:answer-validated', { + playerName: socket.playerName, + playerId: socket.userId, + isCorrect: result.correct, + correctAnswer: pendingState.card.answer, + message: result.correct + ? `✅ ${socket.playerName} answered correctly!` + : `❌ ${socket.playerName} answered incorrectly. Correct answer: ${JSON.stringify(pendingState.card.answer)}`, + timestamp: new Date().toISOString() + }); + + // ========================================== + // NEW: Determine if position guess is required + // ========================================== + const requiresGuess = this.determineGuessRequirement( + pendingState.field.type, + result.correct + ); + + if (requiresGuess) { + // Request position guess + await this.requestPositionGuess(gameCode, socket.userId, socket.playerName!, pendingState); + } else { + // No guess required, handle based on field type + if (pendingState.field.type === 'positive' && !result.correct) { + // Positive field + wrong answer = no movement + this.io.of('/game').to(gameRoomName).emit('game:no-movement', { + playerId: socket.userId, + playerName: socket.playerName, + reason: 'Wrong answer on positive field', + message: `${socket.playerName} stays at position ${pendingState.currentPosition}`, + timestamp: new Date().toISOString() + }); + } else if (pendingState.field.type === 'negative' && result.correct) { + // Negative field + correct answer = no movement (avoided penalty) + this.io.of('/game').to(gameRoomName).emit('game:penalty-avoided', { + playerId: socket.userId, + playerName: socket.playerName, + message: `${socket.playerName} avoided the penalty! Stays at position ${pendingState.currentPosition}`, + timestamp: new Date().toISOString() + }); + } + + // Clean up and advance turn + await this.clearPendingCard(gameCode, socket.userId); + await this.advanceTurn(gameCode); + } + + logOther(`Player ${socket.playerName} answered card: ${result.correct ? 'correct' : 'wrong'}`, { + gameCode, + playerId: socket.userId, + requiresGuess + }); + + } catch (error) { + logError('Error handling card answer', error as Error); + socket.emit('game:error', { message: 'Failed to process card answer' }); + } + } + + private async handleGamemasterDecision(socket: AuthenticatedSocket, data: GamemasterDecisionData): Promise { + try { + const { gameCode, requestId, decision } = JSON.parse(data as any); + + // Validate input + if (!gameCode || !socket.gameCode || socket.gameCode !== gameCode) { + socket.emit('game:error', { message: 'You must be in the game to make decisions' }); + return; + } + + if (!socket.userId) { + socket.emit('game:error', { message: 'Gamemaster not authenticated' }); + return; + } + + // Verify this is the gamemaster + const game = await this.gameRepository.findByGameCode(gameCode); + if (!game || game.createdby !== socket.userId) { + socket.emit('game:error', { message: 'Only the gamemaster can make this decision' }); + return; + } + + // Get pending decision from Redis + const pendingDecision = await this.getPendingDecision(gameCode, requestId); + if (!pendingDecision) { + socket.emit('game:error', { message: 'Decision request not found or expired' }); + return; + } + + const pendingState = pendingDecision as PendingDecisionState; + + // Process decision through GamemasterService + const result = this.gamemasterService.processGamemasterDecision( + requestId, + decision === 'approve' ? GamemasterDecision.APPROVE : GamemasterDecision.REJECT + ); + + if (!result) { + socket.emit('game:error', { message: 'Failed to process gamemaster decision' }); + // Clean up + await this.clearPendingDecision(gameCode, requestId); + return; + } + + const gameRoomName = `game_${gameCode}`; + const approved = decision === 'approve'; + + // Update pending state with decision + pendingState.gamemasterDecided = true; + pendingState.gamemasterApproved = approved; + + // Broadcast decision result to all players + this.io.of('/game').to(gameRoomName).emit('game:gamemaster-decision-result', { + playerName: pendingState.playerName, + playerId: pendingState.playerId, + gamemasterName: socket.playerName, + decision: decision, + approved: approved, + consequence: result.consequence, + description: result.description, + timestamp: new Date().toISOString() + }); + + // ========================================== + // NEW: Determine if position guess is required + // ========================================== + const requiresGuess = ( + (pendingState.field.type === 'positive' && approved) || + (pendingState.field.type === 'negative' && !approved) + ); + + if (requiresGuess) { + // Request position guess + await this.requestJokerPositionGuess( + gameCode, + pendingState.playerId, + pendingState.playerName, + pendingState + ); + } else { + // No guess required + if (pendingState.field.type === 'positive' && !approved) { + // Positive field + rejected = no movement + this.io.of('/game').to(gameRoomName).emit('game:no-movement', { + playerId: pendingState.playerId, + playerName: pendingState.playerName, + reason: 'Gamemaster rejected on positive field', + message: `${pendingState.playerName} stays at position ${pendingState.currentPosition}`, + timestamp: new Date().toISOString() + }); + } else if (pendingState.field.type === 'negative' && approved) { + // Negative field + approved = no movement (avoided penalty) + this.io.of('/game').to(gameRoomName).emit('game:penalty-avoided', { + playerId: pendingState.playerId, + playerName: pendingState.playerName, + message: `${pendingState.playerName} avoided the penalty! Stays at position ${pendingState.currentPosition}`, + timestamp: new Date().toISOString() + }); + } + + // Clean up and advance turn + await this.clearPendingDecision(gameCode, requestId); + await this.advanceTurn(gameCode); + } + + logOther(`Gamemaster ${socket.playerName} made decision: ${decision} for player ${pendingState.playerName}`, { + gameCode, + requestId, + requiresGuess + }); + + } catch (error) { + logError('Error handling gamemaster decision', error as Error); + socket.emit('game:error', { message: 'Failed to process gamemaster decision' }); + } + } + + /** + * Handle player landing on special field (positive, negative, luck) + * Draws card and initiates appropriate flow based on card type + */ + private async handleSpecialFieldLanding( + gameCode: string, + playerId: string, + playerName: string, + field: GameField, + position: number, + dice: number, + currentPosition: number + ): Promise { + try { + const gameRoomName = `game_${gameCode}`; + + // Get game data for card drawing + const gameData = await this.gameRepository.findByGameCode(gameCode); + if (!gameData) { + logError('Game not found when handling special field landing'); + await this.advanceTurn(gameCode); + return; + } + + // Draw a card based on field type + const cardDrawResult = this.cardDrawingService.drawCard( + gameData, + field.type as 'positive' | 'negative' | 'luck', + playerId + ); + + if (!cardDrawResult.success || !cardDrawResult.card) { + // No more cards available or error + this.io.of('/game').to(gameRoomName).emit('game:card-error', { + playerName, + playerId, + error: cardDrawResult.error || 'Failed to draw card', + timestamp: new Date().toISOString() + }); + await this.advanceTurn(gameCode); + return; + } + + const card = cardDrawResult.card; + + // Broadcast card drawn to all players (everyone sees the question) + this.io.of('/game').to(gameRoomName).emit('game:card-drawn', { + playerName, + playerId, + cardType: this.getCardTypeName(card.type), + question: card.question, + fieldType: field.type, + timestamp: new Date().toISOString() + }); + + // Check card type and handle accordingly + if (this.isLuckCard(card.type)) { + // Luck card - process immediately (no answer required) + const result = this.cardDrawingService.processLuckCard(card); + + // Broadcast luck result + this.io.of('/game').to(gameRoomName).emit('game:card-result', { + playerName, + playerId, + correct: true, + consequence: result.consequence, + description: result.description, + timestamp: new Date().toISOString() + }); + + // Process luck card with multi-turn support + if (card.consequence) { + await this.processLuckCard(gameCode, playerId, playerName, card.consequence, position); + } else { + // Fallback to old method if no consequence object + await this.applyCardConsequence(gameCode, playerId, playerName, result.consequence); + } + } else { + // Question card - send to player for answer + if (!cardDrawResult.clientData) { + logError('Client data missing for question card'); + await this.advanceTurn(gameCode); + return; + } + + // Send interactive card to the player who drew it + const playerRoomName = `game_${gameCode}:${playerName}`; + this.io.of('/game').to(playerRoomName).emit('game:card-drawn-self', { + cardData: cardDrawResult.clientData, + timeLimit: 60, // 60 seconds to answer + timestamp: new Date().toISOString() + }); + + // Start answer timeout + const answerKey = this.cardDrawingService.startAnswerTimeout( + gameCode, + playerId, + card, + this.handleCardAnswerTimeout.bind(this) + ); + + // Store pending card in Redis + await this.storePendingCard(gameCode, playerId, { + playerId: playerId, + playerName: playerName, + card: card, + field: field, + dice: dice, + currentPosition: currentPosition, + drawnAt: Date.now() + }); + } + + } catch (error) { + logError('Error handling special field landing', error as Error); + await this.advanceTurn(gameCode); + } + } + + /** + * Handle card answer timeout (player didn't answer in time) + */ + private async handleCardAnswerTimeout(gameCode: string, playerId: string, card: any): Promise { + try { + // Clear from Redis + await this.clearPendingCard(gameCode, playerId); + + const gameRoomName = `game_${gameCode}`; + const pendingCard = await this.getPendingCard(gameCode, playerId); + const playerName = pendingCard?.playerName || 'Player'; + + // Broadcast timeout to all players + this.io.of('/game').to(gameRoomName).emit('game:card-timeout', { + playerName, + playerId, + message: '⏰ Time\'s up!', + timestamp: new Date().toISOString() + }); + + // Process as timeout (automatic wrong answer) + const result = this.cardDrawingService.processTimeoutAnswer(card); + + // Broadcast result + this.io.of('/game').to(gameRoomName).emit('game:card-result', { + playerName, + playerId, + correct: false, + consequence: result.consequence, + description: result.description, + timestamp: new Date().toISOString() + }); + + // Apply penalty + await this.applyCardConsequence(gameCode, playerId, playerName, result.consequence); + + } catch (error) { + logError('Error handling card answer timeout', error as Error); + } + } + + /** + * Get human-readable card type name + */ + private getCardTypeName(cardType?: number): string { + if (cardType === undefined) return 'unknown'; + + const typeNames = ['quiz', 'sentence_pairing', 'own_answer', 'true_false', 'closer', 'joker', 'luck']; + return typeNames[cardType] || 'unknown'; + } + private async handleDisconnect(socket: AuthenticatedSocket): Promise { logOther(`Game socket disconnected: ${socket.id} (player: ${socket.playerName})`); // If the socket was in a game, handle cleanup if (socket.gameCode && socket.playerName) { try { + // Clean up any pending card answer + if (socket.userId) { + const pendingCard = await this.getPendingCard(socket.gameCode, socket.userId); + if (pendingCard) { + // Clear timeout + const answerKey = `${socket.gameCode}:${socket.userId}`; + this.cardDrawingService.clearAnswerTimeout(answerKey); + await this.clearPendingCard(socket.gameCode, socket.userId); + + // Notify others + const gameRoomName = `game_${socket.gameCode}`; + this.io.of('/game').to(gameRoomName).emit('game:player-disconnected-during-card', { + playerName: socket.playerName, + playerId: socket.userId, + timestamp: new Date().toISOString() + }); + } + } + // Update player connection status await this.updatePlayerConnection(socket.gameCode, socket.playerName, false); @@ -958,6 +1371,48 @@ export class GameWebSocketService { return await this.redisService.setMembers(key); } + // Redis methods for pending card answers + private async storePendingCard(gameCode: string, playerId: string, cardState: PendingCardState): Promise { + const key = `game_pending_card:${gameCode}:${playerId}`; + await this.redisService.setWithExpiry(key, JSON.stringify(cardState), 90); // 90 seconds (30 seconds buffer after timeout) + } + + private async getPendingCard(gameCode: string, playerId: string): Promise { + const key = `game_pending_card:${gameCode}:${playerId}`; + const dataStr = await this.redisService.get(key); + return dataStr ? JSON.parse(dataStr) : null; + } + + private async clearPendingCard(gameCode: string, playerId: string): Promise { + const key = `game_pending_card:${gameCode}:${playerId}`; + await this.redisService.del(key); + } + + // Redis methods for pending gamemaster decisions + private async storePendingDecision(gameCode: string, requestId: string, decisionState: PendingDecisionState): Promise { + const key = `game_pending_decision:${gameCode}:${requestId}`; + await this.redisService.setWithExpiry(key, JSON.stringify(decisionState), 150); // 150 seconds (30 seconds buffer after timeout) + } + + private async getPendingDecision(gameCode: string, requestId: string): Promise { + const key = `game_pending_decision:${gameCode}:${requestId}`; + const dataStr = await this.redisService.get(key); + return dataStr ? JSON.parse(dataStr) : null; + } + + private async clearPendingDecision(gameCode: string, requestId: string): Promise { + const key = `game_pending_decision:${gameCode}:${requestId}`; + await this.redisService.del(key); + } + + // Helper to get all pending decision keys for a game + private async getAllPendingDecisionKeys(gameCode: string): Promise { + // Note: This is a simplified version. In production, you might want to maintain a set of request IDs + // For now, we'll rely on the GamemasterService's in-memory tracking + const pendingDecisions = this.gamemasterService.getPendingDecisionsForGame(gameCode); + return pendingDecisions.map(d => d.requestId); + } + private async getCurrentGameState(gameCode: string): Promise { try { const gamePlayKey = `gameplay:${gameCode}`; @@ -1019,17 +1474,695 @@ export class GameWebSocketService { } } + // ============================================ + // TURN TRACKING REDIS METHODS + // ============================================ + + /** + * Set the number of extra turns for a player + */ + private async setPlayerExtraTurns( + gameCode: string, + playerId: string, + count: number + ): Promise { + const key = `player_extra_turns:${gameCode}:${playerId}`; + await this.redisService.set(key, count.toString()); + logOther(`Set extra turns for player ${playerId}`, { gameCode, count }); + } + + /** + * Get the number of extra turns for a player + */ + private async getPlayerExtraTurns( + gameCode: string, + playerId: string + ): Promise { + const key = `player_extra_turns:${gameCode}:${playerId}`; + const value = await this.redisService.get(key); + return value ? parseInt(value, 10) : 0; + } + + /** + * Decrement extra turns by 1, delete key if reaches 0 + */ + private async decrementPlayerExtraTurns( + gameCode: string, + playerId: string + ): Promise { + const current = await this.getPlayerExtraTurns(gameCode, playerId); + if (current > 1) { + await this.setPlayerExtraTurns(gameCode, playerId, current - 1); + } else { + const key = `player_extra_turns:${gameCode}:${playerId}`; + await this.redisService.del(key); + } + } + + /** + * Set the number of turns to lose for a player + */ + private async setPlayerTurnsToLose( + gameCode: string, + playerId: string, + count: number + ): Promise { + const key = `player_turns_to_lose:${gameCode}:${playerId}`; + await this.redisService.set(key, count.toString()); + logOther(`Set turns to lose for player ${playerId}`, { gameCode, count }); + } + + /** + * Get the number of turns to lose for a player + */ + private async getPlayerTurnsToLose( + gameCode: string, + playerId: string + ): Promise { + const key = `player_turns_to_lose:${gameCode}:${playerId}`; + const value = await this.redisService.get(key); + return value ? parseInt(value, 10) : 0; + } + + /** + * Decrement turns to lose by 1, delete key if reaches 0 + */ + private async decrementPlayerTurnsToLose( + gameCode: string, + playerId: string + ): Promise { + const current = await this.getPlayerTurnsToLose(gameCode, playerId); + if (current > 1) { + await this.setPlayerTurnsToLose(gameCode, playerId, current - 1); + } else { + const key = `player_turns_to_lose:${gameCode}:${playerId}`; + await this.redisService.del(key); + } + } + + /** + * Clear all turn tracking data for a player + */ + private async clearPlayerTurnData( + gameCode: string, + playerId: string + ): Promise { + await this.redisService.del(`player_extra_turns:${gameCode}:${playerId}`); + await this.redisService.del(`player_turns_to_lose:${gameCode}:${playerId}`); + } + + // ============================================ + // POSITION GUESSING MECHANICS + // ============================================ + + /** + * Determine if position guess is required based on field type and answer correctness + * + * Logic: + * - Positive field + correct answer = GUESS (reward scenario) + * - Positive field + wrong answer = NO GUESS (no movement) + * - Negative field + correct answer = NO GUESS (avoid penalty) + * - Negative field + wrong answer = GUESS (penalty scenario) + * - Regular field = NO GUESS (never guess on regular fields) + */ + private determineGuessRequirement( + fieldType: 'regular' | 'positive' | 'negative' | 'luck', + answerCorrect: boolean + ): boolean { + if (fieldType === 'positive') { + return answerCorrect; // Correct = guess for reward + } else if (fieldType === 'negative') { + return !answerCorrect; // Wrong = guess for penalty + } + return false; // Regular and luck fields never require guess + } + + /** + * Request position guess from player with stepping calculation info + */ + private async requestPositionGuess( + gameCode: string, + playerId: string, + playerName: string, + pendingState: PendingCardState + ): Promise { + const gameRoomName = `game_${gameCode}`; + const playerRoomName = `game_${gameCode}:${playerName}`; + + // Calculate what the actual position would be (without showing to player yet) + const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( + pendingState.currentPosition, + pendingState.field.stepValue || 0, + pendingState.dice + ); + + const patternModifier = this.getPatternModifier(pendingState.currentPosition); + + // Store stepping info for later validation + pendingState.requiresGuess = true; + const cardKey = `pending_card:${gameCode}:${playerId}`; + await this.redisService.setWithExpiry(cardKey, JSON.stringify(pendingState), 30); + + // Notify player to guess + this.io.of('/game').to(playerRoomName).emit('game:position-guess-request', { + message: 'Guess your final position!', + currentPosition: pendingState.currentPosition, + diceRoll: pendingState.dice, + fieldStepValue: pendingState.field.stepValue || 0, + patternModifier: patternModifier, + timeLimit: 30, + timestamp: new Date().toISOString() + }); + + // Notify others that player is guessing + this.io.of('/game').to(gameRoomName).emit('game:player-guessing', { + playerId, + playerName, + message: `${playerName} is guessing their final position...`, + timestamp: new Date().toISOString() + }); + + logOther(`Position guess requested from ${playerName}`, { gameCode }); + } + + /** + * Get pattern modifier based on position + */ + private getPatternModifier(position: number): number { + if (position <= 20) return 2; + if (position <= 40) return -1; + if (position <= 60) return 1; + if (position <= 80) return -2; + return 3; // 81-100 + } + + /** + * Handle position guess submission from player + */ + private async handlePositionGuess(socket: Socket, data: { + gameCode: string; + guessedPosition: number; + }): Promise { + try { + const { gameCode, guessedPosition } = data; + const playerId = socket.data.playerId; + const playerName = socket.data.playerName; + + // Get pending card state + const cardKey = `pending_card:${gameCode}:${playerId}`; + const stateJson = await this.redisService.get(cardKey); + if (!stateJson) { + socket.emit('error', { message: 'No pending guess found' }); + return; + } + + const pendingState: PendingCardState = JSON.parse(stateJson); + pendingState.guessedPosition = guessedPosition; + + // Broadcast the guess to everyone + const gameRoomName = `game_${gameCode}`; + this.io.of('/game').to(gameRoomName).emit('game:position-guess-broadcast', { + playerId, + playerName, + guessedPosition, + message: `${playerName} guessed position ${guessedPosition}`, + timestamp: new Date().toISOString() + }); + + // Process the guess with FieldEffectService + await this.processQuestionCardWithGuess(gameCode, pendingState); + + // Clean up pending state + await this.redisService.del(cardKey); + + } catch (error) { + logError('Error handling position guess', error as Error); + socket.emit('error', { message: 'Failed to process guess' }); + } + } + + /** + * Process question card with guess using FieldEffectService + */ + private async processQuestionCardWithGuess( + gameCode: string, + pendingState: PendingCardState + ): Promise { + // Calculate actual position using BoardGenerationService + const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( + pendingState.currentPosition, + pendingState.field.stepValue || 0, + pendingState.dice + ); + + let finalPosition = actualPosition; + let guessCorrect = false; + let penaltyApplied = false; + + // Check if guess was correct + if (pendingState.guessedPosition === actualPosition) { + guessCorrect = true; + } else { + // Wrong guess: apply -2 penalty + finalPosition = Math.max(1, actualPosition - 2); + penaltyApplied = true; + } + + // Update player position + await this.updatePlayerPosition(gameCode, pendingState.playerId, finalPosition); + + const patternModifier = this.getPatternModifier(pendingState.currentPosition); + + // Broadcast result + const gameRoomName = `game_${gameCode}`; + this.io.of('/game').to(gameRoomName).emit('game:guess-result', { + playerId: pendingState.playerId, + playerName: pendingState.playerName, + guessedPosition: pendingState.guessedPosition, + actualPosition: actualPosition, + finalPosition: finalPosition, + guessCorrect, + penaltyApplied, + calculation: { + startPosition: pendingState.currentPosition, + diceRoll: pendingState.dice, + stepValue: pendingState.field.stepValue || 0, + patternModifier: patternModifier, + calculatedPosition: actualPosition, + penalty: penaltyApplied ? -2 : 0 + }, + message: guessCorrect + ? `✅ ${pendingState.playerName} guessed correctly! Moved to ${finalPosition}` + : `❌ ${pendingState.playerName} guessed wrong! Penalty applied. Final position: ${finalPosition}`, + timestamp: new Date().toISOString() + }); + + // Check for win condition (position 100) + if (finalPosition >= 100) { + await this.endGame(gameCode, pendingState.playerId, pendingState.playerName); + return; + } + + // Check if landed on special field (secondary landing) + const boardData = await this.getBoardData(gameCode); + if (boardData && boardData.fields) { + const landedField = boardData.fields.find((f: GameField) => f.position === finalPosition); + + if (landedField && this.isSpecialField(landedField)) { + await this.handleSpecialFieldLanding( + gameCode, + pendingState.playerId, + pendingState.playerName, + landedField, + finalPosition, + 6, // Secondary landing uses dice = 6 + pendingState.currentPosition + ); + return; + } + } + + // No special field, advance turn + await this.advanceTurn(gameCode); + } + + /** + * Process luck card consequence with multi-turn support + */ + private async processLuckCard( + gameCode: string, + playerId: string, + playerName: string, + consequence: { type: number; value?: number }, + currentPosition: number + ): Promise { + const gameRoomName = `game_${gameCode}`; + let newPosition = currentPosition; + let shouldAdvanceTurn = true; + const consequenceValue = consequence.value || 1; + + // ConsequenceType enum: 0=MOVE_FORWARD, 1=MOVE_BACKWARD, 2=LOSE_TURN, 3=EXTRA_TURN, 5=GO_TO_START + switch (consequence.type) { + case 0: // MOVE_FORWARD + newPosition = Math.min(currentPosition + consequenceValue, 100); + await this.updatePlayerPosition(gameCode, playerId, newPosition); + this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { + playerId, + playerName, + consequenceType: 'MOVE_FORWARD', + value: consequenceValue, + newPosition, + message: `${playerName} moves forward ${consequenceValue} steps to position ${newPosition}!`, + timestamp: new Date().toISOString() + }); + break; + + case 1: // MOVE_BACKWARD + newPosition = Math.max(1, currentPosition - consequenceValue); + await this.updatePlayerPosition(gameCode, playerId, newPosition); + this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { + playerId, + playerName, + consequenceType: 'MOVE_BACKWARD', + value: consequenceValue, + newPosition, + message: `${playerName} moves backward ${consequenceValue} steps to position ${newPosition}!`, + timestamp: new Date().toISOString() + }); + break; + + case 2: // LOSE_TURN + // Store turns to lose in Redis + await this.setPlayerTurnsToLose(gameCode, playerId, consequenceValue); + this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { + playerId, + playerName, + consequenceType: 'LOSE_TURN', + value: consequenceValue, + turnsToLose: consequenceValue, + message: `${playerName} will lose ${consequenceValue} turn(s)!`, + timestamp: new Date().toISOString() + }); + shouldAdvanceTurn = true; // Skip to next player immediately + break; + + case 3: // EXTRA_TURN + // Store extra turns in Redis + await this.setPlayerExtraTurns(gameCode, playerId, consequenceValue); + this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { + playerId, + playerName, + consequenceType: 'EXTRA_TURN', + value: consequenceValue, + extraTurns: consequenceValue, + message: `${playerName} gets ${consequenceValue} extra turn(s)!`, + timestamp: new Date().toISOString() + }); + shouldAdvanceTurn = true; // Let advanceTurn() handle extra turns + break; + + case 5: // GO_TO_START + newPosition = 1; + await this.updatePlayerPosition(gameCode, playerId, newPosition); + this.io.of('/game').to(gameRoomName).emit('game:luck-consequence', { + playerId, + playerName, + consequenceType: 'GO_TO_START', + value: consequenceValue, + newPosition: 1, + message: `${playerName} goes back to START!`, + timestamp: new Date().toISOString() + }); + break; + } + + // Check for win condition (position 100) + if (newPosition >= 100) { + await this.endGame(gameCode, playerId, playerName); + return; + } + + // Advance turn if needed + if (shouldAdvanceTurn) { + await this.advanceTurn(gameCode); + } + } + + // ============================================ + // JOKER CARD POSITION GUESSING + // ============================================ + + /** + * Request position guess from player AFTER gamemaster decision (for jokers) + */ + private async requestJokerPositionGuess( + gameCode: string, + playerId: string, + playerName: string, + pendingState: PendingDecisionState + ): Promise { + const gameRoomName = `game_${gameCode}`; + const playerRoomName = `game_${gameCode}:${playerName}`; + + // Calculate stepping info with dice = 6 + const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( + pendingState.currentPosition, + pendingState.field.stepValue || 0, + 6 // Joker cards always use dice value of 6 + ); + + const patternModifier = this.getPatternModifier(pendingState.currentPosition); + + // Update pending state + const decisionKey = `pending_decision:${gameCode}:${pendingState.playerId}`; + await this.redisService.setWithExpiry(decisionKey, JSON.stringify(pendingState), 30); + + // Notify player to guess + this.io.of('/game').to(playerRoomName).emit('game:joker-position-guess-request', { + message: 'Guess your final position after joker!', + currentPosition: pendingState.currentPosition, + diceRoll: 6, + fieldStepValue: pendingState.field.stepValue || 0, + patternModifier: patternModifier, + timeLimit: 30, + timestamp: new Date().toISOString() + }); + + // Notify others + this.io.of('/game').to(gameRoomName).emit('game:player-guessing', { + playerId, + playerName, + message: `${playerName} is guessing their position after joker...`, + timestamp: new Date().toISOString() + }); + + logOther(`Joker position guess requested from ${playerName}`, { gameCode }); + } + + /** + * Handle joker position guess submission + */ + private async handleJokerPositionGuess(socket: Socket, data: { + gameCode: string; + guessedPosition: number; + }): Promise { + try { + const { gameCode, guessedPosition } = data; + const playerId = socket.data.playerId; + const playerName = socket.data.playerName; + + // Get pending decision state - try with playerId as key + let decisionKey = `pending_decision:${gameCode}:${playerId}`; + let stateJson = await this.redisService.get(decisionKey); + + if (!stateJson) { + socket.emit('error', { message: 'No pending joker guess found' }); + return; + } + + const pendingState: PendingDecisionState = JSON.parse(stateJson); + pendingState.guessedPosition = guessedPosition; + + // Broadcast the guess + const gameRoomName = `game_${gameCode}`; + this.io.of('/game').to(gameRoomName).emit('game:position-guess-broadcast', { + playerId, + playerName, + guessedPosition, + message: `${playerName} guessed position ${guessedPosition}`, + timestamp: new Date().toISOString() + }); + + // Calculate actual position using BoardGenerationService + const actualPosition = this.boardGenerationService.calculatePatternBasedMovement( + pendingState.currentPosition, + pendingState.field.stepValue || 0, + 6 // Joker always uses dice = 6 + ); + + let finalPosition = actualPosition; + let guessCorrect = false; + let penaltyApplied = false; + + // Check guess + if (guessedPosition === actualPosition) { + guessCorrect = true; + } else { + finalPosition = Math.max(1, actualPosition - 2); + penaltyApplied = true; + } + + // Apply movement based on field type and gamemaster decision + const shouldMove = (pendingState.field.type === 'positive' && pendingState.gamemasterApproved) || + (pendingState.field.type === 'negative' && !pendingState.gamemasterApproved); + + if (shouldMove) { + await this.updatePlayerPosition(gameCode, playerId, finalPosition); + } + + const patternModifier = this.getPatternModifier(pendingState.currentPosition); + + // Broadcast joker complete + this.io.of('/game').to(gameRoomName).emit('game:joker-complete', { + playerId, + playerName, + guessedPosition, + actualPosition: actualPosition, + finalPosition: shouldMove ? finalPosition : pendingState.currentPosition, + guessCorrect, + penaltyApplied, + moved: shouldMove, + calculation: { + startPosition: pendingState.currentPosition, + diceRoll: 6, + stepValue: pendingState.field.stepValue || 0, + patternModifier: patternModifier, + calculatedPosition: actualPosition, + penalty: penaltyApplied ? -2 : 0 + }, + message: shouldMove + ? (guessCorrect + ? `✅ ${playerName} guessed correctly! Moved to ${finalPosition}` + : `❌ ${playerName} guessed wrong! Penalty applied. Final position: ${finalPosition}`) + : `${playerName} did not move (gamemaster decision)`, + timestamp: new Date().toISOString() + }); + + // Clean up + await this.redisService.del(decisionKey); + + // Check for win condition + const movedPosition = shouldMove ? finalPosition : pendingState.currentPosition; + if (movedPosition >= 100) { + await this.endGame(gameCode, playerId, playerName); + return; + } + + // Check if landed on special field (secondary landing) if moved + if (shouldMove) { + const boardData = await this.getBoardData(gameCode); + if (boardData && boardData.fields) { + const landedField = boardData.fields.find((f: GameField) => f.position === finalPosition); + + if (landedField && this.isSpecialField(landedField)) { + await this.handleSpecialFieldLanding( + gameCode, + playerId, + playerName, + landedField, + finalPosition, + 6, // Secondary landing uses dice = 6 + pendingState.currentPosition + ); + return; + } + } + } + + // No special field or didn't move, advance turn + await this.advanceTurn(gameCode); + + } catch (error) { + logError('Error handling joker position guess', error as Error); + socket.emit('error', { message: 'Failed to process joker guess' }); + } + } + private async advanceTurn(gameCode: string): Promise { try { const gameState = await this.getCurrentGameState(gameCode); if (!gameState) return; - // Get next player in turn sequence const currentTurnIndex = gameState.currentTurn || 0; - const nextTurnIndex = (currentTurnIndex + 1) % gameState.turnSequence.length; - const nextPlayerId = gameState.turnSequence[nextTurnIndex]; + const currentPlayerId = gameState.turnSequence[currentTurnIndex]; - // Update game state + // ========================================== + // PHASE 1: Check if current player has extra turns + // ========================================== + const extraTurns = await this.getPlayerExtraTurns(gameCode, currentPlayerId); + if (extraTurns > 0) { + // Current player gets another turn + await this.decrementPlayerExtraTurns(gameCode, currentPlayerId); + + const playerPositions = await this.getPlayerPositions(gameCode); + const currentPlayer = playerPositions.find(p => p.playerId === currentPlayerId); + const currentPlayerName = currentPlayer?.playerName || currentPlayerId; + + // Notify about extra turn + const gameRoomName = `game_${gameCode}`; + this.io.of('/game').to(gameRoomName).emit('game:extra-turn-remaining', { + playerId: currentPlayerId, + playerName: currentPlayerName, + remainingExtraTurns: extraTurns - 1, + message: `${currentPlayerName} has ${extraTurns - 1} extra turn(s) remaining!`, + timestamp: new Date().toISOString() + }); + + // Notify player they can roll again + const playerRoomName = `game_${gameCode}:${currentPlayerName}`; + this.io.of('/game').to(playerRoomName).emit('game:your-turn', { + message: 'Extra turn! Roll the dice again!', + canRoll: true, + isExtraTurn: true, + timestamp: new Date().toISOString() + }); + + logOther(`Player ${currentPlayerName} using extra turn`, { + gameCode, + remainingExtraTurns: extraTurns - 1 + }); + + return; // Same player continues, don't advance + } + + // ========================================== + // PHASE 2: Find next player, skipping those with lost turns + // ========================================== + let nextTurnIndex = (currentTurnIndex + 1) % gameState.turnSequence.length; + const skippedPlayers: Array<{ + playerId: string; + playerName: string; + remainingTurnsToLose: number; + }> = []; + let loopGuard = 0; + const maxLoops = gameState.turnSequence.length; + + while (loopGuard < maxLoops) { + const candidatePlayerId = gameState.turnSequence[nextTurnIndex]; + const turnsToLose = await this.getPlayerTurnsToLose(gameCode, candidatePlayerId); + + if (turnsToLose > 0) { + // This player loses their turn + await this.decrementPlayerTurnsToLose(gameCode, candidatePlayerId); + + const playerPositions = await this.getPlayerPositions(gameCode); + const skippedPlayer = playerPositions.find(p => p.playerId === candidatePlayerId); + const skippedPlayerName = skippedPlayer?.playerName || candidatePlayerId; + + skippedPlayers.push({ + playerId: candidatePlayerId, + playerName: skippedPlayerName, + remainingTurnsToLose: turnsToLose - 1 + }); + + logOther(`Player ${skippedPlayerName} turn skipped`, { + gameCode, + remainingTurnsToLose: turnsToLose - 1 + }); + + // Move to next player in sequence + nextTurnIndex = (nextTurnIndex + 1) % gameState.turnSequence.length; + loopGuard++; + } else { + // Found a player who can play + break; + } + } + + // ========================================== + // PHASE 3: Update game state with valid next player + // ========================================== + const nextPlayerId = gameState.turnSequence[nextTurnIndex]; gameState.currentTurn = nextTurnIndex; gameState.currentPlayer = nextPlayerId; @@ -1037,13 +2170,26 @@ export class GameWebSocketService { const gamePlayKey = `gameplay:${gameCode}`; await this.redisService.set(gamePlayKey, JSON.stringify(gameState)); - // Find next player name + // Get next player info const playerPositions = await this.getPlayerPositions(gameCode); const nextPlayer = playerPositions.find(p => p.playerId === nextPlayerId); const nextPlayerName = nextPlayer?.playerName || nextPlayerId; - // Notify all players about turn change + // ========================================== + // PHASE 4: Notify about skipped players (if any) + // ========================================== const gameRoomName = `game_${gameCode}`; + if (skippedPlayers.length > 0) { + this.io.of('/game').to(gameRoomName).emit('game:players-skipped', { + skippedPlayers, + message: `${skippedPlayers.map(p => p.playerName).join(', ')} skipped due to lost turn(s)`, + timestamp: new Date().toISOString() + }); + } + + // ========================================== + // PHASE 5: Notify about turn change + // ========================================== this.io.of('/game').to(gameRoomName).emit('game:turn-changed', { currentPlayer: nextPlayerId, currentPlayerName: nextPlayerName, @@ -1063,7 +2209,8 @@ export class GameWebSocketService { logOther(`Turn advanced in game ${gameCode}`, { previousTurn: currentTurnIndex, newTurn: nextTurnIndex, - nextPlayer: nextPlayerName + nextPlayer: nextPlayerName, + skippedCount: skippedPlayers.length }); } catch (error) { @@ -1133,6 +2280,321 @@ export class GameWebSocketService { } } + /** + * Apply card consequence (movement, turn effects) to a player + */ + private async applyCardConsequence(gameCode: string, playerId: string, playerName: string, consequence: number, recursionDepth: number = 0): Promise { + try { + // Safety check: prevent infinite loops + const MAX_RECURSION_DEPTH = 5; + if (recursionDepth >= MAX_RECURSION_DEPTH) { + logWarning(`Max recursion depth reached for consequence application in game ${gameCode}`); + await this.advanceTurn(gameCode); + return; + } + + // ConsequenceType enum: + // 0: MOVE_FORWARD, 1: MOVE_BACKWARD, 2: LOSE_TURN, 3: EXTRA_TURN, 5: GO_TO_START + + const positions = await this.getPlayerPositions(gameCode); + const currentPlayer = positions.find(p => p.playerId === playerId); + + if (!currentPlayer) { + logWarning(`Player ${playerId} not found when applying consequence`); + return; + } + + const gameRoomName = `game_${gameCode}`; + let newPosition = currentPlayer.boardPosition; + let positionChanged = false; + + switch (consequence) { + case 0: // MOVE_FORWARD + newPosition = Math.min(currentPlayer.boardPosition + 3, 101); // Move forward 3 steps + positionChanged = newPosition !== currentPlayer.boardPosition; + await this.updatePlayerPosition(gameCode, playerId, newPosition); + + this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { + playerName, + playerId, + type: 'move_forward', + oldPosition: currentPlayer.boardPosition, + newPosition, + timestamp: new Date().toISOString() + }); + break; + + case 1: // MOVE_BACKWARD + newPosition = Math.max(currentPlayer.boardPosition - 3, 0); // Move backward 3 steps + positionChanged = newPosition !== currentPlayer.boardPosition; + await this.updatePlayerPosition(gameCode, playerId, newPosition); + + this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { + playerName, + playerId, + type: 'move_backward', + oldPosition: currentPlayer.boardPosition, + newPosition, + timestamp: new Date().toISOString() + }); + break; + + case 2: // LOSE_TURN + // Immediately advance to next player + await this.advanceTurn(gameCode); + + this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { + playerName, + playerId, + type: 'lose_turn', + timestamp: new Date().toISOString() + }); + return; // Early return, turn already advanced + break; + + case 3: // EXTRA_TURN + // Don't advance turn, player gets to go again + const playerRoomName = `game_${gameCode}:${playerName}`; + this.io.of('/game').to(playerRoomName).emit('game:extra-turn', { + message: 'You get an extra turn!', + canRoll: true, + timestamp: new Date().toISOString() + }); + + this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { + playerName, + playerId, + type: 'extra_turn', + timestamp: new Date().toISOString() + }); + return; // Early return, no turn advance needed + break; + + case 5: // GO_TO_START + newPosition = 0; + positionChanged = newPosition !== currentPlayer.boardPosition; + await this.updatePlayerPosition(gameCode, playerId, newPosition); + + this.io.of('/game').to(gameRoomName).emit('game:consequence-applied', { + playerName, + playerId, + type: 'go_to_start', + oldPosition: currentPlayer.boardPosition, + newPosition, + timestamp: new Date().toISOString() + }); + break; + + default: + logWarning(`Unknown consequence type: ${consequence}`); + } + + // Check for secondary special field landing (only if position changed) + if (positionChanged && newPosition > 0 && newPosition < 101) { + const secondaryLanding = await this.checkSecondaryLanding(gameCode, playerId, playerName, newPosition, recursionDepth); + if (secondaryLanding) { + // Secondary landing detected, joker flow initiated, don't advance turn yet + return; + } + } + + // If no secondary landing, advance to next player + await this.advanceTurn(gameCode); + + } catch (error) { + logError('Error applying card consequence', error as Error); + } + } + + /** + * Check if player landed on special field as result of consequence + * Returns true if joker card flow was initiated, false otherwise + */ + private async checkSecondaryLanding(gameCode: string, playerId: string, playerName: string, position: number, recursionDepth: number): Promise { + try { + const boardData = await this.getBoardData(gameCode); + if (!boardData || !boardData.fields) { + return false; + } + + const landedField = boardData.fields.find((f: GameField) => f.position === position); + + // Check if field is special (positive or negative only for joker) + if (!landedField || !this.isSpecialField(landedField)) { + return false; + } + + // Only positive and negative fields trigger joker on secondary landing + if (landedField.type !== 'positive' && landedField.type !== 'negative') { + return false; + } + + const gameRoomName = `game_${gameCode}`; + + // Notify players about secondary landing + this.io.of('/game').to(gameRoomName).emit('game:secondary-landing', { + playerName, + playerId, + position, + fieldType: landedField.type, + message: `${playerName} landed on a ${landedField.type} field! Drawing joker card...`, + timestamp: new Date().toISOString() + }); + + // Wait 2 seconds for animation + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Draw joker card + await this.handleJokerCardDrawing(gameCode, playerId, playerName, landedField, position, recursionDepth); + + return true; // Joker flow initiated + + } catch (error) { + logError('Error checking secondary landing', error as Error); + return false; + } + } + + /** + * Handle joker card drawing and gamemaster decision flow + */ + private async handleJokerCardDrawing(gameCode: string, playerId: string, playerName: string, field: GameField, position: number, recursionDepth: number): Promise { + try { + const gameRoomName = `game_${gameCode}`; + + // Get game data + const gameData = await this.gameRepository.findByGameCode(gameCode); + if (!gameData) { + logError('Game not found when drawing joker card'); + await this.advanceTurn(gameCode); + return; + } + + // Draw joker card + const jokerResult = this.cardDrawingService.drawJokerCard(gameData, playerId); + + if (!jokerResult.success || !jokerResult.card) { + // No more joker cards available + this.io.of('/game').to(gameRoomName).emit('game:joker-error', { + playerName, + playerId, + error: jokerResult.error || 'No joker cards available', + timestamp: new Date().toISOString() + }); + await this.advanceTurn(gameCode); + return; + } + + const jokerCard = jokerResult.card; + + // Broadcast joker drawn to all players + this.io.of('/game').to(gameRoomName).emit('game:joker-drawn', { + playerName, + playerId, + jokerCard: { + question: jokerCard.question, + consequence: jokerCard.consequence + }, + waitingForGamemaster: true, + timestamp: new Date().toISOString() + }); + + // Request gamemaster decision + const requestId = this.gamemasterService.requestGamemasterDecision( + gameCode, + playerId, + playerName, + jokerCard, + (reqId: string) => this.handleGamemasterDecisionTimeout(gameCode, reqId, playerId, playerName, recursionDepth) + ); + + // Store pending decision in Redis + await this.storePendingDecision(gameCode, requestId, { + playerId, + playerName, + card: jokerCard, + field: field, + dice: 6, // Joker cards always use dice value of 6 + currentPosition: position, // Current position is where they landed + drawnAt: Date.now(), + recursionDepth + }); + + // Find gamemaster + const gamemaster = gameData.createdby; + const gamemasterUser = await this.userRepository.findById(gamemaster); + const gamemasterName = gamemasterUser?.username || 'Gamemaster'; + + // Send decision request to gamemaster only + const gamemasterRoomName = `game_${gameCode}:${gamemasterName}`; + this.io.of('/game').to(gamemasterRoomName).emit('game:gamemaster-decision-request', { + requestId, + playerName, + playerId, + jokerCard: { + question: jokerCard.question, + consequence: jokerCard.consequence + }, + timeLimit: 120, // 120 seconds + recursionDepth, // Send to frontend for context + timestamp: new Date().toISOString() + }); + + logOther(`Joker card drawn for ${playerName}, waiting for gamemaster decision`, { + gameCode, + requestId, + recursionDepth + }); + + } catch (error) { + logError('Error handling joker card drawing', error as Error); + await this.advanceTurn(gameCode); + } + } + + /** + * Handle gamemaster decision timeout (120 seconds elapsed) + */ + private async handleGamemasterDecisionTimeout(gameCode: string, requestId: string, playerId: string, playerName: string, recursionDepth: number): Promise { + try { + // Clear from Redis + await this.clearPendingDecision(gameCode, requestId); + + const gameRoomName = `game_${gameCode}`; + + // Broadcast timeout to all players + this.io.of('/game').to(gameRoomName).emit('game:gamemaster-timeout', { + playerName, + playerId, + message: '🎭 Gamemaster didn\'t respond in time. No effect applied.', + timestamp: new Date().toISOString() + }); + + // Process timeout through GamemasterService + const result = this.gamemasterService.processTimeoutDecision(requestId); + + if (result) { + // Broadcast final result + this.io.of('/game').to(gameRoomName).emit('game:gamemaster-decision-result', { + playerName, + playerId, + gamemasterName: 'System (timeout)', + decision: 'timeout', + consequence: result.consequence, + description: result.description, + timestamp: new Date().toISOString() + }); + } + + // Advance turn (no consequence applied on timeout) + await this.advanceTurn(gameCode); + + } catch (error) { + logError('Error handling gamemaster decision timeout', error as Error); + await this.advanceTurn(gameCode); + } + } + // Public method to broadcast game state updates from external services public async broadcastGameStateUpdate(gameCode: string, gameState: any): Promise { const roomName = `game_${gameCode}`; @@ -1276,7 +2738,8 @@ export class GameWebSocketService { `game_ready:${gameCode}`, // Ready players `game_pending:${gameCode}`, // Pending players (for private games) `game_room:${gameCode}`, // Game room mapping - `game_turns:${gameCode}` // Turn sequence data + `game_turns:${gameCode}`, // Turn sequence data + `game_positions:${gameCode}` // Player positions ]; // Clean up game-specific keys @@ -1284,6 +2747,28 @@ export class GameWebSocketService { await this.redisService.del(key); } + // Clean up all pending card answers for this game + const connectedPlayers = await this.getConnectedPlayers(gameCode); + for (const playerId of connectedPlayers) { + await this.clearPendingCard(gameCode, playerId); + } + + // Clean up all pending gamemaster decisions + const pendingDecisionIds = await this.getAllPendingDecisionKeys(gameCode); + for (const requestId of pendingDecisionIds) { + await this.clearPendingDecision(gameCode, requestId); + // Also cancel in GamemasterService to clear timeouts + this.gamemasterService.cancelDecision(requestId); + } + + // Clean up turn tracking for all players + const gameState = await this.getCurrentGameState(gameCode); + if (gameState?.turnSequence) { + for (const playerId of gameState.turnSequence) { + await this.clearPlayerTurnData(gameCode, playerId); + } + } + // Clean up game by ID if available if (gameId) { const gameIdKeys = [ diff --git a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts index f28f2ccb..ee04745b 100644 --- a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts +++ b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts @@ -1,10 +1,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -<<<<<<<< HEAD:SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts export class Full1758463928499 implements MigrationInterface { -======== -export class Full1757939815062 implements MigrationInterface { ->>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2:SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts + public async up(queryRunner: QueryRunner): Promise { } diff --git a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts index f28f2ccb..e2e2ebab 100644 --- a/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts +++ b/SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts @@ -1,10 +1,5 @@ import { MigrationInterface, QueryRunner } from "typeorm"; - -<<<<<<<< HEAD:SerpentRace_Backend/src/Infrastructure/Migrationsettings/1758463928499-full.ts export class Full1758463928499 implements MigrationInterface { -======== -export class Full1757939815062 implements MigrationInterface { ->>>>>>>> 83fad59878db015ec8d86bdec1ecbbca0baddfd2:SerpentRace_Backend/src/Infrastructure/Migrationsettings/1757939815062-full.ts public async up(queryRunner: QueryRunner): Promise { }