Files
SerpentRace/Documentations/COMPLETE_GAME_WORKFLOW.md
T
2025-10-30 18:43:16 +01:00

1996 lines
54 KiB
Markdown

# 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 <access_token>
```
### 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<void>;
private async getPlayerExtraTurns(
gameCode: string,
playerId: string
): Promise<number>; // Returns 0 if not found
private async decrementPlayerExtraTurns(
gameCode: string,
playerId: string
): Promise<void>; // Decrements or deletes if reaches 0
// Turns to lose
private async setPlayerTurnsToLose(
gameCode: string,
playerId: string,
count: number
): Promise<void>;
private async getPlayerTurnsToLose(
gameCode: string,
playerId: string
): Promise<number>; // Returns 0 if not found
private async decrementPlayerTurnsToLose(
gameCode: string,
playerId: string
): Promise<void>; // Decrements or deletes if reaches 0
// Cleanup
private async clearPlayerTurnData(
gameCode: string,
playerId: string
): Promise<void>; // Deletes both keys
}
```
### Enhanced advanceTurn() Logic
```typescript
private async advanceTurn(gameCode: string): Promise<void> {
// 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<Map<string, string>>(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 (
<div>
{sentencePairs.map(pair => (
<div key={pair.id}>
<span>{pair.left}</span>
<select onChange={(e) => handleMatch(pair.id, pair.left, e.target.value)}>
<option value="">-- Select --</option>
{sentencePairs.map(p => (
<option key={p.id} value={p.right}>{p.right}</option>
))}
</select>
</div>
))}
<button onClick={handleSubmit}>Submit</button>
</div>
);
}
```
#### 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