1996 lines
54 KiB
Markdown
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
|