final POC
This commit is contained in:
@@ -1,338 +0,0 @@
|
||||
# JWT Refresh Token Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The JWT authentication system supports both **cookie-based** and **header-based** (Bearer token) authentication with comprehensive refresh token functionality and proper logout logic. **All authentication methods now use refresh tokens** - there is no legacy single-token mode.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Authentication Methods**: Support for both cookie-based and Bearer token authentication
|
||||
- **Universal Refresh Tokens**: All logins receive both access and refresh tokens
|
||||
- **Automatic Token Refresh**: Tokens are refreshed when 75% of their lifetime has passed
|
||||
- **Logout Functionality**: Proper token blacklisting and cleanup
|
||||
- **Security**: Short-lived access tokens (30 minutes) and longer-lived refresh tokens (7 days)
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### 1. Cookie-Based Authentication
|
||||
- Access token stored in `auth_token` cookie
|
||||
- Refresh token stored in `refresh_token` cookie
|
||||
- Suitable for web applications with same-origin requests
|
||||
- Tokens also returned in response body
|
||||
|
||||
### 2. Bearer Token Authentication
|
||||
- Access token sent in `Authorization: Bearer <token>` header
|
||||
- Refresh token sent in `X-Refresh-Token` header
|
||||
- Suitable for mobile apps, SPAs, and API integrations
|
||||
- Tokens returned in response body
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Login
|
||||
```http
|
||||
POST /api/user/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (all logins):**
|
||||
```json
|
||||
{
|
||||
"user": { ... },
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
}
|
||||
```
|
||||
|
||||
For cookie-based auth, tokens are also set as httpOnly cookies.
|
||||
|
||||
### Refresh Token
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
```
|
||||
|
||||
**For Cookie-based auth:**
|
||||
- Refresh token is read from `refresh_token` cookie
|
||||
- New tokens are set as cookies AND returned in response body
|
||||
|
||||
**For Bearer token auth:**
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Tokens refreshed successfully",
|
||||
"accessToken": "new_access_token",
|
||||
"refreshToken": "new_refresh_token"
|
||||
}
|
||||
```
|
||||
|
||||
### Logout
|
||||
```http
|
||||
POST /api/user/logout
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secret-key-for-access-tokens
|
||||
JWT_REFRESH_SECRET=your-secret-key-for-refresh-tokens
|
||||
|
||||
# Access Token Expiry (use one of these)
|
||||
JWT_ACCESS_TOKEN_EXPIRY=1800 # Access token expiry in seconds (30 minutes)
|
||||
JWT_ACCESS_TOKEN_EXPIRATION=30m # Access token expiry (supports s, m, h, d)
|
||||
JWT_EXPIRY=1800 # Legacy: Access token expiry in seconds
|
||||
JWT_EXPIRATION=30m # Legacy: Access token expiry with duration
|
||||
|
||||
# Refresh Token Expiry (use one of these)
|
||||
JWT_REFRESH_TOKEN_EXPIRY=604800 # Refresh token expiry in seconds (7 days)
|
||||
JWT_REFRESH_TOKEN_EXPIRATION=7d # Refresh token expiry (supports s, m, h, d)
|
||||
JWT_REFRESH_EXPIRATION=7d # Legacy: Refresh token expiry with duration
|
||||
|
||||
# Cookie Names (optional)
|
||||
JWT_COOKIE_NAME=auth_token # Access token cookie name (default: auth_token)
|
||||
JWT_REFRESH_COOKIE_NAME=refresh_token # Refresh token cookie name (default: refresh_token)
|
||||
```
|
||||
|
||||
### Environment Variable Priority
|
||||
|
||||
**Access Token Expiry** (checked in order):
|
||||
1. `JWT_ACCESS_TOKEN_EXPIRY` (seconds)
|
||||
2. `JWT_ACCESS_TOKEN_EXPIRATION` (duration string)
|
||||
3. `JWT_EXPIRY` (seconds) - legacy
|
||||
4. `JWT_EXPIRATION` (duration string) - legacy
|
||||
5. Default: 1800 seconds (30 minutes)
|
||||
|
||||
**Refresh Token Expiry** (checked in order):
|
||||
1. `JWT_REFRESH_TOKEN_EXPIRY` (seconds)
|
||||
2. `JWT_REFRESH_TOKEN_EXPIRATION` (duration string)
|
||||
3. `JWT_REFRESH_EXPIRATION` (duration string) - legacy
|
||||
4. Default: 604800 seconds (7 days)
|
||||
|
||||
### Duration String Format
|
||||
Supports: `s` (seconds), `m` (minutes), `h` (hours), `d` (days)
|
||||
Examples: `30s`, `15m`, `2h`, `7d`
|
||||
|
||||
## Token Structure
|
||||
|
||||
### Access Token Payload
|
||||
```json
|
||||
{
|
||||
"userId": "user-uuid",
|
||||
"authLevel": 0,
|
||||
"userStatus": 1,
|
||||
"orgId": "org-uuid",
|
||||
"type": "access",
|
||||
"iat": 1640995200,
|
||||
"exp": 1640997000
|
||||
}
|
||||
```
|
||||
|
||||
### Refresh Token Payload
|
||||
```json
|
||||
{
|
||||
"userId": "user-uuid",
|
||||
"orgId": "org-uuid",
|
||||
"type": "refresh",
|
||||
"iat": 1640995200,
|
||||
"exp": 1641600000
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic Token Refresh
|
||||
|
||||
The system automatically refreshes tokens when:
|
||||
- Token is within 25% of its expiration time (75% of lifetime has passed)
|
||||
- Valid refresh token is available
|
||||
- User makes an authenticated request
|
||||
|
||||
**✅ Automatic refresh happens on every authenticated API call** - no manual intervention needed!
|
||||
|
||||
### Response Headers
|
||||
For Bearer token authentication, refresh responses include:
|
||||
- `X-New-Access-Token`: New access token
|
||||
- `X-New-Refresh-Token`: New refresh token
|
||||
- `X-Token-Refreshed`: "true" indicator
|
||||
|
||||
### Manual Refresh (Optional)
|
||||
|
||||
While automatic refresh handles most scenarios, manual refresh is available for:
|
||||
- **Proactive refresh**: Before critical operations
|
||||
- **Background apps**: Long-running applications that need fresh tokens
|
||||
- **Offline recovery**: When app reconnects after being offline
|
||||
|
||||
```http
|
||||
POST /api/user/refresh-token
|
||||
X-Refresh-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Client Implementation Examples
|
||||
|
||||
### JavaScript/TypeScript (Fetch API)
|
||||
|
||||
```typescript
|
||||
class ApiClient {
|
||||
private accessToken: string = '';
|
||||
private refreshToken: string = '';
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const response = await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
this.accessToken = data.token;
|
||||
this.refreshToken = data.refreshToken; // Always present now
|
||||
return data;
|
||||
}
|
||||
|
||||
async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
...options.headers
|
||||
};
|
||||
|
||||
let response = await fetch(url, { ...options, headers });
|
||||
|
||||
// Automatically handle token refresh (tokens updated in response headers)
|
||||
if (response.headers.get('X-Token-Refreshed') === 'true') {
|
||||
const newAccessToken = response.headers.get('X-New-Access-Token');
|
||||
const newRefreshToken = response.headers.get('X-New-Refresh-Token');
|
||||
|
||||
if (newAccessToken) this.accessToken = newAccessToken;
|
||||
if (newRefreshToken) this.refreshToken = newRefreshToken;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Optional: Manual refresh (usually not needed due to automatic refresh)
|
||||
async refreshTokens() {
|
||||
const response = await fetch('/api/user/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Refresh-Token': this.refreshToken
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.accessToken = data.accessToken;
|
||||
this.refreshToken = data.refreshToken;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await fetch('/api/user/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${this.accessToken}` }
|
||||
});
|
||||
|
||||
this.accessToken = '';
|
||||
this.refreshToken = '';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React Hook Example
|
||||
|
||||
```typescript
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [accessToken, setAccessToken] = useState<string>('');
|
||||
const [refreshToken, setRefreshToken] = useState<string>('');
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const response = await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setAccessToken(data.token);
|
||||
setRefreshToken(data.refreshToken); // Always present
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (accessToken) {
|
||||
await fetch('/api/user/logout', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
}
|
||||
setAccessToken('');
|
||||
setRefreshToken('');
|
||||
}, [accessToken]);
|
||||
|
||||
return { accessToken, refreshToken, login, logout };
|
||||
};
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Blacklisting**: Logout tokens are blacklisted in Redis with TTL matching token expiration
|
||||
2. **Short-lived Access Tokens**: 30-minute expiry reduces exposure window
|
||||
3. **Secure Cookies**: httpOnly, secure, sameSite attributes for cookie-based auth
|
||||
4. **Token Rotation**: Refresh tokens are rotated on each refresh
|
||||
5. **Environment-specific Secrets**: Different secrets for access and refresh tokens
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Single Token to Refresh Token System
|
||||
|
||||
Since this is a new implementation, all clients should expect:
|
||||
|
||||
1. **Login Response**: Always includes both `token` (access) and `refreshToken`
|
||||
2. **Token Storage**: Store both tokens securely
|
||||
3. **API Requests**: Use access token in Authorization header
|
||||
4. **Automatic Refresh**: Tokens refresh automatically - just watch for response headers
|
||||
5. **Logout**: Call logout endpoint to invalidate tokens
|
||||
|
||||
**Key Point**: Manual refresh is optional since automatic refresh handles token renewal seamlessly.
|
||||
|
||||
**No backward compatibility needed** - this is the only authentication method.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Login and get tokens
|
||||
curl -X POST http://localhost:3000/api/user/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "test@example.com", "password": "password"}'
|
||||
|
||||
# Use access token
|
||||
curl -X GET http://localhost:3000/api/user/profile \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
|
||||
# Refresh tokens
|
||||
curl -X POST http://localhost:3000/api/user/refresh-token \
|
||||
-H "X-Refresh-Token: <refresh_token>"
|
||||
|
||||
# Logout
|
||||
curl -X POST http://localhost:3000/api/user/logout \
|
||||
-H "Authorization: Bearer <access_token>"
|
||||
```
|
||||
@@ -1,24 +0,0 @@
|
||||
# Code Refactoring & Optimization Summary
|
||||
|
||||
## Interface Simplification
|
||||
- Created base repository interfaces (IBaseRepository, IPaginatedRepository)
|
||||
- Refactored all 7 repository interfaces to extend base interfaces
|
||||
- Eliminated ~200 lines of redundant code
|
||||
- Achieved 70% reduction in repeated method signatures
|
||||
|
||||
## Service Container Enhancements
|
||||
- Added EmailService and GameTokenService to DIContainer
|
||||
- Updated command handlers to use dependency injection
|
||||
- Improved testability and consistency
|
||||
|
||||
## Environment Configuration
|
||||
- Created comprehensive .env.example with 40+ variables
|
||||
- Organized into 12 logical sections
|
||||
- Included security guidelines and best practices
|
||||
|
||||
## Impact
|
||||
- Better code quality and maintainability
|
||||
- Improved developer experience
|
||||
- Enhanced production readiness
|
||||
|
||||
*Completed: September 21, 2025*
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* GameWebSocketService Usage Examples
|
||||
*
|
||||
* This file demonstrates how to use the GameWebSocketService with the new
|
||||
* game token authentication system and private game approval workflow.
|
||||
*
|
||||
* BOARD STRUCTURE:
|
||||
* - Starting position: 0 (before the board)
|
||||
* - Gameplay board: positions 1-100
|
||||
* - Winning position: 101 (finish line)
|
||||
* - Field types: 'regular', 'positive', 'negative', 'luck' (special effects to be implemented later)
|
||||
*/
|
||||
|
||||
import { gameWebSocketService } from './src/Api/index';
|
||||
|
||||
// Example 1: Frontend WebSocket Connection with Game Tokens
|
||||
/*
|
||||
const gameSocket = io('/game');
|
||||
|
||||
// Step 1: Join game via REST API to get game token
|
||||
const joinResponse = await fetch('/api/games/join', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Include authorization header if user is authenticated
|
||||
'Authorization': 'Bearer jwt-token-here' // Optional for public games
|
||||
},
|
||||
body: JSON.stringify({
|
||||
gameCode: 'ABC123',
|
||||
playerName: 'Player1' // Required for public games, optional for authenticated users
|
||||
})
|
||||
});
|
||||
|
||||
const gameData = await joinResponse.json();
|
||||
const gameToken = gameData.gameToken; // Game session token from REST API
|
||||
|
||||
// Step 2: Join WebSocket room using the game token
|
||||
gameSocket.emit('game:join', {
|
||||
gameToken: gameToken // Single token contains all game session info
|
||||
});
|
||||
|
||||
// Listen for game events
|
||||
gameSocket.on('game:joined', (data) => {
|
||||
console.log('Successfully joined game:', data);
|
||||
// { gameCode: 'ABC123', playerName: 'Player1', isAuthenticated: false, gameId: 'uuid', isGamemaster: false, timestamp: '...' }
|
||||
});
|
||||
|
||||
// PRIVATE GAME APPROVAL WORKFLOW:
|
||||
gameSocket.on('game:pending-approval', (data) => {
|
||||
console.log('Waiting for gamemaster approval:', data);
|
||||
// Show waiting message to player
|
||||
});
|
||||
|
||||
gameSocket.on('game:approval-granted', (data) => {
|
||||
console.log('Approved! Now joining game rooms:', data);
|
||||
// Re-emit with special approved join event
|
||||
gameSocket.emit('game:join-approved', {
|
||||
gameToken: gameToken
|
||||
});
|
||||
});
|
||||
|
||||
gameSocket.on('game:approval-denied', (data) => {
|
||||
console.log('Join request denied:', data);
|
||||
// Show rejection message and reason
|
||||
});
|
||||
|
||||
// Gamemaster events (private games only)
|
||||
gameSocket.on('game:player-requesting-join', (data) => {
|
||||
console.log('Player requesting to join:', data);
|
||||
// Show approval/reject buttons to gamemaster
|
||||
});
|
||||
|
||||
gameSocket.on('game:state-update', (gameState) => {
|
||||
console.log('Game state updated:', gameState);
|
||||
// gameState.pendingPlayers array available for private games
|
||||
});
|
||||
|
||||
gameSocket.on('game:player-specific-event', (data) => {
|
||||
console.log('Event sent specifically to me:', data);
|
||||
});
|
||||
*/
|
||||
|
||||
// Example 1.5: Gamemaster Controls (Private Games Only)
|
||||
/*
|
||||
// Approve a pending player
|
||||
function approvePlayer(gameCode: string, playerName: string) {
|
||||
gameSocket.emit('game:approve-player', {
|
||||
gameCode: gameCode,
|
||||
playerName: playerName
|
||||
});
|
||||
}
|
||||
|
||||
// Reject a pending player
|
||||
function rejectPlayer(gameCode: string, playerName: string, reason?: string) {
|
||||
gameSocket.emit('game:reject-player', {
|
||||
gameCode: gameCode,
|
||||
playerName: playerName,
|
||||
reason: reason || 'Request denied by gamemaster'
|
||||
});
|
||||
}
|
||||
|
||||
// Example UI for gamemaster approval
|
||||
gameSocket.on('game:state', (gameState) => {
|
||||
if (gameState.pendingPlayers && gameState.pendingPlayers.length > 0) {
|
||||
console.log('Pending players awaiting approval:', gameState.pendingPlayers);
|
||||
// Display approval UI for each pending player:
|
||||
// [Approve] [Reject] PlayerName
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
// Example 2: Backend Broadcasting (from game logic services)
|
||||
export class GameLogicExample {
|
||||
|
||||
// Broadcast to all players in a game
|
||||
async notifyAllPlayers(gameCode: string, message: string): Promise<void> {
|
||||
await gameWebSocketService.broadcastGameEvent(gameCode, 'game:notification', {
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Send event to specific player
|
||||
async notifyPlayer(gameCode: string, playerName: string, action: string, data: any): Promise<void> {
|
||||
await gameWebSocketService.sendToPlayer(gameCode, playerName, 'game:player-action', {
|
||||
action,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Handle dice roll - broadcast to all, send specific result to player
|
||||
async handleDiceRoll(gameCode: string, playerName: string, diceResult: number): Promise<void> {
|
||||
// Broadcast that a player rolled dice
|
||||
await gameWebSocketService.broadcastGameEvent(gameCode, 'game:dice-rolled', {
|
||||
playerName,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send specific dice result to the player
|
||||
await gameWebSocketService.sendToPlayer(gameCode, playerName, 'game:dice-result', {
|
||||
result: diceResult,
|
||||
canMove: true,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Handle turn change - notify all players and give specific instructions to current player
|
||||
async handleTurnChange(gameCode: string, currentPlayer: string, nextPlayer: string): Promise<void> {
|
||||
// Broadcast turn change to all players
|
||||
await gameWebSocketService.broadcastGameEvent(gameCode, 'game:turn-changed', {
|
||||
previousPlayer: currentPlayer,
|
||||
currentPlayer: nextPlayer,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send specific "your turn" message to next player
|
||||
await gameWebSocketService.sendToPlayer(gameCode, nextPlayer, 'game:your-turn', {
|
||||
message: "It's your turn! Roll the dice when ready.",
|
||||
actions: ['roll-dice'],
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send "waiting" message to other players
|
||||
const connectedPlayers = await gameWebSocketService.getConnectedPlayers(gameCode);
|
||||
const waitingPlayers = connectedPlayers.filter((player: string) => player !== nextPlayer);
|
||||
|
||||
await gameWebSocketService.sendToPlayers(gameCode, waitingPlayers, 'game:waiting-turn', {
|
||||
message: `Waiting for ${nextPlayer} to play...`,
|
||||
currentPlayer: nextPlayer,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Handle field effects - different messages for different players
|
||||
async handleFieldEffect(gameCode: string, playerName: string, fieldType: string, effect: any): Promise<void> {
|
||||
// Broadcast the field activation to all players
|
||||
await gameWebSocketService.broadcastGameEvent(gameCode, 'game:field-activated', {
|
||||
playerName,
|
||||
fieldType,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send specific effect to the player who landed on the field
|
||||
await gameWebSocketService.sendToPlayer(gameCode, playerName, 'game:field-effect', {
|
||||
fieldType,
|
||||
effect,
|
||||
message: `You landed on a ${fieldType} field!`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Handle game state monitoring
|
||||
async checkGameStatus(gameCode: string): Promise<void> {
|
||||
const connectedPlayers = await gameWebSocketService.getConnectedPlayers(gameCode);
|
||||
const readyPlayers = await gameWebSocketService.getReadyPlayers(gameCode);
|
||||
|
||||
console.log(`Game ${gameCode} status:`);
|
||||
console.log(`- Connected players: ${connectedPlayers.join(', ')}`);
|
||||
console.log(`- Ready players: ${readyPlayers.join(', ')}`);
|
||||
|
||||
if (connectedPlayers.length === 0) {
|
||||
console.log('- Game is empty');
|
||||
} else if (readyPlayers.length === connectedPlayers.length) {
|
||||
console.log('- All players are ready!');
|
||||
await this.startGame(gameCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Start game when all players are ready
|
||||
async startGame(gameCode: string): Promise<void> {
|
||||
await gameWebSocketService.broadcastGameEvent(gameCode, 'game:started', {
|
||||
message: 'Game is starting! Get ready to play!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Send game board and initial state to all players
|
||||
const gameState = {
|
||||
status: 'active',
|
||||
currentPlayer: 'Player1', // Determine first player
|
||||
board: {}, // Board data
|
||||
players: await gameWebSocketService.getConnectedPlayers(gameCode)
|
||||
};
|
||||
|
||||
await gameWebSocketService.broadcastGameStateUpdate(gameCode, gameState);
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Room Structure
|
||||
/*
|
||||
Dynamic Room Names:
|
||||
- game_ABC123 // All players in game ABC123
|
||||
- game_ABC123:Player1 // Specific to Player1 in game ABC123
|
||||
- game_ABC123:Player2 // Specific to Player2 in game ABC123
|
||||
- game_XYZ789 // All players in game XYZ789
|
||||
- game_XYZ789:PublicPlayer // Specific to PublicPlayer in game XYZ789
|
||||
|
||||
Usage:
|
||||
- Broadcast events: Send to game_ABC123 (all players receive)
|
||||
- Player-specific events: Send to game_ABC123:Player1 (only Player1 receives)
|
||||
*/
|
||||
|
||||
// Example 4: Game Lifecycle Events
|
||||
/*
|
||||
// Game start event (broadcasted when gamemaster starts the game)
|
||||
gameSocket.on('game:start', (data) => {
|
||||
console.log('Game has started!', data);
|
||||
// data includes:
|
||||
// - gameCode: string
|
||||
// - gameId: string
|
||||
// - boardData: { fields: GameField[] } - Complete board layout (100 gameplay fields, positions 1-100)
|
||||
// - playerOrder: string[] - Turn sequence (player IDs in order)
|
||||
// - currentPlayer: string - First player to move
|
||||
// - currentTurn: number - Current turn index (starts at 0)
|
||||
// - players: string[] - All players in game
|
||||
// - startedAt: string - ISO timestamp
|
||||
// - message: 'Game has started! Good luck to all players!'
|
||||
|
||||
// Initialize game board UI
|
||||
renderGameBoard(data.boardData.fields);
|
||||
|
||||
// Set up turn indicator
|
||||
showCurrentPlayer(data.currentPlayer, data.playerOrder);
|
||||
|
||||
// Show start message
|
||||
displayGameMessage(data.message);
|
||||
});
|
||||
|
||||
// Turn notification for current player
|
||||
gameSocket.on('game:your-turn', (data) => {
|
||||
console.log('It\'s your turn!', data);
|
||||
// data: { message: 'It\'s your turn! Roll the dice!', canRoll: true, timestamp: '...' }
|
||||
|
||||
// Enable dice roll button for current player
|
||||
enableDiceRoll();
|
||||
showTurnMessage(data.message);
|
||||
});
|
||||
|
||||
// Turn change notification for all players
|
||||
gameSocket.on('game:turn-changed', (data) => {
|
||||
console.log('Turn changed:', data);
|
||||
// data: { currentPlayer: 'id', currentPlayerName: 'Name', turnNumber: 2, message: '...', timestamp: '...' }
|
||||
|
||||
// Update UI to show whose turn it is
|
||||
updateCurrentPlayerIndicator(data.currentPlayerName);
|
||||
showTurnMessage(data.message);
|
||||
});
|
||||
|
||||
// Player movement notification
|
||||
gameSocket.on('game:player-moved', (data) => {
|
||||
console.log('Player moved:', data);
|
||||
// data: { playerId: 'id', playerName: 'Name', diceValue: 4, oldPosition: 15, newPosition: 19, hasWon: false, timestamp: '...' }
|
||||
// Note: positions 0 (start) → 1-100 (gameplay board) → 101 (finish/win)
|
||||
|
||||
// Animate player movement on board
|
||||
animatePlayerMovement(data.playerName, data.oldPosition, data.newPosition);
|
||||
|
||||
// Show dice result
|
||||
showDiceResult(data.playerName, data.diceValue);
|
||||
|
||||
if (data.hasWon) {
|
||||
showWinnerAnimation(data.playerName);
|
||||
}
|
||||
});
|
||||
|
||||
// Game end notification
|
||||
gameSocket.on('game:ended', (data) => {
|
||||
console.log('Game ended:', data);
|
||||
// data: { winner: 'id', winnerName: 'Name', message: '🎉 Name won!', finalPositions: [...], timestamp: '...' }
|
||||
|
||||
// Show game over screen
|
||||
showGameOverScreen(data.winnerName, data.finalPositions);
|
||||
disableAllGameActions();
|
||||
});
|
||||
|
||||
// Frontend dice roll (when it's your turn)
|
||||
function rollDice() {
|
||||
const diceValue = Math.floor(Math.random() * 6) + 1; // Generate 1-6
|
||||
|
||||
// Send dice value to server
|
||||
gameSocket.emit('game:dice-roll', {
|
||||
gameCode: currentGameCode,
|
||||
diceValue: diceValue
|
||||
});
|
||||
|
||||
// Disable dice roll button until turn changes
|
||||
disableDiceRoll();
|
||||
showDiceAnimation(diceValue);
|
||||
}
|
||||
|
||||
// Other game events
|
||||
gameSocket.on('game:state-update', (gameState) => {
|
||||
console.log('Game state updated:', gameState);
|
||||
});
|
||||
|
||||
gameSocket.on('game:action-result', (data) => {
|
||||
console.log('Player action result:', data);
|
||||
// { action: 'roll-dice', playerName: 'Player1', result: { dice: 4 }, timestamp: '...' }
|
||||
});
|
||||
*/
|
||||
|
||||
// Example 5: REST API Integration (Game Token Flow + Game Start)
|
||||
/*
|
||||
// Step 1: REST API handles game joining and returns game token
|
||||
POST /api/games/join
|
||||
{
|
||||
"gameCode": "ABC123",
|
||||
"playerName": "NewPlayer"
|
||||
}
|
||||
|
||||
// Response includes game data + game token
|
||||
{
|
||||
"id": "game-uuid",
|
||||
"gamecode": "ABC123",
|
||||
"players": ["player1", "player2", "NewPlayer"],
|
||||
...otherGameData,
|
||||
"gameToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // Game session token
|
||||
}
|
||||
|
||||
// Step 2: Player connects to WebSocket using the game token
|
||||
const gameSocket = io('/game');
|
||||
gameSocket.emit('game:join', {
|
||||
gameToken: gameTokenFromRestAPI // Contains gameId, gameCode, playerName, auth status
|
||||
});
|
||||
|
||||
// Step 3: Gamemaster starts the game via REST API
|
||||
POST /api/games/{gameId}/start
|
||||
// Authorization: Bearer {gamemaster-jwt-token}
|
||||
|
||||
// Response includes game and board data
|
||||
{
|
||||
"message": "Game started successfully",
|
||||
"gameId": "game-uuid",
|
||||
"playerCount": 3,
|
||||
"game": { ...gameData },
|
||||
"boardData": {
|
||||
"fields": [
|
||||
{ "position": 1, "type": "regular" },
|
||||
{ "position": 2, "type": "positive", "stepValue": 3 },
|
||||
{ "position": 3, "type": "negative", "stepValue": -2 },
|
||||
{ "position": 4, "type": "luck" },
|
||||
{ "position": 5, "type": "regular" },
|
||||
// ... continues to position 100 (100 gameplay fields)
|
||||
{ "position": 100, "type": "regular" }
|
||||
]
|
||||
// Note: Players start at 0, play on 1-100, win by reaching 101
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: All players automatically receive game:start WebSocket event
|
||||
// (No additional frontend action needed - happens automatically when gamemaster calls start endpoint)
|
||||
*/
|
||||
@@ -188,6 +188,17 @@ AppDataSource.initialize()
|
||||
container.setSocketIO(webSocketService['io']);
|
||||
gameWebSocketService = container.gameWebSocketService;
|
||||
logStartup('Game WebSocket service initialized for /game namespace');
|
||||
|
||||
// Restore active games from snapshots (if any exist)
|
||||
gameWebSocketService.restoreAllActiveGames()
|
||||
.then(restoredCount => {
|
||||
if (restoredCount > 0) {
|
||||
logStartup(`Restored ${restoredCount} active game(s) from snapshots`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logError('Failed to restore games from snapshots', error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
const dbOptions = AppDataSource.options as any;
|
||||
@@ -225,6 +236,16 @@ const server = httpServer.listen(PORT, () => {
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
logStartup(`Received ${signal}. Shutting down gracefully...`);
|
||||
|
||||
// Snapshot all active games before shutdown
|
||||
if (gameWebSocketService) {
|
||||
try {
|
||||
const snapshotCount = await gameWebSocketService.snapshotAllActiveGames();
|
||||
logStartup(`Created ${snapshotCount} game snapshot(s) before shutdown`);
|
||||
} catch (error) {
|
||||
logError('Failed to snapshot games before shutdown', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
server.close(() => {
|
||||
logStartup('HTTP server closed');
|
||||
|
||||
|
||||
@@ -273,10 +273,11 @@ router.delete('/users/:userId',
|
||||
try {
|
||||
const targetUserId = req.params.userId;
|
||||
const adminUserId = (req as any).user.userId;
|
||||
const softDelete = req.query.soft === 'true' || req.query.soft === undefined;
|
||||
|
||||
logRequest('Delete user endpoint accessed', req, res, { adminUserId, targetUserId });
|
||||
logRequest('Delete user endpoint accessed', req, res, { adminUserId, targetUserId, softDelete });
|
||||
|
||||
const result = await container.deleteUserCommandHandler.execute({ id: targetUserId });
|
||||
const result = await container.deleteUserCommandHandler.execute({ id: targetUserId, soft: softDelete });
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
@@ -120,12 +120,14 @@ export class BoardGenerationService {
|
||||
|
||||
// Generate appropriate step value for field type
|
||||
if (specialField.type === 'positive') {
|
||||
// Positive fields: use positive step values (3-8 range for good gameplay)
|
||||
const stepValue = Math.floor(Math.random() * 6) + 3; // 3-8
|
||||
// Positive fields: use positive step values (1-3 range for balanced gameplay)
|
||||
// Max movement: 3 × 6 (dice) = 18 steps
|
||||
const stepValue = Math.floor(Math.random() * 3) + 1; // 1-3
|
||||
fields[fieldIndex].stepValue = Math.min(stepValue, maxStepValue);
|
||||
} else {
|
||||
// Negative fields: use negative step values (-3 to -8 range)
|
||||
const stepValue = -(Math.floor(Math.random() * 6) + 3); // -3 to -8
|
||||
// Negative fields: use negative step values (-1 to -3 range)
|
||||
// Max backward: -3 × 6 (dice) = -18 steps
|
||||
const stepValue = -(Math.floor(Math.random() * 3) + 1); // -1 to -3
|
||||
fields[fieldIndex].stepValue = Math.max(stepValue, minStepValue);
|
||||
}
|
||||
});
|
||||
@@ -156,25 +158,33 @@ export class BoardGenerationService {
|
||||
return finalPosition;
|
||||
}
|
||||
|
||||
private getPatternModifier(position: number, positiveField: boolean): number {
|
||||
// Pattern modifiers for strategic complexity:
|
||||
public getPatternModifier(position: number, positiveField: boolean): number {
|
||||
// Pattern modifiers STACK for strategic complexity:
|
||||
// - Positions ending in 0 (10, 20, 30...): No modifier
|
||||
// - Positions ending in 5 (15, 25, 35...): ±3 modifier
|
||||
// - Positions divisible by 3 (9, 12, 21...): ±2 modifier
|
||||
// - Odd positions (1, 7, 11...): ±1 modifier
|
||||
// - Other even positions: No modifier
|
||||
// Multiple conditions can apply and stack
|
||||
|
||||
if (position % 10 === 0) {
|
||||
return 0; // Positions ending in 0
|
||||
} else if (position % 10 === 5) {
|
||||
return positiveField ? 3 : -3; // Positions ending in 5
|
||||
} else if (position % 3 === 0) {
|
||||
return positiveField ? 2 : -2; // Divisible by 3
|
||||
} else if (position % 2 === 1) {
|
||||
return positiveField ? 1 : -1; // Odd positions
|
||||
} else {
|
||||
return 0; // Other even positions
|
||||
return 0; // Positions ending in 0 - no modifier
|
||||
}
|
||||
|
||||
let modifier = 0;
|
||||
const direction = positiveField ? 1 : -1;
|
||||
|
||||
// Check each condition and stack modifiers
|
||||
if (position % 10 === 5) {
|
||||
modifier += 3 * direction; // Positions ending in 5
|
||||
}
|
||||
if (position % 3 === 0) {
|
||||
modifier += 2 * direction; // Divisible by 3
|
||||
}
|
||||
if (position % 2 === 1) {
|
||||
modifier += 1 * direction; // Odd positions
|
||||
}
|
||||
|
||||
return modifier;
|
||||
}
|
||||
|
||||
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
|
||||
|
||||
@@ -49,7 +49,8 @@ export class JoinGameCommandHandler {
|
||||
}
|
||||
|
||||
// Generate player ID for public games or use provided one
|
||||
const actualPlayerId = command.playerId || uuidv4();
|
||||
// For anonymous players (no playerId), use playerName as the identifier to allow rejoining
|
||||
const actualPlayerId = command.playerId || `guest_${command.playerName}`;
|
||||
|
||||
// Validate game joinability (authentication/org checks done in router)
|
||||
this.validateGameJoinability(game, actualPlayerId, command);
|
||||
@@ -122,7 +123,7 @@ export class JoinGameCommandHandler {
|
||||
|
||||
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
const redisKey = `game:${game.gamecode}`;
|
||||
|
||||
// Get existing game data from Redis or create new
|
||||
let gameData: ActiveGameData;
|
||||
@@ -189,9 +190,9 @@ export class JoinGameCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
|
||||
async getGameFromRedis(gameCode: string): Promise<ActiveGameData | null> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const redisKey = `game:${gameCode}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGameData : null;
|
||||
} catch (error) {
|
||||
@@ -200,9 +201,9 @@ export class JoinGameCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
|
||||
async removePlayerFromRedis(gameCode: string, playerId: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const redisKey = `game:${gameCode}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
|
||||
@@ -163,6 +163,7 @@ export class StartGameCommandHandler {
|
||||
cardid: this.generateCardId(),
|
||||
question: card.text,
|
||||
answer: card.answer || undefined,
|
||||
type: card.type, // Include card type for proper processing
|
||||
consequence: card.consequence || null,
|
||||
played: false,
|
||||
playerid: undefined
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ActiveGamePlayData {
|
||||
createdAt: Date;
|
||||
startedAt: Date;
|
||||
currentTurn: number; // Index of current player in turn order
|
||||
currentPlayer: string; // ID of the player whose turn it is
|
||||
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
|
||||
websocketRoom: string;
|
||||
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
|
||||
@@ -131,10 +132,13 @@ export class StartGamePlayCommandHandler {
|
||||
|
||||
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
|
||||
try {
|
||||
const redisKey = `gameplay:${game.id}`;
|
||||
const redisKey = `gameplay:${game.gamecode}`;
|
||||
|
||||
// Get connected player names from Redis (stored by WebSocket)
|
||||
const playerNamesMap = await this.getPlayerNames(game.gamecode);
|
||||
|
||||
// Generate random turn orders for all players
|
||||
const playersWithPositions = this.initializePlayerPositions(game.players);
|
||||
const playersWithPositions = this.initializePlayerPositions(game.players, playerNamesMap);
|
||||
|
||||
// Sort by turn order to create turn sequence
|
||||
const turnSequence = [...playersWithPositions]
|
||||
@@ -151,6 +155,7 @@ export class StartGamePlayCommandHandler {
|
||||
createdAt: game.createdate,
|
||||
startedAt: new Date(),
|
||||
currentTurn: 0, // Start with first player in sequence
|
||||
currentPlayer: turnSequence[0], // First player in turn sequence
|
||||
turnSequence,
|
||||
websocketRoom: `game_${game.gamecode}`,
|
||||
gamePhase: 'starting',
|
||||
@@ -160,13 +165,6 @@ export class StartGamePlayCommandHandler {
|
||||
// Store game play data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
|
||||
|
||||
// Create turn sequence mapping for quick lookups
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_turns:${game.id}`,
|
||||
JSON.stringify(turnSequence),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
logOther('Game play initialized in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
@@ -182,7 +180,7 @@ export class StartGamePlayCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
|
||||
private initializePlayerPositions(playerIds: string[], playerNamesMap: Map<string, string>): GamePlayerPosition[] {
|
||||
const players: GamePlayerPosition[] = [];
|
||||
|
||||
// Generate random turn orders (1 to playerCount)
|
||||
@@ -191,6 +189,7 @@ export class StartGamePlayCommandHandler {
|
||||
playerIds.forEach((playerId, index) => {
|
||||
players.push({
|
||||
playerId,
|
||||
playerName: playerNamesMap.get(playerId) || playerId, // Use mapped name or fallback to ID
|
||||
position: 0, // All players start at position 0
|
||||
turnOrder: turnOrders[index],
|
||||
isOnline: true, // Assume online when game starts
|
||||
@@ -203,6 +202,7 @@ export class StartGamePlayCommandHandler {
|
||||
turnOrders: turnOrders,
|
||||
playersData: players.map(p => ({
|
||||
playerId: p.playerId,
|
||||
playerName: p.playerName,
|
||||
position: p.position,
|
||||
turnOrder: p.turnOrder
|
||||
}))
|
||||
@@ -226,23 +226,18 @@ export class StartGamePlayCommandHandler {
|
||||
|
||||
private async notifyGameStart(game: GameAggregate): Promise<void> {
|
||||
try {
|
||||
// Get board data from Redis
|
||||
const redisKey = `game_board_${game.id}`;
|
||||
const boardDataStr = await this.redisService.get(redisKey);
|
||||
|
||||
if (!boardDataStr) {
|
||||
logError('Board data not found in Redis during game start notification', new Error('Missing board data'));
|
||||
return;
|
||||
}
|
||||
|
||||
const boardData: BoardData = JSON.parse(boardDataStr);
|
||||
|
||||
// Get turn sequence from Redis
|
||||
const gamePlayData = await this.getGamePlayFromRedis(game.id);
|
||||
// Get game play data from Redis (contains board data)
|
||||
const gamePlayData = await this.getGamePlayFromRedis(game.gamecode);
|
||||
if (!gamePlayData) {
|
||||
logError('Game play data not found in Redis', new Error('Missing game play data'));
|
||||
return;
|
||||
}
|
||||
|
||||
const boardData = gamePlayData.boardData;
|
||||
if (!boardData) {
|
||||
logError('Board data not found in game play data', new Error('Missing board data'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get WebSocket service from DIContainer and broadcast game start
|
||||
const gameWebSocketService = DIContainer.getInstance().gameWebSocketService;
|
||||
@@ -267,9 +262,9 @@ export class StartGamePlayCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
|
||||
async getGamePlayFromRedis(gameCode: string): Promise<ActiveGamePlayData | null> {
|
||||
try {
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
const redisKey = `gameplay:${gameCode}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGamePlayData : null;
|
||||
} catch (error) {
|
||||
@@ -278,9 +273,9 @@ export class StartGamePlayCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
|
||||
async updatePlayerPosition(gameCode: string, playerId: string, newPosition: number): Promise<void> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
const gameData = await this.getGamePlayFromRedis(gameCode);
|
||||
if (!gameData) {
|
||||
throw new Error('Game session not found');
|
||||
}
|
||||
@@ -291,11 +286,11 @@ export class StartGamePlayCommandHandler {
|
||||
player.position = newPosition;
|
||||
|
||||
// Save back to Redis
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
const redisKey = `gameplay:${gameCode}`;
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
logOther('Player position updated', {
|
||||
gameId,
|
||||
gameCode,
|
||||
playerId,
|
||||
newPosition
|
||||
});
|
||||
@@ -306,9 +301,9 @@ export class StartGamePlayCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async getNextPlayer(gameId: string): Promise<string | null> {
|
||||
async getNextPlayer(gameCode: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
const gameData = await this.getGamePlayFromRedis(gameCode);
|
||||
if (!gameData) {
|
||||
return null;
|
||||
}
|
||||
@@ -321,6 +316,39 @@ export class StartGamePlayCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPlayerNames(gameCode: string): Promise<Map<string, string>> {
|
||||
try {
|
||||
// Get active game data from Redis which contains player names
|
||||
const activeGameKey = `game:${gameCode}`;
|
||||
const activeGameStr = await this.redisService.get(activeGameKey);
|
||||
|
||||
const playerNamesMap = new Map<string, string>();
|
||||
|
||||
if (activeGameStr) {
|
||||
const activeGame = JSON.parse(activeGameStr);
|
||||
if (activeGame.currentPlayers && Array.isArray(activeGame.currentPlayers)) {
|
||||
// Map playerIds to playerNames from active game data
|
||||
activeGame.currentPlayers.forEach((player: any) => {
|
||||
if (player.playerId && player.playerName) {
|
||||
playerNamesMap.set(player.playerId, player.playerName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logOther('Retrieved player names map', {
|
||||
gameCode,
|
||||
playerCount: playerNamesMap.size,
|
||||
players: Array.from(playerNamesMap.entries()).map(([id, name]) => ({ id, name }))
|
||||
});
|
||||
|
||||
return playerNamesMap;
|
||||
} catch (error) {
|
||||
logError('Failed to get player names', error instanceof Error ? error : new Error(String(error)));
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTurn(gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
|
||||
@@ -81,15 +81,28 @@ export class CardDrawingService {
|
||||
drawnCard.played = true;
|
||||
drawnCard.playerid = playerId;
|
||||
|
||||
// Check if card has consequence field (joker/luck card) even without type
|
||||
const hasConsequence = drawnCard.consequence !== undefined && drawnCard.consequence !== null;
|
||||
|
||||
// Prepare client data based on card type
|
||||
// Only prepare for question cards (cards without consequence and with defined type)
|
||||
let clientData: CardClientData | undefined;
|
||||
try {
|
||||
if (drawnCard.type !== undefined) {
|
||||
if (!hasConsequence && drawnCard.type !== undefined) {
|
||||
try {
|
||||
clientData = this.cardProcessingService.prepareCardForClient(drawnCard);
|
||||
} catch (error) {
|
||||
// If client data preparation fails, still return the card but log the error
|
||||
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
// If client data preparation fails, still return the card but log the error
|
||||
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
|
||||
} else if (!hasConsequence && drawnCard.type === undefined) {
|
||||
// Card is missing type field - this shouldn't happen, log error
|
||||
console.error(`Card ${drawnCard.cardid} is missing type field. Card data:`, {
|
||||
cardId: drawnCard.cardid,
|
||||
hasQuestion: !!drawnCard.question,
|
||||
hasAnswer: !!drawnCard.answer,
|
||||
hasConsequence,
|
||||
cardKeys: Object.keys(drawnCard)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -413,7 +413,7 @@ export class CardProcessingService {
|
||||
*/
|
||||
private convertToBoolean(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase().trim();
|
||||
return ['true', 'yes', '1', 'correct', 'right'].includes(lowerValue);
|
||||
return ['true', 'yes', '1', 'correct', 'right', 'igaz'].includes(lowerValue);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@ import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
|
||||
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
|
||||
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
|
||||
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
|
||||
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
|
||||
import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository';
|
||||
|
||||
// Repository Implementations
|
||||
import { UserRepository } from '../../Infrastructure/Repository/UserRepository';
|
||||
@@ -15,6 +17,8 @@ import { DeckRepository } from '../../Infrastructure/Repository/DeckRepository';
|
||||
import { OrganizationRepository } from '../../Infrastructure/Repository/OrganizationRepository';
|
||||
import { ContactRepository } from '../../Infrastructure/Repository/ContactRepository';
|
||||
import { GameRepository } from '../../Infrastructure/Repository/GameRepository';
|
||||
import { TurnHistoryRepository } from '../../Infrastructure/Repository/TurnHistoryRepository';
|
||||
import { GameSnapshotRepository } from '../../Infrastructure/Repository/GameSnapshotRepository';
|
||||
|
||||
// Command Handlers
|
||||
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
|
||||
@@ -86,6 +90,8 @@ export class DIContainer {
|
||||
private _organizationRepository: IOrganizationRepository | null = null;
|
||||
private _contactRepository: IContactRepository | null = null;
|
||||
private _gameRepository: IGameRepository | null = null;
|
||||
private _turnHistoryRepository: ITurnHistoryRepository | null = null;
|
||||
private _gameSnapshotRepository: IGameSnapshotRepository | null = null;
|
||||
|
||||
// Services
|
||||
private _jwtService: JWTService | null = null;
|
||||
@@ -202,6 +208,20 @@ export class DIContainer {
|
||||
return this._gameRepository;
|
||||
}
|
||||
|
||||
public get turnHistoryRepository(): ITurnHistoryRepository {
|
||||
if (!this._turnHistoryRepository) {
|
||||
this._turnHistoryRepository = new TurnHistoryRepository();
|
||||
}
|
||||
return this._turnHistoryRepository;
|
||||
}
|
||||
|
||||
public get gameSnapshotRepository(): IGameSnapshotRepository {
|
||||
if (!this._gameSnapshotRepository) {
|
||||
this._gameSnapshotRepository = new GameSnapshotRepository();
|
||||
}
|
||||
return this._gameSnapshotRepository;
|
||||
}
|
||||
|
||||
// Services getters
|
||||
public get jwtService(): JWTService {
|
||||
if (!this._jwtService) {
|
||||
@@ -294,7 +314,9 @@ export class DIContainer {
|
||||
this._socketIOInstance,
|
||||
this.gameRepository as any, // Cast to concrete type
|
||||
this.userRepository as any, // Cast to concrete type
|
||||
RedisService.getInstance()
|
||||
RedisService.getInstance(),
|
||||
this.turnHistoryRepository as any, // Cast to concrete type
|
||||
this.gameSnapshotRepository as any // Cast to concrete type
|
||||
);
|
||||
}
|
||||
return this._gameWebSocketService;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository';
|
||||
import { GameSnapshotAggregate, SnapshotTrigger, GameStateSnapshot, PlayerSnapshot } from '../../Domain/Game/GameSnapshotAggregate';
|
||||
import { RedisService } from './RedisService';
|
||||
import { logOther, logError } from './Logger';
|
||||
|
||||
export class GameSnapshotService {
|
||||
private static readonly SNAPSHOT_INTERVAL = 5; // Every 5 turns
|
||||
private static readonly MAX_SNAPSHOTS_PER_GAME = 20; // Keep last 20 snapshots
|
||||
|
||||
constructor(
|
||||
private snapshotRepository: IGameSnapshotRepository,
|
||||
private redisService: RedisService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a game state snapshot
|
||||
*/
|
||||
async createSnapshot(
|
||||
gameId: string,
|
||||
turnNumber: number,
|
||||
trigger: SnapshotTrigger,
|
||||
notes?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Gather current game state from Redis
|
||||
const gameState = await this.getCurrentGameState(gameId);
|
||||
if (!gameState) {
|
||||
logError('Cannot create snapshot: game state not found', new Error(`Game ${gameId} not in Redis`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather Redis state (pending actions, timers, etc.)
|
||||
const redisState = await this.getRedisState(gameId);
|
||||
|
||||
// Create snapshot
|
||||
const snapshot = new GameSnapshotAggregate();
|
||||
snapshot.gameid = gameId;
|
||||
snapshot.turnNumber = turnNumber;
|
||||
snapshot.trigger = trigger;
|
||||
snapshot.gameState = gameState;
|
||||
snapshot.redisState = redisState;
|
||||
snapshot.notes = notes || null;
|
||||
|
||||
await this.snapshotRepository.save(snapshot);
|
||||
|
||||
// Cleanup old snapshots
|
||||
await this.snapshotRepository.deleteOldSnapshots(
|
||||
gameId,
|
||||
GameSnapshotService.MAX_SNAPSHOTS_PER_GAME
|
||||
);
|
||||
|
||||
logOther(`Game snapshot created: ${trigger}`, {
|
||||
gameId,
|
||||
turnNumber,
|
||||
trigger
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to create game snapshot', error as Error);
|
||||
// Don't throw - snapshots shouldn't break game flow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if snapshot should be created (every N turns)
|
||||
*/
|
||||
shouldCreateSnapshot(turnNumber: number): boolean {
|
||||
return turnNumber % GameSnapshotService.SNAPSHOT_INTERVAL === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore game state from latest snapshot
|
||||
*/
|
||||
async restoreFromSnapshot(gameId: string): Promise<boolean> {
|
||||
try {
|
||||
const snapshot = await this.snapshotRepository.findLatestByGameId(gameId);
|
||||
if (!snapshot) {
|
||||
logOther(`No snapshot found for game ${gameId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore game state to Redis
|
||||
await this.restoreGameState(gameId, snapshot.gameState);
|
||||
|
||||
// Restore Redis state (pending actions, timers)
|
||||
if (snapshot.redisState) {
|
||||
await this.restoreRedisState(gameId, snapshot.redisState);
|
||||
}
|
||||
|
||||
logOther(`Game state restored from snapshot`, {
|
||||
gameId,
|
||||
turnNumber: snapshot.turnNumber,
|
||||
trigger: snapshot.trigger,
|
||||
age: Date.now() - snapshot.createdat.getTime()
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('Failed to restore game from snapshot', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current game state from Redis
|
||||
*/
|
||||
private async getCurrentGameState(gameId: string): Promise<GameStateSnapshot | null> {
|
||||
try {
|
||||
// Get game state
|
||||
const gameStateKey = `game_state:${gameId}`;
|
||||
const gameStateJson = await this.redisService.get(gameStateKey);
|
||||
if (!gameStateJson) return null;
|
||||
|
||||
const gameState = JSON.parse(gameStateJson);
|
||||
|
||||
// Get player positions
|
||||
const playerPositions: PlayerSnapshot[] = [];
|
||||
const positionsKey = `player_positions:${gameId}`;
|
||||
const positionsJson = await this.redisService.get(positionsKey);
|
||||
|
||||
if (positionsJson) {
|
||||
const positions = JSON.parse(positionsJson);
|
||||
for (const [playerId, data] of Object.entries(positions)) {
|
||||
const posData = data as any;
|
||||
|
||||
// Get extra turns
|
||||
const extraTurnsKey = `extra_turns:${gameId}:${playerId}`;
|
||||
const extraTurns = parseInt(await this.redisService.get(extraTurnsKey) || '0');
|
||||
|
||||
// Get turns to lose
|
||||
const turnsToLoseKey = `turns_to_lose:${gameId}:${playerId}`;
|
||||
const turnsToLose = parseInt(await this.redisService.get(turnsToLoseKey) || '0');
|
||||
|
||||
playerPositions.push({
|
||||
playerId: playerId,
|
||||
playerName: posData.playerName || 'Unknown',
|
||||
boardPosition: posData.boardPosition || 0,
|
||||
extraTurns,
|
||||
turnsToLose,
|
||||
isOnline: posData.isOnline !== false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get board data
|
||||
const boardKey = `board_data:${gameId}`;
|
||||
const boardJson = await this.redisService.get(boardKey);
|
||||
const boardFields = boardJson ? JSON.parse(boardJson).fields : undefined;
|
||||
|
||||
return {
|
||||
currentPlayer: gameState.currentPlayer,
|
||||
currentPlayerName: gameState.currentPlayerName || 'Unknown',
|
||||
turnNumber: gameState.turnNumber || 1,
|
||||
turnOrder: gameState.turnOrder || [],
|
||||
playerPositions,
|
||||
boardFields,
|
||||
deckStates: undefined, // TODO: Add deck states if needed
|
||||
pendingActions: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error getting current game state', error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis state (pending cards, decisions, etc.)
|
||||
*/
|
||||
private async getRedisState(gameId: string): Promise<any> {
|
||||
const redisState: any = {
|
||||
pendingCards: {},
|
||||
pendingDecisions: {},
|
||||
timers: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Get all keys for this game
|
||||
const pattern = `*${gameId}*`;
|
||||
const keys = await this.redisService['client'].keys(pattern);
|
||||
|
||||
for (const key of keys) {
|
||||
// Store non-critical state for reference
|
||||
if (key.includes('pending_card') || key.includes('pending_decision')) {
|
||||
const value = await this.redisService.get(key);
|
||||
if (value) {
|
||||
redisState.pendingCards[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Error getting Redis state', error as Error);
|
||||
}
|
||||
|
||||
return redisState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore game state to Redis
|
||||
*/
|
||||
private async restoreGameState(gameId: string, state: GameStateSnapshot): Promise<void> {
|
||||
// Restore game state
|
||||
const gameStateKey = `game_state:${gameId}`;
|
||||
await this.redisService.setWithExpiry(gameStateKey, JSON.stringify({
|
||||
currentPlayer: state.currentPlayer,
|
||||
currentPlayerName: state.currentPlayerName,
|
||||
turnNumber: state.turnNumber,
|
||||
turnOrder: state.turnOrder
|
||||
}), 3600);
|
||||
|
||||
// Restore player positions
|
||||
const positionsKey = `player_positions:${gameId}`;
|
||||
const positions: any = {};
|
||||
for (const player of state.playerPositions) {
|
||||
positions[player.playerId] = {
|
||||
playerName: player.playerName,
|
||||
boardPosition: player.boardPosition,
|
||||
isOnline: player.isOnline
|
||||
};
|
||||
|
||||
// Restore extra turns
|
||||
if (player.extraTurns > 0) {
|
||||
const extraTurnsKey = `extra_turns:${gameId}:${player.playerId}`;
|
||||
await this.redisService.setWithExpiry(extraTurnsKey, player.extraTurns.toString(), 3600);
|
||||
}
|
||||
|
||||
// Restore turns to lose
|
||||
if (player.turnsToLose > 0) {
|
||||
const turnsToLoseKey = `turns_to_lose:${gameId}:${player.playerId}`;
|
||||
await this.redisService.setWithExpiry(turnsToLoseKey, player.turnsToLose.toString(), 3600);
|
||||
}
|
||||
}
|
||||
await this.redisService.setWithExpiry(positionsKey, JSON.stringify(positions), 3600);
|
||||
|
||||
// Restore board data if available
|
||||
if (state.boardFields) {
|
||||
const boardKey = `board_data:${gameId}`;
|
||||
await this.redisService.setWithExpiry(boardKey, JSON.stringify({ fields: state.boardFields }), 3600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore Redis state (partial - pending actions may need re-triggering)
|
||||
*/
|
||||
private async restoreRedisState(gameId: string, redisState: any): Promise<void> {
|
||||
// Note: Pending cards and timers should be recreated by game logic
|
||||
// This is just for reference/debugging
|
||||
logOther('Redis state reference saved (timers/pending actions need manual restart)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup snapshots for finished game
|
||||
*/
|
||||
async cleanupGameSnapshots(gameId: string): Promise<void> {
|
||||
try {
|
||||
await this.snapshotRepository.deleteByGameId(gameId);
|
||||
logOther(`Game snapshots cleaned up for game ${gameId}`);
|
||||
} catch (error) {
|
||||
logError('Failed to cleanup game snapshots', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot history for debugging
|
||||
*/
|
||||
async getSnapshotHistory(gameId: string): Promise<GameSnapshotAggregate[]> {
|
||||
return await this.snapshotRepository.findByGameId(gameId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -256,6 +256,15 @@ export class LoggingService {
|
||||
}
|
||||
|
||||
private logToConsole(entry: LogEntry): void {
|
||||
// In production, skip OTHER, CONNECTION, and REQUEST logs
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (entry.level === LogLevel.OTHER ||
|
||||
entry.level === LogLevel.CONNECTION ||
|
||||
entry.level === LogLevel.REQUEST) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formattedEntry = this.formatLogEntry(entry);
|
||||
|
||||
switch (entry.level) {
|
||||
@@ -287,6 +296,15 @@ export class LoggingService {
|
||||
res?: Response,
|
||||
responseTime?: number
|
||||
): void {
|
||||
// In production, skip OTHER, CONNECTION, and REQUEST logs entirely
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (level === LogLevel.OTHER ||
|
||||
level === LogLevel.CONNECTION ||
|
||||
level === LogLevel.REQUEST) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
|
||||
@@ -307,7 +307,12 @@ export class RedisService {
|
||||
// Generic Redis methods for game data
|
||||
public async get(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
const value = await this.client.get(key);
|
||||
// Refresh TTL on access for game-related keys
|
||||
if (value && this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // Reset to 30 minutes
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
logError(`Failed to get key ${key}`, error as Error);
|
||||
return null;
|
||||
@@ -317,6 +322,10 @@ export class RedisService {
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await this.client.set(key, value);
|
||||
// Auto-expire game-related keys after 30 minutes
|
||||
if (this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // 30 minutes
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to set key ${key}`, error as Error);
|
||||
}
|
||||
@@ -341,6 +350,10 @@ export class RedisService {
|
||||
public async setAdd(key: string, member: string): Promise<void> {
|
||||
try {
|
||||
await this.client.sAdd(key, member);
|
||||
// Refresh TTL for game-related keys
|
||||
if (this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // Reset to 30 minutes
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to add member to set ${key}`, error as Error);
|
||||
}
|
||||
@@ -349,6 +362,10 @@ export class RedisService {
|
||||
public async setRemove(key: string, member: string): Promise<void> {
|
||||
try {
|
||||
await this.client.sRem(key, member);
|
||||
// Refresh TTL for game-related keys
|
||||
if (this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // Reset to 30 minutes
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to remove member from set ${key}`, error as Error);
|
||||
}
|
||||
@@ -356,7 +373,12 @@ export class RedisService {
|
||||
|
||||
public async setMembers(key: string): Promise<string[]> {
|
||||
try {
|
||||
return await this.client.sMembers(key);
|
||||
const members = await this.client.sMembers(key);
|
||||
// Refresh TTL on access for game-related keys
|
||||
if (members.length > 0 && this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // Reset to 30 minutes
|
||||
}
|
||||
return members;
|
||||
} catch (error) {
|
||||
logError(`Failed to get members of set ${key}`, error as Error);
|
||||
return [];
|
||||
@@ -366,10 +388,36 @@ export class RedisService {
|
||||
public async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.client.exists(key);
|
||||
// Refresh TTL on access for game-related keys
|
||||
if (result === 1 && this.isGameRelatedKey(key)) {
|
||||
await this.client.expire(key, 1800); // Reset to 30 minutes
|
||||
}
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logError(`Failed to check existence of key ${key}`, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is game-related and should have auto-expiration
|
||||
* Game-related patterns: gameplay:*, game:*, game_*, board:*, game_pending_card:*, etc.
|
||||
*/
|
||||
private isGameRelatedKey(key: string): boolean {
|
||||
const gamePatterns = [
|
||||
'gameplay:',
|
||||
'game:',
|
||||
'game_',
|
||||
'board:',
|
||||
'game_pending_card:',
|
||||
'game_pending_decision:',
|
||||
'game_player_extra_turns:',
|
||||
'game_player_turns_to_lose:',
|
||||
'game_positions:',
|
||||
'game_ready:',
|
||||
'game_room:',
|
||||
'active_game:'
|
||||
];
|
||||
return gamePatterns.some(pattern => key.startsWith(pattern));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
|
||||
import { TurnHistoryAggregate, TurnActionType, TurnActionData } from '../../Domain/Game/TurnHistoryAggregate';
|
||||
import { logOther, logError } from './Logger';
|
||||
|
||||
export class TurnHistoryService {
|
||||
constructor(private turnHistoryRepository: ITurnHistoryRepository) {}
|
||||
|
||||
/**
|
||||
* Log a turn action
|
||||
*/
|
||||
async logTurnAction(
|
||||
gameId: string,
|
||||
playerId: string,
|
||||
playerName: string,
|
||||
turnNumber: number,
|
||||
actionType: TurnActionType,
|
||||
positionBefore: number,
|
||||
positionAfter: number,
|
||||
actionData?: TurnActionData
|
||||
): Promise<void> {
|
||||
try {
|
||||
const turnHistory = new TurnHistoryAggregate();
|
||||
turnHistory.gameid = gameId;
|
||||
turnHistory.playerid = playerId;
|
||||
turnHistory.playername = playerName;
|
||||
turnHistory.turnNumber = turnNumber;
|
||||
turnHistory.actionType = actionType;
|
||||
turnHistory.positionBefore = positionBefore;
|
||||
turnHistory.positionAfter = positionAfter;
|
||||
turnHistory.actionData = actionData || null;
|
||||
|
||||
await this.turnHistoryRepository.save(turnHistory);
|
||||
|
||||
logOther(`Turn history logged: ${actionType}`, {
|
||||
gameId,
|
||||
playerId,
|
||||
playerName,
|
||||
turnNumber,
|
||||
positionBefore,
|
||||
positionAfter
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to log turn history', error as Error);
|
||||
// Don't throw - logging shouldn't break game flow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game replay data
|
||||
*/
|
||||
async getGameReplay(gameId: string): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.turnHistoryRepository.findByGameId(gameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player's turn history in a game
|
||||
*/
|
||||
async getPlayerHistory(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.turnHistoryRepository.findByGameAndPlayer(gameId, playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent turns for a game
|
||||
*/
|
||||
async getRecentTurns(gameId: string, limit: number = 10): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.turnHistoryRepository.findLastNTurns(gameId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up turn history for a finished game
|
||||
*/
|
||||
async cleanupGameHistory(gameId: string): Promise<void> {
|
||||
try {
|
||||
await this.turnHistoryRepository.deleteByGameId(gameId);
|
||||
logOther(`Turn history cleaned up for game ${gameId}`);
|
||||
} catch (error) {
|
||||
logError('Failed to cleanup turn history', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { GameAggregate } from './GameAggregate';
|
||||
|
||||
export interface PlayerSnapshot {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
boardPosition: number;
|
||||
extraTurns: number;
|
||||
turnsToLose: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export interface GameStateSnapshot {
|
||||
currentPlayer: string;
|
||||
currentPlayerName: string;
|
||||
turnNumber: number;
|
||||
turnOrder: string[];
|
||||
playerPositions: PlayerSnapshot[];
|
||||
boardFields?: any[];
|
||||
deckStates?: any;
|
||||
pendingActions?: any;
|
||||
}
|
||||
|
||||
export enum SnapshotTrigger {
|
||||
TURN_INTERVAL = 'turn_interval', // Every N turns
|
||||
PLAYER_DISCONNECT = 'player_disconnect', // When player disconnects
|
||||
CRITICAL_EVENT = 'critical_event', // Important game events
|
||||
MANUAL = 'manual', // Manual checkpoint
|
||||
SERVER_SHUTDOWN = 'server_shutdown' // Before server shutdown
|
||||
}
|
||||
|
||||
@Entity('GameSnapshots')
|
||||
@Index(['gameid', 'createdat'])
|
||||
@Index(['gameid', 'trigger'])
|
||||
export class GameSnapshotAggregate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid', name: 'gameid' })
|
||||
gameid!: string;
|
||||
|
||||
@Column({ type: 'int', name: 'turn_number' })
|
||||
turnNumber!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SnapshotTrigger,
|
||||
name: 'trigger'
|
||||
})
|
||||
trigger!: SnapshotTrigger;
|
||||
|
||||
@Column({ type: 'jsonb', name: 'game_state' })
|
||||
gameState!: GameStateSnapshot;
|
||||
|
||||
@Column({ type: 'jsonb', name: 'redis_state', nullable: true })
|
||||
redisState!: any | null;
|
||||
|
||||
@Column({ type: 'text', name: 'notes', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'createdat' })
|
||||
createdat!: Date;
|
||||
|
||||
@ManyToOne(() => GameAggregate, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'gameid' })
|
||||
game?: GameAggregate;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { GameAggregate } from './GameAggregate';
|
||||
|
||||
export enum TurnActionType {
|
||||
DICE_ROLL = 'dice_roll',
|
||||
CARD_DRAWN = 'card_drawn',
|
||||
ANSWER_SUBMITTED = 'answer_submitted',
|
||||
POSITION_GUESS = 'position_guess',
|
||||
GAMEMASTER_DECISION = 'gamemaster_decision',
|
||||
LUCK_CONSEQUENCE = 'luck_consequence',
|
||||
EXTRA_TURN = 'extra_turn',
|
||||
TURN_LOST = 'turn_lost',
|
||||
PLAYER_DISCONNECTED = 'player_disconnected',
|
||||
TIMEOUT = 'timeout'
|
||||
}
|
||||
|
||||
export interface TurnActionData {
|
||||
diceValue?: number;
|
||||
cardId?: string;
|
||||
cardType?: string;
|
||||
question?: string;
|
||||
answer?: any;
|
||||
isCorrect?: boolean;
|
||||
guessedPosition?: number;
|
||||
actualPosition?: number;
|
||||
consequenceType?: string;
|
||||
consequenceValue?: number;
|
||||
decision?: string;
|
||||
reason?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
||||
@Entity('TurnHistory')
|
||||
@Index(['gameid', 'turnNumber'])
|
||||
@Index(['gameid', 'playerid'])
|
||||
@Index(['createdat'])
|
||||
export class TurnHistoryAggregate {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid', name: 'gameid' })
|
||||
gameid!: string;
|
||||
|
||||
@Column({ type: 'uuid', name: 'playerid' })
|
||||
playerid!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, name: 'playername' })
|
||||
playername!: string;
|
||||
|
||||
@Column({ type: 'int', name: 'turn_number' })
|
||||
turnNumber!: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TurnActionType,
|
||||
name: 'action_type'
|
||||
})
|
||||
actionType!: TurnActionType;
|
||||
|
||||
@Column({ type: 'jsonb', name: 'action_data', nullable: true })
|
||||
actionData!: TurnActionData | null;
|
||||
|
||||
@Column({ type: 'int', name: 'position_before' })
|
||||
positionBefore!: number;
|
||||
|
||||
@Column({ type: 'int', name: 'position_after' })
|
||||
positionAfter!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'createdat' })
|
||||
createdat!: Date;
|
||||
|
||||
@ManyToOne(() => GameAggregate, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'gameid' })
|
||||
game?: GameAggregate;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { GameSnapshotAggregate, SnapshotTrigger } from '../Game/GameSnapshotAggregate';
|
||||
|
||||
export interface IGameSnapshotRepository {
|
||||
/**
|
||||
* Save a game state snapshot
|
||||
*/
|
||||
save(snapshot: GameSnapshotAggregate): Promise<GameSnapshotAggregate>;
|
||||
|
||||
/**
|
||||
* Get the most recent snapshot for a game
|
||||
*/
|
||||
findLatestByGameId(gameId: string): Promise<GameSnapshotAggregate | null>;
|
||||
|
||||
/**
|
||||
* Get all snapshots for a game
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<GameSnapshotAggregate[]>;
|
||||
|
||||
/**
|
||||
* Get snapshots by trigger type
|
||||
*/
|
||||
findByGameAndTrigger(gameId: string, trigger: SnapshotTrigger): Promise<GameSnapshotAggregate[]>;
|
||||
|
||||
/**
|
||||
* Get snapshot at specific turn
|
||||
*/
|
||||
findByGameAndTurn(gameId: string, turnNumber: number): Promise<GameSnapshotAggregate | null>;
|
||||
|
||||
/**
|
||||
* Delete old snapshots (keep only last N)
|
||||
*/
|
||||
deleteOldSnapshots(gameId: string, keepCount: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all snapshots for a game
|
||||
*/
|
||||
deleteByGameId(gameId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TurnHistoryAggregate, TurnActionType, TurnActionData } from '../Game/TurnHistoryAggregate';
|
||||
|
||||
export interface ITurnHistoryRepository {
|
||||
/**
|
||||
* Save a turn history entry
|
||||
*/
|
||||
save(turnHistory: TurnHistoryAggregate): Promise<TurnHistoryAggregate>;
|
||||
|
||||
/**
|
||||
* Get all turn history for a game
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<TurnHistoryAggregate[]>;
|
||||
|
||||
/**
|
||||
* Get turn history for a specific player in a game
|
||||
*/
|
||||
findByGameAndPlayer(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]>;
|
||||
|
||||
/**
|
||||
* Get the last N turns for a game
|
||||
*/
|
||||
findLastNTurns(gameId: string, limit: number): Promise<TurnHistoryAggregate[]>;
|
||||
|
||||
/**
|
||||
* Get turn count for a game
|
||||
*/
|
||||
countTurnsByGame(gameId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Delete all turn history for a game
|
||||
*/
|
||||
deleteByGameId(gameId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { AppDataSource } from '../ormconfig';
|
||||
import { IGameSnapshotRepository } from '../../Domain/IRepository/IGameSnapshotRepository';
|
||||
import { GameSnapshotAggregate, SnapshotTrigger } from '../../Domain/Game/GameSnapshotAggregate';
|
||||
|
||||
export class GameSnapshotRepository implements IGameSnapshotRepository {
|
||||
private repository: Repository<GameSnapshotAggregate>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(GameSnapshotAggregate);
|
||||
}
|
||||
|
||||
async save(snapshot: GameSnapshotAggregate): Promise<GameSnapshotAggregate> {
|
||||
return await this.repository.save(snapshot);
|
||||
}
|
||||
|
||||
async findLatestByGameId(gameId: string): Promise<GameSnapshotAggregate | null> {
|
||||
return await this.repository.findOne({
|
||||
where: { gameid: gameId },
|
||||
order: { createdat: 'DESC' }
|
||||
});
|
||||
}
|
||||
|
||||
async findByGameId(gameId: string): Promise<GameSnapshotAggregate[]> {
|
||||
return await this.repository.find({
|
||||
where: { gameid: gameId },
|
||||
order: { turnNumber: 'ASC', createdat: 'ASC' }
|
||||
});
|
||||
}
|
||||
|
||||
async findByGameAndTrigger(gameId: string, trigger: SnapshotTrigger): Promise<GameSnapshotAggregate[]> {
|
||||
return await this.repository.find({
|
||||
where: {
|
||||
gameid: gameId,
|
||||
trigger: trigger
|
||||
},
|
||||
order: { createdat: 'DESC' }
|
||||
});
|
||||
}
|
||||
|
||||
async findByGameAndTurn(gameId: string, turnNumber: number): Promise<GameSnapshotAggregate | null> {
|
||||
return await this.repository.findOne({
|
||||
where: {
|
||||
gameid: gameId,
|
||||
turnNumber: turnNumber
|
||||
},
|
||||
order: { createdat: 'DESC' }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOldSnapshots(gameId: string, keepCount: number): Promise<void> {
|
||||
const snapshots = await this.repository.find({
|
||||
where: { gameid: gameId },
|
||||
order: { createdat: 'DESC' },
|
||||
select: ['id', 'createdat']
|
||||
});
|
||||
|
||||
if (snapshots.length > keepCount) {
|
||||
const idsToDelete = snapshots
|
||||
.slice(keepCount)
|
||||
.map(s => s.id);
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await this.repository.delete(idsToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteByGameId(gameId: string): Promise<void> {
|
||||
await this.repository.delete({ gameid: gameId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppDataSource } from '../ormconfig';
|
||||
import { ITurnHistoryRepository } from '../../Domain/IRepository/ITurnHistoryRepository';
|
||||
import { TurnHistoryAggregate } from '../../Domain/Game/TurnHistoryAggregate';
|
||||
|
||||
export class TurnHistoryRepository implements ITurnHistoryRepository {
|
||||
private repository: Repository<TurnHistoryAggregate>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(TurnHistoryAggregate);
|
||||
}
|
||||
|
||||
async save(turnHistory: TurnHistoryAggregate): Promise<TurnHistoryAggregate> {
|
||||
return await this.repository.save(turnHistory);
|
||||
}
|
||||
|
||||
async findByGameId(gameId: string): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.repository.find({
|
||||
where: { gameid: gameId },
|
||||
order: { turnNumber: 'ASC', createdat: 'ASC' }
|
||||
});
|
||||
}
|
||||
|
||||
async findByGameAndPlayer(gameId: string, playerId: string): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.repository.find({
|
||||
where: {
|
||||
gameid: gameId,
|
||||
playerid: playerId
|
||||
},
|
||||
order: { turnNumber: 'ASC', createdat: 'ASC' }
|
||||
});
|
||||
}
|
||||
|
||||
async findLastNTurns(gameId: string, limit: number): Promise<TurnHistoryAggregate[]> {
|
||||
return await this.repository.find({
|
||||
where: { gameid: gameId },
|
||||
order: { turnNumber: 'DESC', createdat: 'DESC' },
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
|
||||
async countTurnsByGame(gameId: string): Promise<number> {
|
||||
return await this.repository.count({
|
||||
where: { gameid: gameId }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteByGameId(gameId: string): Promise<void> {
|
||||
await this.repository.delete({ gameid: gameId });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user