final POC

This commit is contained in:
magdo
2025-11-24 23:28:57 +01:00
parent ce02f55a99
commit 6b3446e9b6
49 changed files with 4634 additions and 4620 deletions
@@ -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)
*/
+21
View File
@@ -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 });
}
}