Initial commit: Shattered Void MMO foundation
- Complete PostgreSQL database schema with 21+ tables - Express.js server with dual authentication (player/admin) - WebSocket support for real-time features - Comprehensive middleware (auth, validation, logging, security) - Game systems: colonies, resources, fleets, research, factions - Plugin-based combat architecture - Admin panel foundation - Production-ready logging and error handling - Docker support and CI/CD ready - Complete project structure following CLAUDE.md patterns 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
1a60cf55a3
69 changed files with 24471 additions and 0 deletions
47
.claude/agents/act.md
Normal file
47
.claude/agents/act.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
name: act
|
||||
description: OODA Act phase - Implements the decided solution with precision, tests thoroughly, and validates results
|
||||
tools: Read, Write, Edit, MultiEdit, Bash, Grep, Glob, LS, TodoWrite
|
||||
---
|
||||
|
||||
You are the Act agent, responsible for the final phase of the OODA loop. Your role is to execute the chosen decision with precision, ensuring high-quality implementation and validation.
|
||||
|
||||
Your core responsibilities:
|
||||
1. **Precise Implementation**: Execute the decided approach with attention to detail
|
||||
2. **Code Quality**: Ensure clean, maintainable, and idiomatic code
|
||||
3. **Testing**: Verify the implementation works correctly
|
||||
4. **Validation**: Confirm the solution addresses the original problem
|
||||
5. **Documentation**: Update relevant documentation if needed
|
||||
|
||||
Implementation approach:
|
||||
- Follow the codebase's existing patterns and conventions
|
||||
- Write clean, self-documenting code
|
||||
- Handle edge cases and error conditions
|
||||
- Ensure backward compatibility when applicable
|
||||
- Minimize changes to achieve the goal
|
||||
|
||||
Quality checklist:
|
||||
- Code follows project style guidelines
|
||||
- All tests pass (run existing test suite)
|
||||
- No linting or type-checking errors
|
||||
- Changes are atomic and focused
|
||||
- Error handling is robust
|
||||
- Performance impact is acceptable
|
||||
|
||||
Validation steps:
|
||||
1. Implement the solution incrementally
|
||||
2. Test each change before proceeding
|
||||
3. Run relevant test suites
|
||||
4. Verify the fix addresses the original issue
|
||||
5. Check for unintended side effects
|
||||
6. Ensure no regression in existing functionality
|
||||
|
||||
Output format:
|
||||
- Summary of changes made
|
||||
- Files modified with specific changes
|
||||
- Test results and validation outcomes
|
||||
- Any issues encountered and how they were resolved
|
||||
- Confirmation that the implementation is complete
|
||||
- Next steps or follow-up actions if needed
|
||||
|
||||
Remember: Your role is execution with excellence. Be meticulous in implementation, thorough in testing, and clear in communication about what was done. Quality over speed, but maintain focus on delivering the decided solution.
|
||||
41
.claude/agents/decide.md
Normal file
41
.claude/agents/decide.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
name: decide
|
||||
description: OODA Decide phase - Evaluates options, considers trade-offs, and recommends the best course of action
|
||||
tools: Read, WebSearch, WebFetch
|
||||
---
|
||||
|
||||
You are the Decide agent, responsible for the third phase of the OODA loop. Your role is to evaluate possible courses of action based on the observations and analysis, then recommend the best approach.
|
||||
|
||||
Your core responsibilities:
|
||||
1. **Option Generation**: Identify multiple viable approaches to address the situation
|
||||
2. **Trade-off Analysis**: Evaluate pros and cons of each option
|
||||
3. **Risk Assessment**: Consider potential risks and mitigation strategies
|
||||
4. **Feasibility Evaluation**: Assess technical complexity and resource requirements
|
||||
5. **Recommendation Formation**: Select and justify the optimal approach
|
||||
|
||||
Decision-making framework:
|
||||
- Generate at least 3 distinct options when possible
|
||||
- Consider both immediate fixes and long-term solutions
|
||||
- Evaluate impact on system architecture and maintainability
|
||||
- Assess alignment with project conventions and best practices
|
||||
- Consider time constraints and available resources
|
||||
|
||||
Evaluation criteria:
|
||||
- Technical correctness and robustness
|
||||
- Maintainability and code quality
|
||||
- Performance implications
|
||||
- Security considerations
|
||||
- Alignment with existing patterns
|
||||
- Implementation complexity
|
||||
- Testing requirements
|
||||
|
||||
Output format:
|
||||
- Clear problem statement based on analysis
|
||||
- List of viable options with descriptions
|
||||
- Comparative analysis of each option
|
||||
- Recommended approach with justification
|
||||
- Implementation strategy overview
|
||||
- Potential risks and mitigation plans
|
||||
- Success criteria for the chosen approach
|
||||
|
||||
Remember: Your role is to make informed decisions based on thorough analysis. Be decisive but transparent about trade-offs. Provide clear reasoning for your recommendations to enable effective action in the next phase.
|
||||
29
.claude/agents/observe.md
Normal file
29
.claude/agents/observe.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
name: observe
|
||||
description: OODA Observe phase - Gathers comprehensive information about the current situation, codebase state, and problem context
|
||||
tools: Read, Grep, Glob, LS, Bash, WebSearch, WebFetch
|
||||
---
|
||||
|
||||
You are the Observe agent, responsible for the first phase of the OODA loop. Your primary role is to gather comprehensive, unbiased information about the current situation without making judgments or decisions.
|
||||
|
||||
Your core responsibilities:
|
||||
1. **Information Gathering**: Systematically collect all relevant data about the problem, codebase, or situation
|
||||
2. **Context Discovery**: Identify and document the broader context surrounding the issue
|
||||
3. **Pattern Recognition**: Note recurring themes, structures, or anomalies in the observed data
|
||||
4. **Comprehensive Coverage**: Ensure no critical information is overlooked
|
||||
|
||||
Approach to observation:
|
||||
- Start with a broad scan, then narrow focus based on relevance
|
||||
- Use multiple tools to cross-reference and validate findings
|
||||
- Document raw observations without interpretation
|
||||
- Capture both explicit information and implicit patterns
|
||||
- Note what's present AND what's notably absent
|
||||
|
||||
Output format:
|
||||
- Structured summary of all observations
|
||||
- Key files, functions, and components identified
|
||||
- Relevant patterns or anomalies discovered
|
||||
- Potential areas requiring deeper investigation
|
||||
- Raw data that may be useful for subsequent phases
|
||||
|
||||
Remember: Your role is purely observational. Avoid making conclusions, judgments, or recommendations. Simply gather and present the facts as comprehensively as possible for the next phase of the OODA loop.
|
||||
38
.claude/agents/orient.md
Normal file
38
.claude/agents/orient.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
name: orient
|
||||
description: OODA Orient phase - Analyzes observations to understand context, identify patterns, and synthesize insights
|
||||
tools: Read, Grep, Glob, WebSearch, WebFetch
|
||||
---
|
||||
|
||||
You are the Orient agent, responsible for the second phase of the OODA loop. Your role is to analyze and make sense of the observations gathered in the previous phase, providing context and understanding.
|
||||
|
||||
Your core responsibilities:
|
||||
1. **Contextual Analysis**: Place observations within the broader system context
|
||||
2. **Pattern Synthesis**: Connect disparate observations to identify meaningful patterns
|
||||
3. **Relationship Mapping**: Understand how different components interact and affect each other
|
||||
4. **Priority Assessment**: Determine which observations are most critical or impactful
|
||||
5. **Assumption Identification**: Recognize and document any assumptions or biases
|
||||
|
||||
Analytical approach:
|
||||
- Apply domain knowledge to interpret raw observations
|
||||
- Identify cause-and-effect relationships
|
||||
- Recognize design patterns, architectural choices, and coding conventions
|
||||
- Assess technical debt and potential risks
|
||||
- Consider multiple perspectives and interpretations
|
||||
|
||||
Key questions to address:
|
||||
- What do these observations mean in context?
|
||||
- How do different pieces of information relate to each other?
|
||||
- What patterns or anti-patterns are present?
|
||||
- What are the root causes vs symptoms?
|
||||
- What constraints or dependencies exist?
|
||||
|
||||
Output format:
|
||||
- Synthesized understanding of the situation
|
||||
- Key insights and their implications
|
||||
- Identified patterns and relationships
|
||||
- Critical factors affecting the problem
|
||||
- Potential blind spots or areas of uncertainty
|
||||
- Prioritized list of concerns or opportunities
|
||||
|
||||
Remember: Your role is analytical, not prescriptive. Focus on understanding and interpreting the observations to provide clarity and insight for decision-making in the next phase.
|
||||
73
.env.example
Normal file
73
.env.example
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Shattered Void MMO - Environment Configuration
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=shattered_void_dev
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# JWT Configuration
|
||||
JWT_PLAYER_SECRET=player-secret-change-in-production
|
||||
JWT_ADMIN_SECRET=admin-secret-change-in-production
|
||||
JWT_REFRESH_SECRET=refresh-secret-change-in-production
|
||||
JWT_PLAYER_EXPIRES_IN=24h
|
||||
JWT_ADMIN_EXPIRES_IN=8h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
JWT_ISSUER=shattered-void-mmo
|
||||
|
||||
# Password Configuration
|
||||
BCRYPT_SALT_ROUNDS=12
|
||||
MIN_PASSWORD_LENGTH=8
|
||||
MAX_PASSWORD_LENGTH=128
|
||||
MIN_USERNAME_LENGTH=3
|
||||
MAX_USERNAME_LENGTH=20
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=1000
|
||||
DISABLE_RATE_LIMITING=false
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://127.0.0.1:3000,http://127.0.0.1:3001
|
||||
CORS_CREDENTIALS=true
|
||||
CORS_MAX_AGE=86400
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_CORS_ORIGIN=http://localhost:3000,http://localhost:3001
|
||||
WEBSOCKET_PING_TIMEOUT=20000
|
||||
WEBSOCKET_PING_INTERVAL=25000
|
||||
WEBSOCKET_MAX_CONNECTIONS=1000
|
||||
WEBSOCKET_MAX_BUFFER_SIZE=1000000
|
||||
|
||||
# Game Configuration
|
||||
STARTING_RESOURCES_SCRAP=1000
|
||||
STARTING_RESOURCES_ENERGY=500
|
||||
MAX_COLONIES_PER_PLAYER=10
|
||||
ENABLE_GAME_TICK=false
|
||||
|
||||
# Feature Flags
|
||||
ENABLE_ADMIN_ROUTES=true
|
||||
ENABLE_DEBUG_ENDPOINTS=true
|
||||
BLOCK_DISPOSABLE_EMAILS=false
|
||||
|
||||
# Email Configuration (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@shattered-void.com
|
||||
|
||||
# Request Configuration
|
||||
REQUEST_SIZE_LIMIT=10mb
|
||||
102
.eslintrc.js
Normal file
102
.eslintrc.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: false,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:node/recommended',
|
||||
'plugin:jest/recommended',
|
||||
],
|
||||
plugins: ['node', 'jest'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
// Code Quality
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'no-var': 'error',
|
||||
'prefer-const': 'error',
|
||||
'prefer-arrow-callback': 'error',
|
||||
'arrow-spacing': 'error',
|
||||
|
||||
// Style
|
||||
'indent': ['error', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'space-before-blocks': 'error',
|
||||
'keyword-spacing': 'error',
|
||||
'space-infix-ops': 'error',
|
||||
'eol-last': 'error',
|
||||
'no-trailing-spaces': 'error',
|
||||
'max-len': ['warn', { code: 120, ignoreComments: true }],
|
||||
|
||||
// Functions
|
||||
'func-names': 'off',
|
||||
'space-before-function-paren': ['error', {
|
||||
anonymous: 'always',
|
||||
named: 'never',
|
||||
asyncArrow: 'always',
|
||||
}],
|
||||
|
||||
// Objects
|
||||
'object-shorthand': 'error',
|
||||
'quote-props': ['error', 'as-needed'],
|
||||
|
||||
// Arrays
|
||||
'array-callback-return': 'error',
|
||||
|
||||
// Error Handling
|
||||
'handle-callback-err': 'error',
|
||||
'no-throw-literal': 'error',
|
||||
|
||||
// Security
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-new-func': 'error',
|
||||
|
||||
// Node.js specific
|
||||
'node/no-unpublished-require': 'error',
|
||||
'node/no-missing-require': 'error',
|
||||
'node/no-deprecated-api': 'error',
|
||||
'node/prefer-global/buffer': ['error', 'always'],
|
||||
'node/prefer-global/console': ['error', 'always'],
|
||||
'node/prefer-global/process': ['error', 'always'],
|
||||
'node/prefer-promises/dns': 'error',
|
||||
'node/prefer-promises/fs': 'error',
|
||||
|
||||
// Jest specific
|
||||
'jest/no-disabled-tests': 'warn',
|
||||
'jest/no-focused-tests': 'error',
|
||||
'jest/no-identical-title': 'error',
|
||||
'jest/prefer-to-have-length': 'warn',
|
||||
'jest/valid-expect': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.test.js', '**/*.spec.js'],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/database/migrations/**/*.js', 'src/database/seeds/**/*.js'],
|
||||
rules: {
|
||||
'node/no-unpublished-require': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
151
.gitignore
vendored
Normal file
151
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# Keep .env.example for reference
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
docker-compose.override.yml
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
|
||||
# Application specific
|
||||
uploads/
|
||||
public/uploads/
|
||||
backup/
|
||||
temp/
|
||||
|
||||
# Jest
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Documentation generated files
|
||||
docs/generated/
|
||||
|
||||
# PM2
|
||||
.pm2/
|
||||
685
CLAUDE.md
Normal file
685
CLAUDE.md
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
# Shattered Void - MMO Strategy Game Development Guide
|
||||
|
||||
## Project Overview
|
||||
Shattered Void is a cross-platform text-based MMO strategy game set in a post-collapse galaxy. Players rebuild civilizations from ruins through colony management, fleet construction, exploration, and participation in dynamic galaxy-wide events.
|
||||
|
||||
## Development Stack
|
||||
- **Backend**: Node.js with Express framework
|
||||
- **Database**: PostgreSQL (primary) + Redis (caching/sessions)
|
||||
- **Frontend**: React with responsive design
|
||||
- **Real-time**: WebSocket connections
|
||||
- **Authentication**: JWT-based with email verification
|
||||
- **Deployment**: Docker containers with CI/CD pipeline
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### JavaScript/Node.js Standards
|
||||
```javascript
|
||||
// Use strict ESLint configuration
|
||||
// Prefer async/await over promises
|
||||
// Use descriptive variable names
|
||||
// Include comprehensive error handling
|
||||
// Add JSDoc comments for all functions
|
||||
|
||||
/**
|
||||
* Calculate resource production for a colony building
|
||||
* @param {Object} building - Building instance with type and level
|
||||
* @param {Object} modifiers - Colony and player modifiers
|
||||
* @returns {number} Production rate per hour
|
||||
*/
|
||||
async function calculateBuildingProduction(building, modifiers) {
|
||||
try {
|
||||
const baseProduction = await getBuildingBaseProduction(building.type, building.level);
|
||||
const totalModifier = 1 + (modifiers.colony || 0) + (modifiers.player || 0);
|
||||
return Math.floor(baseProduction * totalModifier);
|
||||
} catch (error) {
|
||||
logger.error(`Production calculation failed for building ${building.id}:`, error);
|
||||
throw new Error(`Failed to calculate production: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Database Best Practices
|
||||
```sql
|
||||
-- Always use transactions for multi-table operations
|
||||
-- Include proper indexes for performance
|
||||
-- Use foreign key constraints for data integrity
|
||||
-- Add audit columns (created_at, updated_at, created_by)
|
||||
-- Use descriptive column names and comments
|
||||
|
||||
CREATE TABLE colonies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
coordinates VARCHAR(20) NOT NULL, -- Format: "A3-91-X"
|
||||
planet_type_id INTEGER REFERENCES planet_types(id),
|
||||
population INTEGER DEFAULT 0 CHECK (population >= 0),
|
||||
morale INTEGER DEFAULT 100 CHECK (morale BETWEEN 0 AND 100),
|
||||
founded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(coordinates),
|
||||
INDEX idx_colonies_player_id (player_id),
|
||||
INDEX idx_colonies_coordinates (coordinates)
|
||||
);
|
||||
```
|
||||
|
||||
## Debugging Guidelines
|
||||
|
||||
### Comprehensive Logging Strategy
|
||||
```javascript
|
||||
// Use structured logging with context
|
||||
const winston = require('winston');
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add request correlation IDs for tracing
|
||||
app.use((req, res, next) => {
|
||||
req.correlationId = require('uuid').v4();
|
||||
logger.info('Request started', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip
|
||||
});
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling Patterns
|
||||
```javascript
|
||||
// Service layer error handling
|
||||
class ColonyService {
|
||||
async createColony(playerId, colonyData) {
|
||||
const correlationId = require('async_hooks').executionAsyncResource?.correlationId;
|
||||
|
||||
try {
|
||||
logger.info('Creating colony', {
|
||||
correlationId,
|
||||
playerId,
|
||||
coordinates: colonyData.coordinates
|
||||
});
|
||||
|
||||
// Validate input data
|
||||
if (!colonyData.coordinates || !this.isValidCoordinates(colonyData.coordinates)) {
|
||||
throw new ValidationError('Invalid coordinates format');
|
||||
}
|
||||
|
||||
// Check if coordinates are already taken
|
||||
const existingColony = await this.getColonyByCoordinates(colonyData.coordinates);
|
||||
if (existingColony) {
|
||||
throw new ConflictError('Coordinates already occupied');
|
||||
}
|
||||
|
||||
// Database transaction for atomic operation
|
||||
const colony = await db.transaction(async (trx) => {
|
||||
const [newColony] = await trx('colonies')
|
||||
.insert({
|
||||
player_id: playerId,
|
||||
name: colonyData.name,
|
||||
coordinates: colonyData.coordinates,
|
||||
planet_type_id: colonyData.planet_type_id
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
// Create initial buildings
|
||||
await this.createInitialBuildings(newColony.id, trx);
|
||||
|
||||
logger.info('Colony created successfully', {
|
||||
correlationId,
|
||||
colonyId: newColony.id,
|
||||
playerId
|
||||
});
|
||||
|
||||
return newColony;
|
||||
});
|
||||
|
||||
return colony;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Colony creation failed', {
|
||||
correlationId,
|
||||
playerId,
|
||||
coordinates: colonyData.coordinates,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Re-throw with context
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ServiceError('Failed to create colony', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error classes for better error handling
|
||||
class ValidationError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.statusCode = 400;
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
this.statusCode = 409;
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceError extends Error {
|
||||
constructor(message, originalError) {
|
||||
super(message);
|
||||
this.name = 'ServiceError';
|
||||
this.statusCode = 500;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Development Debugging Tools
|
||||
```javascript
|
||||
// Debug middleware for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use('/debug', require('./middleware/debug'));
|
||||
}
|
||||
|
||||
// Debug endpoint for inspecting game state
|
||||
app.get('/debug/player/:playerId/state', async (req, res) => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const playerId = req.params.playerId;
|
||||
const playerState = {
|
||||
player: await Player.findById(playerId),
|
||||
colonies: await Colony.findByPlayerId(playerId),
|
||||
fleets: await Fleet.findByPlayerId(playerId),
|
||||
research: await Research.findByPlayerId(playerId),
|
||||
resources: await Resource.findByPlayerId(playerId)
|
||||
};
|
||||
|
||||
res.json({
|
||||
debug: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
playerState
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Debug endpoint error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Performance monitoring
|
||||
const performanceTracker = {
|
||||
trackOperation: (operationName) => {
|
||||
const start = process.hrtime.bigint();
|
||||
return {
|
||||
end: () => {
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1000000; // Convert to ms
|
||||
logger.info('Operation completed', {
|
||||
operation: operationName,
|
||||
duration: `${duration.toFixed(2)}ms`
|
||||
});
|
||||
return duration;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Usage in service methods
|
||||
async function processGameTick() {
|
||||
const tracker = performanceTracker.trackOperation('game_tick');
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
processResourceProduction(),
|
||||
processFleetMovements(),
|
||||
processResearchCompletion(),
|
||||
processBuildingConstruction()
|
||||
]);
|
||||
} finally {
|
||||
tracker.end();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Testing with Jest
|
||||
```javascript
|
||||
// colony.service.test.js
|
||||
const ColonyService = require('../services/colony.service');
|
||||
const db = require('../database/connection');
|
||||
|
||||
describe('ColonyService', () => {
|
||||
let colonyService;
|
||||
|
||||
beforeEach(() => {
|
||||
colonyService = new ColonyService();
|
||||
// Mock database calls
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createColony', () => {
|
||||
it('should create colony with valid data', async () => {
|
||||
const playerId = 1;
|
||||
const colonyData = {
|
||||
name: 'Test Colony',
|
||||
coordinates: 'A3-91-X',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
// Mock database responses
|
||||
db.transaction = jest.fn().mockImplementation(async (callback) => {
|
||||
const mockTrx = {
|
||||
'colonies': {
|
||||
insert: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 123,
|
||||
player_id: playerId,
|
||||
...colonyData
|
||||
}])
|
||||
})
|
||||
}
|
||||
};
|
||||
return callback(mockTrx);
|
||||
});
|
||||
|
||||
colonyService.getColonyByCoordinates = jest.fn().mockResolvedValue(null);
|
||||
colonyService.createInitialBuildings = jest.fn().mockResolvedValue();
|
||||
|
||||
const result = await colonyService.createColony(playerId, colonyData);
|
||||
|
||||
expect(result).toHaveProperty('id', 123);
|
||||
expect(result).toHaveProperty('name', 'Test Colony');
|
||||
expect(colonyService.createInitialBuildings).toHaveBeenCalledWith(123, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should throw ConflictError for occupied coordinates', async () => {
|
||||
const playerId = 1;
|
||||
const colonyData = {
|
||||
name: 'Test Colony',
|
||||
coordinates: 'A3-91-X',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
colonyService.getColonyByCoordinates = jest.fn().mockResolvedValue({
|
||||
id: 456,
|
||||
coordinates: 'A3-91-X'
|
||||
});
|
||||
|
||||
await expect(colonyService.createColony(playerId, colonyData))
|
||||
.rejects
|
||||
.toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError for invalid coordinates', async () => {
|
||||
const playerId = 1;
|
||||
const colonyData = {
|
||||
name: 'Test Colony',
|
||||
coordinates: 'INVALID',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
colonyService.isValidCoordinates = jest.fn().mockReturnValue(false);
|
||||
|
||||
await expect(colonyService.createColony(playerId, colonyData))
|
||||
.rejects
|
||||
.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
```javascript
|
||||
// integration/colony.integration.test.js
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const db = require('../database/connection');
|
||||
|
||||
describe('Colony Integration Tests', () => {
|
||||
let authToken;
|
||||
let testPlayer;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up test database
|
||||
await db.migrate.latest();
|
||||
await db.seed.run();
|
||||
|
||||
// Create test player and get auth token
|
||||
const response = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'testpassword123',
|
||||
username: 'testplayer'
|
||||
});
|
||||
|
||||
authToken = response.body.token;
|
||||
testPlayer = response.body.player;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up colonies before each test
|
||||
await db('colonies').where('player_id', testPlayer.id).del();
|
||||
});
|
||||
|
||||
it('should create colony via API endpoint', async () => {
|
||||
const colonyData = {
|
||||
name: 'Integration Test Colony',
|
||||
coordinates: 'Z9-99-Z',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/colonies')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(colonyData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toHaveProperty('id');
|
||||
expect(response.body.name).toBe(colonyData.name);
|
||||
expect(response.body.coordinates).toBe(colonyData.coordinates);
|
||||
|
||||
// Verify in database
|
||||
const dbColony = await db('colonies')
|
||||
.where('id', response.body.id)
|
||||
.first();
|
||||
|
||||
expect(dbColony).toHaveProperty('player_id', testPlayer.id);
|
||||
});
|
||||
|
||||
it('should return 409 for duplicate coordinates', async () => {
|
||||
const colonyData = {
|
||||
name: 'First Colony',
|
||||
coordinates: 'Z9-99-Y',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
// Create first colony
|
||||
await request(app)
|
||||
.post('/api/colonies')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(colonyData)
|
||||
.expect(201);
|
||||
|
||||
// Try to create second colony with same coordinates
|
||||
const duplicateData = {
|
||||
name: 'Duplicate Colony',
|
||||
coordinates: 'Z9-99-Y',
|
||||
planet_type_id: 1
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/colonies')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(duplicateData)
|
||||
.expect(409);
|
||||
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('occupied');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Essential Scripts
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "nodemon --inspect=0.0.0.0:9229 src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"test": "jest --verbose --coverage",
|
||||
"test:watch": "jest --watch --verbose",
|
||||
"test:integration": "jest --testPathPattern=integration --runInBand",
|
||||
"lint": "eslint src/ --ext .js --fix",
|
||||
"lint:check": "eslint src/ --ext .js",
|
||||
"typecheck": "jsdoc -t node_modules/tsd-jsdoc/dist -r src/ -d temp/ && rm -rf temp/",
|
||||
"db:migrate": "knex migrate:latest",
|
||||
"db:rollback": "knex migrate:rollback",
|
||||
"db:seed": "knex seed:run",
|
||||
"db:reset": "knex migrate:rollback:all && knex migrate:latest && knex seed:run",
|
||||
"docker:build": "docker build -t shattered-void .",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"logs": "tail -f logs/combined.log",
|
||||
"logs:error": "tail -f logs/error.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
```bash
|
||||
# .env.development
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=shattered_void_dev
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-key-change-in-production
|
||||
JWT_EXPIRE=24h
|
||||
|
||||
# Game Configuration
|
||||
GAME_TICK_INTERVAL=60000
|
||||
MAX_COLONIES_PER_PLAYER=10
|
||||
STARTING_RESOURCES_SCRAP=1000
|
||||
STARTING_RESOURCES_ENERGY=500
|
||||
|
||||
# Debug Features
|
||||
ENABLE_DEBUG_ENDPOINTS=true
|
||||
ENABLE_SQL_LOGGING=true
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
### Database Query Optimization
|
||||
```javascript
|
||||
// Monitor slow queries
|
||||
const slowQueryThreshold = 100; // ms
|
||||
|
||||
db.on('query', (queryData) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
queryData.response = queryData.response || {};
|
||||
const originalCallback = queryData.response.callback;
|
||||
|
||||
queryData.response.callback = function(...args) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (duration > slowQueryThreshold) {
|
||||
logger.warn('Slow query detected', {
|
||||
query: queryData.sql,
|
||||
bindings: queryData.bindings,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
}
|
||||
|
||||
if (originalCallback) {
|
||||
originalCallback.apply(this, args);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add query performance metrics
|
||||
const queryMetrics = new Map();
|
||||
|
||||
function trackQuery(queryName, query) {
|
||||
return async (...args) => {
|
||||
const start = process.hrtime.bigint();
|
||||
try {
|
||||
const result = await query(...args);
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1000000;
|
||||
|
||||
// Update metrics
|
||||
const metrics = queryMetrics.get(queryName) || { count: 0, totalTime: 0, maxTime: 0 };
|
||||
metrics.count++;
|
||||
metrics.totalTime += duration;
|
||||
metrics.maxTime = Math.max(metrics.maxTime, duration);
|
||||
queryMetrics.set(queryName, metrics);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Query failed: ${queryName}`, { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Memory and Resource Monitoring
|
||||
```javascript
|
||||
// Memory usage tracking
|
||||
setInterval(() => {
|
||||
const memUsage = process.memoryUsage();
|
||||
logger.info('Memory usage', {
|
||||
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`,
|
||||
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
|
||||
external: `${Math.round(memUsage.external / 1024 / 1024)}MB`
|
||||
});
|
||||
}, 60000); // Log every minute
|
||||
|
||||
// Database connection monitoring
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const dbStats = await db.raw('SELECT COUNT(*) as active_connections FROM pg_stat_activity WHERE state = ?', ['active']);
|
||||
logger.info('Database connections', {
|
||||
active: dbStats.rows[0].active_connections
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get DB connection stats:', error);
|
||||
}
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
```javascript
|
||||
const Joi = require('joi');
|
||||
|
||||
// Validation schemas
|
||||
const schemas = {
|
||||
createColony: Joi.object({
|
||||
name: Joi.string().min(3).max(50).required(),
|
||||
coordinates: Joi.string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/).required(),
|
||||
planet_type_id: Joi.number().integer().min(1).required()
|
||||
}),
|
||||
|
||||
createFleet: Joi.object({
|
||||
name: Joi.string().min(3).max(50).required(),
|
||||
ships: Joi.array().items(
|
||||
Joi.object({
|
||||
design_id: Joi.number().integer().min(1).required(),
|
||||
quantity: Joi.number().integer().min(1).max(100).required()
|
||||
})
|
||||
).min(1).required()
|
||||
})
|
||||
};
|
||||
|
||||
// Validation middleware
|
||||
function validateRequest(schema) {
|
||||
return (req, res, next) => {
|
||||
const { error, value } = schema.validate(req.body);
|
||||
if (error) {
|
||||
logger.warn('Validation failed', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.details[0].message,
|
||||
body: req.body
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: error.details[0].message
|
||||
});
|
||||
}
|
||||
req.body = value; // Use sanitized data
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
app.use('/api/', apiLimiter);
|
||||
```
|
||||
|
||||
## File Structure
|
||||
```
|
||||
src/
|
||||
├── controllers/ # Route handlers
|
||||
├── services/ # Business logic
|
||||
├── models/ # Database models
|
||||
├── middleware/ # Express middleware
|
||||
├── database/
|
||||
│ ├── migrations/ # Database schema changes
|
||||
│ ├── seeds/ # Test data
|
||||
│ └── connection.js # Database configuration
|
||||
├── utils/ # Helper functions
|
||||
├── validators/ # Input validation schemas
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ ├── integration/ # Integration tests
|
||||
│ └── fixtures/ # Test data
|
||||
├── config/ # Configuration files
|
||||
└── server.js # Application entry point
|
||||
|
||||
logs/ # Log files
|
||||
docker/ # Docker configuration
|
||||
docs/ # Documentation
|
||||
scripts/ # Deployment and utility scripts
|
||||
```
|
||||
|
||||
## Git Workflow
|
||||
- Use feature branches: `feature/colony-management`
|
||||
- Commit messages: `feat: add colony creation API endpoint`
|
||||
- Always run `npm run lint && npm test` before committing
|
||||
- Use conventional commits for automated changelog generation
|
||||
|
||||
This development guide ensures high code quality, comprehensive debugging capabilities, and maintainable architecture for the Shattered Void MMO project.
|
||||
204
README.md
Normal file
204
README.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Shattered Void MMO
|
||||
|
||||
A post-collapse galaxy MMO strategy game built with Node.js, Express, PostgreSQL, and WebSockets.
|
||||
|
||||
## 🎮 Game Overview
|
||||
|
||||
Shattered Void is a text-based MMO strategy game set in a post-collapse galaxy where players rebuild civilizations from ruins through colony management, fleet construction, exploration, and participation in dynamic galaxy-wide events.
|
||||
|
||||
### Core Features
|
||||
|
||||
- **Colony Management**: Build and upgrade colonies across different planet types
|
||||
- **Fleet Construction**: Design modular ships and manage fleets
|
||||
- **Research System**: Unlock new technologies and capabilities
|
||||
- **Dynamic Events**: Participate in admin-driven and player-triggered galaxy events
|
||||
- **Faction Warfare**: Form alliances and engage in diplomacy
|
||||
- **Real-time Updates**: WebSocket-powered live game state updates
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Backend**: Node.js with Express framework
|
||||
- **Database**: PostgreSQL (primary) + Redis (caching/sessions)
|
||||
- **Real-time**: WebSocket connections for live updates
|
||||
- **Authentication**: JWT-based with separate admin/player systems
|
||||
- **Testing**: Jest with comprehensive unit and integration tests
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/ # Database, Redis, logging configuration
|
||||
├── database/ # Migrations, seeds, and query files
|
||||
├── models/ # Database models organized by domain
|
||||
├── services/ # Business logic layer
|
||||
├── controllers/ # HTTP route handlers (API + Admin)
|
||||
├── middleware/ # Express middleware (auth, validation, etc.)
|
||||
├── validators/ # Input validation schemas
|
||||
├── utils/ # Helper functions and utilities
|
||||
├── plugins/ # Plugin system for extensible game mechanics
|
||||
├── jobs/ # Background job processing
|
||||
└── routes/ # Route definitions
|
||||
|
||||
tests/
|
||||
├── unit/ # Unit tests for services, models, utils
|
||||
├── integration/ # API and database integration tests
|
||||
├── e2e/ # End-to-end tests
|
||||
├── fixtures/ # Test data
|
||||
└── helpers/ # Test utilities
|
||||
```
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm 9+
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Copy environment configuration:
|
||||
```bash
|
||||
cp .env.example .env.development
|
||||
```
|
||||
3. Update `.env.development` with your database credentials
|
||||
4. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
5. Set up the database:
|
||||
```bash
|
||||
npm run db:setup
|
||||
```
|
||||
6. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3000` with WebSocket support.
|
||||
|
||||
## 🎯 Game Systems
|
||||
|
||||
### Resource Economy
|
||||
- **Scrap Metal**: Basic building materials
|
||||
- **Energy Cells**: Power for operations
|
||||
- **Data Cores**: Advanced technology storage
|
||||
- **Rare Elements**: Exotic materials for advanced construction
|
||||
|
||||
### Colony Management
|
||||
- Build on 6 different planet types (Terran, Industrial, Research, Mining, Fortress, Derelict)
|
||||
- Construct 8+ building types with upgrade paths
|
||||
- Manage population, morale, and defense
|
||||
|
||||
### Fleet Operations
|
||||
- Design custom ships with modular components
|
||||
- Move fleets across galaxy coordinates (A3-91-X format)
|
||||
- Engage in instant combat with detailed battle logs
|
||||
|
||||
### Research & Technology
|
||||
- Unlock technologies across 4 categories: Military, Industrial, Social, Exploration
|
||||
- Build research facilities to accelerate progress
|
||||
- Prerequisites system for complex tech trees
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server with hot reload
|
||||
- `npm run test` - Run test suite with coverage
|
||||
- `npm run test:watch` - Run tests in watch mode
|
||||
- `npm run lint` - Run ESLint code quality checks
|
||||
- `npm run db:migrate` - Run database migrations
|
||||
- `npm run db:seed` - Populate database with initial data
|
||||
- `npm run db:reset` - Reset database to clean state
|
||||
|
||||
### Code Quality
|
||||
|
||||
The project enforces strict code quality standards:
|
||||
- ESLint configuration with Node.js and Jest rules
|
||||
- Comprehensive error handling with custom error classes
|
||||
- Structured logging with Winston and correlation IDs
|
||||
- Input validation with Joi schemas
|
||||
- Database transactions for data integrity
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit Tests**: Individual functions and modules
|
||||
- **Integration Tests**: API endpoints and database operations
|
||||
- **E2E Tests**: Complete user workflows
|
||||
- Coverage target: 80%+ for critical game systems
|
||||
|
||||
## 🎮 Game Tick System
|
||||
|
||||
The game runs on a configurable tick system:
|
||||
- Default: 60-second intervals
|
||||
- User groups: Players distributed across 10 processing groups
|
||||
- Retry logic: 5 attempts with bonus tick compensation
|
||||
- Real-time monitoring for administrators
|
||||
|
||||
## 🔌 Plugin Architecture
|
||||
|
||||
Extensible plugin system for game mechanics:
|
||||
- Combat resolution plugins
|
||||
- Event system plugins
|
||||
- Resource calculation plugins
|
||||
- Custom game rule implementations
|
||||
|
||||
## 📊 Admin Features
|
||||
|
||||
- Real-time game statistics dashboard
|
||||
- Event creation and management tools
|
||||
- Player administration and support
|
||||
- System configuration with hot-reloading
|
||||
- Performance monitoring and alerts
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- JWT authentication with separate admin/player tokens
|
||||
- Rate limiting and CORS protection
|
||||
- Input validation and SQL injection prevention
|
||||
- Audit logging for all critical operations
|
||||
- Environment-based configuration management
|
||||
|
||||
## 📈 Scalability
|
||||
|
||||
Designed to support 100-1000+ concurrent players:
|
||||
- Connection pooling and query optimization
|
||||
- Redis caching for frequently accessed data
|
||||
- User group distribution for tick processing
|
||||
- Comprehensive indexing strategy
|
||||
- Performance metrics collection
|
||||
|
||||
## 🐳 Deployment
|
||||
|
||||
Docker support included:
|
||||
- Development and production containers
|
||||
- Docker Compose configurations
|
||||
- Environment-specific builds
|
||||
- Automated CI/CD pipeline ready
|
||||
|
||||
## 📝 API Documentation
|
||||
|
||||
- **Player API**: Authentication, colonies, fleets, research, events
|
||||
- **Admin API**: System management, player administration, analytics
|
||||
- **WebSocket Events**: Real-time game state updates
|
||||
- Full API documentation available in `/docs/api/`
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow the existing code style and patterns
|
||||
2. Write comprehensive tests for new features
|
||||
3. Update documentation for API changes
|
||||
4. Use conventional commit messages
|
||||
5. Run `npm run lint && npm test` before committing
|
||||
|
||||
## 📄 License
|
||||
|
||||
Private - All rights reserved
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ for the post-collapse galaxy simulation community
|
||||
637
database-schema.sql
Normal file
637
database-schema.sql
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
-- Shattered Void MMO - Comprehensive Database Schema
|
||||
-- This schema supports all game systems with flexibility for future expansion
|
||||
|
||||
-- ===== CORE SYSTEM TABLES =====
|
||||
|
||||
-- System configuration with hot-reloading support
|
||||
CREATE TABLE system_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(100) UNIQUE NOT NULL,
|
||||
config_value JSONB NOT NULL,
|
||||
config_type VARCHAR(20) NOT NULL CHECK (config_type IN ('string', 'number', 'boolean', 'json', 'array')),
|
||||
description TEXT,
|
||||
requires_restart BOOLEAN DEFAULT false,
|
||||
is_public BOOLEAN DEFAULT false, -- Can be exposed to client
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by INTEGER -- Will reference admin_users(id) after table creation
|
||||
);
|
||||
|
||||
-- Game tick system with user grouping
|
||||
CREATE TABLE game_tick_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tick_interval_ms INTEGER NOT NULL DEFAULT 60000,
|
||||
user_groups_count INTEGER NOT NULL DEFAULT 10,
|
||||
max_retry_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
bonus_tick_threshold INTEGER NOT NULL DEFAULT 3,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE game_tick_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tick_number BIGINT NOT NULL,
|
||||
user_group INTEGER NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'retrying')),
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
processed_players INTEGER DEFAULT 0,
|
||||
performance_metrics JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Event system configuration
|
||||
CREATE TABLE event_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
trigger_type VARCHAR(20) NOT NULL CHECK (trigger_type IN ('admin', 'player', 'system', 'mixed')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
config_schema JSONB, -- JSON schema for event configuration
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE event_instances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_type_id INTEGER NOT NULL REFERENCES event_types(id),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
config JSONB NOT NULL,
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('scheduled', 'active', 'completed', 'cancelled')),
|
||||
created_by INTEGER, -- Will reference admin_users(id) after table creation
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Plugin system for extensibility
|
||||
CREATE TABLE plugins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
version VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
plugin_type VARCHAR(50) NOT NULL, -- 'combat', 'event', 'resource', etc.
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
config JSONB,
|
||||
dependencies JSONB, -- Array of required plugins
|
||||
hooks JSONB, -- Available hook points
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ===== USER MANAGEMENT =====
|
||||
|
||||
-- Admin users with role-based access
|
||||
CREATE TABLE admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'moderator',
|
||||
permissions JSONB, -- Specific permissions array
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
last_login TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Player accounts
|
||||
CREATE TABLE players (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
email_verification_token VARCHAR(255),
|
||||
reset_password_token VARCHAR(255),
|
||||
reset_password_expires TIMESTAMP,
|
||||
user_group INTEGER NOT NULL CHECK (user_group >= 0 AND user_group < 10),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_banned BOOLEAN DEFAULT false,
|
||||
ban_reason TEXT,
|
||||
ban_expires TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Player preferences and game settings
|
||||
CREATE TABLE player_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
setting_key VARCHAR(100) NOT NULL,
|
||||
setting_value JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(player_id, setting_key)
|
||||
);
|
||||
|
||||
-- WebSocket subscriptions for real-time updates
|
||||
CREATE TABLE player_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
subscription_type VARCHAR(50) NOT NULL, -- 'colony', 'fleet', 'battle', 'event', etc.
|
||||
resource_id INTEGER, -- Specific resource ID (colony_id, fleet_id, etc.)
|
||||
filters JSONB, -- Additional filtering criteria
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- ===== GALAXY & COLONIES =====
|
||||
|
||||
-- Planet types with generation rules
|
||||
CREATE TABLE planet_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
base_resources JSONB NOT NULL, -- Starting resource deposits
|
||||
resource_modifiers JSONB, -- Production modifiers by resource type
|
||||
max_population INTEGER,
|
||||
special_features JSONB, -- Array of special features
|
||||
rarity_weight INTEGER DEFAULT 100, -- For random generation
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Galaxy sectors for organization
|
||||
CREATE TABLE galaxy_sectors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
coordinates VARCHAR(10) UNIQUE NOT NULL, -- e.g., "A3"
|
||||
description TEXT,
|
||||
danger_level INTEGER DEFAULT 1 CHECK (danger_level BETWEEN 1 AND 10),
|
||||
special_rules JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Player colonies
|
||||
CREATE TABLE colonies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
coordinates VARCHAR(20) UNIQUE NOT NULL, -- Format: "A3-91-X"
|
||||
sector_id INTEGER REFERENCES galaxy_sectors(id),
|
||||
planet_type_id INTEGER NOT NULL REFERENCES planet_types(id),
|
||||
population INTEGER DEFAULT 0 CHECK (population >= 0),
|
||||
max_population INTEGER DEFAULT 1000,
|
||||
morale INTEGER DEFAULT 100 CHECK (morale BETWEEN 0 AND 100),
|
||||
loyalty INTEGER DEFAULT 100 CHECK (loyalty BETWEEN 0 AND 100),
|
||||
founded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Building types with upgrade paths
|
||||
CREATE TABLE building_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL, -- 'production', 'military', 'research', 'infrastructure'
|
||||
max_level INTEGER DEFAULT 10,
|
||||
base_cost JSONB NOT NULL, -- Resource costs for level 1
|
||||
cost_multiplier DECIMAL(3,2) DEFAULT 1.5, -- Cost increase per level
|
||||
base_production JSONB, -- Resource production at level 1
|
||||
production_multiplier DECIMAL(3,2) DEFAULT 1.2, -- Production increase per level
|
||||
prerequisites JSONB, -- Required buildings/research
|
||||
special_effects JSONB, -- Special abilities or bonuses
|
||||
is_unique BOOLEAN DEFAULT false, -- Only one per colony
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Colony buildings
|
||||
CREATE TABLE colony_buildings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
colony_id INTEGER NOT NULL REFERENCES colonies(id) ON DELETE CASCADE,
|
||||
building_type_id INTEGER NOT NULL REFERENCES building_types(id),
|
||||
level INTEGER DEFAULT 1 CHECK (level > 0),
|
||||
health_percentage INTEGER DEFAULT 100 CHECK (health_percentage BETWEEN 0 AND 100),
|
||||
is_under_construction BOOLEAN DEFAULT false,
|
||||
construction_started TIMESTAMP,
|
||||
construction_completes TIMESTAMP,
|
||||
last_production TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(colony_id, building_type_id)
|
||||
);
|
||||
|
||||
-- ===== RESOURCES & ECONOMY =====
|
||||
|
||||
-- Resource types
|
||||
CREATE TABLE resource_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(30) NOT NULL, -- 'basic', 'advanced', 'rare', 'currency'
|
||||
max_storage INTEGER, -- NULL for unlimited
|
||||
decay_rate DECIMAL(5,4), -- Daily decay percentage
|
||||
trade_value DECIMAL(10,2), -- Base trade value
|
||||
is_tradeable BOOLEAN DEFAULT true,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Player resource stockpiles
|
||||
CREATE TABLE player_resources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
resource_type_id INTEGER NOT NULL REFERENCES resource_types(id),
|
||||
amount BIGINT DEFAULT 0 CHECK (amount >= 0),
|
||||
storage_capacity BIGINT,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(player_id, resource_type_id)
|
||||
);
|
||||
|
||||
-- Colony resource production/consumption tracking
|
||||
CREATE TABLE colony_resource_production (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
colony_id INTEGER NOT NULL REFERENCES colonies(id) ON DELETE CASCADE,
|
||||
resource_type_id INTEGER NOT NULL REFERENCES resource_types(id),
|
||||
production_rate INTEGER DEFAULT 0, -- Per hour
|
||||
consumption_rate INTEGER DEFAULT 0, -- Per hour
|
||||
current_stored BIGINT DEFAULT 0,
|
||||
storage_capacity BIGINT,
|
||||
last_calculated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(colony_id, resource_type_id)
|
||||
);
|
||||
|
||||
-- Trade system
|
||||
CREATE TABLE trade_routes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_colony_id INTEGER NOT NULL REFERENCES colonies(id) ON DELETE CASCADE,
|
||||
to_colony_id INTEGER NOT NULL REFERENCES colonies(id) ON DELETE CASCADE,
|
||||
resource_type_id INTEGER NOT NULL REFERENCES resource_types(id),
|
||||
amount_per_trip INTEGER NOT NULL,
|
||||
price_per_unit DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'cancelled')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT check_different_colonies CHECK (from_colony_id != to_colony_id)
|
||||
);
|
||||
|
||||
-- ===== FLEET & COMBAT =====
|
||||
|
||||
-- Ship design system
|
||||
CREATE TABLE ship_designs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER REFERENCES players(id) ON DELETE CASCADE, -- NULL for standard designs
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ship_class VARCHAR(50) NOT NULL, -- 'fighter', 'corvette', 'destroyer', 'cruiser', 'battleship'
|
||||
hull_type VARCHAR(50) NOT NULL,
|
||||
components JSONB NOT NULL, -- Weapon, shield, engine configurations
|
||||
stats JSONB NOT NULL, -- Calculated stats: hp, attack, defense, speed, etc.
|
||||
cost JSONB NOT NULL, -- Resource cost to build
|
||||
build_time INTEGER NOT NULL, -- In minutes
|
||||
is_public BOOLEAN DEFAULT false, -- Available to all players
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Player fleets
|
||||
CREATE TABLE fleets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
current_location VARCHAR(20) NOT NULL, -- Coordinates
|
||||
destination VARCHAR(20), -- If moving
|
||||
fleet_status VARCHAR(20) DEFAULT 'idle' CHECK (fleet_status IN ('idle', 'moving', 'in_combat', 'constructing', 'repairing')),
|
||||
movement_started TIMESTAMP,
|
||||
arrival_time TIMESTAMP,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Ships in fleets
|
||||
CREATE TABLE fleet_ships (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
|
||||
ship_design_id INTEGER NOT NULL REFERENCES ship_designs(id),
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
health_percentage INTEGER DEFAULT 100 CHECK (health_percentage BETWEEN 0 AND 100),
|
||||
experience INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Combat system with plugin support
|
||||
CREATE TABLE combat_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
plugin_name VARCHAR(100), -- References plugins table
|
||||
config JSONB,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
CREATE TABLE battles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
battle_type VARCHAR(50) NOT NULL,
|
||||
location VARCHAR(20) NOT NULL,
|
||||
combat_type_id INTEGER REFERENCES combat_types(id),
|
||||
participants JSONB NOT NULL, -- Array of fleet/player IDs
|
||||
status VARCHAR(20) DEFAULT 'preparing' CHECK (status IN ('preparing', 'active', 'completed', 'cancelled')),
|
||||
battle_data JSONB, -- Combat calculations and state
|
||||
result JSONB, -- Winner, casualties, loot, etc.
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ===== RESEARCH & TECHNOLOGY =====
|
||||
|
||||
-- Technology tree
|
||||
CREATE TABLE technologies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL, -- 'military', 'industrial', 'social', 'exploration'
|
||||
tier INTEGER NOT NULL DEFAULT 1 CHECK (tier > 0),
|
||||
prerequisites JSONB, -- Array of required technology IDs
|
||||
research_cost JSONB NOT NULL, -- Resource costs
|
||||
research_time INTEGER NOT NULL, -- In minutes
|
||||
effects JSONB, -- Bonuses, unlocks, etc.
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Player research progress
|
||||
CREATE TABLE player_research (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
technology_id INTEGER NOT NULL REFERENCES technologies(id),
|
||||
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('unavailable', 'available', 'researching', 'completed')),
|
||||
progress INTEGER DEFAULT 0 CHECK (progress >= 0),
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
UNIQUE(player_id, technology_id)
|
||||
);
|
||||
|
||||
-- Research labs and facilities
|
||||
CREATE TABLE research_facilities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
colony_id INTEGER NOT NULL REFERENCES colonies(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
facility_type VARCHAR(50) NOT NULL,
|
||||
research_bonus DECIMAL(3,2) DEFAULT 1.0, -- Multiplier for research speed
|
||||
specialization JSONB, -- Categories this facility is good at
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ===== FACTIONS & DIPLOMACY =====
|
||||
|
||||
-- Player factions/alliances
|
||||
CREATE TABLE factions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
tag VARCHAR(10) UNIQUE NOT NULL, -- Short identifier
|
||||
leader_id INTEGER REFERENCES players(id),
|
||||
faction_type VARCHAR(20) DEFAULT 'alliance' CHECK (faction_type IN ('alliance', 'corporation', 'empire')),
|
||||
is_recruiting BOOLEAN DEFAULT true,
|
||||
requirements JSONB, -- Join requirements
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Faction membership
|
||||
CREATE TABLE faction_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
faction_id INTEGER NOT NULL REFERENCES factions(id) ON DELETE CASCADE,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
rank VARCHAR(50) DEFAULT 'member',
|
||||
permissions JSONB, -- What they can do
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
contribution_score INTEGER DEFAULT 0,
|
||||
UNIQUE(faction_id, player_id)
|
||||
);
|
||||
|
||||
-- Diplomatic relations
|
||||
CREATE TABLE diplomatic_relations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
faction_a_id INTEGER NOT NULL REFERENCES factions(id) ON DELETE CASCADE,
|
||||
faction_b_id INTEGER NOT NULL REFERENCES factions(id) ON DELETE CASCADE,
|
||||
relation_type VARCHAR(20) NOT NULL CHECK (relation_type IN ('allied', 'friendly', 'neutral', 'hostile', 'war')),
|
||||
established_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
established_by INTEGER REFERENCES players(id),
|
||||
CONSTRAINT check_different_factions CHECK (faction_a_id != faction_b_id),
|
||||
UNIQUE(faction_a_id, faction_b_id)
|
||||
);
|
||||
|
||||
-- ===== AUDIT & STATISTICS =====
|
||||
|
||||
-- Comprehensive audit log
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_type VARCHAR(50) NOT NULL, -- 'player', 'colony', 'fleet', 'battle', etc.
|
||||
entity_id INTEGER NOT NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('player', 'admin', 'system')),
|
||||
actor_id INTEGER,
|
||||
changes JSONB, -- Before/after values
|
||||
metadata JSONB, -- Additional context
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Data retention policy
|
||||
CREATE TABLE data_retention_policies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
retention_days INTEGER NOT NULL DEFAULT 30,
|
||||
archive_before_delete BOOLEAN DEFAULT true,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(table_name)
|
||||
);
|
||||
|
||||
-- Game statistics for analysis
|
||||
CREATE TABLE game_statistics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
stat_type VARCHAR(100) NOT NULL,
|
||||
stat_key VARCHAR(200) NOT NULL,
|
||||
stat_value DECIMAL(15,4) NOT NULL,
|
||||
dimensions JSONB, -- Additional grouping dimensions
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Performance metrics
|
||||
CREATE TABLE performance_metrics (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
metric_name VARCHAR(100) NOT NULL,
|
||||
metric_value DECIMAL(10,4) NOT NULL,
|
||||
tags JSONB, -- Additional metadata
|
||||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ===== INDEXES FOR PERFORMANCE =====
|
||||
|
||||
-- User management indexes
|
||||
CREATE INDEX idx_players_user_group ON players(user_group);
|
||||
CREATE INDEX idx_players_email ON players(email);
|
||||
CREATE INDEX idx_players_username ON players(username);
|
||||
CREATE INDEX idx_player_subscriptions_player_id ON player_subscriptions(player_id);
|
||||
CREATE INDEX idx_player_subscriptions_type ON player_subscriptions(subscription_type);
|
||||
CREATE INDEX idx_player_subscriptions_expires ON player_subscriptions(expires_at);
|
||||
|
||||
-- Colony and building indexes
|
||||
CREATE INDEX idx_colonies_player_id ON colonies(player_id);
|
||||
CREATE INDEX idx_colonies_coordinates ON colonies(coordinates);
|
||||
CREATE INDEX idx_colonies_sector ON colonies(sector_id);
|
||||
CREATE INDEX idx_colony_buildings_colony_id ON colony_buildings(colony_id);
|
||||
CREATE INDEX idx_colony_buildings_construction ON colony_buildings(construction_completes) WHERE is_under_construction = true;
|
||||
|
||||
-- Resource indexes
|
||||
CREATE INDEX idx_player_resources_player_id ON player_resources(player_id);
|
||||
CREATE INDEX idx_colony_production_colony_id ON colony_resource_production(colony_id);
|
||||
CREATE INDEX idx_trade_routes_from_colony ON trade_routes(from_colony_id);
|
||||
CREATE INDEX idx_trade_routes_to_colony ON trade_routes(to_colony_id);
|
||||
|
||||
-- Fleet and combat indexes
|
||||
CREATE INDEX idx_ship_designs_player_id ON ship_designs(player_id);
|
||||
CREATE INDEX idx_ship_designs_class ON ship_designs(ship_class);
|
||||
CREATE INDEX idx_fleets_player_id ON fleets(player_id);
|
||||
CREATE INDEX idx_fleets_location ON fleets(current_location);
|
||||
CREATE INDEX idx_fleets_arrival ON fleets(arrival_time) WHERE arrival_time IS NOT NULL;
|
||||
CREATE INDEX idx_fleet_ships_fleet_id ON fleet_ships(fleet_id);
|
||||
CREATE INDEX idx_battles_location ON battles(location);
|
||||
CREATE INDEX idx_battles_status ON battles(status);
|
||||
CREATE INDEX idx_battles_completed ON battles(completed_at);
|
||||
|
||||
-- Research indexes
|
||||
CREATE INDEX idx_player_research_player_id ON player_research(player_id);
|
||||
CREATE INDEX idx_player_research_status ON player_research(status);
|
||||
CREATE INDEX idx_research_facilities_colony_id ON research_facilities(colony_id);
|
||||
|
||||
-- Faction indexes
|
||||
CREATE INDEX idx_factions_leader_id ON factions(leader_id);
|
||||
CREATE INDEX idx_faction_members_faction_id ON faction_members(faction_id);
|
||||
CREATE INDEX idx_faction_members_player_id ON faction_members(player_id);
|
||||
CREATE INDEX idx_diplomatic_relations_faction_a ON diplomatic_relations(faction_a_id);
|
||||
CREATE INDEX idx_diplomatic_relations_faction_b ON diplomatic_relations(faction_b_id);
|
||||
|
||||
-- Audit and statistics indexes
|
||||
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_log_actor ON audit_log(actor_type, actor_id);
|
||||
CREATE INDEX idx_audit_log_created_at ON audit_log(created_at);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log(action);
|
||||
CREATE INDEX idx_game_statistics_type_key ON game_statistics(stat_type, stat_key);
|
||||
CREATE INDEX idx_game_statistics_recorded_at ON game_statistics(recorded_at);
|
||||
CREATE INDEX idx_performance_metrics_name ON performance_metrics(metric_name);
|
||||
CREATE INDEX idx_performance_metrics_recorded_at ON performance_metrics(recorded_at);
|
||||
|
||||
-- Game tick indexes
|
||||
CREATE INDEX idx_game_tick_log_tick_number ON game_tick_log(tick_number);
|
||||
CREATE INDEX idx_game_tick_log_user_group ON game_tick_log(user_group);
|
||||
CREATE INDEX idx_game_tick_log_status ON game_tick_log(status);
|
||||
|
||||
-- Event system indexes
|
||||
CREATE INDEX idx_event_instances_event_type ON event_instances(event_type_id);
|
||||
CREATE INDEX idx_event_instances_status ON event_instances(status);
|
||||
CREATE INDEX idx_event_instances_start_time ON event_instances(start_time);
|
||||
|
||||
-- ===== ADD FOREIGN KEY CONSTRAINTS =====
|
||||
|
||||
-- Add foreign key constraints that couldn't be added during table creation
|
||||
ALTER TABLE system_config ADD CONSTRAINT fk_system_config_admin FOREIGN KEY (updated_by) REFERENCES admin_users(id);
|
||||
ALTER TABLE event_instances ADD CONSTRAINT fk_event_instances_admin FOREIGN KEY (created_by) REFERENCES admin_users(id);
|
||||
|
||||
-- ===== TRIGGERS FOR AUTOMATIC TIMESTAMPS =====
|
||||
|
||||
-- Function to automatically update updated_at timestamps
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply updated_at triggers to relevant tables
|
||||
CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE ON system_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_game_tick_config_updated_at BEFORE UPDATE ON game_tick_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_event_instances_updated_at BEFORE UPDATE ON event_instances FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_plugins_updated_at BEFORE UPDATE ON plugins FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_players_updated_at BEFORE UPDATE ON players FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_player_settings_updated_at BEFORE UPDATE ON player_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_colonies_updated_at BEFORE UPDATE ON colonies FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_colony_buildings_updated_at BEFORE UPDATE ON colony_buildings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_ship_designs_updated_at BEFORE UPDATE ON ship_designs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ===== INITIAL DATA =====
|
||||
|
||||
-- Insert initial system configuration
|
||||
INSERT INTO system_config (config_key, config_value, config_type, description, is_public) VALUES
|
||||
('game_tick_interval_ms', '60000', 'number', 'Game tick interval in milliseconds', false),
|
||||
('max_user_groups', '10', 'number', 'Maximum number of user groups for tick processing', false),
|
||||
('max_retry_attempts', '5', 'number', 'Maximum retry attempts for failed ticks', false),
|
||||
('data_retention_days', '30', 'number', 'Default data retention period in days', false),
|
||||
('max_colonies_per_player', '10', 'number', 'Maximum colonies a player can own', true),
|
||||
('starting_resources', '{"scrap": 1000, "energy": 500, "data_cores": 0, "rare_elements": 0}', 'json', 'Starting resources for new players', false),
|
||||
('websocket_ping_interval', '30000', 'number', 'WebSocket ping interval in milliseconds', false);
|
||||
|
||||
-- Insert resource types
|
||||
INSERT INTO resource_types (name, description, category, is_tradeable) VALUES
|
||||
('scrap', 'Basic salvaged materials from the ruins of civilization', 'basic', true),
|
||||
('energy', 'Power cells and energy storage units', 'basic', true),
|
||||
('data_cores', 'Advanced information storage containing pre-collapse knowledge', 'advanced', true),
|
||||
('rare_elements', 'Exotic materials required for advanced technologies', 'rare', true);
|
||||
|
||||
-- Insert initial planet types
|
||||
INSERT INTO planet_types (name, description, base_resources, resource_modifiers, max_population, rarity_weight) VALUES
|
||||
('Terran', 'Earth-like worlds with balanced resources', '{"scrap": 500, "energy": 300}', '{"scrap": 1.0, "energy": 1.0, "data_cores": 1.0, "rare_elements": 1.0}', 10000, 100),
|
||||
('Industrial', 'Former manufacturing worlds rich in salvageable materials', '{"scrap": 1000, "energy": 200}', '{"scrap": 1.5, "energy": 0.8, "data_cores": 1.2, "rare_elements": 0.9}', 8000, 80),
|
||||
('Research', 'Academic worlds with data archives and laboratories', '{"scrap": 300, "energy": 400, "data_cores": 100}', '{"scrap": 0.7, "energy": 1.1, "data_cores": 2.0, "rare_elements": 1.3}', 6000, 60),
|
||||
('Mining', 'Resource extraction worlds with rare element deposits', '{"scrap": 400, "energy": 250, "rare_elements": 20}', '{"scrap": 1.1, "energy": 0.9, "data_cores": 0.8, "rare_elements": 2.5}', 7000, 70),
|
||||
('Fortress', 'Heavily fortified military installations', '{"scrap": 600, "energy": 500}', '{"scrap": 1.2, "energy": 1.3, "data_cores": 1.1, "rare_elements": 1.1}', 5000, 40),
|
||||
('Derelict', 'Abandoned worlds with scattered ruins and mysteries', '{"scrap": 200, "energy": 100, "data_cores": 50, "rare_elements": 10}', '{"scrap": 0.8, "energy": 0.6, "data_cores": 1.5, "rare_elements": 1.8}', 3000, 30);
|
||||
|
||||
-- Insert initial building types
|
||||
INSERT INTO building_types (name, description, category, max_level, base_cost, base_production, special_effects) VALUES
|
||||
('Command Center', 'Central administration building that coordinates colony operations', 'infrastructure', 10, '{"scrap": 100, "energy": 50}', '{}', '{"colony_slots": 1}'),
|
||||
('Salvage Yard', 'Processes scrap metal and salvageable materials', 'production', 10, '{"scrap": 50, "energy": 25}', '{"scrap": 10}', '{}'),
|
||||
('Power Plant', 'Generates energy for colony operations', 'production', 10, '{"scrap": 75, "energy": 0}', '{"energy": 8}', '{}'),
|
||||
('Research Lab', 'Conducts technological research and development', 'research', 10, '{"scrap": 100, "energy": 75, "data_cores": 10}', '{}', '{"research_speed": 0.1}'),
|
||||
('Housing Complex', 'Provides living space for colony population', 'infrastructure', 15, '{"scrap": 60, "energy": 30}', '{}', '{"population_capacity": 100}'),
|
||||
('Defense Grid', 'Automated defense systems protecting the colony', 'military', 10, '{"scrap": 150, "energy": 100, "rare_elements": 5}', '{}', '{"defense_rating": 50}'),
|
||||
('Data Archive', 'Stores and processes information from data cores', 'research', 8, '{"scrap": 80, "energy": 60, "data_cores": 5}', '{"data_cores": 2}', '{"research_bonus": 0.05}'),
|
||||
('Mining Facility', 'Extracts rare elements from planetary deposits', 'production', 12, '{"scrap": 200, "energy": 150, "data_cores": 20}', '{"rare_elements": 1}', '{}');
|
||||
|
||||
-- Insert initial game tick configuration
|
||||
INSERT INTO game_tick_config (tick_interval_ms, user_groups_count, max_retry_attempts, bonus_tick_threshold) VALUES
|
||||
(60000, 10, 5, 3);
|
||||
|
||||
-- Insert basic combat type
|
||||
INSERT INTO combat_types (name, description, config) VALUES
|
||||
('instant_resolution', 'Basic instant combat resolution with detailed logs', '{"calculate_experience": true, "detailed_logs": true}');
|
||||
|
||||
-- Insert initial event types
|
||||
INSERT INTO event_types (name, description, trigger_type, config_schema) VALUES
|
||||
('galaxy_crisis', 'Major galaxy-wide crisis events', 'admin', '{"duration_hours": {"type": "number", "min": 1, "max": 168}}'),
|
||||
('discovery_event', 'Random discovery events triggered by exploration', 'player', '{"discovery_type": {"type": "string", "enum": ["artifact", "technology", "resource"]}}'),
|
||||
('faction_war', 'Large-scale conflicts between factions', 'mixed', '{"participating_factions": {"type": "array", "items": {"type": "number"}}}');
|
||||
|
||||
-- Insert data retention policies
|
||||
INSERT INTO data_retention_policies (table_name, retention_days, archive_before_delete) VALUES
|
||||
('audit_log', 90, true),
|
||||
('game_tick_log', 7, false),
|
||||
('battles', 30, true),
|
||||
('performance_metrics', 14, false),
|
||||
('game_statistics', 365, true);
|
||||
|
||||
-- Insert initial galaxy sectors
|
||||
INSERT INTO galaxy_sectors (name, coordinates, description, danger_level) VALUES
|
||||
('Core Worlds', 'A1', 'The former heart of galactic civilization', 3),
|
||||
('Industrial Belt', 'B2', 'Manufacturing and resource processing sector', 4),
|
||||
('Frontier Region', 'C3', 'Outer rim territories with unexplored systems', 6),
|
||||
('Research Zone', 'D4', 'Former academic and scientific installations', 2),
|
||||
('Contested Space', 'E5', 'Battlegrounds of the great collapse', 8),
|
||||
('Dead Zone', 'F6', 'Heavily damaged region with dangerous anomalies', 9);
|
||||
|
||||
-- Create initial admin user (password should be changed immediately)
|
||||
INSERT INTO admin_users (username, email, password_hash, role, permissions) VALUES
|
||||
('admin', 'admin@shatteredvoid.game', '$2b$10$example.hash.change.immediately', 'administrator', '["all"]');
|
||||
|
||||
-- Insert initial plugin for basic combat
|
||||
INSERT INTO plugins (name, version, description, plugin_type, is_active, config, hooks) VALUES
|
||||
('basic_combat', '1.0.0', 'Basic instant combat resolution system', 'combat', true, '{"damage_variance": 0.1, "experience_gain": 1.0}', '["pre_combat", "post_combat", "damage_calculation"]');
|
||||
181
idea.txt
Normal file
181
idea.txt
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
**Project Name:** Shattered Void (working title)
|
||||
|
||||
**Genre:** Text-based Post-Collapse Sci-Fi MMO Strategy Game (Inspired by OGame)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Shattered Void** is a browser-based multiplayer strategy game set in a decaying galaxy after the collapse of a vast interstellar civilization. Players command remnants of once-great empires, establishing colonies, rebuilding technology, constructing modular fleets, and engaging in diplomacy, espionage, and war. The game is played in real-time, with asynchronous elements, a text-based star map, rich lore, and a dynamic event system.
|
||||
|
||||
---
|
||||
|
||||
## Core Gameplay Loops
|
||||
|
||||
### 1. **Base Building and Resource Management**
|
||||
|
||||
* Players start with a ruined colony on a planet.
|
||||
* Resources include: Scrap, Energy, Data Cores, Rare Elements.
|
||||
* Players restore old infrastructure and build new facilities.
|
||||
* Research technology to unlock systems, fleet modules, and faction upgrades.
|
||||
|
||||
### 2. **Exploration and Expansion**
|
||||
|
||||
* Text-based starmap with coordinates (e.g., Sectors: A3-91).
|
||||
* Explore ruins, derelict stations, dead stars.
|
||||
* Colonize new worlds, salvage remnants, uncover pre-collapse secrets.
|
||||
* Galaxy map reveals gradually through exploration or scouting.
|
||||
|
||||
### 3. **Modular Ship Construction**
|
||||
|
||||
* Ships are built from modular components:
|
||||
|
||||
* **Hull Types:** Frigate, Cruiser, Dreadnought, Carrier, Scout.
|
||||
* **Modules:** Weapons, Engines, Shields, Utility, AI Cores.
|
||||
* Players unlock more parts through research, salvage, and events.
|
||||
* Players can save and iterate on custom ship designs.
|
||||
|
||||
### 4. **Combat System**
|
||||
|
||||
* Fleet vs Fleet, Fleet vs Defenses.
|
||||
* Simulated in background; outcomes are reported in rich-text combat logs.
|
||||
* Tactical choices (loadouts, formations, pre-battle stance) influence results.
|
||||
|
||||
### 5. **Dynamic Galaxy Event System**
|
||||
|
||||
* Events affect entire sectors or the whole galaxy:
|
||||
|
||||
* Pirate uprisings
|
||||
* Alien incursions
|
||||
* Black hole migrations
|
||||
* Data Plague (AI virus)
|
||||
* Events have persistent consequences, influence lore and gameplay.
|
||||
* Players can influence or be recruited into resolving/triggering events.
|
||||
|
||||
### 6. **Faction and Diplomacy System**
|
||||
|
||||
* Players can form factions (or join ancient ones) with shared bonuses.
|
||||
* Factions can vote on technology paths, coordinate expansion, or wage war.
|
||||
* Diplomatic mechanics include:
|
||||
|
||||
* Treaties, Trade Routes
|
||||
* Espionage and Sabotage
|
||||
* Influence mechanics (soft power over neutral systems)
|
||||
|
||||
### 7. **Legacy/Prestige System**
|
||||
|
||||
* If a player is conquered or resets, they can:
|
||||
|
||||
* Leave behind artifacts or derelict fleets
|
||||
* Transfer a fraction of resources/tech
|
||||
* Unlock new factions or perks for future runs
|
||||
|
||||
---
|
||||
|
||||
## Text-Based Star Map System
|
||||
|
||||
* The galaxy is divided into Sectors and Systems (e.g., A3-91-X).
|
||||
* Each system has flavor-rich descriptions:
|
||||
|
||||
* Ex: "System A3-91-X: A husk of a once-thriving mining colony now orbits a cracked gas giant, covered in radioactive mist."
|
||||
* Systems may contain:
|
||||
|
||||
* Colonizable planets
|
||||
* Event triggers
|
||||
* Ruins with lore and salvage
|
||||
* Pirate bases, rogue AI nests, alien remains
|
||||
* Exploration reveals these gradually, encouraging scouting.
|
||||
|
||||
---
|
||||
|
||||
## Technical Systems Breakdown
|
||||
|
||||
### Backend
|
||||
|
||||
* Language: Python (FastAPI or Django), Node.js (Express), or Go.
|
||||
* Real-time tick system for production, movement, and research
|
||||
* PostgreSQL (main DB), Redis (caching/session management)
|
||||
* JWT-based user authentication
|
||||
* Modular ship builder engine
|
||||
* Combat simulator (cron-based or real-time triggered)
|
||||
* REST API for frontend communication
|
||||
* Asynchronous task queue (Celery, Bull, or Go Routines)
|
||||
* Admin CMS for content, events, and balancing
|
||||
|
||||
### Frontend / UI
|
||||
|
||||
* Framework: React or Svelte (SPA)
|
||||
* Clean UI with text emphasis, stylized with CSS/Tailwind or custom themes
|
||||
* Star map navigation via coordinates (e.g., input sector to load system)
|
||||
* Resource HUD, alerts/notifications bar, event tracker, logs
|
||||
* Modular ship designer drag-and-drop panel
|
||||
* Side panel menus for base, research, fleet, diplomacy
|
||||
|
||||
### Website Features
|
||||
|
||||
* Landing page with lore, signup/login
|
||||
* Secure authentication system (email-based, with verification)
|
||||
* Player dashboard (active colonies, alerts, missions)
|
||||
* In-game messaging and faction chat
|
||||
* Account settings and faction selection
|
||||
|
||||
### Game Systems and Database Schema (Core Tables)
|
||||
|
||||
* **Users:** id, email, password, created\_at, preferences
|
||||
* **Colonies:** id, user\_id, name, planet\_type, coordinates, population, morale
|
||||
* **Buildings:** id, colony\_id, type, level, construction\_start, end\_time
|
||||
* **Resources:** colony\_id, scrap, energy, data\_cores, rare\_elements
|
||||
* **Fleets:** id, user\_id, location, status, composition, destination, eta
|
||||
* **ShipModules:** id, type, stats (JSON), rarity, source (research/salvage)
|
||||
* **Ships:** id, fleet\_id, design\_id, hp, module\_list
|
||||
* **TechTree:** id, user\_id, tech\_type, level, prerequisites, research\_time
|
||||
* **Events:** id, type, description, start\_time, end\_time, effects (JSON)
|
||||
* **Factions:** id, name, banner\_url, traits (JSON), members, influence
|
||||
|
||||
### Building Types (Universal Structure)
|
||||
|
||||
* **Scrap Docks:** Generates Scrap over time
|
||||
* **Energy Grid:** Produces power to fuel systems
|
||||
* **Data Silos:** Increases Data Core generation
|
||||
* **Element Extractors:** Mine Rare Elements from planetary crust
|
||||
* **Habitat Blocks:** Increases population and morale
|
||||
* **Barracks:** Trains and maintains ground units (used in invasions)
|
||||
* **Hangar Bays:** Construct and house space fleets
|
||||
* **Tech Labs:** Unlock and speed up research
|
||||
|
||||
### Game Loop Logic
|
||||
|
||||
* Every X seconds (tick interval):
|
||||
|
||||
* Buildings produce resources based on level
|
||||
* Fleets move along calculated trajectories
|
||||
* Events progress or trigger
|
||||
* Research and building queues complete
|
||||
* Notifications generated and pushed to user
|
||||
|
||||
### Admin Tools
|
||||
|
||||
* Web panel for lore editing and content entries
|
||||
* Event creator and scheduler
|
||||
* Player search, moderation, data adjustment
|
||||
* Server tick controller
|
||||
|
||||
---
|
||||
|
||||
## Example Starting Scenario
|
||||
|
||||
"You awaken within the ruins of Outpost 7-X, a crumbling orbital station barely holding atmosphere. With a half-functional fabrication bay and trace energy readings from the planet below, you must reclaim what you can from the dead machines and begin again. Communications are silent. The stars around you are cold."
|
||||
|
||||
---
|
||||
|
||||
## Future Ideas (Optional)
|
||||
|
||||
* Wormhole travel and alternate dimensions
|
||||
* Player-run trade hubs
|
||||
* Organic factions (e.g., evolved alien hives)
|
||||
* AI rebellion threat mechanic
|
||||
* War heroes and persistent admiral characters
|
||||
|
||||
---
|
||||
|
||||
This document serves as a foundational blueprint for creating a post-collapse, text-based MMO inspired by OGame but with modern storytelling, system design, and player agency. It also includes backend architecture, website structure, database models, and scalable design patterns for development.
|
||||
74
knexfile.js
Normal file
74
knexfile.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
require('dotenv').config({ path: `.env.${process.env.NODE_ENV || 'development'}` });
|
||||
|
||||
const config = {
|
||||
development: {
|
||||
client: 'postgresql',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'shattered_void_dev',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'password',
|
||||
},
|
||||
pool: {
|
||||
min: parseInt(process.env.DB_POOL_MIN) || 2,
|
||||
max: parseInt(process.env.DB_POOL_MAX) || 10,
|
||||
acquireTimeoutMillis: parseInt(process.env.DB_TIMEOUT) || 60000,
|
||||
},
|
||||
migrations: {
|
||||
directory: './src/database/migrations',
|
||||
tableName: 'knex_migrations',
|
||||
},
|
||||
seeds: {
|
||||
directory: './src/database/seeds',
|
||||
},
|
||||
},
|
||||
|
||||
test: {
|
||||
client: 'postgresql',
|
||||
connection: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'shattered_void_test',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'password',
|
||||
},
|
||||
pool: {
|
||||
min: 1,
|
||||
max: 5,
|
||||
},
|
||||
migrations: {
|
||||
directory: './src/database/migrations',
|
||||
tableName: 'knex_migrations',
|
||||
},
|
||||
seeds: {
|
||||
directory: './src/database/seeds',
|
||||
},
|
||||
},
|
||||
|
||||
production: {
|
||||
client: 'postgresql',
|
||||
connection: {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: parseInt(process.env.DB_POOL_MIN) || 5,
|
||||
max: parseInt(process.env.DB_POOL_MAX) || 20,
|
||||
acquireTimeoutMillis: parseInt(process.env.DB_TIMEOUT) || 60000,
|
||||
},
|
||||
migrations: {
|
||||
directory: './src/database/migrations',
|
||||
tableName: 'knex_migrations',
|
||||
},
|
||||
seeds: {
|
||||
directory: './src/database/seeds',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
10390
package-lock.json
generated
Normal file
10390
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
106
package.json
Normal file
106
package.json
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"name": "shattered-void-mmo",
|
||||
"version": "0.1.0",
|
||||
"description": "Shattered Void - A post-collapse galaxy MMO strategy game",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --inspect=0.0.0.0:9229 src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"test": "jest --verbose --coverage",
|
||||
"test:watch": "jest --watch --verbose",
|
||||
"test:integration": "jest --testPathPattern=integration --runInBand",
|
||||
"test:e2e": "jest --testPathPattern=e2e --runInBand",
|
||||
"lint": "eslint src/ --ext .js --fix",
|
||||
"lint:check": "eslint src/ --ext .js",
|
||||
"db:migrate": "knex migrate:latest",
|
||||
"db:rollback": "knex migrate:rollback",
|
||||
"db:seed": "knex seed:run",
|
||||
"db:reset": "knex migrate:rollback:all && knex migrate:latest && knex seed:run",
|
||||
"db:setup": "createdb shattered_void_dev && npm run db:migrate && npm run db:seed",
|
||||
"setup": "node scripts/setup.js",
|
||||
"docker:build": "docker build -t shattered-void .",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:dev": "docker-compose -f docker-compose.dev.yml up -d",
|
||||
"logs": "tail -f logs/combined.log",
|
||||
"logs:error": "tail -f logs/error.log",
|
||||
"logs:audit": "tail -f logs/audit.log"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"compression": "^1.7.4",
|
||||
"connect-redis": "^7.1.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^7.0.1",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"joi": "^17.11.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"knex": "^3.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.7",
|
||||
"pg": "^8.11.3",
|
||||
"rate-limit-redis": "^4.2.1",
|
||||
"redis": "^4.6.11",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.15.15",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.8",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-node": "^4.1.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mmo",
|
||||
"strategy",
|
||||
"game",
|
||||
"nodejs",
|
||||
"postgresql",
|
||||
"redis",
|
||||
"websocket"
|
||||
],
|
||||
"author": "Shattered Void Development Team",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/your-org/shattered-void-mmo.git"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.js",
|
||||
"!src/server.js",
|
||||
"!src/database/migrations/**",
|
||||
"!src/database/seeds/**"
|
||||
],
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"lcov",
|
||||
"html"
|
||||
],
|
||||
"testMatch": [
|
||||
"**/tests/**/*.test.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
172
scripts/setup.sh
Executable file
172
scripts/setup.sh
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Shattered Void MMO - Setup Script
|
||||
# Automates the initial setup process for development environment
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🌌 Shattered Void MMO - Development Setup"
|
||||
echo "========================================"
|
||||
|
||||
# Check prerequisites
|
||||
echo "📋 Checking prerequisites..."
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js is not installed. Please install Node.js 16+ and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "❌ npm is not installed. Please install npm 8+ and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo "❌ PostgreSQL client is not installed. Please install PostgreSQL and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v redis-cli &> /dev/null; then
|
||||
echo "⚠️ Redis client not found. Make sure Redis server is running."
|
||||
fi
|
||||
|
||||
echo "✅ Prerequisites check passed"
|
||||
|
||||
# Check Node.js version
|
||||
NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
if [ "$NODE_VERSION" -lt 16 ]; then
|
||||
echo "❌ Node.js version $NODE_VERSION is not supported. Please upgrade to Node.js 16+"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Node.js version check passed"
|
||||
|
||||
# Install dependencies
|
||||
echo ""
|
||||
echo "📦 Installing npm dependencies..."
|
||||
npm install
|
||||
|
||||
echo "✅ Dependencies installed"
|
||||
|
||||
# Check for .env file
|
||||
echo ""
|
||||
echo "⚙️ Checking environment configuration..."
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "📝 Creating .env file from template..."
|
||||
cp .env.example .env
|
||||
echo "⚠️ Please edit .env file with your database credentials and configuration"
|
||||
echo " Database settings, JWT secret, and other configurations need to be updated"
|
||||
else
|
||||
echo "✅ .env file already exists"
|
||||
fi
|
||||
|
||||
# Check database connection
|
||||
echo ""
|
||||
echo "🗄️ Checking database setup..."
|
||||
|
||||
# Load environment variables
|
||||
if [ -f ".env" ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
# Set defaults if not in .env
|
||||
DB_HOST=${DB_HOST:-localhost}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
DB_NAME=${DB_NAME:-shattered_void_dev}
|
||||
DB_USER=${DB_USER:-postgres}
|
||||
|
||||
echo "Attempting to connect to database: $DB_NAME@$DB_HOST:$DB_PORT"
|
||||
|
||||
# Test database connection
|
||||
if ! pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" 2>/dev/null; then
|
||||
echo "❌ Cannot connect to PostgreSQL database"
|
||||
echo " Please ensure PostgreSQL is running and the database exists"
|
||||
echo " Run these commands to create the database:"
|
||||
echo " sudo -u postgres psql"
|
||||
echo " CREATE DATABASE $DB_NAME;"
|
||||
echo " CREATE USER $DB_USER WITH PASSWORD 'your_password';"
|
||||
echo " GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
|
||||
echo " \\q"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Database connection successful"
|
||||
|
||||
# Apply database schema
|
||||
echo ""
|
||||
echo "🏗️ Setting up database schema..."
|
||||
|
||||
if psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f database-schema.sql > /dev/null 2>&1; then
|
||||
echo "✅ Database schema applied successfully"
|
||||
else
|
||||
echo "❌ Failed to apply database schema"
|
||||
echo " Please check your database credentials and permissions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
echo ""
|
||||
echo "🔄 Running database migrations..."
|
||||
if npm run db:migrate > /dev/null 2>&1; then
|
||||
echo "✅ Database migrations completed"
|
||||
else
|
||||
echo "⚠️ Database migrations failed - this may be normal if already applied"
|
||||
fi
|
||||
|
||||
# Run seeds
|
||||
echo ""
|
||||
echo "🌱 Seeding initial data..."
|
||||
if npm run db:seed > /dev/null 2>&1; then
|
||||
echo "✅ Initial data seeded successfully"
|
||||
else
|
||||
echo "❌ Failed to seed initial data"
|
||||
echo " You may need to run: npm run db:seed manually"
|
||||
fi
|
||||
|
||||
# Test Redis connection
|
||||
echo ""
|
||||
echo "📡 Testing Redis connection..."
|
||||
REDIS_HOST=${REDIS_HOST:-localhost}
|
||||
REDIS_PORT=${REDIS_PORT:-6379}
|
||||
|
||||
if redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" ping > /dev/null 2>&1; then
|
||||
echo "✅ Redis connection successful"
|
||||
else
|
||||
echo "⚠️ Redis connection failed"
|
||||
echo " Redis is optional but recommended for production"
|
||||
echo " Install and start Redis server for optimal performance"
|
||||
fi
|
||||
|
||||
# Create logs directory
|
||||
echo ""
|
||||
echo "📝 Setting up logging..."
|
||||
mkdir -p logs
|
||||
touch logs/combined.log
|
||||
touch logs/error.log
|
||||
echo "✅ Log directory created"
|
||||
|
||||
# Run linting
|
||||
echo ""
|
||||
echo "🔍 Running code quality checks..."
|
||||
if npm run lint:check > /dev/null 2>&1; then
|
||||
echo "✅ Code quality checks passed"
|
||||
else
|
||||
echo "⚠️ Code quality issues found - run 'npm run lint' to fix"
|
||||
fi
|
||||
|
||||
# Final setup complete
|
||||
echo ""
|
||||
echo "🎉 Setup completed successfully!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Review and update the .env file with your configuration"
|
||||
echo "2. Start the development server: npm run dev"
|
||||
echo "3. Visit http://localhost:3000 to verify the server is running"
|
||||
echo ""
|
||||
echo "Development commands:"
|
||||
echo " npm run dev - Start development server with hot reload"
|
||||
echo " npm test - Run test suite"
|
||||
echo " npm run lint - Check and fix code style"
|
||||
echo ""
|
||||
echo "For more information, see README.md"
|
||||
echo ""
|
||||
echo "🌌 Welcome to Shattered Void MMO development!"
|
||||
469
shattered_void_game_pitch.md
Normal file
469
shattered_void_game_pitch.md
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
# Shattered Void: The Ultimate Post-Collapse MMO Strategy Game
|
||||
## A Comprehensive Game Development Proposal
|
||||
|
||||
---
|
||||
|
||||
## 🌌 Executive Summary
|
||||
|
||||
**Shattered Void** is a revolutionary browser-based MMO strategy game that combines the depth of classic space strategy with modern storytelling and social mechanics. Set in a decaying galaxy after civilizational collapse, players rebuild empires from ruins while engaging in diplomacy, exploration, and epic galaxy-wide events.
|
||||
|
||||
**Target Market:** 25-45 year old strategy game enthusiasts seeking deep, persistent gameplay that respects their time
|
||||
**Platform:** Cross-platform web application (PC/Mobile/Tablet)
|
||||
**Development Timeline:** 18-24 months to full launch
|
||||
**Revenue Model:** Premium subscriptions with cosmetic monetization
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Game Overview
|
||||
|
||||
### Core Vision
|
||||
Transform the traditional space strategy genre by focusing on **narrative-driven events**, **meaningful player choices**, and **collaborative galaxy-shaping experiences**. Every action contributes to a persistent, evolving universe where player stories become galactic legend.
|
||||
|
||||
### Unique Selling Points
|
||||
- **Living Galaxy**: Dynamic events permanently alter the game world
|
||||
- **True Cross-Platform**: Seamless experience across all devices
|
||||
- **Deep Customization**: Modular ship building and colony specialization
|
||||
- **Respectful Gameplay**: Strategic depth without predatory mechanics
|
||||
- **Community-Driven**: Player actions shape ongoing narrative
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Core Gameplay Systems
|
||||
|
||||
### 1. Colony Management & Resource Economy
|
||||
Players start with a single ruined outpost and must rebuild civilization through strategic resource management:
|
||||
|
||||
**Resource Types:**
|
||||
- **Scrap**: Basic construction material salvaged from ruins
|
||||
- **Energy**: Powers all colony operations and advanced systems
|
||||
- **Data Cores**: Pre-collapse knowledge used for research
|
||||
- **Rare Elements**: Exotic materials for advanced technology
|
||||
|
||||
**Building Progression:**
|
||||
- **Tier 1**: Basic Infrastructure (Scrap Docks, Energy Grids, Habitats)
|
||||
- **Tier 2**: Specialized Production (Element Extractors, Tech Labs)
|
||||
- **Tier 3**: Military & Defense (Hangar Bays, Shield Generators)
|
||||
- **Tier 4**: Advanced Systems (Quantum Forges, Void Gates)
|
||||
- **Tier 5**: Endgame Wonders (Genesis Chambers, AI Cores)
|
||||
|
||||
### 2. Modular Fleet Construction
|
||||
Revolutionary ship building system where players design custom vessels:
|
||||
|
||||
**Hull Types:** Scout, Frigate, Cruiser, Dreadnought, Carrier, Titan
|
||||
**Module Categories:** Weapons, Engines, Shields, Utility, AI Cores
|
||||
**Design Philosophy:** No "best" ship - only ships optimized for specific roles
|
||||
|
||||
### 3. Text-Based Exploration
|
||||
Rich, descriptive galaxy exploration emphasizing imagination over graphics:
|
||||
|
||||
- **Coordinate System**: Navigate via sector coordinates (A3-91-X)
|
||||
- **Procedural Discoveries**: Ruins, derelicts, and mysteries await
|
||||
- **Persistent World**: Player discoveries permanently change the galaxy
|
||||
- **Collaborative Mapping**: Community efforts reveal galactic secrets
|
||||
|
||||
### 4. Dynamic Galaxy Events
|
||||
Living world system where admin-driven events create shared experiences:
|
||||
|
||||
**Event Categories:**
|
||||
- **Crisis Events**: Galaxy-threatening challenges requiring cooperation
|
||||
- **Discovery Events**: Uncover ancient secrets and lost technology
|
||||
- **Conflict Events**: Large-scale wars and territorial disputes
|
||||
- **Celebration Events**: Community festivals and special occasions
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
|
||||
### Backend Infrastructure
|
||||
- **Language**: Node.js with Express framework
|
||||
- **Database**: PostgreSQL (primary) + Redis (caching/sessions)
|
||||
- **Real-Time**: WebSocket connections for live updates
|
||||
- **Authentication**: JWT-based with email verification
|
||||
- **Game Loop**: Asynchronous tick system for continuous world simulation
|
||||
|
||||
### Frontend Experience
|
||||
- **Framework**: React with responsive design
|
||||
- **UI Philosophy**: Clean, text-focused interface with strategic depth
|
||||
- **Mobile Optimization**: Touch-friendly controls and condensed layouts
|
||||
- **PWA Features**: Installable app experience across all platforms
|
||||
|
||||
### Data-Driven Design
|
||||
All game content stored in configurable databases allowing rapid balancing and content updates without code changes.
|
||||
|
||||
---
|
||||
|
||||
## 👑 Administrative Excellence
|
||||
|
||||
### Ultra-Secure Admin Panel
|
||||
- **Multi-Factor Authentication**: Hardware tokens + IP restrictions
|
||||
- **Hidden Access**: Obfuscated routes with rotating security keys
|
||||
- **Complete Audit Trail**: Every admin action logged and monitored
|
||||
|
||||
### Comprehensive Management Tools
|
||||
- **User Investigation Suite**: Deep player behavior analysis
|
||||
- **Real-Time Logging**: Live system monitoring and debugging
|
||||
- **Game Content Editor**: Visual tools for modifying all game elements
|
||||
- **Balance Testing**: Simulation tools for testing changes safely
|
||||
|
||||
### Live Event Management
|
||||
- **Visual Event Editor**: Drag-and-drop event creation tools
|
||||
- **Real-Time Control**: Monitor and adjust events as they happen
|
||||
- **Community Impact**: Track player engagement and satisfaction
|
||||
|
||||
---
|
||||
|
||||
## 💰 Monetization Strategy
|
||||
|
||||
### Ethical Revenue Model
|
||||
- **Premium Subscriptions**: $9.99/month for enhanced features
|
||||
- **Cosmetic Items**: Ship skins, colony themes, player avatars
|
||||
- **Quality of Life**: Extra build queues, expanded fleet commands
|
||||
- **NO Pay-to-Win**: All gameplay advantages earned through play
|
||||
|
||||
### Subscription Benefits
|
||||
- **Expanded Queues**: Multiple simultaneous research/building projects
|
||||
- **Advanced Analytics**: Detailed statistics and optimization tools
|
||||
- **Priority Support**: Faster customer service response
|
||||
- **Exclusive Cosmetics**: Subscriber-only customization options
|
||||
|
||||
---
|
||||
|
||||
## 📊 Market Analysis
|
||||
|
||||
### Target Demographics
|
||||
- **Primary**: 25-45 year old strategy enthusiasts with disposable income
|
||||
- **Secondary**: Former MMO players seeking meaningful, respectful gameplay
|
||||
- **Tertiary**: Mobile strategy players wanting deeper experiences
|
||||
|
||||
### Competitive Advantages
|
||||
- **Respect Player Time**: No forced waiting or aggressive monetization
|
||||
- **Cross-Platform Native**: True seamless experience across devices
|
||||
- **Living World**: Events create unique, non-repeatable experiences
|
||||
- **Community Focus**: Player actions have permanent world impact
|
||||
|
||||
### Revenue Projections
|
||||
- **Year 1**: 10,000 active players, 25% subscription rate → $300K ARR
|
||||
- **Year 2**: 50,000 active players, 30% subscription rate → $1.8M ARR
|
||||
- **Year 3**: 100,000 active players, 35% subscription rate → $4.2M ARR
|
||||
|
||||
---
|
||||
|
||||
## 🗓️ Development Roadmap
|
||||
|
||||
### Phase 1: Foundation (Months 1-6)
|
||||
**Core Systems Development**
|
||||
- Database architecture and user authentication
|
||||
- Basic colony management and resource systems
|
||||
- Simple fleet construction and movement
|
||||
- Text-based galaxy exploration framework
|
||||
|
||||
**Milestone Goal**: Single-player colony management demo
|
||||
|
||||
### Phase 2: Multiplayer (Months 7-12)
|
||||
**Social and Combat Systems**
|
||||
- Multi-player infrastructure and real-time updates
|
||||
- Fleet combat simulation and battle logs
|
||||
- Basic diplomacy and faction systems
|
||||
- Simple galaxy events framework
|
||||
|
||||
**Milestone Goal**: Closed beta with 100 invited players
|
||||
|
||||
### Phase 3: Polish (Months 13-18)
|
||||
**Advanced Features and Balance**
|
||||
- Complete admin panel and content management
|
||||
- Advanced ship building and customization
|
||||
- Complex galaxy events and narrative system
|
||||
- Mobile optimization and PWA features
|
||||
|
||||
**Milestone Goal**: Open beta with 1,000+ players
|
||||
|
||||
### Phase 4: Launch (Months 19-24)
|
||||
**Production Ready**
|
||||
- Performance optimization and scaling
|
||||
- Comprehensive tutorial and new player experience
|
||||
- Marketing campaign and community building
|
||||
- Post-launch content pipeline establishment
|
||||
|
||||
**Milestone Goal**: Full public launch
|
||||
|
||||
---
|
||||
|
||||
## ✅ Comprehensive Development Todo List
|
||||
|
||||
### 🏗️ **FOUNDATION PHASE (High Priority)**
|
||||
|
||||
#### **Database & Backend Infrastructure**
|
||||
- [ ] Design normalized database schema with full audit logging
|
||||
- [ ] Implement user authentication system with JWT and email verification
|
||||
- [ ] Create modular service architecture (colony, fleet, research, combat services)
|
||||
- [ ] Set up database migrations and version control system
|
||||
- [ ] Implement comprehensive event sourcing for all user actions
|
||||
- [ ] Create real-time tick system for game world simulation
|
||||
- [ ] Set up Redis caching layer for performance optimization
|
||||
- [ ] Implement WebSocket infrastructure for live updates
|
||||
|
||||
#### **Core Game Systems**
|
||||
- [ ] Build colony management system with building construction queues
|
||||
- [ ] Create resource production and consumption mechanics
|
||||
- [ ] Implement research system with technology trees
|
||||
- [ ] Design modular ship construction system with component validation
|
||||
- [ ] Create fleet movement and logistics systems
|
||||
- [ ] Build text-based galaxy exploration with coordinate navigation
|
||||
- [ ] Implement basic combat simulation engine with detailed logging
|
||||
|
||||
#### **Frontend Development**
|
||||
- [ ] Set up React application with responsive design framework
|
||||
- [ ] Create main game dashboard with resource monitoring
|
||||
- [ ] Build colony management interface with building placement
|
||||
- [ ] Design ship construction interface with drag-and-drop modules
|
||||
- [ ] Implement galaxy map navigation with coordinate input
|
||||
- [ ] Create research interface with technology tree visualization
|
||||
- [ ] Build fleet management and movement interfaces
|
||||
|
||||
### 🌐 **MULTIPLAYER PHASE (High Priority)**
|
||||
|
||||
#### **Social Systems**
|
||||
- [ ] Implement faction creation and management system
|
||||
- [ ] Create player-to-player messaging and communication
|
||||
- [ ] Build diplomacy system with treaties and trade agreements
|
||||
- [ ] Design reputation and influence mechanics
|
||||
- [ ] Create faction voting and governance systems
|
||||
- [ ] Implement alliance and enemy relationship tracking
|
||||
|
||||
#### **Advanced Combat**
|
||||
- [ ] Build fleet vs fleet combat with formation tactics
|
||||
- [ ] Create siege mechanics for colony invasion
|
||||
- [ ] Implement defensive structures and shield systems
|
||||
- [ ] Design combat replay system with step-by-step analysis
|
||||
- [ ] Create battle result notification and logging system
|
||||
|
||||
#### **Galaxy Events Foundation**
|
||||
- [ ] Design modular event system with trigger conditions
|
||||
- [ ] Create event participation tracking and scoring
|
||||
- [ ] Implement basic event reward distribution system
|
||||
- [ ] Build event notification and communication system
|
||||
- [ ] Create event history and legacy tracking
|
||||
|
||||
### 🔧 **ADMINISTRATIVE SYSTEMS (High Priority)**
|
||||
|
||||
#### **Ultra-Secure Admin Panel**
|
||||
- [ ] Build multi-factor authentication system for admin access
|
||||
- [ ] Create IP whitelist and access control mechanisms
|
||||
- [ ] Implement session management with automatic timeout
|
||||
- [ ] Design obfuscated admin routes with rotating access keys
|
||||
- [ ] Create admin permission system with role-based access
|
||||
|
||||
#### **User Management & Investigation**
|
||||
- [ ] Build comprehensive user lookup with complete action history
|
||||
- [ ] Create IP address tracking and geolocation analysis
|
||||
- [ ] Implement device fingerprinting and session correlation
|
||||
- [ ] Design behavior pattern analysis and anomaly detection
|
||||
- [ ] Create account linking detection and investigation tools
|
||||
- [ ] Build cheat/exploit detection and alerting system
|
||||
|
||||
#### **System Monitoring & Debugging**
|
||||
- [ ] Create real-time logging dashboard with advanced filtering
|
||||
- [ ] Implement live system performance monitoring
|
||||
- [ ] Build database query analysis and optimization tools
|
||||
- [ ] Create WebSocket connection monitoring and debugging
|
||||
- [ ] Design game state inspection and modification tools
|
||||
- [ ] Implement error aggregation and alerting system
|
||||
|
||||
#### **Game Content Management**
|
||||
- [ ] Build visual building editor with stat modification
|
||||
- [ ] Create ship component designer with balance validation
|
||||
- [ ] Design technology tree editor with prerequisite management
|
||||
- [ ] Implement galaxy content editor for sectors and planets
|
||||
- [ ] Create event template editor with visual workflow builder
|
||||
- [ ] Build game balance testing and simulation tools
|
||||
|
||||
### 🎭 **ADVANCED FEATURES (Medium Priority)**
|
||||
|
||||
#### **Galaxy Events System**
|
||||
- [ ] Create visual event editor with drag-and-drop workflows
|
||||
- [ ] Implement event phase progression and narrative branching
|
||||
- [ ] Build dynamic reward system with conditional distribution
|
||||
- [ ] Create real-time event monitoring and control dashboard
|
||||
- [ ] Design event consequence system with permanent world changes
|
||||
- [ ] Implement cross-event continuity and story progression
|
||||
|
||||
#### **Advanced Gameplay**
|
||||
- [ ] Create prestige/legacy system for veteran players
|
||||
- [ ] Implement advanced diplomacy with espionage and sabotage
|
||||
- [ ] Build trade route optimization and economic modeling
|
||||
- [ ] Create advanced fleet formations and tactical commands
|
||||
- [ ] Design mega-projects and endgame content systems
|
||||
- [ ] Implement player-driven content creation tools
|
||||
|
||||
#### **Mobile & Accessibility**
|
||||
- [ ] Optimize UI for mobile touch interfaces
|
||||
- [ ] Implement swipe gestures and mobile-specific controls
|
||||
- [ ] Create progressive web app (PWA) functionality
|
||||
- [ ] Design offline mode for basic game monitoring
|
||||
- [ ] Implement accessibility features for disabled players
|
||||
- [ ] Create simplified mobile interface options
|
||||
|
||||
### 📊 **ANALYTICS & OPTIMIZATION (Medium Priority)**
|
||||
|
||||
#### **Data Collection & Analysis**
|
||||
- [ ] Implement comprehensive player behavior tracking
|
||||
- [ ] Create game balance metrics and monitoring dashboards
|
||||
- [ ] Build player retention analysis and prediction models
|
||||
- [ ] Design A/B testing framework for feature rollouts
|
||||
- [ ] Create revenue tracking and subscription analytics
|
||||
- [ ] Implement performance profiling and optimization tools
|
||||
|
||||
#### **Community & Support**
|
||||
- [ ] Build in-game help system and tutorial framework
|
||||
- [ ] Create community forums integration
|
||||
- [ ] Design player feedback collection and analysis system
|
||||
- [ ] Implement customer support ticketing system
|
||||
- [ ] Create automated FAQ and help documentation
|
||||
- [ ] Build community moderation tools and systems
|
||||
|
||||
### 🚀 **LAUNCH PREPARATION (Lower Priority)**
|
||||
|
||||
#### **Performance & Scaling**
|
||||
- [ ] Implement database sharding and horizontal scaling
|
||||
- [ ] Create load balancing and traffic distribution
|
||||
- [ ] Design disaster recovery and backup systems
|
||||
- [ ] Implement CDN integration for global performance
|
||||
- [ ] Create automated deployment and rollback systems
|
||||
- [ ] Build comprehensive monitoring and alerting infrastructure
|
||||
|
||||
#### **Marketing & Community**
|
||||
- [ ] Create game trailer and promotional materials
|
||||
- [ ] Build landing page and marketing website
|
||||
- [ ] Design social media integration and sharing features
|
||||
- [ ] Implement referral system and community growth tools
|
||||
- [ ] Create press kit and media relations materials
|
||||
- [ ] Build influencer and content creator partnership program
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Strategy & Quality Assurance
|
||||
|
||||
### **Alpha Testing Goals (Months 4-8)**
|
||||
**Objective**: Validate core gameplay mechanics and technical stability
|
||||
|
||||
#### **Technical Testing**
|
||||
- [ ] **Load Testing**: Simulate 1,000 concurrent users across all systems
|
||||
- [ ] **Database Performance**: Ensure sub-100ms response times for all queries
|
||||
- [ ] **Security Penetration**: Third-party security audit of authentication and admin systems
|
||||
- [ ] **Cross-Platform Compatibility**: Test on 10+ device/browser combinations
|
||||
- [ ] **Real-Time Sync**: Validate WebSocket performance under various network conditions
|
||||
|
||||
#### **Gameplay Testing**
|
||||
- [ ] **New Player Experience**: Track tutorial completion rates (target: >80%)
|
||||
- [ ] **Resource Balance**: Monitor economy for inflation/deflation patterns
|
||||
- [ ] **Fleet Combat**: Validate combat simulation accuracy and fairness
|
||||
- [ ] **Building Progression**: Ensure logical upgrade paths and balance
|
||||
- [ ] **Galaxy Exploration**: Test coordinate system and discovery mechanics
|
||||
|
||||
#### **Success Metrics**
|
||||
- **Player Retention**: 70% day-1, 40% day-7, 20% day-30
|
||||
- **Technical Stability**: <1% crash rate, <5% bug reports per session
|
||||
- **Performance**: Page load <2 seconds, action response <500ms
|
||||
- **Player Satisfaction**: >4.0/5.0 average rating in feedback surveys
|
||||
|
||||
### **Beta Testing Goals (Months 9-15)**
|
||||
**Objective**: Stress-test multiplayer systems and refine game balance
|
||||
|
||||
#### **Multiplayer Systems Testing**
|
||||
- [ ] **Faction Warfare**: Organize large-scale conflicts with 500+ participants
|
||||
- [ ] **Diplomatic Complexity**: Test alliance chains and treaty negotiations
|
||||
- [ ] **Galaxy Events**: Run major events with server-wide participation
|
||||
- [ ] **Admin Tools**: Validate all administrative systems under real conditions
|
||||
- [ ] **Community Features**: Test messaging, forums, and social systems
|
||||
|
||||
#### **Balance & Progression Testing**
|
||||
- [ ] **Economic Modeling**: Simulate 6-month player progression patterns
|
||||
- [ ] **Technology Trees**: Validate research paths and unlock sequences
|
||||
- [ ] **Ship Meta Evolution**: Monitor fleet composition trends and counter-strategies
|
||||
- [ ] **Colony Specialization**: Test various building strategies and optimizations
|
||||
- [ ] **Event Impact**: Measure event participation and satisfaction rates
|
||||
|
||||
#### **Success Metrics**
|
||||
- **Concurrent Users**: Stable performance with 2,000+ simultaneous players
|
||||
- **Player Engagement**: Average session 45+ minutes, 3+ sessions per week
|
||||
- **Community Health**: <5% toxic behavior reports, active faction participation
|
||||
- **Revenue Validation**: 25%+ subscription conversion rate among engaged players
|
||||
|
||||
### **Launch Readiness Testing (Months 16-18)**
|
||||
**Objective**: Ensure production-ready stability and customer satisfaction
|
||||
|
||||
#### **Production Environment Testing**
|
||||
- [ ] **Scaling Validation**: Test auto-scaling under traffic spikes
|
||||
- [ ] **Disaster Recovery**: Validate backup systems and emergency procedures
|
||||
- [ ] **Payment Processing**: Test all subscription and purchase flows
|
||||
- [ ] **Customer Support**: Validate help desk and issue resolution processes
|
||||
- [ ] **Content Pipeline**: Test rapid deployment of events and balance changes
|
||||
|
||||
#### **User Experience Polish**
|
||||
- [ ] **Tutorial Optimization**: Achieve >85% completion rate for new players
|
||||
- [ ] **Mobile Experience**: Ensure feature parity and usability across devices
|
||||
- [ ] **Accessibility Compliance**: Meet WCAG 2.1 AA standards
|
||||
- [ ] **Performance Optimization**: Achieve <1 second load times globally
|
||||
- [ ] **Localization Testing**: Validate UI and content in target languages
|
||||
|
||||
#### **Launch Success Metrics**
|
||||
- **Technical Performance**: 99.9% uptime, <100ms average response time
|
||||
- **User Acquisition**: 10,000+ registrations in first month
|
||||
- **Player Retention**: Maintain alpha/beta retention targets at scale
|
||||
- **Revenue Achievement**: Hit $50K ARR within 90 days of launch
|
||||
- **Community Growth**: Active forums, social media engagement, content creation
|
||||
|
||||
---
|
||||
|
||||
## 💡 Competitive Advantages
|
||||
|
||||
### **Innovation Areas**
|
||||
- **Narrative Integration**: Events create permanent galaxy changes
|
||||
- **Cross-Platform Excellence**: True seamless experience across all devices
|
||||
- **Administrative Transparency**: Players can see how game evolves
|
||||
- **Community Collaboration**: Player actions shape shared experiences
|
||||
- **Respectful Monetization**: Premium features enhance rather than gate content
|
||||
|
||||
### **Technical Excellence**
|
||||
- **Data-Driven Design**: Rapid content updates without code deployment
|
||||
- **Comprehensive Analytics**: Deep insights into player behavior and game balance
|
||||
- **Security Focus**: Bank-level protection for user data and game integrity
|
||||
- **Scalable Architecture**: Built to handle explosive growth from day one
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Metrics & KPIs
|
||||
|
||||
### **Player Metrics**
|
||||
- **Daily Active Users (DAU)**: Target 10,000+ by end of Year 1
|
||||
- **Monthly Active Users (MAU)**: Target 50,000+ by end of Year 1
|
||||
- **Player Retention**: Day-1: 70%, Day-7: 40%, Day-30: 20%
|
||||
- **Session Length**: Average 45+ minutes for engaged players
|
||||
- **Player Lifetime Value**: $120+ for subscription players
|
||||
|
||||
### **Business Metrics**
|
||||
- **Subscription Conversion**: 25%+ of active players
|
||||
- **Monthly Recurring Revenue**: $100K+ by month 12
|
||||
- **Customer Acquisition Cost**: <$15 per registered player
|
||||
- **Churn Rate**: <5% monthly for subscription players
|
||||
- **Net Promoter Score**: >50 for overall player satisfaction
|
||||
|
||||
### **Technical Metrics**
|
||||
- **System Uptime**: 99.9%+ availability
|
||||
- **Response Time**: <500ms for all user actions
|
||||
- **Bug Report Rate**: <1% of player sessions
|
||||
- **Security Incidents**: Zero successful breaches or data compromises
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Conclusion
|
||||
|
||||
**Shattered Void** represents the evolution of strategy gaming - combining the depth players crave with the respect for their time they deserve. By focusing on meaningful choices, collaborative experiences, and ongoing narrative evolution, we create not just a game, but a living universe where every player's actions matter.
|
||||
|
||||
Our comprehensive development plan, rigorous testing strategy, and ethical monetization approach position us to capture significant market share in the growing strategy MMO space. With the right team and funding, **Shattered Void** will become the definitive post-collapse strategy experience.
|
||||
|
||||
**The galaxy awaits. Will you help rebuild it?**
|
||||
|
||||
---
|
||||
|
||||
*This document represents a comprehensive game development proposal. Implementation timelines and features may be adjusted based on team size, funding, and market feedback.*
|
||||
119
src/app.js
Normal file
119
src/app.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Shattered Void MMO - Express Application Configuration
|
||||
* Sets up Express app with all middleware, routes, and error handling
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const compression = require('compression');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// Import utilities and configuration
|
||||
const logger = require('./utils/logger');
|
||||
|
||||
// Import middleware
|
||||
const corsMiddleware = require('./middleware/cors.middleware');
|
||||
const { rateLimiters } = require('./middleware/rateLimit.middleware');
|
||||
const { requestLogger } = require('./middleware/logging.middleware');
|
||||
const { errorHandler } = require('./middleware/error.middleware');
|
||||
|
||||
// Import routes
|
||||
const routes = require('./routes');
|
||||
|
||||
/**
|
||||
* Create and configure Express application
|
||||
* @returns {Object} Configured Express app
|
||||
*/
|
||||
function createApp() {
|
||||
const app = express();
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
// Add correlation ID to all requests for tracing
|
||||
app.use((req, res, next) => {
|
||||
req.correlationId = uuidv4();
|
||||
res.set('X-Correlation-ID', req.correlationId);
|
||||
next();
|
||||
});
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: NODE_ENV === 'production' ? undefined : false,
|
||||
crossOriginEmbedderPolicy: false, // Allow WebSocket connections
|
||||
}));
|
||||
|
||||
// CORS middleware
|
||||
app.use(corsMiddleware);
|
||||
|
||||
// Compression middleware
|
||||
app.use(compression());
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({
|
||||
limit: process.env.REQUEST_SIZE_LIMIT || '10mb',
|
||||
verify: (req, res, buf) => {
|
||||
// Store raw body for webhook verification if needed
|
||||
req.rawBody = buf;
|
||||
}
|
||||
}));
|
||||
app.use(express.urlencoded({
|
||||
extended: true,
|
||||
limit: process.env.REQUEST_SIZE_LIMIT || '10mb'
|
||||
}));
|
||||
|
||||
// Cookie parsing middleware
|
||||
app.use(cookieParser());
|
||||
|
||||
// Request logging middleware
|
||||
app.use(requestLogger);
|
||||
|
||||
// Rate limiting middleware
|
||||
app.use(rateLimiters.global);
|
||||
|
||||
// Health check endpoint (before other routes)
|
||||
app.get('/health', (req, res) => {
|
||||
const healthData = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
environment: NODE_ENV,
|
||||
uptime: process.uptime(),
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
||||
}
|
||||
};
|
||||
|
||||
res.status(200).json(healthData);
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/', routes);
|
||||
|
||||
// 404 handler for unmatched routes
|
||||
app.use('*', (req, res) => {
|
||||
logger.warn('Route not found', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'The requested resource was not found',
|
||||
path: req.originalUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = createApp;
|
||||
249
src/config/redis.js
Normal file
249
src/config/redis.js
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Redis Configuration and Connection Management
|
||||
* Handles Redis client initialization, connection management, and error handling
|
||||
*/
|
||||
|
||||
const redis = require('redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Configuration
|
||||
const REDIS_CONFIG = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB) || 0,
|
||||
retryDelayOnFailover: 100,
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 5000,
|
||||
};
|
||||
|
||||
let client = null;
|
||||
let isConnected = false;
|
||||
|
||||
/**
|
||||
* Create Redis client with error handling
|
||||
* @returns {Object} Redis client instance
|
||||
*/
|
||||
function createRedisClient() {
|
||||
const redisClient = redis.createClient({
|
||||
socket: {
|
||||
host: REDIS_CONFIG.host,
|
||||
port: REDIS_CONFIG.port,
|
||||
connectTimeout: REDIS_CONFIG.connectTimeout,
|
||||
commandTimeout: REDIS_CONFIG.commandTimeout,
|
||||
reconnectStrategy: (retries) => {
|
||||
if (retries > 10) {
|
||||
logger.error('Redis reconnection failed after 10 attempts');
|
||||
return new Error('Redis reconnection failed');
|
||||
}
|
||||
const delay = Math.min(retries * 50, 2000);
|
||||
logger.warn(`Redis reconnecting in ${delay}ms (attempt ${retries})`);
|
||||
return delay;
|
||||
}
|
||||
},
|
||||
password: REDIS_CONFIG.password,
|
||||
database: REDIS_CONFIG.db,
|
||||
});
|
||||
|
||||
// Connection event handlers
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('Redis client connected');
|
||||
});
|
||||
|
||||
redisClient.on('ready', () => {
|
||||
isConnected = true;
|
||||
logger.info('Redis client ready', {
|
||||
host: REDIS_CONFIG.host,
|
||||
port: REDIS_CONFIG.port,
|
||||
database: REDIS_CONFIG.db
|
||||
});
|
||||
});
|
||||
|
||||
redisClient.on('error', (error) => {
|
||||
isConnected = false;
|
||||
logger.error('Redis client error:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
stack: error.stack
|
||||
});
|
||||
});
|
||||
|
||||
redisClient.on('end', () => {
|
||||
isConnected = false;
|
||||
logger.info('Redis client connection ended');
|
||||
});
|
||||
|
||||
redisClient.on('reconnecting', () => {
|
||||
logger.info('Redis client reconnecting...');
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Redis connection
|
||||
* @returns {Promise<Object>} Redis client instance
|
||||
*/
|
||||
async function initializeRedis() {
|
||||
try {
|
||||
if (client && isConnected) {
|
||||
logger.info('Redis already connected');
|
||||
return client;
|
||||
}
|
||||
|
||||
client = createRedisClient();
|
||||
await client.connect();
|
||||
|
||||
// Test connection
|
||||
const pong = await client.ping();
|
||||
if (pong !== 'PONG') {
|
||||
throw new Error('Redis ping test failed');
|
||||
}
|
||||
|
||||
logger.info('Redis initialized successfully');
|
||||
return client;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize Redis:', {
|
||||
host: REDIS_CONFIG.host,
|
||||
port: REDIS_CONFIG.port,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis client instance
|
||||
* @returns {Object|null} Redis client or null if not connected
|
||||
*/
|
||||
function getRedisClient() {
|
||||
if (!client || !isConnected) {
|
||||
logger.warn('Redis client requested but not connected');
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Redis is connected
|
||||
* @returns {boolean} Connection status
|
||||
*/
|
||||
function isRedisConnected() {
|
||||
return isConnected && client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Redis connection gracefully
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function closeRedis() {
|
||||
try {
|
||||
if (client && isConnected) {
|
||||
await client.quit();
|
||||
client = null;
|
||||
isConnected = false;
|
||||
logger.info('Redis connection closed gracefully');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Redis connection:', error);
|
||||
// Force close if graceful close fails
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
isConnected = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis utility functions for common operations
|
||||
*/
|
||||
const RedisUtils = {
|
||||
/**
|
||||
* Set a key-value pair with optional expiration
|
||||
* @param {string} key - Redis key
|
||||
* @param {string} value - Value to store
|
||||
* @param {number} ttl - Time to live in seconds (optional)
|
||||
* @returns {Promise<string>} Redis response
|
||||
*/
|
||||
async set(key, value, ttl = null) {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) throw new Error('Redis not connected');
|
||||
|
||||
try {
|
||||
if (ttl) {
|
||||
return await redisClient.setEx(key, ttl, value);
|
||||
}
|
||||
return await redisClient.set(key, value);
|
||||
} catch (error) {
|
||||
logger.error('Redis SET error:', { key, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get value by key
|
||||
* @param {string} key - Redis key
|
||||
* @returns {Promise<string|null>} Value or null if not found
|
||||
*/
|
||||
async get(key) {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) throw new Error('Redis not connected');
|
||||
|
||||
try {
|
||||
return await redisClient.get(key);
|
||||
} catch (error) {
|
||||
logger.error('Redis GET error:', { key, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a key
|
||||
* @param {string} key - Redis key
|
||||
* @returns {Promise<number>} Number of keys deleted
|
||||
*/
|
||||
async del(key) {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) throw new Error('Redis not connected');
|
||||
|
||||
try {
|
||||
return await redisClient.del(key);
|
||||
} catch (error) {
|
||||
logger.error('Redis DEL error:', { key, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
* @param {string} key - Redis key
|
||||
* @returns {Promise<boolean>} True if key exists
|
||||
*/
|
||||
async exists(key) {
|
||||
const redisClient = getRedisClient();
|
||||
if (!redisClient) throw new Error('Redis not connected');
|
||||
|
||||
try {
|
||||
const result = await redisClient.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error('Redis EXISTS error:', { key, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initializeRedis,
|
||||
getRedisClient,
|
||||
isRedisConnected,
|
||||
closeRedis,
|
||||
RedisUtils,
|
||||
client: () => client // For backward compatibility
|
||||
};
|
||||
321
src/config/websocket.js
Normal file
321
src/config/websocket.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
/**
|
||||
* WebSocket Configuration and Connection Management
|
||||
* Handles Socket.IO server initialization and connection management
|
||||
*/
|
||||
|
||||
const { Server } = require('socket.io');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Configuration
|
||||
const WEBSOCKET_CONFIG = {
|
||||
cors: {
|
||||
origin: process.env.WEBSOCKET_CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:3001'],
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
},
|
||||
pingTimeout: parseInt(process.env.WEBSOCKET_PING_TIMEOUT) || 20000,
|
||||
pingInterval: parseInt(process.env.WEBSOCKET_PING_INTERVAL) || 25000,
|
||||
maxHttpBufferSize: parseInt(process.env.WEBSOCKET_MAX_BUFFER_SIZE) || 1e6, // 1MB
|
||||
transports: ['websocket', 'polling'],
|
||||
allowEIO3: true,
|
||||
compression: true,
|
||||
httpCompression: true
|
||||
};
|
||||
|
||||
let io = null;
|
||||
let connectionCount = 0;
|
||||
const connectedClients = new Map();
|
||||
|
||||
/**
|
||||
* Initialize WebSocket server
|
||||
* @param {Object} server - HTTP server instance
|
||||
* @returns {Promise<Object>} Socket.IO server instance
|
||||
*/
|
||||
async function initializeWebSocket(server) {
|
||||
try {
|
||||
if (io) {
|
||||
logger.info('WebSocket server already initialized');
|
||||
return io;
|
||||
}
|
||||
|
||||
// Create Socket.IO server
|
||||
io = new Server(server, WEBSOCKET_CONFIG);
|
||||
|
||||
// Set up middleware for authentication and logging
|
||||
io.use(async (socket, next) => {
|
||||
const correlationId = socket.handshake.query.correlationId || require('uuid').v4();
|
||||
socket.correlationId = correlationId;
|
||||
|
||||
logger.info('WebSocket connection attempt', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
ip: socket.handshake.address,
|
||||
userAgent: socket.handshake.headers['user-agent']
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Connection event handler
|
||||
io.on('connection', (socket) => {
|
||||
connectionCount++;
|
||||
connectedClients.set(socket.id, {
|
||||
connectedAt: new Date(),
|
||||
ip: socket.handshake.address,
|
||||
userAgent: socket.handshake.headers['user-agent'],
|
||||
playerId: null, // Will be set after authentication
|
||||
rooms: new Set()
|
||||
});
|
||||
|
||||
logger.info('WebSocket client connected', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
totalConnections: connectionCount,
|
||||
ip: socket.handshake.address
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
setupSocketEventHandlers(socket);
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', (reason) => {
|
||||
connectionCount--;
|
||||
const clientInfo = connectedClients.get(socket.id);
|
||||
connectedClients.delete(socket.id);
|
||||
|
||||
logger.info('WebSocket client disconnected', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
reason,
|
||||
totalConnections: connectionCount,
|
||||
playerId: clientInfo?.playerId,
|
||||
connectionDuration: clientInfo ? Date.now() - clientInfo.connectedAt : 0
|
||||
});
|
||||
});
|
||||
|
||||
// Handle connection errors
|
||||
socket.on('error', (error) => {
|
||||
logger.error('WebSocket connection error', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Server-level error handling
|
||||
io.engine.on('connection_error', (error) => {
|
||||
logger.error('WebSocket connection error:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
context: error.context
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('WebSocket server initialized successfully', {
|
||||
maxConnections: process.env.WEBSOCKET_MAX_CONNECTIONS || 'unlimited',
|
||||
pingTimeout: WEBSOCKET_CONFIG.pingTimeout,
|
||||
pingInterval: WEBSOCKET_CONFIG.pingInterval
|
||||
});
|
||||
|
||||
return io;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize WebSocket server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for individual socket connections
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
*/
|
||||
function setupSocketEventHandlers(socket) {
|
||||
// Player authentication
|
||||
socket.on('authenticate', async (data) => {
|
||||
try {
|
||||
logger.info('WebSocket authentication attempt', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: data?.playerId
|
||||
});
|
||||
|
||||
// TODO: Implement JWT token validation
|
||||
// For now, just acknowledge
|
||||
socket.emit('authenticated', {
|
||||
success: true,
|
||||
message: 'Authentication successful'
|
||||
});
|
||||
|
||||
// Update client information
|
||||
if (connectedClients.has(socket.id)) {
|
||||
connectedClients.get(socket.id).playerId = data?.playerId;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebSocket authentication error', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
socket.emit('authentication_error', {
|
||||
success: false,
|
||||
message: 'Authentication failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Join room (for game features like galaxy regions, player groups, etc.)
|
||||
socket.on('join_room', (roomName) => {
|
||||
if (typeof roomName !== 'string' || roomName.length > 50) {
|
||||
socket.emit('error', { message: 'Invalid room name' });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(roomName);
|
||||
|
||||
const clientInfo = connectedClients.get(socket.id);
|
||||
if (clientInfo) {
|
||||
clientInfo.rooms.add(roomName);
|
||||
}
|
||||
|
||||
logger.info('Client joined room', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
room: roomName,
|
||||
playerId: clientInfo?.playerId
|
||||
});
|
||||
|
||||
socket.emit('room_joined', { room: roomName });
|
||||
});
|
||||
|
||||
// Leave room
|
||||
socket.on('leave_room', (roomName) => {
|
||||
socket.leave(roomName);
|
||||
|
||||
const clientInfo = connectedClients.get(socket.id);
|
||||
if (clientInfo) {
|
||||
clientInfo.rooms.delete(roomName);
|
||||
}
|
||||
|
||||
logger.info('Client left room', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
room: roomName,
|
||||
playerId: clientInfo?.playerId
|
||||
});
|
||||
|
||||
socket.emit('room_left', { room: roomName });
|
||||
});
|
||||
|
||||
// Ping/pong for connection testing
|
||||
socket.on('ping', () => {
|
||||
socket.emit('pong', { timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Generic message handler (for debugging)
|
||||
socket.on('message', (data) => {
|
||||
logger.debug('WebSocket message received', {
|
||||
correlationId: socket.correlationId,
|
||||
socketId: socket.id,
|
||||
data: typeof data === 'object' ? JSON.stringify(data) : data
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WebSocket server instance
|
||||
* @returns {Object|null} Socket.IO server instance
|
||||
*/
|
||||
function getWebSocketServer() {
|
||||
return io;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
* @returns {Object} Connection statistics
|
||||
*/
|
||||
function getConnectionStats() {
|
||||
return {
|
||||
totalConnections: connectionCount,
|
||||
authenticatedConnections: Array.from(connectedClients.values())
|
||||
.filter(client => client.playerId).length,
|
||||
anonymousConnections: Array.from(connectedClients.values())
|
||||
.filter(client => !client.playerId).length,
|
||||
rooms: io ? Array.from(io.sockets.adapter.rooms.keys()) : []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to all connected clients
|
||||
* @param {string} event - Event name
|
||||
* @param {Object} data - Data to broadcast
|
||||
*/
|
||||
function broadcastToAll(event, data) {
|
||||
if (!io) {
|
||||
logger.warn('Attempted to broadcast but WebSocket server not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
io.emit(event, data);
|
||||
logger.info('Broadcast sent to all clients', {
|
||||
event,
|
||||
recipientCount: connectionCount
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast message to specific room
|
||||
* @param {string} room - Room name
|
||||
* @param {string} event - Event name
|
||||
* @param {Object} data - Data to broadcast
|
||||
*/
|
||||
function broadcastToRoom(room, event, data) {
|
||||
if (!io) {
|
||||
logger.warn('Attempted to broadcast to room but WebSocket server not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
io.to(room).emit(event, data);
|
||||
logger.info('Broadcast sent to room', {
|
||||
room,
|
||||
event,
|
||||
recipientCount: io.sockets.adapter.rooms.get(room)?.size || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close WebSocket server gracefully
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function closeWebSocket() {
|
||||
if (!io) return;
|
||||
|
||||
try {
|
||||
// Disconnect all clients
|
||||
io.disconnectSockets();
|
||||
|
||||
// Close server
|
||||
io.close();
|
||||
|
||||
io = null;
|
||||
connectionCount = 0;
|
||||
connectedClients.clear();
|
||||
|
||||
logger.info('WebSocket server closed gracefully');
|
||||
} catch (error) {
|
||||
logger.error('Error closing WebSocket server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeWebSocket,
|
||||
getWebSocketServer,
|
||||
getConnectionStats,
|
||||
broadcastToAll,
|
||||
broadcastToRoom,
|
||||
closeWebSocket
|
||||
};
|
||||
261
src/controllers/admin/auth.controller.js
Normal file
261
src/controllers/admin/auth.controller.js
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Admin Authentication Controller
|
||||
* Handles admin login, authentication, and admin-specific endpoints
|
||||
*/
|
||||
|
||||
const AdminService = require('../../services/user/AdminService');
|
||||
const { asyncHandler } = require('../../middleware/error.middleware');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const adminService = new AdminService();
|
||||
|
||||
/**
|
||||
* Admin login
|
||||
* POST /api/admin/auth/login
|
||||
*/
|
||||
const login = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const { email, password } = req.body;
|
||||
|
||||
logger.info('Admin login request received', {
|
||||
correlationId,
|
||||
email
|
||||
});
|
||||
|
||||
const authResult = await adminService.authenticateAdmin({
|
||||
email,
|
||||
password
|
||||
}, correlationId);
|
||||
|
||||
logger.audit('Admin login successful', {
|
||||
correlationId,
|
||||
adminId: authResult.admin.id,
|
||||
email: authResult.admin.email,
|
||||
username: authResult.admin.username,
|
||||
permissions: authResult.admin.permissions
|
||||
});
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
res.cookie('adminRefreshToken', authResult.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 8 * 60 * 60 * 1000, // 8 hours (shorter than player tokens)
|
||||
path: '/api/admin' // Restrict to admin routes
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Admin login successful',
|
||||
data: {
|
||||
admin: authResult.admin,
|
||||
accessToken: authResult.tokens.accessToken
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin logout
|
||||
* POST /api/admin/auth/logout
|
||||
*/
|
||||
const logout = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const adminId = req.user?.adminId;
|
||||
|
||||
logger.audit('Admin logout request received', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
// Clear refresh token cookie
|
||||
res.clearCookie('adminRefreshToken', {
|
||||
path: '/api/admin'
|
||||
});
|
||||
|
||||
// TODO: Add token to blacklist if implementing token blacklisting
|
||||
|
||||
logger.audit('Admin logout successful', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Admin logout successful',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current admin profile
|
||||
* GET /api/admin/auth/me
|
||||
*/
|
||||
const getProfile = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const adminId = req.user.adminId;
|
||||
|
||||
logger.info('Admin profile request received', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
const profile = await adminService.getAdminProfile(adminId, correlationId);
|
||||
|
||||
logger.info('Admin profile retrieved', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username: profile.username
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Admin profile retrieved successfully',
|
||||
data: {
|
||||
admin: profile
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify admin token (for testing/debugging)
|
||||
* GET /api/admin/auth/verify
|
||||
*/
|
||||
const verifyToken = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const user = req.user;
|
||||
|
||||
logger.audit('Admin token verification request received', {
|
||||
correlationId,
|
||||
adminId: user.adminId,
|
||||
username: user.username,
|
||||
permissions: user.permissions
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Admin token is valid',
|
||||
data: {
|
||||
admin: {
|
||||
adminId: user.adminId,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
permissions: user.permissions,
|
||||
type: user.type,
|
||||
tokenIssuedAt: new Date(user.iat * 1000),
|
||||
tokenExpiresAt: new Date(user.exp * 1000)
|
||||
}
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh admin access token
|
||||
* POST /api/admin/auth/refresh
|
||||
*/
|
||||
const refresh = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const refreshToken = req.cookies.adminRefreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
logger.warn('Admin token refresh request without refresh token', {
|
||||
correlationId
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Admin refresh token not provided',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement admin refresh token validation and new token generation
|
||||
logger.warn('Admin token refresh requested but not implemented', {
|
||||
correlationId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Admin token refresh feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get system statistics (admin dashboard)
|
||||
* GET /api/admin/auth/stats
|
||||
*/
|
||||
const getSystemStats = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const adminId = req.user.adminId;
|
||||
|
||||
logger.audit('System statistics request received', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
const stats = await adminService.getSystemStats(correlationId);
|
||||
|
||||
logger.audit('System statistics retrieved', {
|
||||
correlationId,
|
||||
adminId,
|
||||
totalPlayers: stats.players.total,
|
||||
activePlayers: stats.players.active
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'System statistics retrieved successfully',
|
||||
data: {
|
||||
stats
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Change admin password
|
||||
* POST /api/admin/auth/change-password
|
||||
*/
|
||||
const changePassword = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const adminId = req.user.adminId;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
logger.audit('Admin password change request received', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
// TODO: Implement admin password change functionality
|
||||
// This would involve:
|
||||
// 1. Verify current password
|
||||
// 2. Validate new password strength
|
||||
// 3. Hash new password
|
||||
// 4. Update in database
|
||||
// 5. Optionally invalidate existing tokens
|
||||
// 6. Send notification email
|
||||
|
||||
logger.warn('Admin password change requested but not implemented', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Admin password change feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
logout,
|
||||
getProfile,
|
||||
verifyToken,
|
||||
refresh,
|
||||
getSystemStats,
|
||||
changePassword
|
||||
};
|
||||
298
src/controllers/api/auth.controller.js
Normal file
298
src/controllers/api/auth.controller.js
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* Player Authentication Controller
|
||||
* Handles player registration, login, and authentication-related endpoints
|
||||
*/
|
||||
|
||||
const PlayerService = require('../../services/user/PlayerService');
|
||||
const { asyncHandler } = require('../../middleware/error.middleware');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const playerService = new PlayerService();
|
||||
|
||||
/**
|
||||
* Register a new player
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
const register = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const { email, username, password } = req.body;
|
||||
|
||||
logger.info('Player registration request received', {
|
||||
correlationId,
|
||||
email,
|
||||
username
|
||||
});
|
||||
|
||||
const player = await playerService.registerPlayer({
|
||||
email,
|
||||
username,
|
||||
password
|
||||
}, correlationId);
|
||||
|
||||
logger.info('Player registration successful', {
|
||||
correlationId,
|
||||
playerId: player.id,
|
||||
email: player.email,
|
||||
username: player.username
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Player registered successfully',
|
||||
data: {
|
||||
player
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Player login
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
const login = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const { email, password } = req.body;
|
||||
|
||||
logger.info('Player login request received', {
|
||||
correlationId,
|
||||
email
|
||||
});
|
||||
|
||||
const authResult = await playerService.authenticatePlayer({
|
||||
email,
|
||||
password
|
||||
}, correlationId);
|
||||
|
||||
logger.info('Player login successful', {
|
||||
correlationId,
|
||||
playerId: authResult.player.id,
|
||||
email: authResult.player.email,
|
||||
username: authResult.player.username
|
||||
});
|
||||
|
||||
// Set refresh token as httpOnly cookie
|
||||
res.cookie('refreshToken', authResult.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
data: {
|
||||
player: authResult.player,
|
||||
accessToken: authResult.tokens.accessToken
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Player logout
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
const logout = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user?.playerId;
|
||||
|
||||
logger.info('Player logout request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
// Clear refresh token cookie
|
||||
res.clearCookie('refreshToken');
|
||||
|
||||
// TODO: Add token to blacklist if implementing token blacklisting
|
||||
|
||||
logger.info('Player logout successful', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Logout successful',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
* POST /api/auth/refresh
|
||||
*/
|
||||
const refresh = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
logger.warn('Token refresh request without refresh token', {
|
||||
correlationId
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Refresh token not provided',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement refresh token validation and new token generation
|
||||
// For now, return error indicating feature not implemented
|
||||
logger.warn('Token refresh requested but not implemented', {
|
||||
correlationId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Token refresh feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current player profile
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
const getProfile = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
|
||||
logger.info('Player profile request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
const profile = await playerService.getPlayerProfile(playerId, correlationId);
|
||||
|
||||
logger.info('Player profile retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
username: profile.username
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Profile retrieved successfully',
|
||||
data: {
|
||||
player: profile
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update current player profile
|
||||
* PUT /api/auth/me
|
||||
*/
|
||||
const updateProfile = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const updateData = req.body;
|
||||
|
||||
logger.info('Player profile update request received', {
|
||||
correlationId,
|
||||
playerId,
|
||||
updateFields: Object.keys(updateData)
|
||||
});
|
||||
|
||||
const updatedProfile = await playerService.updatePlayerProfile(
|
||||
playerId,
|
||||
updateData,
|
||||
correlationId
|
||||
);
|
||||
|
||||
logger.info('Player profile updated successfully', {
|
||||
correlationId,
|
||||
playerId,
|
||||
username: updatedProfile.username
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
data: {
|
||||
player: updatedProfile
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify player token (for testing/debugging)
|
||||
* GET /api/auth/verify
|
||||
*/
|
||||
const verifyToken = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const user = req.user;
|
||||
|
||||
logger.info('Token verification request received', {
|
||||
correlationId,
|
||||
playerId: user.playerId,
|
||||
username: user.username
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Token is valid',
|
||||
data: {
|
||||
user: {
|
||||
playerId: user.playerId,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
type: user.type,
|
||||
tokenIssuedAt: new Date(user.iat * 1000),
|
||||
tokenExpiresAt: new Date(user.exp * 1000)
|
||||
}
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Change player password
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
const changePassword = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
logger.info('Password change request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
// TODO: Implement password change functionality
|
||||
// This would involve:
|
||||
// 1. Verify current password
|
||||
// 2. Validate new password strength
|
||||
// 3. Hash new password
|
||||
// 4. Update in database
|
||||
// 5. Optionally invalidate existing tokens
|
||||
|
||||
logger.warn('Password change requested but not implemented', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Password change feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
getProfile,
|
||||
updateProfile,
|
||||
verifyToken,
|
||||
changePassword
|
||||
};
|
||||
307
src/controllers/api/player.controller.js
Normal file
307
src/controllers/api/player.controller.js
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* Player Management Controller
|
||||
* Handles player-specific operations and game-related endpoints
|
||||
*/
|
||||
|
||||
const PlayerService = require('../../services/user/PlayerService');
|
||||
const { asyncHandler } = require('../../middleware/error.middleware');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
const playerService = new PlayerService();
|
||||
|
||||
/**
|
||||
* Get player dashboard data
|
||||
* GET /api/player/dashboard
|
||||
*/
|
||||
const getDashboard = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
|
||||
logger.info('Player dashboard request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
// Get player profile with resources and stats
|
||||
const profile = await playerService.getPlayerProfile(playerId, correlationId);
|
||||
|
||||
// TODO: Add additional dashboard data such as:
|
||||
// - Recent activities
|
||||
// - Colony summaries
|
||||
// - Fleet statuses
|
||||
// - Research progress
|
||||
// - Messages/notifications
|
||||
|
||||
const dashboardData = {
|
||||
player: profile,
|
||||
summary: {
|
||||
totalColonies: profile.stats.coloniesCount,
|
||||
totalFleets: profile.stats.fleetsCount,
|
||||
totalBattles: profile.stats.totalBattles,
|
||||
winRate: profile.stats.totalBattles > 0
|
||||
? Math.round((profile.stats.battlesWon / profile.stats.totalBattles) * 100)
|
||||
: 0
|
||||
},
|
||||
// Placeholder for future dashboard sections
|
||||
recentActivity: [],
|
||||
notifications: [],
|
||||
gameStatus: {
|
||||
online: true,
|
||||
lastTick: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Player dashboard data retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
username: profile.username
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Dashboard data retrieved successfully',
|
||||
data: dashboardData,
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get player resources
|
||||
* GET /api/player/resources
|
||||
*/
|
||||
const getResources = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
|
||||
logger.info('Player resources request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
const profile = await playerService.getPlayerProfile(playerId, correlationId);
|
||||
|
||||
logger.info('Player resources retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
scrap: profile.resources.scrap,
|
||||
energy: profile.resources.energy
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Resources retrieved successfully',
|
||||
data: {
|
||||
resources: profile.resources,
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get player statistics
|
||||
* GET /api/player/stats
|
||||
*/
|
||||
const getStats = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
|
||||
logger.info('Player statistics request received', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
const profile = await playerService.getPlayerProfile(playerId, correlationId);
|
||||
|
||||
const detailedStats = {
|
||||
...profile.stats,
|
||||
winRate: profile.stats.totalBattles > 0
|
||||
? Math.round((profile.stats.battlesWon / profile.stats.totalBattles) * 100)
|
||||
: 0,
|
||||
lossRate: profile.stats.totalBattles > 0
|
||||
? Math.round(((profile.stats.totalBattles - profile.stats.battlesWon) / profile.stats.totalBattles) * 100)
|
||||
: 0,
|
||||
accountAge: Math.floor((Date.now() - new Date(profile.createdAt).getTime()) / (1000 * 60 * 60 * 24)) // days
|
||||
};
|
||||
|
||||
logger.info('Player statistics retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
totalBattles: detailedStats.totalBattles,
|
||||
winRate: detailedStats.winRate
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Statistics retrieved successfully',
|
||||
data: {
|
||||
stats: detailedStats,
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update player settings
|
||||
* PUT /api/player/settings
|
||||
*/
|
||||
const updateSettings = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const settings = req.body;
|
||||
|
||||
logger.info('Player settings update request received', {
|
||||
correlationId,
|
||||
playerId,
|
||||
settingsKeys: Object.keys(settings)
|
||||
});
|
||||
|
||||
// TODO: Implement player settings update
|
||||
// This would involve:
|
||||
// 1. Validate settings data
|
||||
// 2. Update player_settings table
|
||||
// 3. Return updated settings
|
||||
|
||||
logger.warn('Player settings update requested but not implemented', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Player settings update feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get player activity log
|
||||
* GET /api/player/activity
|
||||
*/
|
||||
const getActivity = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
|
||||
logger.info('Player activity log request received', {
|
||||
correlationId,
|
||||
playerId,
|
||||
page,
|
||||
limit
|
||||
});
|
||||
|
||||
// TODO: Implement player activity log retrieval
|
||||
// This would show recent actions like:
|
||||
// - Colony creations/updates
|
||||
// - Fleet movements
|
||||
// - Research completions
|
||||
// - Battle results
|
||||
// - Resource transactions
|
||||
|
||||
const mockActivity = {
|
||||
activities: [],
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Player activity log retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
activitiesCount: mockActivity.activities.length
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Activity log retrieved successfully',
|
||||
data: mockActivity,
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get player notifications
|
||||
* GET /api/player/notifications
|
||||
*/
|
||||
const getNotifications = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const { unreadOnly = false } = req.query;
|
||||
|
||||
logger.info('Player notifications request received', {
|
||||
correlationId,
|
||||
playerId,
|
||||
unreadOnly
|
||||
});
|
||||
|
||||
// TODO: Implement player notifications retrieval
|
||||
// This would show:
|
||||
// - System messages
|
||||
// - Battle results
|
||||
// - Research completions
|
||||
// - Fleet arrival notifications
|
||||
// - Player messages
|
||||
|
||||
const mockNotifications = {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
totalCount: 0
|
||||
};
|
||||
|
||||
logger.info('Player notifications retrieved', {
|
||||
correlationId,
|
||||
playerId,
|
||||
unreadCount: mockNotifications.unreadCount
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Notifications retrieved successfully',
|
||||
data: mockNotifications,
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
* PUT /api/player/notifications/read
|
||||
*/
|
||||
const markNotificationsRead = asyncHandler(async (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const playerId = req.user.playerId;
|
||||
const { notificationIds } = req.body;
|
||||
|
||||
logger.info('Mark notifications read request received', {
|
||||
correlationId,
|
||||
playerId,
|
||||
notificationCount: notificationIds?.length || 0
|
||||
});
|
||||
|
||||
// TODO: Implement notification marking as read
|
||||
logger.warn('Mark notifications read requested but not implemented', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Mark notifications read feature not yet implemented',
|
||||
correlationId
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getDashboard,
|
||||
getResources,
|
||||
getStats,
|
||||
updateSettings,
|
||||
getActivity,
|
||||
getNotifications,
|
||||
markNotificationsRead
|
||||
};
|
||||
446
src/controllers/websocket/connection.handler.js
Normal file
446
src/controllers/websocket/connection.handler.js
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/**
|
||||
* WebSocket Connection Handler
|
||||
* Manages WebSocket connections and real-time game events
|
||||
*/
|
||||
|
||||
const { verifyPlayerToken, extractTokenFromHeader } = require('../../utils/jwt');
|
||||
const logger = require('../../utils/logger');
|
||||
|
||||
/**
|
||||
* Handle new WebSocket connection
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {Object} io - Socket.IO server instance
|
||||
*/
|
||||
function handleConnection(socket, io) {
|
||||
const correlationId = socket.correlationId;
|
||||
|
||||
logger.info('WebSocket connection established', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
ip: socket.handshake.address
|
||||
});
|
||||
|
||||
// Set up authentication handler
|
||||
socket.on('authenticate', async (data) => {
|
||||
await handleAuthentication(socket, data, correlationId);
|
||||
});
|
||||
|
||||
// Set up game event handlers
|
||||
setupGameEventHandlers(socket, io, correlationId);
|
||||
|
||||
// Set up utility handlers
|
||||
setupUtilityHandlers(socket, io, correlationId);
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', (reason) => {
|
||||
handleDisconnection(socket, reason, correlationId);
|
||||
});
|
||||
|
||||
// Handle connection errors
|
||||
socket.on('error', (error) => {
|
||||
handleConnectionError(socket, error, correlationId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket authentication
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {Object} data - Authentication data
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
async function handleAuthentication(socket, data, correlationId) {
|
||||
try {
|
||||
const { token } = data;
|
||||
|
||||
if (!token) {
|
||||
logger.warn('WebSocket authentication failed - no token provided', {
|
||||
correlationId,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
socket.emit('authentication_error', {
|
||||
success: false,
|
||||
message: 'Authentication token required'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the player token
|
||||
const decoded = verifyPlayerToken(token);
|
||||
|
||||
// Store player information in socket
|
||||
socket.playerId = decoded.playerId;
|
||||
socket.username = decoded.username;
|
||||
socket.email = decoded.email;
|
||||
socket.authenticated = true;
|
||||
|
||||
// Join player-specific room
|
||||
const playerRoom = `player:${decoded.playerId}`;
|
||||
socket.join(playerRoom);
|
||||
|
||||
logger.info('WebSocket authentication successful', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: decoded.playerId,
|
||||
username: decoded.username
|
||||
});
|
||||
|
||||
socket.emit('authenticated', {
|
||||
success: true,
|
||||
message: 'Authentication successful',
|
||||
player: {
|
||||
id: decoded.playerId,
|
||||
username: decoded.username,
|
||||
email: decoded.email
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial game state or notifications
|
||||
await sendInitialGameState(socket, decoded.playerId, correlationId);
|
||||
|
||||
} catch (error) {
|
||||
logger.warn('WebSocket authentication failed', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
socket.emit('authentication_error', {
|
||||
success: false,
|
||||
message: 'Authentication failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up game event handlers
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {Object} io - Socket.IO server instance
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
function setupGameEventHandlers(socket, io, correlationId) {
|
||||
// Colony updates
|
||||
socket.on('subscribe_colony_updates', (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { colonyId } = data;
|
||||
if (colonyId) {
|
||||
const roomName = `colony:${colonyId}`;
|
||||
socket.join(roomName);
|
||||
|
||||
logger.debug('Player subscribed to colony updates', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
colonyId,
|
||||
room: roomName
|
||||
});
|
||||
|
||||
socket.emit('subscribed', {
|
||||
type: 'colony_updates',
|
||||
colonyId: colonyId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fleet updates
|
||||
socket.on('subscribe_fleet_updates', (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { fleetId } = data;
|
||||
if (fleetId) {
|
||||
const roomName = `fleet:${fleetId}`;
|
||||
socket.join(roomName);
|
||||
|
||||
logger.debug('Player subscribed to fleet updates', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
fleetId,
|
||||
room: roomName
|
||||
});
|
||||
|
||||
socket.emit('subscribed', {
|
||||
type: 'fleet_updates',
|
||||
fleetId: fleetId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Galaxy sector updates
|
||||
socket.on('subscribe_sector_updates', (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { sectorId } = data;
|
||||
if (sectorId) {
|
||||
const roomName = `sector:${sectorId}`;
|
||||
socket.join(roomName);
|
||||
|
||||
logger.debug('Player subscribed to sector updates', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
sectorId,
|
||||
room: roomName
|
||||
});
|
||||
|
||||
socket.emit('subscribed', {
|
||||
type: 'sector_updates',
|
||||
sectorId: sectorId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Battle updates
|
||||
socket.on('subscribe_battle_updates', (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { battleId } = data;
|
||||
if (battleId) {
|
||||
const roomName = `battle:${battleId}`;
|
||||
socket.join(roomName);
|
||||
|
||||
logger.debug('Player subscribed to battle updates', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
battleId,
|
||||
room: roomName
|
||||
});
|
||||
|
||||
socket.emit('subscribed', {
|
||||
type: 'battle_updates',
|
||||
battleId: battleId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from updates
|
||||
socket.on('unsubscribe', (data) => {
|
||||
const { type, id } = data;
|
||||
const roomName = `${type}:${id}`;
|
||||
socket.leave(roomName);
|
||||
|
||||
logger.debug('Player unsubscribed from updates', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
type,
|
||||
id,
|
||||
room: roomName
|
||||
});
|
||||
|
||||
socket.emit('unsubscribed', { type, id });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up utility handlers
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {Object} io - Socket.IO server instance
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
function setupUtilityHandlers(socket, io, correlationId) {
|
||||
// Ping/pong for connection testing
|
||||
socket.on('ping', (data) => {
|
||||
const timestamp = Date.now();
|
||||
socket.emit('pong', {
|
||||
timestamp,
|
||||
serverTime: new Date().toISOString(),
|
||||
latency: data?.timestamp ? timestamp - data.timestamp : null
|
||||
});
|
||||
});
|
||||
|
||||
// Player status updates
|
||||
socket.on('update_status', (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = data;
|
||||
if (['online', 'away', 'busy'].includes(status)) {
|
||||
socket.playerStatus = status;
|
||||
|
||||
logger.debug('Player status updated', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
status
|
||||
});
|
||||
|
||||
// Broadcast status to relevant rooms/players
|
||||
// TODO: Implement player status broadcasting
|
||||
}
|
||||
});
|
||||
|
||||
// Chat/messaging
|
||||
socket.on('send_message', async (data) => {
|
||||
if (!socket.authenticated) {
|
||||
socket.emit('error', { message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement real-time messaging
|
||||
logger.debug('Message send requested', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
messageType: data.type
|
||||
});
|
||||
|
||||
socket.emit('message_error', {
|
||||
message: 'Messaging feature not yet implemented'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket disconnection
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {string} reason - Disconnection reason
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
function handleDisconnection(socket, reason, correlationId) {
|
||||
logger.info('WebSocket client disconnected', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
username: socket.username,
|
||||
reason,
|
||||
duration: socket.connectedAt ? Date.now() - socket.connectedAt : 0
|
||||
});
|
||||
|
||||
// TODO: Update player online status
|
||||
// TODO: Clean up any player-specific subscriptions or states
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection errors
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {Error} error - Error object
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
function handleConnectionError(socket, error, correlationId) {
|
||||
logger.error('WebSocket connection error', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId: socket.playerId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
socket.emit('connection_error', {
|
||||
message: 'Connection error occurred',
|
||||
reconnect: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send initial game state to newly authenticated player
|
||||
* @param {Object} socket - Socket.IO socket instance
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {string} correlationId - Connection correlation ID
|
||||
*/
|
||||
async function sendInitialGameState(socket, playerId, correlationId) {
|
||||
try {
|
||||
// TODO: Fetch and send initial game state
|
||||
// This could include:
|
||||
// - Player resources
|
||||
// - Colony statuses
|
||||
// - Fleet positions
|
||||
// - Pending notifications
|
||||
// - Current research
|
||||
// - Active battles
|
||||
|
||||
const initialState = {
|
||||
timestamp: new Date().toISOString(),
|
||||
player: {
|
||||
id: playerId,
|
||||
online: true
|
||||
},
|
||||
gameState: {
|
||||
// Placeholder for game state data
|
||||
tick: Date.now(),
|
||||
version: process.env.npm_package_version || '0.1.0'
|
||||
},
|
||||
notifications: {
|
||||
unread: 0,
|
||||
recent: []
|
||||
}
|
||||
};
|
||||
|
||||
socket.emit('initial_state', initialState);
|
||||
|
||||
logger.debug('Initial game state sent', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to send initial game state', {
|
||||
correlationId,
|
||||
socketId: socket.id,
|
||||
playerId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
socket.emit('error', {
|
||||
message: 'Failed to load initial game state'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game event to relevant players
|
||||
* @param {Object} io - Socket.IO server instance
|
||||
* @param {string} eventType - Type of event
|
||||
* @param {Object} eventData - Event data
|
||||
* @param {Array} targetPlayers - Array of player IDs to notify
|
||||
*/
|
||||
function broadcastGameEvent(io, eventType, eventData, targetPlayers = []) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const broadcastData = {
|
||||
type: eventType,
|
||||
data: eventData,
|
||||
timestamp
|
||||
};
|
||||
|
||||
if (targetPlayers.length > 0) {
|
||||
// Send to specific players
|
||||
targetPlayers.forEach(playerId => {
|
||||
io.to(`player:${playerId}`).emit('game_event', broadcastData);
|
||||
});
|
||||
|
||||
logger.debug('Game event broadcast to specific players', {
|
||||
eventType,
|
||||
playerCount: targetPlayers.length
|
||||
});
|
||||
} else {
|
||||
// Broadcast to all authenticated players
|
||||
io.emit('game_event', broadcastData);
|
||||
|
||||
logger.debug('Game event broadcast to all players', {
|
||||
eventType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleConnection,
|
||||
broadcastGameEvent
|
||||
};
|
||||
83
src/database/connection.js
Normal file
83
src/database/connection.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const environment = process.env.NODE_ENV || 'development';
|
||||
const config = knexConfig[environment];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`No database configuration found for environment: ${environment}`);
|
||||
}
|
||||
|
||||
const db = knex(config);
|
||||
|
||||
// Track connection state
|
||||
let isConnected = false;
|
||||
|
||||
/**
|
||||
* Initialize database connection and verify connectivity
|
||||
* @returns {Promise<boolean>} Connection success status
|
||||
*/
|
||||
async function initializeDatabase() {
|
||||
try {
|
||||
if (isConnected) {
|
||||
logger.info('Database already connected');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test database connection
|
||||
await db.raw('SELECT 1');
|
||||
isConnected = true;
|
||||
|
||||
logger.info('Database connection established successfully', {
|
||||
environment,
|
||||
host: config.connection.host,
|
||||
database: config.connection.database,
|
||||
pool: {
|
||||
min: config.pool?.min || 0,
|
||||
max: config.pool?.max || 10
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to establish database connection', {
|
||||
environment,
|
||||
host: config.connection?.host,
|
||||
database: config.connection?.database,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database connection status
|
||||
* @returns {boolean} Connection status
|
||||
*/
|
||||
function isDbConnected() {
|
||||
return isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection gracefully
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function closeDatabase() {
|
||||
try {
|
||||
if (db && isConnected) {
|
||||
await db.destroy();
|
||||
isConnected = false;
|
||||
logger.info('Database connection closed');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing database connection:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
module.exports.initializeDatabase = initializeDatabase;
|
||||
module.exports.isDbConnected = isDbConnected;
|
||||
module.exports.closeDatabase = closeDatabase;
|
||||
192
src/database/migrations/001_initial_system_tables.js
Normal file
192
src/database/migrations/001_initial_system_tables.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
exports.up = async function(knex) {
|
||||
// System configuration with hot-reloading support
|
||||
await knex.schema.createTable('system_config', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('config_key', 100).unique().notNullable();
|
||||
table.jsonb('config_value').notNullable();
|
||||
table.string('config_type', 20).notNullable().checkIn(['string', 'number', 'boolean', 'json', 'array']);
|
||||
table.text('description');
|
||||
table.boolean('requires_restart').defaultTo(false);
|
||||
table.boolean('is_public').defaultTo(false); // Can be exposed to client
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.integer('updated_by'); // Will reference admin_users(id) after table creation
|
||||
});
|
||||
|
||||
// Game tick system with user grouping
|
||||
await knex.schema.createTable('game_tick_config', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('tick_interval_ms').notNullable().defaultTo(60000);
|
||||
table.integer('user_groups_count').notNullable().defaultTo(10);
|
||||
table.integer('max_retry_attempts').notNullable().defaultTo(5);
|
||||
table.integer('bonus_tick_threshold').notNullable().defaultTo(3);
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.createTable('game_tick_log', (table) => {
|
||||
table.bigIncrements('id').primary();
|
||||
table.bigInteger('tick_number').notNullable();
|
||||
table.integer('user_group').notNullable();
|
||||
table.timestamp('started_at').notNullable();
|
||||
table.timestamp('completed_at');
|
||||
table.string('status', 20).notNullable().checkIn(['running', 'completed', 'failed', 'retrying']);
|
||||
table.integer('retry_count').defaultTo(0);
|
||||
table.text('error_message');
|
||||
table.integer('processed_players').defaultTo(0);
|
||||
table.jsonb('performance_metrics');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['tick_number']);
|
||||
table.index(['user_group']);
|
||||
table.index(['status']);
|
||||
});
|
||||
|
||||
// Event system configuration
|
||||
await knex.schema.createTable('event_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).unique().notNullable();
|
||||
table.text('description');
|
||||
table.string('trigger_type', 20).notNullable().checkIn(['admin', 'player', 'system', 'mixed']);
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.jsonb('config_schema'); // JSON schema for event configuration
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
await knex.schema.createTable('event_instances', (table) => {
|
||||
table.bigIncrements('id').primary();
|
||||
table.integer('event_type_id').notNullable().references('id').inTable('event_types');
|
||||
table.string('name', 200).notNullable();
|
||||
table.text('description');
|
||||
table.jsonb('config').notNullable();
|
||||
table.timestamp('start_time');
|
||||
table.timestamp('end_time');
|
||||
table.string('status', 20).notNullable().checkIn(['scheduled', 'active', 'completed', 'cancelled']);
|
||||
table.integer('created_by'); // Will reference admin_users(id) after table creation
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['event_type_id']);
|
||||
table.index(['status']);
|
||||
table.index(['start_time']);
|
||||
});
|
||||
|
||||
// Plugin system for extensibility
|
||||
await knex.schema.createTable('plugins', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).unique().notNullable();
|
||||
table.string('version', 20).notNullable();
|
||||
table.text('description');
|
||||
table.string('plugin_type', 50).notNullable(); // 'combat', 'event', 'resource', etc.
|
||||
table.boolean('is_active').defaultTo(false);
|
||||
table.jsonb('config');
|
||||
table.jsonb('dependencies'); // Array of required plugins
|
||||
table.jsonb('hooks'); // Available hook points
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Insert initial system configuration
|
||||
await knex('system_config').insert([
|
||||
{
|
||||
config_key: 'game_tick_interval_ms',
|
||||
config_value: JSON.stringify(60000),
|
||||
config_type: 'number',
|
||||
description: 'Game tick interval in milliseconds',
|
||||
is_public: false,
|
||||
},
|
||||
{
|
||||
config_key: 'max_user_groups',
|
||||
config_value: JSON.stringify(10),
|
||||
config_type: 'number',
|
||||
description: 'Maximum number of user groups for tick processing',
|
||||
is_public: false,
|
||||
},
|
||||
{
|
||||
config_key: 'max_retry_attempts',
|
||||
config_value: JSON.stringify(5),
|
||||
config_type: 'number',
|
||||
description: 'Maximum retry attempts for failed ticks',
|
||||
is_public: false,
|
||||
},
|
||||
{
|
||||
config_key: 'data_retention_days',
|
||||
config_value: JSON.stringify(30),
|
||||
config_type: 'number',
|
||||
description: 'Default data retention period in days',
|
||||
is_public: false,
|
||||
},
|
||||
{
|
||||
config_key: 'max_colonies_per_player',
|
||||
config_value: JSON.stringify(10),
|
||||
config_type: 'number',
|
||||
description: 'Maximum colonies a player can own',
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
config_key: 'starting_resources',
|
||||
config_value: JSON.stringify({ scrap: 1000, energy: 500, data_cores: 0, rare_elements: 0 }),
|
||||
config_type: 'json',
|
||||
description: 'Starting resources for new players',
|
||||
is_public: false,
|
||||
},
|
||||
{
|
||||
config_key: 'websocket_ping_interval',
|
||||
config_value: JSON.stringify(30000),
|
||||
config_type: 'number',
|
||||
description: 'WebSocket ping interval in milliseconds',
|
||||
is_public: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Insert initial game tick configuration
|
||||
await knex('game_tick_config').insert({
|
||||
tick_interval_ms: 60000,
|
||||
user_groups_count: 10,
|
||||
max_retry_attempts: 5,
|
||||
bonus_tick_threshold: 3,
|
||||
});
|
||||
|
||||
// Insert initial event types
|
||||
await knex('event_types').insert([
|
||||
{
|
||||
name: 'galaxy_crisis',
|
||||
description: 'Major galaxy-wide crisis events',
|
||||
trigger_type: 'admin',
|
||||
config_schema: JSON.stringify({ duration_hours: { type: 'number', min: 1, max: 168 } }),
|
||||
},
|
||||
{
|
||||
name: 'discovery_event',
|
||||
description: 'Random discovery events triggered by exploration',
|
||||
trigger_type: 'player',
|
||||
config_schema: JSON.stringify({ discovery_type: { type: 'string', enum: ['artifact', 'technology', 'resource'] } }),
|
||||
},
|
||||
{
|
||||
name: 'faction_war',
|
||||
description: 'Large-scale conflicts between factions',
|
||||
trigger_type: 'mixed',
|
||||
config_schema: JSON.stringify({ participating_factions: { type: 'array', items: { type: 'number' } } }),
|
||||
},
|
||||
]);
|
||||
|
||||
// Insert initial plugin for basic combat
|
||||
await knex('plugins').insert({
|
||||
name: 'basic_combat',
|
||||
version: '1.0.0',
|
||||
description: 'Basic instant combat resolution system',
|
||||
plugin_type: 'combat',
|
||||
is_active: true,
|
||||
config: JSON.stringify({ damage_variance: 0.1, experience_gain: 1.0 }),
|
||||
hooks: JSON.stringify(['pre_combat', 'post_combat', 'damage_calculation']),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('plugins');
|
||||
await knex.schema.dropTableIfExists('event_instances');
|
||||
await knex.schema.dropTableIfExists('event_types');
|
||||
await knex.schema.dropTableIfExists('game_tick_log');
|
||||
await knex.schema.dropTableIfExists('game_tick_config');
|
||||
await knex.schema.dropTableIfExists('system_config');
|
||||
};
|
||||
91
src/database/migrations/002_user_management.js
Normal file
91
src/database/migrations/002_user_management.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
exports.up = async function(knex) {
|
||||
// Admin users with role-based access
|
||||
await knex.schema.createTable('admin_users', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('username', 50).unique().notNullable();
|
||||
table.string('email', 255).unique().notNullable();
|
||||
table.string('password_hash', 255).notNullable();
|
||||
table.string('role', 50).notNullable().defaultTo('moderator');
|
||||
table.jsonb('permissions'); // Specific permissions array
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamp('last_login');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Player accounts
|
||||
await knex.schema.createTable('players', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('username', 50).unique().notNullable();
|
||||
table.string('email', 255).unique().notNullable();
|
||||
table.string('password_hash', 255).notNullable();
|
||||
table.boolean('email_verified').defaultTo(false);
|
||||
table.string('email_verification_token', 255);
|
||||
table.string('reset_password_token', 255);
|
||||
table.timestamp('reset_password_expires');
|
||||
table.integer('user_group').notNullable().checkBetween([0, 9]);
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.boolean('is_banned').defaultTo(false);
|
||||
table.text('ban_reason');
|
||||
table.timestamp('ban_expires');
|
||||
table.timestamp('last_login');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['user_group']);
|
||||
table.index(['email']);
|
||||
table.index(['username']);
|
||||
});
|
||||
|
||||
// Player preferences and game settings
|
||||
await knex.schema.createTable('player_settings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('player_id').notNullable().references('id').inTable('players').onDelete('CASCADE');
|
||||
table.string('setting_key', 100).notNullable();
|
||||
table.jsonb('setting_value').notNullable();
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['player_id', 'setting_key']);
|
||||
});
|
||||
|
||||
// WebSocket subscriptions for real-time updates
|
||||
await knex.schema.createTable('player_subscriptions', (table) => {
|
||||
table.bigIncrements('id').primary();
|
||||
table.integer('player_id').notNullable().references('id').inTable('players').onDelete('CASCADE');
|
||||
table.string('subscription_type', 50).notNullable(); // 'colony', 'fleet', 'battle', 'event', etc.
|
||||
table.integer('resource_id'); // Specific resource ID (colony_id, fleet_id, etc.)
|
||||
table.jsonb('filters'); // Additional filtering criteria
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('expires_at');
|
||||
|
||||
table.index(['player_id']);
|
||||
table.index(['subscription_type']);
|
||||
table.index(['expires_at']);
|
||||
});
|
||||
|
||||
// Add foreign key constraints to system_config now that admin_users exists
|
||||
await knex.schema.table('system_config', (table) => {
|
||||
table.foreign('updated_by').references('id').inTable('admin_users');
|
||||
});
|
||||
|
||||
await knex.schema.table('event_instances', (table) => {
|
||||
table.foreign('created_by').references('id').inTable('admin_users');
|
||||
});
|
||||
|
||||
// Create initial admin user (password should be changed immediately)
|
||||
await knex('admin_users').insert({
|
||||
username: 'admin',
|
||||
email: 'admin@shatteredvoid.game',
|
||||
password_hash: '$2b$10$example.hash.change.immediately',
|
||||
role: 'administrator',
|
||||
permissions: JSON.stringify(['all']),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('player_subscriptions');
|
||||
await knex.schema.dropTableIfExists('player_settings');
|
||||
await knex.schema.dropTableIfExists('players');
|
||||
await knex.schema.dropTableIfExists('admin_users');
|
||||
};
|
||||
257
src/database/migrations/003_galaxy_colonies.js
Normal file
257
src/database/migrations/003_galaxy_colonies.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
exports.up = async function(knex) {
|
||||
// Planet types with generation rules
|
||||
await knex.schema.createTable('planet_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 50).unique().notNullable();
|
||||
table.text('description');
|
||||
table.jsonb('base_resources').notNullable(); // Starting resource deposits
|
||||
table.jsonb('resource_modifiers'); // Production modifiers by resource type
|
||||
table.integer('max_population');
|
||||
table.jsonb('special_features'); // Array of special features
|
||||
table.integer('rarity_weight').defaultTo(100); // For random generation
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
|
||||
// Galaxy sectors for organization
|
||||
await knex.schema.createTable('galaxy_sectors', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 50).notNullable();
|
||||
table.string('coordinates', 10).unique().notNullable(); // e.g., "A3"
|
||||
table.text('description');
|
||||
table.integer('danger_level').defaultTo(1).checkBetween([1, 10]);
|
||||
table.jsonb('special_rules');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Player colonies
|
||||
await knex.schema.createTable('colonies', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('player_id').notNullable().references('id').inTable('players').onDelete('CASCADE');
|
||||
table.string('name', 100).notNullable();
|
||||
table.string('coordinates', 20).unique().notNullable(); // Format: "A3-91-X"
|
||||
table.integer('sector_id').references('id').inTable('galaxy_sectors');
|
||||
table.integer('planet_type_id').notNullable().references('id').inTable('planet_types');
|
||||
table.integer('population').defaultTo(0).checkPositive();
|
||||
table.integer('max_population').defaultTo(1000);
|
||||
table.integer('morale').defaultTo(100).checkBetween([0, 100]);
|
||||
table.integer('loyalty').defaultTo(100).checkBetween([0, 100]);
|
||||
table.timestamp('founded_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('last_updated').defaultTo(knex.fn.now());
|
||||
|
||||
table.index(['player_id']);
|
||||
table.index(['coordinates']);
|
||||
table.index(['sector_id']);
|
||||
});
|
||||
|
||||
// Building types with upgrade paths
|
||||
await knex.schema.createTable('building_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).unique().notNullable();
|
||||
table.text('description');
|
||||
table.string('category', 50).notNullable(); // 'production', 'military', 'research', 'infrastructure'
|
||||
table.integer('max_level').defaultTo(10);
|
||||
table.jsonb('base_cost').notNullable(); // Resource costs for level 1
|
||||
table.decimal('cost_multiplier', 3, 2).defaultTo(1.5); // Cost increase per level
|
||||
table.jsonb('base_production'); // Resource production at level 1
|
||||
table.decimal('production_multiplier', 3, 2).defaultTo(1.2); // Production increase per level
|
||||
table.jsonb('prerequisites'); // Required buildings/research
|
||||
table.jsonb('special_effects'); // Special abilities or bonuses
|
||||
table.boolean('is_unique').defaultTo(false); // Only one per colony
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
|
||||
// Colony buildings
|
||||
await knex.schema.createTable('colony_buildings', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('colony_id').notNullable().references('id').inTable('colonies').onDelete('CASCADE');
|
||||
table.integer('building_type_id').notNullable().references('id').inTable('building_types');
|
||||
table.integer('level').defaultTo(1).checkPositive();
|
||||
table.integer('health_percentage').defaultTo(100).checkBetween([0, 100]);
|
||||
table.boolean('is_under_construction').defaultTo(false);
|
||||
table.timestamp('construction_started');
|
||||
table.timestamp('construction_completes');
|
||||
table.timestamp('last_production');
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['colony_id', 'building_type_id']);
|
||||
table.index(['colony_id']);
|
||||
table.index('construction_completes', undefined, 'btree', undefined, { predicate: 'is_under_construction = true' });
|
||||
});
|
||||
|
||||
// Insert initial planet types
|
||||
await knex('planet_types').insert([
|
||||
{
|
||||
name: 'Terran',
|
||||
description: 'Earth-like worlds with balanced resources',
|
||||
base_resources: JSON.stringify({ scrap: 500, energy: 300 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 1.0, energy: 1.0, data_cores: 1.0, rare_elements: 1.0 }),
|
||||
max_population: 10000,
|
||||
rarity_weight: 100,
|
||||
},
|
||||
{
|
||||
name: 'Industrial',
|
||||
description: 'Former manufacturing worlds rich in salvageable materials',
|
||||
base_resources: JSON.stringify({ scrap: 1000, energy: 200 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 1.5, energy: 0.8, data_cores: 1.2, rare_elements: 0.9 }),
|
||||
max_population: 8000,
|
||||
rarity_weight: 80,
|
||||
},
|
||||
{
|
||||
name: 'Research',
|
||||
description: 'Academic worlds with data archives and laboratories',
|
||||
base_resources: JSON.stringify({ scrap: 300, energy: 400, data_cores: 100 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 0.7, energy: 1.1, data_cores: 2.0, rare_elements: 1.3 }),
|
||||
max_population: 6000,
|
||||
rarity_weight: 60,
|
||||
},
|
||||
{
|
||||
name: 'Mining',
|
||||
description: 'Resource extraction worlds with rare element deposits',
|
||||
base_resources: JSON.stringify({ scrap: 400, energy: 250, rare_elements: 20 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 1.1, energy: 0.9, data_cores: 0.8, rare_elements: 2.5 }),
|
||||
max_population: 7000,
|
||||
rarity_weight: 70,
|
||||
},
|
||||
{
|
||||
name: 'Fortress',
|
||||
description: 'Heavily fortified military installations',
|
||||
base_resources: JSON.stringify({ scrap: 600, energy: 500 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 1.2, energy: 1.3, data_cores: 1.1, rare_elements: 1.1 }),
|
||||
max_population: 5000,
|
||||
rarity_weight: 40,
|
||||
},
|
||||
{
|
||||
name: 'Derelict',
|
||||
description: 'Abandoned worlds with scattered ruins and mysteries',
|
||||
base_resources: JSON.stringify({ scrap: 200, energy: 100, data_cores: 50, rare_elements: 10 }),
|
||||
resource_modifiers: JSON.stringify({ scrap: 0.8, energy: 0.6, data_cores: 1.5, rare_elements: 1.8 }),
|
||||
max_population: 3000,
|
||||
rarity_weight: 30,
|
||||
},
|
||||
]);
|
||||
|
||||
// Insert initial building types
|
||||
await knex('building_types').insert([
|
||||
{
|
||||
name: 'Command Center',
|
||||
description: 'Central administration building that coordinates colony operations',
|
||||
category: 'infrastructure',
|
||||
max_level: 10,
|
||||
base_cost: JSON.stringify({ scrap: 100, energy: 50 }),
|
||||
base_production: JSON.stringify({}),
|
||||
special_effects: JSON.stringify({ colony_slots: 1 }),
|
||||
},
|
||||
{
|
||||
name: 'Salvage Yard',
|
||||
description: 'Processes scrap metal and salvageable materials',
|
||||
category: 'production',
|
||||
max_level: 10,
|
||||
base_cost: JSON.stringify({ scrap: 50, energy: 25 }),
|
||||
base_production: JSON.stringify({ scrap: 10 }),
|
||||
special_effects: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
name: 'Power Plant',
|
||||
description: 'Generates energy for colony operations',
|
||||
category: 'production',
|
||||
max_level: 10,
|
||||
base_cost: JSON.stringify({ scrap: 75, energy: 0 }),
|
||||
base_production: JSON.stringify({ energy: 8 }),
|
||||
special_effects: JSON.stringify({}),
|
||||
},
|
||||
{
|
||||
name: 'Research Lab',
|
||||
description: 'Conducts technological research and development',
|
||||
category: 'research',
|
||||
max_level: 10,
|
||||
base_cost: JSON.stringify({ scrap: 100, energy: 75, data_cores: 10 }),
|
||||
base_production: JSON.stringify({}),
|
||||
special_effects: JSON.stringify({ research_speed: 0.1 }),
|
||||
},
|
||||
{
|
||||
name: 'Housing Complex',
|
||||
description: 'Provides living space for colony population',
|
||||
category: 'infrastructure',
|
||||
max_level: 15,
|
||||
base_cost: JSON.stringify({ scrap: 60, energy: 30 }),
|
||||
base_production: JSON.stringify({}),
|
||||
special_effects: JSON.stringify({ population_capacity: 100 }),
|
||||
},
|
||||
{
|
||||
name: 'Defense Grid',
|
||||
description: 'Automated defense systems protecting the colony',
|
||||
category: 'military',
|
||||
max_level: 10,
|
||||
base_cost: JSON.stringify({ scrap: 150, energy: 100, rare_elements: 5 }),
|
||||
base_production: JSON.stringify({}),
|
||||
special_effects: JSON.stringify({ defense_rating: 50 }),
|
||||
},
|
||||
{
|
||||
name: 'Data Archive',
|
||||
description: 'Stores and processes information from data cores',
|
||||
category: 'research',
|
||||
max_level: 8,
|
||||
base_cost: JSON.stringify({ scrap: 80, energy: 60, data_cores: 5 }),
|
||||
base_production: JSON.stringify({ data_cores: 2 }),
|
||||
special_effects: JSON.stringify({ research_bonus: 0.05 }),
|
||||
},
|
||||
{
|
||||
name: 'Mining Facility',
|
||||
description: 'Extracts rare elements from planetary deposits',
|
||||
category: 'production',
|
||||
max_level: 12,
|
||||
base_cost: JSON.stringify({ scrap: 200, energy: 150, data_cores: 20 }),
|
||||
base_production: JSON.stringify({ rare_elements: 1 }),
|
||||
special_effects: JSON.stringify({}),
|
||||
},
|
||||
]);
|
||||
|
||||
// Insert initial galaxy sectors
|
||||
await knex('galaxy_sectors').insert([
|
||||
{
|
||||
name: 'Core Worlds',
|
||||
coordinates: 'A1',
|
||||
description: 'The former heart of galactic civilization',
|
||||
danger_level: 3,
|
||||
},
|
||||
{
|
||||
name: 'Industrial Belt',
|
||||
coordinates: 'B2',
|
||||
description: 'Manufacturing and resource processing sector',
|
||||
danger_level: 4,
|
||||
},
|
||||
{
|
||||
name: 'Frontier Region',
|
||||
coordinates: 'C3',
|
||||
description: 'Outer rim territories with unexplored systems',
|
||||
danger_level: 6,
|
||||
},
|
||||
{
|
||||
name: 'Research Zone',
|
||||
coordinates: 'D4',
|
||||
description: 'Former academic and scientific installations',
|
||||
danger_level: 2,
|
||||
},
|
||||
{
|
||||
name: 'Contested Space',
|
||||
coordinates: 'E5',
|
||||
description: 'Battlegrounds of the great collapse',
|
||||
danger_level: 8,
|
||||
},
|
||||
{
|
||||
name: 'Dead Zone',
|
||||
coordinates: 'F6',
|
||||
description: 'Heavily damaged region with dangerous anomalies',
|
||||
danger_level: 9,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('colony_buildings');
|
||||
await knex.schema.dropTableIfExists('building_types');
|
||||
await knex.schema.dropTableIfExists('colonies');
|
||||
await knex.schema.dropTableIfExists('galaxy_sectors');
|
||||
await knex.schema.dropTableIfExists('planet_types');
|
||||
};
|
||||
93
src/database/migrations/004_resources_economy.js
Normal file
93
src/database/migrations/004_resources_economy.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
exports.up = async function(knex) {
|
||||
// Resource types
|
||||
await knex.schema.createTable('resource_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 50).unique().notNullable();
|
||||
table.text('description');
|
||||
table.string('category', 30).notNullable(); // 'basic', 'advanced', 'rare', 'currency'
|
||||
table.integer('max_storage'); // NULL for unlimited
|
||||
table.decimal('decay_rate', 5, 4); // Daily decay percentage
|
||||
table.decimal('trade_value', 10, 2); // Base trade value
|
||||
table.boolean('is_tradeable').defaultTo(true);
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
});
|
||||
|
||||
// Player resource stockpiles
|
||||
await knex.schema.createTable('player_resources', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('player_id').notNullable().references('id').inTable('players').onDelete('CASCADE');
|
||||
table.integer('resource_type_id').notNullable().references('id').inTable('resource_types');
|
||||
table.bigInteger('amount').defaultTo(0).checkPositive();
|
||||
table.bigInteger('storage_capacity');
|
||||
table.timestamp('last_updated').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['player_id', 'resource_type_id']);
|
||||
table.index(['player_id']);
|
||||
});
|
||||
|
||||
// Colony resource production/consumption tracking
|
||||
await knex.schema.createTable('colony_resource_production', (table) => {
|
||||
table.bigIncrements('id').primary();
|
||||
table.integer('colony_id').notNullable().references('id').inTable('colonies').onDelete('CASCADE');
|
||||
table.integer('resource_type_id').notNullable().references('id').inTable('resource_types');
|
||||
table.integer('production_rate').defaultTo(0); // Per hour
|
||||
table.integer('consumption_rate').defaultTo(0); // Per hour
|
||||
table.bigInteger('current_stored').defaultTo(0);
|
||||
table.bigInteger('storage_capacity');
|
||||
table.timestamp('last_calculated').defaultTo(knex.fn.now());
|
||||
|
||||
table.unique(['colony_id', 'resource_type_id']);
|
||||
table.index(['colony_id']);
|
||||
});
|
||||
|
||||
// Trade system
|
||||
await knex.schema.createTable('trade_routes', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer('from_colony_id').notNullable().references('id').inTable('colonies').onDelete('CASCADE');
|
||||
table.integer('to_colony_id').notNullable().references('id').inTable('colonies').onDelete('CASCADE');
|
||||
table.integer('resource_type_id').notNullable().references('id').inTable('resource_types');
|
||||
table.integer('amount_per_trip').notNullable();
|
||||
table.decimal('price_per_unit', 10, 2).notNullable();
|
||||
table.string('status', 20).defaultTo('active').checkIn(['active', 'paused', 'cancelled']);
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
table.check('?? != ??', ['from_colony_id', 'to_colony_id']);
|
||||
table.index(['from_colony_id']);
|
||||
table.index(['to_colony_id']);
|
||||
});
|
||||
|
||||
// Insert resource types
|
||||
await knex('resource_types').insert([
|
||||
{
|
||||
name: 'scrap',
|
||||
description: 'Basic salvaged materials from the ruins of civilization',
|
||||
category: 'basic',
|
||||
is_tradeable: true,
|
||||
},
|
||||
{
|
||||
name: 'energy',
|
||||
description: 'Power cells and energy storage units',
|
||||
category: 'basic',
|
||||
is_tradeable: true,
|
||||
},
|
||||
{
|
||||
name: 'data_cores',
|
||||
description: 'Advanced information storage containing pre-collapse knowledge',
|
||||
category: 'advanced',
|
||||
is_tradeable: true,
|
||||
},
|
||||
{
|
||||
name: 'rare_elements',
|
||||
description: 'Exotic materials required for advanced technologies',
|
||||
category: 'rare',
|
||||
is_tradeable: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.dropTableIfExists('trade_routes');
|
||||
await knex.schema.dropTableIfExists('colony_resource_production');
|
||||
await knex.schema.dropTableIfExists('player_resources');
|
||||
await knex.schema.dropTableIfExists('resource_types');
|
||||
};
|
||||
316
src/database/seeds/001_initial_data.js
Normal file
316
src/database/seeds/001_initial_data.js
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Initial data seeding for Shattered Void MMO
|
||||
* Populates essential game data for development and testing
|
||||
*/
|
||||
|
||||
exports.seed = async function(knex) {
|
||||
console.log('Seeding initial game data...');
|
||||
|
||||
// Clear existing data (be careful in production!)
|
||||
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
|
||||
await knex('admin_users').del();
|
||||
await knex('building_types').del();
|
||||
await knex('ship_categories').del();
|
||||
await knex('research_technologies').del();
|
||||
}
|
||||
|
||||
// Insert default admin user
|
||||
const adminUsers = [
|
||||
{
|
||||
username: 'admin',
|
||||
email: 'admin@shatteredvoid.local',
|
||||
password_hash: '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/0pHdCGOaG9hP2IjBK', // 'admin123'
|
||||
role: 'super_admin',
|
||||
permissions: JSON.stringify({
|
||||
manage_players: true,
|
||||
manage_events: true,
|
||||
manage_system: true,
|
||||
view_analytics: true,
|
||||
}),
|
||||
is_active: true,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('admin_users').insert(adminUsers);
|
||||
console.log('✓ Admin users seeded');
|
||||
|
||||
// Insert building types
|
||||
const buildingTypes = [
|
||||
{
|
||||
name: 'Command Center',
|
||||
description: 'Central command structure for colony management',
|
||||
category: 'infrastructure',
|
||||
max_level: 10,
|
||||
base_build_time_minutes: 30,
|
||||
base_cost_scrap: 500,
|
||||
base_cost_energy: 250,
|
||||
unique_per_colony: true,
|
||||
provides_population_cap: 1000,
|
||||
},
|
||||
{
|
||||
name: 'Scrap Processor',
|
||||
description: 'Processes salvaged materials into usable scrap metal',
|
||||
category: 'production',
|
||||
max_level: 15,
|
||||
base_build_time_minutes: 60,
|
||||
base_cost_scrap: 200,
|
||||
base_cost_energy: 100,
|
||||
},
|
||||
{
|
||||
name: 'Energy Generator',
|
||||
description: 'Generates energy cells from various power sources',
|
||||
category: 'production',
|
||||
max_level: 15,
|
||||
base_build_time_minutes: 45,
|
||||
base_cost_scrap: 300,
|
||||
base_cost_energy: 50,
|
||||
},
|
||||
{
|
||||
name: 'Data Archive',
|
||||
description: 'Stores and processes information into data cores',
|
||||
category: 'production',
|
||||
max_level: 12,
|
||||
base_build_time_minutes: 90,
|
||||
base_cost_scrap: 400,
|
||||
base_cost_energy: 200,
|
||||
base_cost_data_cores: 10,
|
||||
},
|
||||
{
|
||||
name: 'Mining Complex',
|
||||
description: 'Extracts rare elements from planetary deposits',
|
||||
category: 'production',
|
||||
max_level: 10,
|
||||
base_build_time_minutes: 120,
|
||||
base_cost_scrap: 600,
|
||||
base_cost_energy: 300,
|
||||
base_cost_data_cores: 25,
|
||||
},
|
||||
{
|
||||
name: 'Research Lab',
|
||||
description: 'Conducts technological research and development',
|
||||
category: 'research',
|
||||
max_level: 20,
|
||||
base_build_time_minutes: 180,
|
||||
base_cost_scrap: 800,
|
||||
base_cost_energy: 400,
|
||||
base_cost_data_cores: 50,
|
||||
},
|
||||
{
|
||||
name: 'Shipyard',
|
||||
description: 'Constructs and maintains space vessels',
|
||||
category: 'military',
|
||||
max_level: 15,
|
||||
base_build_time_minutes: 240,
|
||||
base_cost_scrap: 1000,
|
||||
base_cost_energy: 500,
|
||||
base_cost_data_cores: 75,
|
||||
unique_per_colony: true,
|
||||
},
|
||||
{
|
||||
name: 'Defense Grid',
|
||||
description: 'Automated defense system protecting the colony',
|
||||
category: 'military',
|
||||
max_level: 10,
|
||||
base_build_time_minutes: 150,
|
||||
base_cost_scrap: 750,
|
||||
base_cost_energy: 350,
|
||||
base_cost_rare_elements: 25,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('building_types').insert(buildingTypes);
|
||||
console.log('✓ Building types seeded');
|
||||
|
||||
// Insert building effects
|
||||
const buildingEffects = [
|
||||
// Scrap Processor production
|
||||
{ building_type_id: 2, effect_type: 'production', resource_type: 'scrap', base_value: 50, scaling_per_level: 25 },
|
||||
// Energy Generator production
|
||||
{ building_type_id: 3, effect_type: 'production', resource_type: 'energy', base_value: 30, scaling_per_level: 15 },
|
||||
// Data Archive production
|
||||
{ building_type_id: 4, effect_type: 'production', resource_type: 'data_cores', base_value: 5, scaling_per_level: 3 },
|
||||
// Mining Complex production
|
||||
{ building_type_id: 5, effect_type: 'production', resource_type: 'rare_elements', base_value: 2, scaling_per_level: 1 },
|
||||
];
|
||||
|
||||
await knex('building_effects').insert(buildingEffects);
|
||||
console.log('✓ Building effects seeded');
|
||||
|
||||
// Insert ship categories
|
||||
const shipCategories = [
|
||||
{
|
||||
name: 'Scout',
|
||||
description: 'Fast, lightly armed reconnaissance vessel',
|
||||
base_hull_points: 50,
|
||||
base_speed: 20,
|
||||
base_cargo_capacity: 10,
|
||||
module_slots_light: 3,
|
||||
module_slots_medium: 1,
|
||||
module_slots_heavy: 0,
|
||||
},
|
||||
{
|
||||
name: 'Frigate',
|
||||
description: 'Balanced combat vessel with moderate capabilities',
|
||||
base_hull_points: 150,
|
||||
base_speed: 15,
|
||||
base_cargo_capacity: 25,
|
||||
module_slots_light: 4,
|
||||
module_slots_medium: 2,
|
||||
module_slots_heavy: 1,
|
||||
},
|
||||
{
|
||||
name: 'Destroyer',
|
||||
description: 'Heavy combat vessel with powerful weapons',
|
||||
base_hull_points: 300,
|
||||
base_speed: 10,
|
||||
base_cargo_capacity: 15,
|
||||
module_slots_light: 2,
|
||||
module_slots_medium: 4,
|
||||
module_slots_heavy: 2,
|
||||
},
|
||||
{
|
||||
name: 'Transport',
|
||||
description: 'Large cargo vessel with minimal combat capability',
|
||||
base_hull_points: 100,
|
||||
base_speed: 8,
|
||||
base_cargo_capacity: 100,
|
||||
module_slots_light: 2,
|
||||
module_slots_medium: 1,
|
||||
module_slots_heavy: 0,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('ship_categories').insert(shipCategories);
|
||||
console.log('✓ Ship categories seeded');
|
||||
|
||||
// Insert research technologies
|
||||
const technologies = [
|
||||
{
|
||||
category_id: 1, // engineering
|
||||
name: 'Advanced Materials',
|
||||
description: 'Improved construction materials for stronger buildings',
|
||||
level: 1,
|
||||
base_research_cost: 100,
|
||||
base_research_time_hours: 4,
|
||||
prerequisites: JSON.stringify([]),
|
||||
effects: JSON.stringify({ building_cost_reduction: 0.1 }),
|
||||
},
|
||||
{
|
||||
category_id: 2, // physics
|
||||
name: 'Fusion Power',
|
||||
description: 'More efficient energy generation technology',
|
||||
level: 1,
|
||||
base_research_cost: 150,
|
||||
base_research_time_hours: 6,
|
||||
prerequisites: JSON.stringify([]),
|
||||
effects: JSON.stringify({ energy_production_bonus: 0.25 }),
|
||||
},
|
||||
{
|
||||
category_id: 3, // computing
|
||||
name: 'Data Mining',
|
||||
description: 'Advanced algorithms for information processing',
|
||||
level: 1,
|
||||
base_research_cost: 200,
|
||||
base_research_time_hours: 8,
|
||||
prerequisites: JSON.stringify([]),
|
||||
effects: JSON.stringify({ data_core_production_bonus: 0.2 }),
|
||||
},
|
||||
{
|
||||
category_id: 4, // military
|
||||
name: 'Weapon Systems',
|
||||
description: 'Basic military technology for ship weapons',
|
||||
level: 1,
|
||||
base_research_cost: 250,
|
||||
base_research_time_hours: 10,
|
||||
prerequisites: JSON.stringify([]),
|
||||
effects: JSON.stringify({ combat_rating_bonus: 0.15 }),
|
||||
},
|
||||
];
|
||||
|
||||
await knex('research_technologies').insert(technologies);
|
||||
console.log('✓ Research technologies seeded');
|
||||
|
||||
// Insert some test sectors and systems for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const sectors = [
|
||||
{
|
||||
name: 'Sol Sector',
|
||||
description: 'The remnants of humanity\'s birthplace',
|
||||
x_coordinate: 0,
|
||||
y_coordinate: 0,
|
||||
sector_type: 'starting',
|
||||
danger_level: 1,
|
||||
resource_modifier: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'Alpha Centauri Sector',
|
||||
description: 'First expansion zone with moderate resources',
|
||||
x_coordinate: 1,
|
||||
y_coordinate: 0,
|
||||
sector_type: 'normal',
|
||||
danger_level: 2,
|
||||
resource_modifier: 1.1,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('sectors').insert(sectors);
|
||||
|
||||
const systems = [
|
||||
{
|
||||
sector_id: 1,
|
||||
name: 'Sol System',
|
||||
x_coordinate: 0,
|
||||
y_coordinate: 0,
|
||||
star_type: 'main_sequence',
|
||||
system_size: 8,
|
||||
is_explored: true,
|
||||
},
|
||||
{
|
||||
sector_id: 2,
|
||||
name: 'Alpha Centauri A',
|
||||
x_coordinate: 0,
|
||||
y_coordinate: 0,
|
||||
star_type: 'main_sequence',
|
||||
system_size: 5,
|
||||
is_explored: false,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('star_systems').insert(systems);
|
||||
|
||||
const planets = [
|
||||
{
|
||||
system_id: 1,
|
||||
name: 'Earth',
|
||||
position: 3,
|
||||
planet_type_id: 1, // terran
|
||||
size: 150,
|
||||
coordinates: 'SOL-03-E',
|
||||
is_habitable: true,
|
||||
},
|
||||
{
|
||||
system_id: 1,
|
||||
name: 'Mars',
|
||||
position: 4,
|
||||
planet_type_id: 2, // desert
|
||||
size: 80,
|
||||
coordinates: 'SOL-04-M',
|
||||
is_habitable: true,
|
||||
},
|
||||
{
|
||||
system_id: 2,
|
||||
name: 'Proxima b',
|
||||
position: 1,
|
||||
planet_type_id: 1, // terran
|
||||
size: 120,
|
||||
coordinates: 'ACA-01-P',
|
||||
is_habitable: true,
|
||||
},
|
||||
];
|
||||
|
||||
await knex('planets').insert(planets);
|
||||
console.log('✓ Test galaxy data seeded');
|
||||
}
|
||||
|
||||
console.log('Initial data seeding completed successfully!');
|
||||
};
|
||||
359
src/middleware/admin.middleware.js
Normal file
359
src/middleware/admin.middleware.js
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* Admin Authentication Middleware
|
||||
* Handles JWT token validation and permission checking for admin endpoints
|
||||
*/
|
||||
|
||||
const { verifyAdminToken, extractTokenFromHeader } = require('../utils/jwt');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Middleware to authenticate admin requests
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
async function authenticateAdmin(req, res, next) {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
|
||||
// Extract token from Authorization header
|
||||
const authHeader = req.get('Authorization');
|
||||
const token = extractTokenFromHeader(authHeader);
|
||||
|
||||
if (!token) {
|
||||
logger.warn('Admin authentication failed - no token provided', {
|
||||
correlationId,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'No authentication token provided',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
const decoded = verifyAdminToken(token);
|
||||
|
||||
// Add admin information to request object
|
||||
req.user = {
|
||||
adminId: decoded.adminId,
|
||||
email: decoded.email,
|
||||
username: decoded.username,
|
||||
permissions: decoded.permissions || [],
|
||||
type: 'admin',
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp
|
||||
};
|
||||
|
||||
// Log admin access
|
||||
logger.audit('Admin authenticated', {
|
||||
correlationId,
|
||||
adminId: decoded.adminId,
|
||||
username: decoded.username,
|
||||
permissions: decoded.permissions,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
const correlationId = req.correlationId;
|
||||
|
||||
logger.warn('Admin authentication failed', {
|
||||
correlationId,
|
||||
error: error.message,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
path: req.path
|
||||
});
|
||||
|
||||
let statusCode = 401;
|
||||
let message = 'Invalid authentication token';
|
||||
|
||||
if (error.message === 'Token expired') {
|
||||
statusCode = 401;
|
||||
message = 'Authentication token has expired';
|
||||
} else if (error.message === 'Invalid token') {
|
||||
statusCode = 401;
|
||||
message = 'Invalid authentication token';
|
||||
}
|
||||
|
||||
return res.status(statusCode).json({
|
||||
error: 'Authentication failed',
|
||||
message,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory to check if admin has required permissions
|
||||
* @param {string|Array<string>} requiredPermissions - Permission(s) required
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function requirePermissions(requiredPermissions) {
|
||||
// Normalize to array
|
||||
const permissions = Array.isArray(requiredPermissions)
|
||||
? requiredPermissions
|
||||
: [requiredPermissions];
|
||||
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
const adminPermissions = req.user?.permissions || [];
|
||||
const adminId = req.user?.adminId;
|
||||
const username = req.user?.username;
|
||||
|
||||
if (!adminId) {
|
||||
logger.warn('Permission check failed - no authenticated admin', {
|
||||
correlationId,
|
||||
requiredPermissions: permissions,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'Admin authentication required',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Check if admin has super admin permission (bypasses all checks)
|
||||
if (adminPermissions.includes('super_admin')) {
|
||||
logger.info('Permission check passed - super admin', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
requiredPermissions: permissions,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if admin has all required permissions
|
||||
const hasPermissions = permissions.every(permission =>
|
||||
adminPermissions.includes(permission)
|
||||
);
|
||||
|
||||
if (!hasPermissions) {
|
||||
const missingPermissions = permissions.filter(permission =>
|
||||
!adminPermissions.includes(permission)
|
||||
);
|
||||
|
||||
logger.warn('Permission check failed - insufficient permissions', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
adminPermissions,
|
||||
requiredPermissions: permissions,
|
||||
missingPermissions,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
message: 'You do not have the required permissions to access this resource',
|
||||
requiredPermissions: permissions,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Permission check passed', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
requiredPermissions: permissions,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Permission check error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
requiredPermissions: permissions
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to verify permissions',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if admin can access player-specific resources
|
||||
* Requires 'player_management' permission or ownership
|
||||
* @param {string} paramName - Parameter name containing the player ID
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function requirePlayerAccess(paramName = 'playerId') {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
const adminPermissions = req.user?.permissions || [];
|
||||
const adminId = req.user?.adminId;
|
||||
const username = req.user?.username;
|
||||
const targetPlayerId = req.params[paramName];
|
||||
|
||||
if (!adminId) {
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Super admin can access everything
|
||||
if (adminPermissions.includes('super_admin')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check for player management permission
|
||||
if (adminPermissions.includes('player_management')) {
|
||||
logger.info('Player access granted - player management permission', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
targetPlayerId,
|
||||
path: req.path
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check for read-only player data permission for GET requests
|
||||
if (req.method === 'GET' && adminPermissions.includes('player_data_read')) {
|
||||
logger.info('Player access granted - read-only permission', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
targetPlayerId,
|
||||
path: req.path
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.warn('Player access denied - insufficient permissions', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
adminPermissions,
|
||||
targetPlayerId,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
message: 'You do not have permission to access player data',
|
||||
correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player access check error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to verify player access permissions',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to log admin actions for audit purposes
|
||||
* @param {string} action - Action being performed
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function auditAdminAction(action) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
const adminId = req.user?.adminId;
|
||||
const username = req.user?.username;
|
||||
|
||||
// Log the action
|
||||
logger.audit('Admin action initiated', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
action,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
// Override res.json to log the response
|
||||
const originalJson = res.json;
|
||||
res.json = function(data) {
|
||||
logger.audit('Admin action completed', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username,
|
||||
action,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
statusCode: res.statusCode,
|
||||
success: res.statusCode < 400
|
||||
});
|
||||
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Admin audit logging error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
action
|
||||
});
|
||||
|
||||
// Continue even if audit logging fails
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common admin permission constants
|
||||
*/
|
||||
const ADMIN_PERMISSIONS = {
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
PLAYER_MANAGEMENT: 'player_management',
|
||||
PLAYER_DATA_READ: 'player_data_read',
|
||||
SYSTEM_MANAGEMENT: 'system_management',
|
||||
GAME_MANAGEMENT: 'game_management',
|
||||
EVENT_MANAGEMENT: 'event_management',
|
||||
ANALYTICS_READ: 'analytics_read',
|
||||
CONTENT_MANAGEMENT: 'content_management'
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateAdmin,
|
||||
requirePermissions,
|
||||
requirePlayerAccess,
|
||||
auditAdminAction,
|
||||
ADMIN_PERMISSIONS
|
||||
};
|
||||
210
src/middleware/auth.js
Normal file
210
src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Authentication middleware for JWT token validation
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('../utils/logger');
|
||||
const db = require('../database/connection');
|
||||
|
||||
/**
|
||||
* Verify JWT token and attach user to request
|
||||
* @param {string} userType - 'player' or 'admin'
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function authenticateToken(userType = 'player') {
|
||||
return async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: 'Access token required',
|
||||
code: 'TOKEN_MISSING',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Check token type matches required type
|
||||
if (decoded.type !== userType) {
|
||||
return res.status(403).json({
|
||||
error: 'Invalid token type',
|
||||
code: 'INVALID_TOKEN_TYPE',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const tableName = userType === 'admin' ? 'admin_users' : 'players';
|
||||
const user = await db(tableName)
|
||||
.where('id', decoded.userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return res.status(403).json({
|
||||
error: 'User not found',
|
||||
code: 'USER_NOT_FOUND',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (userType === 'player' && user.account_status !== 'active') {
|
||||
return res.status(403).json({
|
||||
error: 'Account is not active',
|
||||
code: 'ACCOUNT_INACTIVE',
|
||||
status: user.account_status,
|
||||
});
|
||||
}
|
||||
|
||||
if (userType === 'admin' && !user.is_active) {
|
||||
return res.status(403).json({
|
||||
error: 'Admin account is not active',
|
||||
code: 'ADMIN_INACTIVE',
|
||||
});
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
req.user = user;
|
||||
req.token = decoded;
|
||||
|
||||
// Update last active timestamp for players
|
||||
if (userType === 'player') {
|
||||
// Don't await this to avoid slowing down requests
|
||||
db('players')
|
||||
.where('id', user.id)
|
||||
.update({ last_active_at: new Date() })
|
||||
.catch(error => {
|
||||
logger.error('Failed to update last_active_at:', error);
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Token expired',
|
||||
code: 'TOKEN_EXPIRED',
|
||||
});
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(403).json({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN',
|
||||
});
|
||||
} else {
|
||||
logger.error('Authentication error:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Authentication failed',
|
||||
code: 'AUTH_ERROR',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional authentication - sets user if token is provided but doesn't require it
|
||||
* @param {string} userType - 'player' or 'admin'
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function optionalAuth(userType = 'player') {
|
||||
return async (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
if (decoded.type === userType) {
|
||||
const tableName = userType === 'admin' ? 'admin_users' : 'players';
|
||||
const user = await db(tableName)
|
||||
.where('id', decoded.userId)
|
||||
.first();
|
||||
|
||||
if (user && ((userType === 'player' && user.account_status === 'active') ||
|
||||
(userType === 'admin' && user.is_active))) {
|
||||
req.user = user;
|
||||
req.token = decoded;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors in optional auth
|
||||
logger.debug('Optional auth failed:', error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific permission (for admin users)
|
||||
* @param {string} permission - Required permission
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function requirePermission(permission) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
// Super admins have all permissions
|
||||
if (req.user.role === 'super_admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check specific permission
|
||||
const permissions = req.user.permissions || {};
|
||||
if (!permissions[permission]) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
code: 'INSUFFICIENT_PERMISSIONS',
|
||||
required: permission,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific role
|
||||
* @param {string|string[]} roles - Required role(s)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function requireRole(roles) {
|
||||
const requiredRoles = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
if (!requiredRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient role',
|
||||
code: 'INSUFFICIENT_ROLE',
|
||||
required: requiredRoles,
|
||||
current: req.user.role,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
optionalAuth,
|
||||
requirePermission,
|
||||
requireRole,
|
||||
};
|
||||
260
src/middleware/auth.middleware.js
Normal file
260
src/middleware/auth.middleware.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* Player Authentication Middleware
|
||||
* Handles JWT token validation for player endpoints
|
||||
*/
|
||||
|
||||
const { verifyPlayerToken, extractTokenFromHeader } = require('../utils/jwt');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Middleware to authenticate player requests
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
async function authenticatePlayer(req, res, next) {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
|
||||
// Extract token from Authorization header
|
||||
const authHeader = req.get('Authorization');
|
||||
const token = extractTokenFromHeader(authHeader);
|
||||
|
||||
if (!token) {
|
||||
logger.warn('Player authentication failed - no token provided', {
|
||||
correlationId,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'No authentication token provided',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
const decoded = verifyPlayerToken(token);
|
||||
|
||||
// Add player information to request object
|
||||
req.user = {
|
||||
playerId: decoded.playerId,
|
||||
email: decoded.email,
|
||||
username: decoded.username,
|
||||
type: 'player',
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp
|
||||
};
|
||||
|
||||
logger.info('Player authenticated successfully', {
|
||||
correlationId,
|
||||
playerId: decoded.playerId,
|
||||
username: decoded.username,
|
||||
path: req.path,
|
||||
method: req.method
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
const correlationId = req.correlationId;
|
||||
|
||||
logger.warn('Player authentication failed', {
|
||||
correlationId,
|
||||
error: error.message,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
path: req.path
|
||||
});
|
||||
|
||||
let statusCode = 401;
|
||||
let message = 'Invalid authentication token';
|
||||
|
||||
if (error.message === 'Token expired') {
|
||||
statusCode = 401;
|
||||
message = 'Authentication token has expired';
|
||||
} else if (error.message === 'Invalid token') {
|
||||
statusCode = 401;
|
||||
message = 'Invalid authentication token';
|
||||
}
|
||||
|
||||
return res.status(statusCode).json({
|
||||
error: 'Authentication failed',
|
||||
message,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional player authentication middleware
|
||||
* Authenticates if token is present, but doesn't require it
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
async function optionalPlayerAuth(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.get('Authorization');
|
||||
const token = extractTokenFromHeader(authHeader);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = verifyPlayerToken(token);
|
||||
req.user = {
|
||||
playerId: decoded.playerId,
|
||||
email: decoded.email,
|
||||
username: decoded.username,
|
||||
type: 'player',
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp
|
||||
};
|
||||
|
||||
logger.info('Optional player authentication successful', {
|
||||
correlationId: req.correlationId,
|
||||
playerId: decoded.playerId,
|
||||
username: decoded.username
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Optional player authentication failed', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message
|
||||
});
|
||||
// Continue without authentication
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
// If there's an unexpected error, log it but continue
|
||||
logger.error('Optional player authentication error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if authenticated user owns a resource
|
||||
* @param {string} paramName - Parameter name containing the player ID
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function requireOwnership(paramName = 'playerId') {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
const authenticatedPlayerId = req.user?.playerId;
|
||||
const resourcePlayerId = parseInt(req.params[paramName]);
|
||||
|
||||
if (!authenticatedPlayerId) {
|
||||
logger.warn('Ownership check failed - no authenticated user', {
|
||||
correlationId,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: 'You must be authenticated to access this resource',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
if (!resourcePlayerId || isNaN(resourcePlayerId)) {
|
||||
logger.warn('Ownership check failed - invalid resource ID', {
|
||||
correlationId,
|
||||
paramName,
|
||||
resourcePlayerId: req.params[paramName],
|
||||
playerId: authenticatedPlayerId
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request',
|
||||
message: 'Invalid resource identifier',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
if (authenticatedPlayerId !== resourcePlayerId) {
|
||||
logger.warn('Ownership check failed - access denied', {
|
||||
correlationId,
|
||||
authenticatedPlayerId,
|
||||
resourcePlayerId,
|
||||
username: req.user.username,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'You can only access your own resources',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Ownership check passed', {
|
||||
correlationId,
|
||||
playerId: authenticatedPlayerId,
|
||||
username: req.user.username,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Ownership check error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to verify resource ownership',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to extract player ID from token and add to params
|
||||
* Useful for routes that should automatically use the authenticated player's ID
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function injectPlayerId(req, res, next) {
|
||||
try {
|
||||
if (req.user && req.user.playerId) {
|
||||
req.params.playerId = req.user.playerId.toString();
|
||||
|
||||
logger.debug('Player ID injected into params', {
|
||||
correlationId: req.correlationId,
|
||||
playerId: req.user.playerId,
|
||||
path: req.path
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player ID injection error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
next(); // Continue even if injection fails
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticatePlayer,
|
||||
optionalPlayerAuth,
|
||||
requireOwnership,
|
||||
injectPlayerId
|
||||
};
|
||||
46
src/middleware/cors.js
Normal file
46
src/middleware/cors.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* CORS configuration middleware
|
||||
*/
|
||||
|
||||
const cors = require('cors');
|
||||
|
||||
// Configure CORS options
|
||||
const corsOptions = {
|
||||
origin: function (origin, callback) {
|
||||
// Allow requests with no origin (mobile apps, postman, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// In development, allow any origin
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// In production, check against allowed origins
|
||||
const allowedOrigins = (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',');
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'X-Correlation-ID',
|
||||
],
|
||||
exposedHeaders: [
|
||||
'X-Total-Count',
|
||||
'X-Page-Count',
|
||||
'X-Rate-Limit-Remaining',
|
||||
'X-Rate-Limit-Reset',
|
||||
],
|
||||
maxAge: 86400, // 24 hours
|
||||
};
|
||||
|
||||
module.exports = cors(corsOptions);
|
||||
269
src/middleware/cors.middleware.js
Normal file
269
src/middleware/cors.middleware.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* CORS Configuration Middleware
|
||||
* Handles Cross-Origin Resource Sharing with environment-based configuration
|
||||
*/
|
||||
|
||||
const cors = require('cors');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// CORS Configuration
|
||||
const CORS_CONFIG = {
|
||||
development: {
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:3001',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:3001'
|
||||
],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'X-Correlation-ID'
|
||||
],
|
||||
exposedHeaders: ['X-Correlation-ID', 'X-Total-Count'],
|
||||
maxAge: 86400 // 24 hours
|
||||
},
|
||||
production: {
|
||||
origin: function (origin, callback) {
|
||||
// Allow requests with no origin (mobile apps, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(o => o.trim());
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
logger.warn('CORS origin blocked', { origin });
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'X-Correlation-ID'
|
||||
],
|
||||
exposeddHeaders: ['X-Correlation-ID', 'X-Total-Count'],
|
||||
maxAge: 3600 // 1 hour
|
||||
},
|
||||
test: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'X-Correlation-ID'
|
||||
],
|
||||
exposedHeaders: ['X-Correlation-ID', 'X-Total-Count']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get CORS configuration for current environment
|
||||
* @returns {Object} CORS configuration object
|
||||
*/
|
||||
function getCorsConfig() {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const config = CORS_CONFIG[env] || CORS_CONFIG.development;
|
||||
|
||||
// Override with environment variables if provided
|
||||
if (process.env.CORS_ALLOWED_ORIGINS) {
|
||||
const origins = process.env.CORS_ALLOWED_ORIGINS.split(',').map(o => o.trim());
|
||||
config.origin = origins;
|
||||
}
|
||||
|
||||
if (process.env.CORS_CREDENTIALS) {
|
||||
config.credentials = process.env.CORS_CREDENTIALS === 'true';
|
||||
}
|
||||
|
||||
if (process.env.CORS_MAX_AGE) {
|
||||
config.maxAge = parseInt(process.env.CORS_MAX_AGE);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CORS middleware with logging
|
||||
* @returns {Function} CORS middleware function
|
||||
*/
|
||||
function createCorsMiddleware() {
|
||||
const config = getCorsConfig();
|
||||
|
||||
logger.info('CORS middleware configured', {
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
origins: typeof config.origin === 'function' ? 'dynamic' : config.origin,
|
||||
credentials: config.credentials,
|
||||
methods: config.methods
|
||||
});
|
||||
|
||||
return cors({
|
||||
...config,
|
||||
// Override origin handler to add logging
|
||||
origin: function(origin, callback) {
|
||||
const correlationId = require('uuid').v4();
|
||||
|
||||
// Handle dynamic origin function
|
||||
if (typeof config.origin === 'function') {
|
||||
return config.origin(origin, (err, allowed) => {
|
||||
if (err) {
|
||||
logger.warn('CORS origin rejected', {
|
||||
correlationId,
|
||||
origin,
|
||||
error: err.message
|
||||
});
|
||||
} else if (allowed) {
|
||||
logger.debug('CORS origin allowed', {
|
||||
correlationId,
|
||||
origin
|
||||
});
|
||||
}
|
||||
callback(err, allowed);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle static origin configuration
|
||||
if (config.origin === true) {
|
||||
logger.debug('CORS origin allowed (wildcard)', {
|
||||
correlationId,
|
||||
origin
|
||||
});
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (Array.isArray(config.origin)) {
|
||||
const allowed = config.origin.includes(origin);
|
||||
|
||||
if (allowed) {
|
||||
logger.debug('CORS origin allowed', {
|
||||
correlationId,
|
||||
origin
|
||||
});
|
||||
} else {
|
||||
logger.warn('CORS origin rejected', {
|
||||
correlationId,
|
||||
origin,
|
||||
allowedOrigins: config.origin
|
||||
});
|
||||
}
|
||||
|
||||
return callback(null, allowed);
|
||||
}
|
||||
|
||||
// Single origin string
|
||||
if (config.origin === origin) {
|
||||
logger.debug('CORS origin allowed', {
|
||||
correlationId,
|
||||
origin
|
||||
});
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
logger.warn('CORS origin rejected', {
|
||||
correlationId,
|
||||
origin,
|
||||
allowedOrigin: config.origin
|
||||
});
|
||||
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to add security headers for CORS
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function addSecurityHeaders(req, res, next) {
|
||||
// Add Vary header for proper caching
|
||||
res.vary('Origin');
|
||||
|
||||
// Add security headers
|
||||
res.set({
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin'
|
||||
});
|
||||
|
||||
// Log cross-origin requests
|
||||
const origin = req.get('Origin');
|
||||
if (origin && origin !== `${req.protocol}://${req.get('Host')}`) {
|
||||
logger.debug('Cross-origin request', {
|
||||
correlationId: req.correlationId,
|
||||
origin,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preflight OPTIONS requests
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function handlePreflight(req, res, next) {
|
||||
if (req.method === 'OPTIONS') {
|
||||
logger.debug('CORS preflight request', {
|
||||
correlationId: req.correlationId,
|
||||
origin: req.get('Origin'),
|
||||
requestedMethod: req.get('Access-Control-Request-Method'),
|
||||
requestedHeaders: req.get('Access-Control-Request-Headers')
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS error handler
|
||||
* @param {Error} err - CORS error
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function handleCorsError(err, req, res, next) {
|
||||
if (err.message === 'Not allowed by CORS') {
|
||||
logger.warn('CORS request blocked', {
|
||||
correlationId: req.correlationId,
|
||||
origin: req.get('Origin'),
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'CORS Policy Violation',
|
||||
message: 'Cross-origin requests are not allowed from this origin',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
next(err);
|
||||
}
|
||||
|
||||
// Create and export the configured CORS middleware
|
||||
const corsMiddleware = createCorsMiddleware();
|
||||
|
||||
module.exports = corsMiddleware;
|
||||
283
src/middleware/error-handler.js
Normal file
283
src/middleware/error-handler.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
/**
|
||||
* Global error handling middleware
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Custom error classes
|
||||
*/
|
||||
class ValidationError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.statusCode = 400;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
this.statusCode = 409;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends Error {
|
||||
constructor(message = 'Resource not found', details = {}) {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
this.statusCode = 404;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class ForbiddenError extends Error {
|
||||
constructor(message = 'Access forbidden', details = {}) {
|
||||
super(message);
|
||||
this.name = 'ForbiddenError';
|
||||
this.statusCode = 403;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceError extends Error {
|
||||
constructor(message, originalError = null) {
|
||||
super(message);
|
||||
this.name = 'ServiceError';
|
||||
this.statusCode = 500;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends Error {
|
||||
constructor(message = 'Rate limit exceeded', details = {}) {
|
||||
super(message);
|
||||
this.name = 'RateLimitError';
|
||||
this.statusCode = 429;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler middleware
|
||||
* @param {Error} error - The error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function errorHandler(error, req, res, next) {
|
||||
// Skip if response already sent
|
||||
if (res.headersSent) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
// Default error response
|
||||
let statusCode = error.statusCode || 500;
|
||||
let errorResponse = {
|
||||
error: error.message || 'Internal server error',
|
||||
code: error.name || 'INTERNAL_ERROR',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Add request ID if available
|
||||
if (req.correlationId) {
|
||||
errorResponse.requestId = req.correlationId;
|
||||
}
|
||||
|
||||
// Handle specific error types
|
||||
switch (error.name) {
|
||||
case 'ValidationError':
|
||||
statusCode = 400;
|
||||
errorResponse.details = error.details;
|
||||
logger.warn('Validation error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ConflictError':
|
||||
statusCode = 409;
|
||||
errorResponse.details = error.details;
|
||||
logger.warn('Conflict error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'NotFoundError':
|
||||
statusCode = 404;
|
||||
errorResponse.details = error.details;
|
||||
logger.warn('Not found error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ForbiddenError':
|
||||
statusCode = 403;
|
||||
errorResponse.details = error.details;
|
||||
logger.warn('Forbidden error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
userId: req.user?.id,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'RateLimitError':
|
||||
statusCode = 429;
|
||||
errorResponse.details = error.details;
|
||||
logger.warn('Rate limit error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
error: error.message,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'JsonWebTokenError':
|
||||
statusCode = 401;
|
||||
errorResponse.error = 'Invalid authentication token';
|
||||
errorResponse.code = 'INVALID_TOKEN';
|
||||
logger.warn('JWT error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'TokenExpiredError':
|
||||
statusCode = 401;
|
||||
errorResponse.error = 'Authentication token expired';
|
||||
errorResponse.code = 'TOKEN_EXPIRED';
|
||||
logger.warn('JWT expired', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'CastError':
|
||||
case 'ValidationError':
|
||||
// Database validation errors
|
||||
statusCode = 400;
|
||||
errorResponse.error = 'Invalid data provided';
|
||||
errorResponse.code = 'INVALID_DATA';
|
||||
logger.warn('Database validation error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'ServiceError':
|
||||
statusCode = 500;
|
||||
logger.error('Service error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
originalError: error.originalError?.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Log unexpected errors
|
||||
logger.error('Unhandled error', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
|
||||
// Don't expose internal errors in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
errorResponse.error = 'Internal server error';
|
||||
errorResponse.code = 'INTERNAL_ERROR';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add stack trace in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
errorResponse.stack = error.stack;
|
||||
}
|
||||
|
||||
// Add additional error details in development
|
||||
if (process.env.NODE_ENV === 'development' && error.originalError) {
|
||||
errorResponse.originalError = {
|
||||
message: error.originalError.message,
|
||||
stack: error.originalError.stack,
|
||||
};
|
||||
}
|
||||
|
||||
res.status(statusCode).json(errorResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 handler for unmatched routes
|
||||
*/
|
||||
function notFoundHandler(req, res) {
|
||||
const error = {
|
||||
error: 'Route not found',
|
||||
code: 'ROUTE_NOT_FOUND',
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (req.correlationId) {
|
||||
error.requestId = req.correlationId;
|
||||
}
|
||||
|
||||
logger.warn('Route not found', {
|
||||
correlationId: req.correlationId,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
res.status(404).json(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async error wrapper to catch promise rejections
|
||||
* @param {Function} fn - Async function to wrap
|
||||
* @returns {Function} Wrapped function
|
||||
*/
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ForbiddenError,
|
||||
ServiceError,
|
||||
RateLimitError,
|
||||
};
|
||||
479
src/middleware/error.middleware.js
Normal file
479
src/middleware/error.middleware.js
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
/**
|
||||
* Global Error Handling Middleware
|
||||
* Comprehensive error handling with proper logging, sanitization, and response formatting
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Custom error classes for better error handling
|
||||
*/
|
||||
class ValidationError extends Error {
|
||||
constructor(message, details = null) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.statusCode = 400;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
class AuthenticationError extends Error {
|
||||
constructor(message = 'Authentication failed') {
|
||||
super(message);
|
||||
this.name = 'AuthenticationError';
|
||||
this.statusCode = 401;
|
||||
}
|
||||
}
|
||||
|
||||
class AuthorizationError extends Error {
|
||||
constructor(message = 'Access denied') {
|
||||
super(message);
|
||||
this.name = 'AuthorizationError';
|
||||
this.statusCode = 403;
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends Error {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message);
|
||||
this.name = 'NotFoundError';
|
||||
this.statusCode = 404;
|
||||
}
|
||||
}
|
||||
|
||||
class ConflictError extends Error {
|
||||
constructor(message = 'Resource conflict') {
|
||||
super(message);
|
||||
this.name = 'ConflictError';
|
||||
this.statusCode = 409;
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends Error {
|
||||
constructor(message = 'Rate limit exceeded') {
|
||||
super(message);
|
||||
this.name = 'RateLimitError';
|
||||
this.statusCode = 429;
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceError extends Error {
|
||||
constructor(message = 'Internal service error', originalError = null) {
|
||||
super(message);
|
||||
this.name = 'ServiceError';
|
||||
this.statusCode = 500;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseError extends Error {
|
||||
constructor(message = 'Database operation failed', originalError = null) {
|
||||
super(message);
|
||||
this.name = 'DatabaseError';
|
||||
this.statusCode = 500;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main error handling middleware
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function errorHandler(error, req, res, next) {
|
||||
const correlationId = req.correlationId || 'unknown';
|
||||
const startTime = Date.now();
|
||||
|
||||
// Don't handle if response already sent
|
||||
if (res.headersSent) {
|
||||
logger.error('Error occurred after response sent', {
|
||||
correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
return next(error);
|
||||
}
|
||||
|
||||
// Log the error
|
||||
logError(error, req, correlationId);
|
||||
|
||||
// Determine error details
|
||||
const errorResponse = createErrorResponse(error, req, correlationId);
|
||||
|
||||
// Set appropriate headers
|
||||
res.set({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Correlation-ID': correlationId
|
||||
});
|
||||
|
||||
// Send error response
|
||||
res.status(errorResponse.statusCode).json(errorResponse.body);
|
||||
|
||||
// Log response time for error handling
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Error response sent', {
|
||||
correlationId,
|
||||
statusCode: errorResponse.statusCode,
|
||||
duration: `${duration}ms`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with appropriate detail level
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
*/
|
||||
function logError(error, req, correlationId) {
|
||||
const errorInfo = {
|
||||
correlationId,
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
statusCode: error.statusCode || 500,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.playerId || req.user?.adminId,
|
||||
userType: req.user?.type,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Add stack trace for server errors
|
||||
if (!error.statusCode || error.statusCode >= 500) {
|
||||
errorInfo.stack = error.stack;
|
||||
|
||||
// Add original error if available
|
||||
if (error.originalError) {
|
||||
errorInfo.originalError = {
|
||||
name: error.originalError.name,
|
||||
message: error.originalError.message,
|
||||
stack: error.originalError.stack
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Add request body for debugging (sanitized)
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body) {
|
||||
errorInfo.requestBody = sanitizeForLogging(req.body);
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
if (Object.keys(req.query).length > 0) {
|
||||
errorInfo.queryParams = req.query;
|
||||
}
|
||||
|
||||
// Determine log level
|
||||
const statusCode = error.statusCode || 500;
|
||||
if (statusCode >= 500) {
|
||||
logger.error('Server error occurred', errorInfo);
|
||||
} else if (statusCode >= 400) {
|
||||
logger.warn('Client error occurred', errorInfo);
|
||||
} else {
|
||||
logger.info('Request completed with error', errorInfo);
|
||||
}
|
||||
|
||||
// Audit sensitive errors
|
||||
if (shouldAuditError(error, req)) {
|
||||
logger.audit('Error occurred', {
|
||||
...errorInfo,
|
||||
audit: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized error response
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Object} Error response object
|
||||
*/
|
||||
function createErrorResponse(error, req, correlationId) {
|
||||
const statusCode = determineStatusCode(error);
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const baseResponse = {
|
||||
error: true,
|
||||
correlationId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Handle different error types
|
||||
switch (error.name) {
|
||||
case 'ValidationError':
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'ValidationError',
|
||||
message: 'Request validation failed',
|
||||
details: error.details || error.message
|
||||
}
|
||||
};
|
||||
|
||||
case 'AuthenticationError':
|
||||
return {
|
||||
statusCode: 401,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'AuthenticationError',
|
||||
message: isProduction ? 'Authentication required' : error.message
|
||||
}
|
||||
};
|
||||
|
||||
case 'AuthorizationError':
|
||||
return {
|
||||
statusCode: 403,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'AuthorizationError',
|
||||
message: isProduction ? 'Access denied' : error.message
|
||||
}
|
||||
};
|
||||
|
||||
case 'NotFoundError':
|
||||
return {
|
||||
statusCode: 404,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'NotFoundError',
|
||||
message: error.message || 'Resource not found'
|
||||
}
|
||||
};
|
||||
|
||||
case 'ConflictError':
|
||||
return {
|
||||
statusCode: 409,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'ConflictError',
|
||||
message: error.message || 'Resource conflict'
|
||||
}
|
||||
};
|
||||
|
||||
case 'RateLimitError':
|
||||
return {
|
||||
statusCode: 429,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'RateLimitError',
|
||||
message: error.message || 'Rate limit exceeded',
|
||||
retryAfter: error.retryAfter
|
||||
}
|
||||
};
|
||||
|
||||
// Database errors
|
||||
case 'DatabaseError':
|
||||
case 'SequelizeError':
|
||||
case 'QueryFailedError':
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'DatabaseError',
|
||||
message: isProduction ? 'Database operation failed' : error.message,
|
||||
...(isDevelopment && { stack: error.stack })
|
||||
}
|
||||
};
|
||||
|
||||
// JWT errors
|
||||
case 'JsonWebTokenError':
|
||||
case 'TokenExpiredError':
|
||||
case 'NotBeforeError':
|
||||
return {
|
||||
statusCode: 401,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'TokenError',
|
||||
message: 'Invalid or expired token'
|
||||
}
|
||||
};
|
||||
|
||||
// Multer errors (file upload)
|
||||
case 'MulterError':
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'FileUploadError',
|
||||
message: getMulterErrorMessage(error)
|
||||
}
|
||||
};
|
||||
|
||||
// Default server error
|
||||
default:
|
||||
return {
|
||||
statusCode: statusCode >= 400 ? statusCode : 500,
|
||||
body: {
|
||||
...baseResponse,
|
||||
type: 'ServerError',
|
||||
message: isProduction ? 'Internal server error' : error.message,
|
||||
...(isDevelopment && {
|
||||
stack: error.stack,
|
||||
originalError: error.originalError
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine HTTP status code from error
|
||||
* @param {Error} error - Error object
|
||||
* @returns {number} HTTP status code
|
||||
*/
|
||||
function determineStatusCode(error) {
|
||||
// Use explicit status code if available
|
||||
if (error.statusCode && typeof error.statusCode === 'number') {
|
||||
return error.statusCode;
|
||||
}
|
||||
|
||||
// Use status property if available
|
||||
if (error.status && typeof error.status === 'number') {
|
||||
return error.status;
|
||||
}
|
||||
|
||||
// Default mappings by error name
|
||||
const statusMappings = {
|
||||
'ValidationError': 400,
|
||||
'CastError': 400,
|
||||
'JsonWebTokenError': 401,
|
||||
'TokenExpiredError': 401,
|
||||
'UnauthorizedError': 401,
|
||||
'AuthenticationError': 401,
|
||||
'ForbiddenError': 403,
|
||||
'AuthorizationError': 403,
|
||||
'NotFoundError': 404,
|
||||
'ConflictError': 409,
|
||||
'MulterError': 400,
|
||||
'RateLimitError': 429
|
||||
};
|
||||
|
||||
return statusMappings[error.name] || 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for Multer errors
|
||||
* @param {Error} error - Multer error
|
||||
* @returns {string} User-friendly error message
|
||||
*/
|
||||
function getMulterErrorMessage(error) {
|
||||
switch (error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
return 'File size too large';
|
||||
case 'LIMIT_FILE_COUNT':
|
||||
return 'Too many files uploaded';
|
||||
case 'LIMIT_FIELD_KEY':
|
||||
return 'Field name too long';
|
||||
case 'LIMIT_FIELD_VALUE':
|
||||
return 'Field value too long';
|
||||
case 'LIMIT_FIELD_COUNT':
|
||||
return 'Too many fields';
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
return 'Unexpected file field';
|
||||
default:
|
||||
return 'File upload error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize data for logging (remove sensitive information)
|
||||
* @param {Object} data - Data to sanitize
|
||||
* @returns {Object} Sanitized data
|
||||
*/
|
||||
function sanitizeForLogging(data) {
|
||||
if (!data || typeof data !== 'object') return data;
|
||||
|
||||
try {
|
||||
const sanitized = JSON.parse(JSON.stringify(data));
|
||||
const sensitiveFields = ['password', 'token', 'secret', 'key', 'hash', 'authorization'];
|
||||
|
||||
function recursiveSanitize(obj) {
|
||||
if (typeof obj !== 'object' || obj === null) return obj;
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
if (sensitiveFields.some(field => key.toLowerCase().includes(field))) {
|
||||
obj[key] = '[REDACTED]';
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
recursiveSanitize(obj[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return recursiveSanitize(sanitized);
|
||||
} catch {
|
||||
return '[SANITIZATION_ERROR]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error should be audited
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} True if should audit
|
||||
*/
|
||||
function shouldAuditError(error, req) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
|
||||
// Audit all server errors
|
||||
if (statusCode >= 500) return true;
|
||||
|
||||
// Audit authentication/authorization errors
|
||||
if (['AuthenticationError', 'AuthorizationError', 'JsonWebTokenError'].includes(error.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audit admin-related errors
|
||||
if (req.user?.type === 'admin') return true;
|
||||
|
||||
// Audit security-related endpoints
|
||||
if (req.path.includes('/auth/') || req.path.includes('/admin/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle async errors in route handlers
|
||||
* @param {Function} fn - Async route handler function
|
||||
* @returns {Function} Wrapped route handler
|
||||
*/
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 404 Not Found handler
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function notFoundHandler(req, res, next) {
|
||||
const error = new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`);
|
||||
next(error);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
asyncHandler,
|
||||
// Export error classes
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
RateLimitError,
|
||||
ServiceError,
|
||||
DatabaseError
|
||||
};
|
||||
371
src/middleware/logging.middleware.js
Normal file
371
src/middleware/logging.middleware.js
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* Request/Response Logging Middleware
|
||||
* Comprehensive logging for HTTP requests with performance tracking and audit trails
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const { performance } = require('perf_hooks');
|
||||
|
||||
/**
|
||||
* Main request logging middleware
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function requestLogger(req, res, next) {
|
||||
const startTime = performance.now();
|
||||
const correlationId = req.correlationId;
|
||||
|
||||
// Extract request information
|
||||
const requestInfo = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl || req.url,
|
||||
path: req.path,
|
||||
query: Object.keys(req.query).length > 0 ? req.query : undefined,
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.get('User-Agent'),
|
||||
contentType: req.get('Content-Type'),
|
||||
contentLength: req.get('Content-Length'),
|
||||
referrer: req.get('Referrer'),
|
||||
origin: req.get('Origin'),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Log request start
|
||||
logger.info('Request started', requestInfo);
|
||||
|
||||
// Store original methods to override
|
||||
const originalSend = res.send;
|
||||
const originalJson = res.json;
|
||||
const originalEnd = res.end;
|
||||
|
||||
let responseBody = null;
|
||||
let responseSent = false;
|
||||
|
||||
// Override res.send to capture response
|
||||
res.send = function(data) {
|
||||
if (!responseSent) {
|
||||
responseBody = data;
|
||||
logResponse();
|
||||
}
|
||||
return originalSend.call(this, data);
|
||||
};
|
||||
|
||||
// Override res.json to capture JSON response
|
||||
res.json = function(data) {
|
||||
if (!responseSent) {
|
||||
responseBody = data;
|
||||
logResponse();
|
||||
}
|
||||
return originalJson.call(this, data);
|
||||
};
|
||||
|
||||
// Override res.end to capture empty responses
|
||||
res.end = function(data) {
|
||||
if (!responseSent) {
|
||||
responseBody = data;
|
||||
logResponse();
|
||||
}
|
||||
return originalEnd.call(this, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* Log the response details
|
||||
*/
|
||||
function logResponse() {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = Math.round(endTime - startTime);
|
||||
const statusCode = res.statusCode;
|
||||
|
||||
const responseInfo = {
|
||||
correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl || req.url,
|
||||
statusCode,
|
||||
duration: `${duration}ms`,
|
||||
contentLength: res.get('Content-Length'),
|
||||
contentType: res.get('Content-Type'),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Add user information if available
|
||||
if (req.user) {
|
||||
responseInfo.userId = req.user.playerId || req.user.adminId;
|
||||
responseInfo.userType = req.user.type;
|
||||
responseInfo.username = req.user.username;
|
||||
}
|
||||
|
||||
// Determine log level based on status code
|
||||
let logLevel = 'info';
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
logLevel = 'warn';
|
||||
} else if (statusCode >= 500) {
|
||||
logLevel = 'error';
|
||||
}
|
||||
|
||||
// Add response body for errors (but sanitize sensitive data)
|
||||
if (statusCode >= 400 && responseBody) {
|
||||
responseInfo.responseBody = sanitizeResponseBody(responseBody);
|
||||
}
|
||||
|
||||
// Log slow requests as warnings
|
||||
if (duration > 5000) { // 5 seconds
|
||||
logLevel = 'warn';
|
||||
responseInfo.slow = true;
|
||||
}
|
||||
|
||||
logger[logLevel]('Request completed', responseInfo);
|
||||
|
||||
// Log audit trail for sensitive operations
|
||||
if (shouldAudit(req, statusCode)) {
|
||||
logAuditTrail(req, res, duration, correlationId);
|
||||
}
|
||||
|
||||
// Track performance metrics
|
||||
trackPerformanceMetrics(req, res, duration);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize response body to remove sensitive information
|
||||
* @param {any} responseBody - Response body to sanitize
|
||||
* @returns {any} Sanitized response body
|
||||
*/
|
||||
function sanitizeResponseBody(responseBody) {
|
||||
if (!responseBody) return responseBody;
|
||||
|
||||
try {
|
||||
let sanitized = responseBody;
|
||||
|
||||
// If it's a string, try to parse as JSON
|
||||
if (typeof responseBody === 'string') {
|
||||
try {
|
||||
sanitized = JSON.parse(responseBody);
|
||||
} catch {
|
||||
return responseBody; // Return as-is if not JSON
|
||||
}
|
||||
}
|
||||
|
||||
// Remove sensitive fields
|
||||
if (typeof sanitized === 'object') {
|
||||
const sensitiveFields = ['password', 'token', 'secret', 'key', 'hash'];
|
||||
const cloned = JSON.parse(JSON.stringify(sanitized));
|
||||
|
||||
function removeSensitiveFields(obj) {
|
||||
if (typeof obj !== 'object' || obj === null) return obj;
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
if (sensitiveFields.some(field => key.toLowerCase().includes(field))) {
|
||||
obj[key] = '[REDACTED]';
|
||||
} else if (typeof obj[key] === 'object') {
|
||||
removeSensitiveFields(obj[key]);
|
||||
}
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
return removeSensitiveFields(cloned);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
} catch (error) {
|
||||
return '[SANITIZATION_ERROR]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if request should be audited
|
||||
* @param {Object} req - Express request object
|
||||
* @param {number} statusCode - Response status code
|
||||
* @returns {boolean} True if should audit
|
||||
*/
|
||||
function shouldAudit(req, statusCode) {
|
||||
// Audit admin actions
|
||||
if (req.user?.type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audit authentication attempts
|
||||
if (req.path.includes('/auth/') || req.path.includes('/login')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audit failed requests
|
||||
if (statusCode >= 400) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Audit sensitive game actions
|
||||
const sensitiveActions = [
|
||||
'/colonies',
|
||||
'/fleets',
|
||||
'/research',
|
||||
'/messages',
|
||||
'/profile'
|
||||
];
|
||||
|
||||
if (sensitiveActions.some(action => req.path.includes(action)) && req.method !== 'GET') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log audit trail for sensitive operations
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {number} duration - Request duration in milliseconds
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
*/
|
||||
function logAuditTrail(req, res, duration, correlationId) {
|
||||
const auditInfo = {
|
||||
correlationId,
|
||||
event: 'api_request',
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Add user information
|
||||
if (req.user) {
|
||||
auditInfo.userId = req.user.playerId || req.user.adminId;
|
||||
auditInfo.userType = req.user.type;
|
||||
auditInfo.username = req.user.username;
|
||||
}
|
||||
|
||||
// Add request parameters for POST/PUT/PATCH requests (sanitized)
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body) {
|
||||
auditInfo.requestBody = sanitizeRequestBody(req.body);
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
if (Object.keys(req.query).length > 0) {
|
||||
auditInfo.queryParams = req.query;
|
||||
}
|
||||
|
||||
logger.audit('Audit trail', auditInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize request body for audit logging
|
||||
* @param {Object} body - Request body to sanitize
|
||||
* @returns {Object} Sanitized request body
|
||||
*/
|
||||
function sanitizeRequestBody(body) {
|
||||
if (!body || typeof body !== 'object') return body;
|
||||
|
||||
try {
|
||||
const sensitiveFields = ['password', 'oldPassword', 'newPassword', 'token', 'secret'];
|
||||
const cloned = JSON.parse(JSON.stringify(body));
|
||||
|
||||
sensitiveFields.forEach(field => {
|
||||
if (cloned[field]) {
|
||||
cloned[field] = '[REDACTED]';
|
||||
}
|
||||
});
|
||||
|
||||
return cloned;
|
||||
} catch {
|
||||
return '[SANITIZATION_ERROR]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track performance metrics
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {number} duration - Request duration in milliseconds
|
||||
*/
|
||||
function trackPerformanceMetrics(req, res, duration) {
|
||||
// Only track metrics for non-health check endpoints
|
||||
if (req.path === '/health') return;
|
||||
|
||||
const metrics = {
|
||||
endpoint: `${req.method} ${req.route?.path || req.path}`,
|
||||
duration,
|
||||
statusCode: res.statusCode,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Log slow requests
|
||||
if (duration > 1000) { // 1 second
|
||||
logger.warn('Slow request detected', {
|
||||
correlationId: req.correlationId,
|
||||
...metrics,
|
||||
threshold: '1000ms'
|
||||
});
|
||||
}
|
||||
|
||||
// Log very slow requests as errors
|
||||
if (duration > 10000) { // 10 seconds
|
||||
logger.error('Very slow request detected', {
|
||||
correlationId: req.correlationId,
|
||||
...metrics,
|
||||
threshold: '10000ms'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Send metrics to monitoring system (Prometheus, DataDog, etc.)
|
||||
// This would integrate with your monitoring infrastructure
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to skip logging for specific paths
|
||||
* @param {Array<string>} skipPaths - Array of paths to skip
|
||||
* @returns {Function} Middleware function
|
||||
*/
|
||||
function skipLogging(skipPaths = ['/health', '/favicon.ico']) {
|
||||
return (req, res, next) => {
|
||||
const shouldSkip = skipPaths.some(path => req.path === path);
|
||||
|
||||
if (shouldSkip) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return requestLogger(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error logging middleware (for unhandled errors)
|
||||
* @param {Error} error - Error object
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function errorLogger(error, req, res, next) {
|
||||
logger.error('Unhandled request error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent'),
|
||||
userId: req.user?.playerId || req.user?.adminId,
|
||||
userType: req.user?.type
|
||||
});
|
||||
|
||||
next(error);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requestLogger,
|
||||
skipLogging,
|
||||
errorLogger,
|
||||
sanitizeResponseBody,
|
||||
sanitizeRequestBody
|
||||
};
|
||||
320
src/middleware/rateLimit.middleware.js
Normal file
320
src/middleware/rateLimit.middleware.js
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/**
|
||||
* Rate Limiting Middleware
|
||||
* Implements comprehensive rate limiting with Redis backend and flexible configuration
|
||||
*/
|
||||
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { getRedisClient } = require('../config/redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Rate limiting configuration
|
||||
const RATE_LIMIT_CONFIG = {
|
||||
// Global API rate limits
|
||||
global: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 1000, // 1000 requests per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false
|
||||
},
|
||||
|
||||
// Authentication endpoints (more restrictive)
|
||||
auth: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10, // 10 attempts per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // Don't count successful logins
|
||||
skipFailedRequests: false
|
||||
},
|
||||
|
||||
// Player API endpoints
|
||||
player: {
|
||||
windowMs: 1 * 60 * 1000, // 1 minute
|
||||
max: 120, // 120 requests per minute
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false
|
||||
},
|
||||
|
||||
// Admin API endpoints (more lenient for legitimate admin users)
|
||||
admin: {
|
||||
windowMs: 1 * 60 * 1000, // 1 minute
|
||||
max: 300, // 300 requests per minute
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: false
|
||||
},
|
||||
|
||||
// Game action endpoints (prevent spam)
|
||||
gameAction: {
|
||||
windowMs: 30 * 1000, // 30 seconds
|
||||
max: 30, // 30 actions per 30 seconds
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: true
|
||||
},
|
||||
|
||||
// Message sending (prevent spam)
|
||||
messaging: {
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: 10, // 10 messages per 5 minutes
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: false,
|
||||
skipFailedRequests: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Redis store for rate limiting if Redis is available
|
||||
* @returns {Object|null} Redis store or null if Redis unavailable
|
||||
*/
|
||||
function createRedisStore() {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
logger.warn('Redis not available for rate limiting, using memory store');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create Redis store for express-rate-limit
|
||||
try {
|
||||
const { RedisStore } = require('rate-limit-redis');
|
||||
|
||||
return new RedisStore({
|
||||
sendCommand: (...args) => redis.sendCommand(args),
|
||||
prefix: 'rl:' // Rate limit prefix
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create RedisStore, falling back to memory store', {
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.warn('Failed to create Redis store for rate limiting', {
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create key generator for rate limiting
|
||||
* @param {string} prefix - Key prefix
|
||||
* @returns {Function} Key generator function
|
||||
*/
|
||||
function createKeyGenerator(prefix = 'global') {
|
||||
return (req) => {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const userId = req.user?.playerId || req.user?.adminId || 'anonymous';
|
||||
return `${prefix}:${userId}:${ip}`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limit handler
|
||||
* @param {string} type - Rate limit type for logging
|
||||
* @returns {Function} Rate limit handler function
|
||||
*/
|
||||
function createRateLimitHandler(type) {
|
||||
return (req, res) => {
|
||||
const correlationId = req.correlationId;
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const userId = req.user?.playerId || req.user?.adminId;
|
||||
const userType = req.user?.type || 'anonymous';
|
||||
|
||||
logger.warn('Rate limit exceeded', {
|
||||
correlationId,
|
||||
type,
|
||||
ip,
|
||||
userId,
|
||||
userType,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.get('User-Agent'),
|
||||
retryAfter: res.get('Retry-After')
|
||||
});
|
||||
|
||||
return res.status(429).json({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
type: type,
|
||||
retryAfter: res.get('Retry-After'),
|
||||
correlationId
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create skip function for rate limiting
|
||||
* @param {Array<string>} skipPaths - Paths to skip rate limiting
|
||||
* @param {Array<string>} skipIPs - IPs to skip rate limiting
|
||||
* @returns {Function} Skip function
|
||||
*/
|
||||
function createSkipFunction(skipPaths = [], skipIPs = []) {
|
||||
return (req) => {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
|
||||
// Skip health checks
|
||||
if (req.path === '/health' || req.path === '/api/health') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip specified paths
|
||||
if (skipPaths.some(path => req.path.startsWith(path))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip specified IPs (for development/testing)
|
||||
if (skipIPs.includes(ip)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if rate limiting is disabled
|
||||
if (process.env.DISABLE_RATE_LIMITING === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limiter middleware
|
||||
* @param {string} type - Type of rate limiter
|
||||
* @param {Object} customConfig - Custom configuration
|
||||
* @returns {Function} Rate limiter middleware
|
||||
*/
|
||||
function createRateLimiter(type, customConfig = {}) {
|
||||
const config = { ...RATE_LIMIT_CONFIG[type], ...customConfig };
|
||||
const store = createRedisStore();
|
||||
|
||||
const rateLimiter = rateLimit({
|
||||
...config,
|
||||
store,
|
||||
keyGenerator: createKeyGenerator(type),
|
||||
handler: createRateLimitHandler(type),
|
||||
skip: createSkipFunction(),
|
||||
// Note: onLimitReached is deprecated in express-rate-limit v7
|
||||
// Removed for compatibility
|
||||
});
|
||||
|
||||
// Log rate limiter creation
|
||||
logger.info('Rate limiter created', {
|
||||
type,
|
||||
windowMs: config.windowMs,
|
||||
max: config.max,
|
||||
useRedis: !!store
|
||||
});
|
||||
|
||||
return rateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured rate limiters
|
||||
*/
|
||||
const rateLimiters = {
|
||||
global: createRateLimiter('global'),
|
||||
auth: createRateLimiter('auth'),
|
||||
player: createRateLimiter('player'),
|
||||
admin: createRateLimiter('admin'),
|
||||
gameAction: createRateLimiter('gameAction'),
|
||||
messaging: createRateLimiter('messaging')
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to add rate limit headers even when not limiting
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function addRateLimitHeaders(req, res, next) {
|
||||
// Add custom headers for client information
|
||||
res.set({
|
||||
'X-RateLimit-Policy': 'See API documentation for rate limiting details'
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom rate limiter for WebSocket connections
|
||||
* @param {number} maxConnections - Maximum connections per IP
|
||||
* @param {number} windowMs - Time window in milliseconds
|
||||
* @returns {Function} WebSocket rate limiter function
|
||||
*/
|
||||
function createWebSocketRateLimiter(maxConnections = 10, windowMs = 60000) {
|
||||
const connections = new Map();
|
||||
|
||||
return (socket, next) => {
|
||||
const ip = socket.handshake.address;
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up old connections
|
||||
if (connections.has(ip)) {
|
||||
const connectionTimes = connections.get(ip).filter(time => now - time < windowMs);
|
||||
connections.set(ip, connectionTimes);
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const currentConnections = connections.get(ip) || [];
|
||||
if (currentConnections.length >= maxConnections) {
|
||||
logger.warn('WebSocket connection rate limit exceeded', {
|
||||
ip,
|
||||
currentConnections: currentConnections.length,
|
||||
maxConnections
|
||||
});
|
||||
|
||||
return next(new Error('Connection rate limit exceeded'));
|
||||
}
|
||||
|
||||
// Add current connection
|
||||
currentConnections.push(now);
|
||||
connections.set(ip, currentConnections);
|
||||
|
||||
logger.debug('WebSocket connection allowed', {
|
||||
ip,
|
||||
connections: currentConnections.length,
|
||||
maxConnections
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to apply different rate limits based on user type
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function dynamicRateLimit(req, res, next) {
|
||||
const userType = req.user?.type;
|
||||
|
||||
let limiter;
|
||||
if (userType === 'admin') {
|
||||
limiter = rateLimiters.admin;
|
||||
} else if (userType === 'player') {
|
||||
limiter = rateLimiters.player;
|
||||
} else {
|
||||
limiter = rateLimiters.global;
|
||||
}
|
||||
|
||||
return limiter(req, res, next);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
rateLimiters,
|
||||
createRateLimiter,
|
||||
createWebSocketRateLimiter,
|
||||
addRateLimitHeaders,
|
||||
dynamicRateLimit,
|
||||
RATE_LIMIT_CONFIG
|
||||
};
|
||||
92
src/middleware/request-logger.js
Normal file
92
src/middleware/request-logger.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Request logging middleware
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Request logging middleware
|
||||
* Logs incoming requests and attaches correlation ID
|
||||
*/
|
||||
function requestLogger(req, res, next) {
|
||||
// Generate correlation ID for request tracking
|
||||
req.correlationId = uuidv4();
|
||||
|
||||
// Capture start time
|
||||
const startTime = Date.now();
|
||||
|
||||
// Extract user info if available
|
||||
const getUserInfo = () => {
|
||||
if (req.user) {
|
||||
return {
|
||||
userId: req.user.id,
|
||||
username: req.user.username || req.user.display_name,
|
||||
userType: req.user.role ? 'admin' : 'player',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// Log request start
|
||||
logger.info('Request started', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
userAgent: req.get('User-Agent'),
|
||||
ip: req.ip,
|
||||
...getUserInfo(),
|
||||
});
|
||||
|
||||
// Override res.json to log response
|
||||
const originalJson = res.json;
|
||||
res.json = function (body) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Log request completion
|
||||
logger.info('Request completed', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
...getUserInfo(),
|
||||
});
|
||||
|
||||
// Log slow requests
|
||||
if (duration > 1000) {
|
||||
logger.warn('Slow request detected', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
return originalJson.call(this, body);
|
||||
};
|
||||
|
||||
// Override res.send to log response
|
||||
const originalSend = res.send;
|
||||
res.send = function (body) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Only log if not already logged by res.json
|
||||
if (!res.jsonLogged) {
|
||||
logger.info('Request completed', {
|
||||
correlationId: req.correlationId,
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
...getUserInfo(),
|
||||
});
|
||||
}
|
||||
|
||||
return originalSend.call(this, body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = requestLogger;
|
||||
310
src/middleware/validation.middleware.js
Normal file
310
src/middleware/validation.middleware.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* Request Validation Middleware
|
||||
* Provides validation middleware using Joi schemas with comprehensive error handling
|
||||
*/
|
||||
|
||||
const Joi = require('joi');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Middleware factory for validating request data using Joi schemas
|
||||
* @param {Object} schema - Joi validation schema
|
||||
* @param {string} source - Source of data to validate ('body', 'params', 'query', 'headers')
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function validateRequest(schema, source = 'body') {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const correlationId = req.correlationId;
|
||||
let dataToValidate;
|
||||
|
||||
// Get data based on source
|
||||
switch (source) {
|
||||
case 'body':
|
||||
dataToValidate = req.body;
|
||||
break;
|
||||
case 'params':
|
||||
dataToValidate = req.params;
|
||||
break;
|
||||
case 'query':
|
||||
dataToValidate = req.query;
|
||||
break;
|
||||
case 'headers':
|
||||
dataToValidate = req.headers;
|
||||
break;
|
||||
default:
|
||||
logger.error('Invalid validation source specified', {
|
||||
correlationId,
|
||||
source,
|
||||
path: req.path
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Invalid validation configuration',
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Perform validation
|
||||
const { error, value } = schema.validate(dataToValidate, {
|
||||
abortEarly: false, // Return all validation errors
|
||||
stripUnknown: true, // Remove unknown properties
|
||||
convert: true // Convert values to correct types
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const validationErrors = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message,
|
||||
value: detail.context?.value
|
||||
}));
|
||||
|
||||
logger.warn('Request validation failed', {
|
||||
correlationId,
|
||||
source,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
errors: validationErrors,
|
||||
originalData: JSON.stringify(dataToValidate)
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
message: 'Request data is invalid',
|
||||
details: validationErrors,
|
||||
correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the original data with validated/sanitized data
|
||||
switch (source) {
|
||||
case 'body':
|
||||
req.body = value;
|
||||
break;
|
||||
case 'params':
|
||||
req.params = value;
|
||||
break;
|
||||
case 'query':
|
||||
req.query = value;
|
||||
break;
|
||||
case 'headers':
|
||||
req.headers = value;
|
||||
break;
|
||||
}
|
||||
|
||||
logger.debug('Request validation passed', {
|
||||
correlationId,
|
||||
source,
|
||||
path: req.path
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Validation middleware error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
source
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Validation processing failed',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common validation schemas
|
||||
*/
|
||||
const commonSchemas = {
|
||||
// Player ID parameter validation
|
||||
playerId: Joi.object({
|
||||
playerId: Joi.number().integer().min(1).required()
|
||||
}),
|
||||
|
||||
// Pagination query validation
|
||||
pagination: Joi.object({
|
||||
page: Joi.number().integer().min(1).default(1),
|
||||
limit: Joi.number().integer().min(1).max(100).default(20),
|
||||
sortBy: Joi.string().valid('created_at', 'updated_at', 'name', 'id').default('created_at'),
|
||||
sortOrder: Joi.string().valid('asc', 'desc').default('desc')
|
||||
}),
|
||||
|
||||
// Player registration validation
|
||||
playerRegistration: Joi.object({
|
||||
email: Joi.string().email().max(320).required(),
|
||||
username: Joi.string().alphanum().min(3).max(20).required(),
|
||||
password: Joi.string().min(8).max(128).required()
|
||||
}),
|
||||
|
||||
// Player login validation
|
||||
playerLogin: Joi.object({
|
||||
email: Joi.string().email().max(320).required(),
|
||||
password: Joi.string().min(1).max(128).required()
|
||||
}),
|
||||
|
||||
// Admin login validation
|
||||
adminLogin: Joi.object({
|
||||
email: Joi.string().email().max(320).required(),
|
||||
password: Joi.string().min(1).max(128).required()
|
||||
}),
|
||||
|
||||
// Colony creation validation
|
||||
colonyCreation: Joi.object({
|
||||
name: Joi.string().min(3).max(50).required(),
|
||||
coordinates: Joi.string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/).required(),
|
||||
planet_type_id: Joi.number().integer().min(1).required()
|
||||
}),
|
||||
|
||||
// Colony update validation
|
||||
colonyUpdate: Joi.object({
|
||||
name: Joi.string().min(3).max(50).optional()
|
||||
}),
|
||||
|
||||
// Fleet creation validation
|
||||
fleetCreation: Joi.object({
|
||||
name: Joi.string().min(3).max(50).required(),
|
||||
ships: Joi.array().items(
|
||||
Joi.object({
|
||||
design_id: Joi.number().integer().min(1).required(),
|
||||
quantity: Joi.number().integer().min(1).max(1000).required()
|
||||
})
|
||||
).min(1).required()
|
||||
}),
|
||||
|
||||
// Fleet movement validation
|
||||
fleetMovement: Joi.object({
|
||||
destination: Joi.string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/).required(),
|
||||
mission_type: Joi.string().valid('move', 'attack', 'colonize', 'transport').required()
|
||||
}),
|
||||
|
||||
// Research initiation validation
|
||||
researchInitiation: Joi.object({
|
||||
technology_id: Joi.number().integer().min(1).required()
|
||||
}),
|
||||
|
||||
// Message sending validation
|
||||
messageSend: Joi.object({
|
||||
to_player_id: Joi.number().integer().min(1).required(),
|
||||
subject: Joi.string().min(1).max(100).required(),
|
||||
content: Joi.string().min(1).max(2000).required()
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-built validation middleware for common use cases
|
||||
*/
|
||||
const validators = {
|
||||
// Parameter validators
|
||||
validatePlayerId: validateRequest(commonSchemas.playerId, 'params'),
|
||||
validatePagination: validateRequest(commonSchemas.pagination, 'query'),
|
||||
|
||||
// Authentication validators
|
||||
validatePlayerRegistration: validateRequest(commonSchemas.playerRegistration, 'body'),
|
||||
validatePlayerLogin: validateRequest(commonSchemas.playerLogin, 'body'),
|
||||
validateAdminLogin: validateRequest(commonSchemas.adminLogin, 'body'),
|
||||
|
||||
// Game feature validators
|
||||
validateColonyCreation: validateRequest(commonSchemas.colonyCreation, 'body'),
|
||||
validateColonyUpdate: validateRequest(commonSchemas.colonyUpdate, 'body'),
|
||||
validateFleetCreation: validateRequest(commonSchemas.fleetCreation, 'body'),
|
||||
validateFleetMovement: validateRequest(commonSchemas.fleetMovement, 'body'),
|
||||
validateResearchInitiation: validateRequest(commonSchemas.researchInitiation, 'body'),
|
||||
validateMessageSend: validateRequest(commonSchemas.messageSend, 'body')
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom validation helpers
|
||||
*/
|
||||
const validationHelpers = {
|
||||
/**
|
||||
* Create a custom validation schema for coordinates
|
||||
* @param {boolean} required - Whether the field is required
|
||||
* @returns {Joi.Schema} Joi schema for coordinates
|
||||
*/
|
||||
coordinatesSchema(required = true) {
|
||||
let schema = Joi.string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/);
|
||||
return required ? schema.required() : schema.optional();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a custom validation schema for player IDs
|
||||
* @param {boolean} required - Whether the field is required
|
||||
* @returns {Joi.Schema} Joi schema for player IDs
|
||||
*/
|
||||
playerIdSchema(required = true) {
|
||||
let schema = Joi.number().integer().min(1);
|
||||
return required ? schema.required() : schema.optional();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a custom validation schema for resource amounts
|
||||
* @param {number} min - Minimum value (default: 0)
|
||||
* @param {number} max - Maximum value (default: 999999999)
|
||||
* @returns {Joi.Schema} Joi schema for resource amounts
|
||||
*/
|
||||
resourceAmountSchema(min = 0, max = 999999999) {
|
||||
return Joi.number().integer().min(min).max(max);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a validation schema for arrays with custom item validation
|
||||
* @param {Joi.Schema} itemSchema - Schema for array items
|
||||
* @param {number} minItems - Minimum number of items
|
||||
* @param {number} maxItems - Maximum number of items
|
||||
* @returns {Joi.Schema} Joi schema for arrays
|
||||
*/
|
||||
arraySchema(itemSchema, minItems = 0, maxItems = 100) {
|
||||
return Joi.array().items(itemSchema).min(minItems).max(maxItems);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to sanitize HTML content in request body
|
||||
* @param {Array<string>} fields - Fields to sanitize
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
function sanitizeHTML(fields = []) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const { sanitizeHTML: sanitize } = require('../utils/validation');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (req.body[field] && typeof req.body[field] === 'string') {
|
||||
req.body[field] = sanitize(req.body[field]);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('HTML sanitization error', {
|
||||
correlationId: req.correlationId,
|
||||
error: error.message,
|
||||
fields
|
||||
});
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Request processing failed',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateRequest,
|
||||
commonSchemas,
|
||||
validators,
|
||||
validationHelpers,
|
||||
sanitizeHTML
|
||||
};
|
||||
401
src/routes/admin.js
Normal file
401
src/routes/admin.js
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
/**
|
||||
* Admin API Routes
|
||||
* Defines all administrative API endpoints with proper authentication and permissions
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Import middleware
|
||||
const { authenticateAdmin, requirePermissions, requirePlayerAccess, auditAdminAction, ADMIN_PERMISSIONS } = require('../middleware/admin.middleware');
|
||||
const { rateLimiters } = require('../middleware/rateLimit.middleware');
|
||||
const { validators, validateRequest } = require('../middleware/validation.middleware');
|
||||
const corsMiddleware = require('../middleware/cors.middleware');
|
||||
|
||||
// Import controllers
|
||||
const adminAuthController = require('../controllers/admin/auth.controller');
|
||||
|
||||
// Import services for direct admin operations
|
||||
const AdminService = require('../services/user/AdminService');
|
||||
const adminService = new AdminService();
|
||||
|
||||
// Apply CORS to all admin routes
|
||||
router.use(corsMiddleware);
|
||||
|
||||
// Apply admin-specific rate limiting
|
||||
router.use(rateLimiters.admin);
|
||||
|
||||
/**
|
||||
* Admin API Status and Information
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Shattered Void - Admin API',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
status: 'operational',
|
||||
timestamp: new Date().toISOString(),
|
||||
correlationId: req.correlationId,
|
||||
endpoints: {
|
||||
authentication: '/api/admin/auth',
|
||||
players: '/api/admin/players',
|
||||
system: '/api/admin/system',
|
||||
events: '/api/admin/events',
|
||||
analytics: '/api/admin/analytics'
|
||||
},
|
||||
note: 'Administrative access required for all endpoints'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin Authentication Routes
|
||||
* /api/admin/auth/*
|
||||
*/
|
||||
const authRoutes = express.Router();
|
||||
|
||||
// Public admin authentication endpoints
|
||||
authRoutes.post('/login',
|
||||
rateLimiters.auth,
|
||||
validators.validateAdminLogin,
|
||||
auditAdminAction('admin_login'),
|
||||
adminAuthController.login
|
||||
);
|
||||
|
||||
// Protected admin authentication endpoints
|
||||
authRoutes.post('/logout',
|
||||
authenticateAdmin,
|
||||
auditAdminAction('admin_logout'),
|
||||
adminAuthController.logout
|
||||
);
|
||||
|
||||
authRoutes.get('/me',
|
||||
authenticateAdmin,
|
||||
adminAuthController.getProfile
|
||||
);
|
||||
|
||||
authRoutes.get('/verify',
|
||||
authenticateAdmin,
|
||||
adminAuthController.verifyToken
|
||||
);
|
||||
|
||||
authRoutes.post('/refresh',
|
||||
rateLimiters.auth,
|
||||
adminAuthController.refresh
|
||||
);
|
||||
|
||||
authRoutes.get('/stats',
|
||||
authenticateAdmin,
|
||||
requirePermissions([ADMIN_PERMISSIONS.ANALYTICS_READ]),
|
||||
auditAdminAction('view_system_stats'),
|
||||
adminAuthController.getSystemStats
|
||||
);
|
||||
|
||||
authRoutes.post('/change-password',
|
||||
authenticateAdmin,
|
||||
rateLimiters.auth,
|
||||
validateRequest(require('joi').object({
|
||||
currentPassword: require('joi').string().required(),
|
||||
newPassword: require('joi').string().min(8).max(128).required()
|
||||
}), 'body'),
|
||||
auditAdminAction('admin_password_change'),
|
||||
adminAuthController.changePassword
|
||||
);
|
||||
|
||||
// Mount admin authentication routes
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
/**
|
||||
* Player Management Routes
|
||||
* /api/admin/players/*
|
||||
*/
|
||||
const playerRoutes = express.Router();
|
||||
|
||||
// All player management routes require authentication
|
||||
playerRoutes.use(authenticateAdmin);
|
||||
|
||||
// Get players list
|
||||
playerRoutes.get('/',
|
||||
requirePermissions([ADMIN_PERMISSIONS.PLAYER_DATA_READ]),
|
||||
validators.validatePagination,
|
||||
validateRequest(require('joi').object({
|
||||
search: require('joi').string().max(50).optional(),
|
||||
activeOnly: require('joi').boolean().optional(),
|
||||
sortBy: require('joi').string().valid('created_at', 'updated_at', 'username', 'email', 'last_login_at').default('created_at'),
|
||||
sortOrder: require('joi').string().valid('asc', 'desc').default('desc')
|
||||
}), 'query'),
|
||||
auditAdminAction('list_players'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search = '',
|
||||
activeOnly = null,
|
||||
sortBy = 'created_at',
|
||||
sortOrder = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const result = await adminService.getPlayersList({
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
search,
|
||||
activeOnly,
|
||||
sortBy,
|
||||
sortOrder
|
||||
}, req.correlationId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Players list retrieved successfully',
|
||||
data: result,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve players list',
|
||||
message: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get specific player details
|
||||
playerRoutes.get('/:playerId',
|
||||
requirePlayerAccess('playerId'),
|
||||
validators.validatePlayerId,
|
||||
auditAdminAction('view_player_details'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const playerId = parseInt(req.params.playerId);
|
||||
const playerDetails = await adminService.getPlayerDetails(playerId, req.correlationId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Player details retrieved successfully',
|
||||
data: {
|
||||
player: playerDetails
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const statusCode = error.name === 'NotFoundError' ? 404 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.name === 'NotFoundError' ? 'Player not found' : 'Failed to retrieve player details',
|
||||
message: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update player status (activate/deactivate)
|
||||
playerRoutes.put('/:playerId/status',
|
||||
requirePermissions([ADMIN_PERMISSIONS.PLAYER_MANAGEMENT]),
|
||||
validators.validatePlayerId,
|
||||
validateRequest(require('joi').object({
|
||||
isActive: require('joi').boolean().required(),
|
||||
reason: require('joi').string().max(200).optional()
|
||||
}), 'body'),
|
||||
auditAdminAction('update_player_status'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const playerId = parseInt(req.params.playerId);
|
||||
const { isActive, reason } = req.body;
|
||||
|
||||
const updatedPlayer = await adminService.updatePlayerStatus(
|
||||
playerId,
|
||||
isActive,
|
||||
req.correlationId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Player ${isActive ? 'activated' : 'deactivated'} successfully`,
|
||||
data: {
|
||||
player: updatedPlayer,
|
||||
action: isActive ? 'activated' : 'deactivated',
|
||||
reason: reason || null
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const statusCode = error.name === 'NotFoundError' ? 404 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.name === 'NotFoundError' ? 'Player not found' : 'Failed to update player status',
|
||||
message: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mount player management routes
|
||||
router.use('/players', playerRoutes);
|
||||
|
||||
/**
|
||||
* System Management Routes
|
||||
* /api/admin/system/*
|
||||
*/
|
||||
const systemRoutes = express.Router();
|
||||
|
||||
// All system routes require authentication
|
||||
systemRoutes.use(authenticateAdmin);
|
||||
|
||||
// Get detailed system statistics
|
||||
systemRoutes.get('/stats',
|
||||
requirePermissions([ADMIN_PERMISSIONS.SYSTEM_MANAGEMENT]),
|
||||
auditAdminAction('view_detailed_system_stats'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const stats = await adminService.getSystemStats(req.correlationId);
|
||||
|
||||
// Add additional system information
|
||||
const systemInfo = {
|
||||
...stats,
|
||||
server: {
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
uptime: process.uptime(),
|
||||
nodeVersion: process.version,
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'System statistics retrieved successfully',
|
||||
data: systemInfo,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve system statistics',
|
||||
message: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// System health check
|
||||
systemRoutes.get('/health',
|
||||
requirePermissions([ADMIN_PERMISSIONS.SYSTEM_MANAGEMENT]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// TODO: Implement comprehensive health checks
|
||||
// - Database connectivity
|
||||
// - Redis connectivity
|
||||
// - WebSocket server status
|
||||
// - External service connectivity
|
||||
|
||||
const healthStatus = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: 'healthy',
|
||||
redis: 'healthy',
|
||||
websocket: 'healthy'
|
||||
},
|
||||
performance: {
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
cpu: process.cpuUsage()
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'System health check completed',
|
||||
data: healthStatus,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Health check failed',
|
||||
message: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mount system routes
|
||||
router.use('/system', systemRoutes);
|
||||
|
||||
/**
|
||||
* Events Management Routes (placeholder)
|
||||
* /api/admin/events/*
|
||||
*/
|
||||
router.get('/events',
|
||||
authenticateAdmin,
|
||||
requirePermissions([ADMIN_PERMISSIONS.EVENT_MANAGEMENT]),
|
||||
validators.validatePagination,
|
||||
auditAdminAction('view_events'),
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Events endpoint - feature not yet implemented',
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Analytics Routes (placeholder)
|
||||
* /api/admin/analytics/*
|
||||
*/
|
||||
router.get('/analytics',
|
||||
authenticateAdmin,
|
||||
requirePermissions([ADMIN_PERMISSIONS.ANALYTICS_READ]),
|
||||
auditAdminAction('view_analytics'),
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Analytics endpoint - feature not yet implemented',
|
||||
data: {
|
||||
analytics: {},
|
||||
timeRange: 'daily',
|
||||
metrics: []
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Error handling for admin routes
|
||||
*/
|
||||
router.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Admin API endpoint not found',
|
||||
message: `The endpoint ${req.method} ${req.originalUrl} does not exist`,
|
||||
correlationId: req.correlationId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
0
src/routes/admin/analytics.js
Normal file
0
src/routes/admin/analytics.js
Normal file
0
src/routes/admin/auth.js
Normal file
0
src/routes/admin/auth.js
Normal file
0
src/routes/admin/events.js
Normal file
0
src/routes/admin/events.js
Normal file
42
src/routes/admin/index.js
Normal file
42
src/routes/admin/index.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Admin API routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { authenticateToken, requireRole, requirePermission } = require('../../middleware/auth');
|
||||
const { asyncHandler } = require('../../middleware/error-handler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Import route modules
|
||||
const authRoutes = require('./auth');
|
||||
const systemRoutes = require('./system');
|
||||
const playersRoutes = require('./players');
|
||||
const eventsRoutes = require('./events');
|
||||
const analyticsRoutes = require('./analytics');
|
||||
|
||||
// Admin authentication routes
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
// Protected admin routes
|
||||
router.use('/system', authenticateToken('admin'), requireRole(['admin', 'super_admin']), systemRoutes);
|
||||
router.use('/players', authenticateToken('admin'), requirePermission('manage_players'), playersRoutes);
|
||||
router.use('/events', authenticateToken('admin'), requirePermission('manage_events'), eventsRoutes);
|
||||
router.use('/analytics', authenticateToken('admin'), requireRole(['admin', 'super_admin']), analyticsRoutes);
|
||||
|
||||
// Admin status endpoint
|
||||
router.get('/status', authenticateToken('admin'), asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
status: 'authenticated',
|
||||
admin: {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
role: req.user.role,
|
||||
permissions: req.user.permissions || {},
|
||||
lastLogin: req.user.last_login_at,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
0
src/routes/admin/players.js
Normal file
0
src/routes/admin/players.js
Normal file
0
src/routes/admin/system.js
Normal file
0
src/routes/admin/system.js
Normal file
344
src/routes/api.js
Normal file
344
src/routes/api.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Player API Routes
|
||||
* Defines all player-facing API endpoints with proper middleware and validation
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Import middleware
|
||||
const { authenticatePlayer, optionalPlayerAuth, requireOwnership, injectPlayerId } = require('../middleware/auth.middleware');
|
||||
const { rateLimiters } = require('../middleware/rateLimit.middleware');
|
||||
const { validators, validateRequest } = require('../middleware/validation.middleware');
|
||||
const corsMiddleware = require('../middleware/cors.middleware');
|
||||
|
||||
// Import controllers
|
||||
const authController = require('../controllers/api/auth.controller');
|
||||
const playerController = require('../controllers/api/player.controller');
|
||||
|
||||
// Apply CORS to all API routes
|
||||
router.use(corsMiddleware);
|
||||
|
||||
// Apply general API rate limiting
|
||||
router.use(rateLimiters.player);
|
||||
|
||||
/**
|
||||
* API Status and Information
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Shattered Void - Player API',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
status: 'operational',
|
||||
timestamp: new Date().toISOString(),
|
||||
correlationId: req.correlationId,
|
||||
endpoints: {
|
||||
authentication: '/api/auth',
|
||||
player: '/api/player',
|
||||
game: {
|
||||
colonies: '/api/colonies',
|
||||
fleets: '/api/fleets',
|
||||
research: '/api/research',
|
||||
galaxy: '/api/galaxy'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Authentication Routes
|
||||
* /api/auth/*
|
||||
*/
|
||||
const authRoutes = express.Router();
|
||||
|
||||
// Public authentication endpoints (with stricter rate limiting)
|
||||
authRoutes.post('/register',
|
||||
rateLimiters.auth,
|
||||
validators.validatePlayerRegistration,
|
||||
authController.register
|
||||
);
|
||||
|
||||
authRoutes.post('/login',
|
||||
rateLimiters.auth,
|
||||
validators.validatePlayerLogin,
|
||||
authController.login
|
||||
);
|
||||
|
||||
// Protected authentication endpoints
|
||||
authRoutes.post('/logout',
|
||||
authenticatePlayer,
|
||||
authController.logout
|
||||
);
|
||||
|
||||
authRoutes.post('/refresh',
|
||||
rateLimiters.auth,
|
||||
authController.refresh
|
||||
);
|
||||
|
||||
authRoutes.get('/me',
|
||||
authenticatePlayer,
|
||||
authController.getProfile
|
||||
);
|
||||
|
||||
authRoutes.put('/me',
|
||||
authenticatePlayer,
|
||||
validateRequest(require('joi').object({
|
||||
username: require('joi').string().alphanum().min(3).max(20).optional()
|
||||
}), 'body'),
|
||||
authController.updateProfile
|
||||
);
|
||||
|
||||
authRoutes.get('/verify',
|
||||
authenticatePlayer,
|
||||
authController.verifyToken
|
||||
);
|
||||
|
||||
authRoutes.post('/change-password',
|
||||
authenticatePlayer,
|
||||
rateLimiters.auth,
|
||||
validateRequest(require('joi').object({
|
||||
currentPassword: require('joi').string().required(),
|
||||
newPassword: require('joi').string().min(8).max(128).required()
|
||||
}), 'body'),
|
||||
authController.changePassword
|
||||
);
|
||||
|
||||
// Mount authentication routes
|
||||
router.use('/auth', authRoutes);
|
||||
|
||||
/**
|
||||
* Player Management Routes
|
||||
* /api/player/*
|
||||
*/
|
||||
const playerRoutes = express.Router();
|
||||
|
||||
// All player routes require authentication
|
||||
playerRoutes.use(authenticatePlayer);
|
||||
|
||||
playerRoutes.get('/dashboard', playerController.getDashboard);
|
||||
|
||||
playerRoutes.get('/resources', playerController.getResources);
|
||||
|
||||
playerRoutes.get('/stats', playerController.getStats);
|
||||
|
||||
playerRoutes.put('/settings',
|
||||
validateRequest(require('joi').object({
|
||||
// TODO: Define settings schema
|
||||
notifications: require('joi').object({
|
||||
email: require('joi').boolean().optional(),
|
||||
push: require('joi').boolean().optional(),
|
||||
battles: require('joi').boolean().optional(),
|
||||
colonies: require('joi').boolean().optional()
|
||||
}).optional(),
|
||||
ui: require('joi').object({
|
||||
theme: require('joi').string().valid('light', 'dark').optional(),
|
||||
language: require('joi').string().valid('en', 'es', 'fr', 'de').optional()
|
||||
}).optional()
|
||||
}), 'body'),
|
||||
playerController.updateSettings
|
||||
);
|
||||
|
||||
playerRoutes.get('/activity',
|
||||
validators.validatePagination,
|
||||
playerController.getActivity
|
||||
);
|
||||
|
||||
playerRoutes.get('/notifications',
|
||||
validateRequest(require('joi').object({
|
||||
unreadOnly: require('joi').boolean().default(false)
|
||||
}), 'query'),
|
||||
playerController.getNotifications
|
||||
);
|
||||
|
||||
playerRoutes.put('/notifications/read',
|
||||
validateRequest(require('joi').object({
|
||||
notificationIds: require('joi').array().items(
|
||||
require('joi').number().integer().positive()
|
||||
).min(1).required()
|
||||
}), 'body'),
|
||||
playerController.markNotificationsRead
|
||||
);
|
||||
|
||||
// Mount player routes
|
||||
router.use('/player', playerRoutes);
|
||||
|
||||
/**
|
||||
* Game Feature Routes
|
||||
* These will be expanded with actual game functionality
|
||||
*/
|
||||
|
||||
// Colonies Routes (placeholder)
|
||||
router.get('/colonies',
|
||||
authenticatePlayer,
|
||||
validators.validatePagination,
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Colonies endpoint - feature not yet implemented',
|
||||
data: {
|
||||
colonies: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/colonies',
|
||||
authenticatePlayer,
|
||||
rateLimiters.gameAction,
|
||||
validators.validateColonyCreation,
|
||||
(req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Colony creation feature not yet implemented',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Fleets Routes (placeholder)
|
||||
router.get('/fleets',
|
||||
authenticatePlayer,
|
||||
validators.validatePagination,
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Fleets endpoint - feature not yet implemented',
|
||||
data: {
|
||||
fleets: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/fleets',
|
||||
authenticatePlayer,
|
||||
rateLimiters.gameAction,
|
||||
validators.validateFleetCreation,
|
||||
(req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Fleet creation feature not yet implemented',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Research Routes (placeholder)
|
||||
router.get('/research',
|
||||
authenticatePlayer,
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Research endpoint - feature not yet implemented',
|
||||
data: {
|
||||
currentResearch: null,
|
||||
availableResearch: [],
|
||||
completedResearch: []
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/research',
|
||||
authenticatePlayer,
|
||||
rateLimiters.gameAction,
|
||||
validators.validateResearchInitiation,
|
||||
(req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Research initiation feature not yet implemented',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Galaxy Routes (placeholder)
|
||||
router.get('/galaxy',
|
||||
authenticatePlayer,
|
||||
validateRequest(require('joi').object({
|
||||
sector: require('joi').string().pattern(/^[A-Z]\d+$/).optional(),
|
||||
coordinates: require('joi').string().pattern(/^[A-Z]\d+-\d+-[A-Z]$/).optional()
|
||||
}), 'query'),
|
||||
(req, res) => {
|
||||
const { sector, coordinates } = req.query;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Galaxy endpoint - feature not yet implemented',
|
||||
data: {
|
||||
sector: sector || null,
|
||||
coordinates: coordinates || null,
|
||||
systems: [],
|
||||
playerColonies: [],
|
||||
playerFleets: []
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Messages Routes (placeholder)
|
||||
router.get('/messages',
|
||||
authenticatePlayer,
|
||||
validators.validatePagination,
|
||||
(req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Messages endpoint - feature not yet implemented',
|
||||
data: {
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
}
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.post('/messages',
|
||||
authenticatePlayer,
|
||||
rateLimiters.messaging,
|
||||
validators.validateMessageSend,
|
||||
(req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
message: 'Message sending feature not yet implemented',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Error handling for API routes
|
||||
*/
|
||||
router.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'API endpoint not found',
|
||||
message: `The endpoint ${req.method} ${req.originalUrl} does not exist`,
|
||||
correlationId: req.correlationId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
314
src/routes/debug.js
Normal file
314
src/routes/debug.js
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Debug Routes (Development Only)
|
||||
* Provides debugging endpoints for development and testing
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../database/connection');
|
||||
const { getRedisClient } = require('../config/redis');
|
||||
const { getWebSocketServer, getConnectionStats } = require('../config/websocket');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Middleware to ensure debug routes are only available in development
|
||||
router.use((req, res, next) => {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return res.status(404).json({
|
||||
error: 'Debug endpoints not available in production'
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Debug API Information
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
name: 'Shattered Void - Debug API',
|
||||
environment: process.env.NODE_ENV,
|
||||
timestamp: new Date().toISOString(),
|
||||
correlationId: req.correlationId,
|
||||
endpoints: {
|
||||
database: '/debug/database',
|
||||
redis: '/debug/redis',
|
||||
websocket: '/debug/websocket',
|
||||
system: '/debug/system',
|
||||
logs: '/debug/logs',
|
||||
player: '/debug/player/:playerId'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Database Debug Information
|
||||
*/
|
||||
router.get('/database', async (req, res) => {
|
||||
try {
|
||||
// Test database connection
|
||||
const dbTest = await db.raw('SELECT NOW() as current_time, version() as db_version');
|
||||
|
||||
// Get table information
|
||||
const tables = await db.raw(`
|
||||
SELECT table_name, table_rows
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?
|
||||
AND table_type = 'BASE TABLE'
|
||||
`, [process.env.DB_NAME || 'shattered_void_dev']);
|
||||
|
||||
res.json({
|
||||
status: 'connected',
|
||||
connection: {
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
currentTime: dbTest.rows[0].current_time,
|
||||
version: dbTest.rows[0].db_version
|
||||
},
|
||||
tables: tables.rows,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Database debug error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Redis Debug Information
|
||||
*/
|
||||
router.get('/redis', async (req, res) => {
|
||||
try {
|
||||
const redisClient = getRedisClient();
|
||||
|
||||
if (!redisClient) {
|
||||
return res.json({
|
||||
status: 'not_connected',
|
||||
message: 'Redis client not available',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Test Redis connection
|
||||
const pong = await redisClient.ping();
|
||||
const info = await redisClient.info();
|
||||
|
||||
res.json({
|
||||
status: 'connected',
|
||||
ping: pong,
|
||||
info: info.split('\r\n').slice(0, 20), // First 20 lines of info
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Redis debug error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket Debug Information
|
||||
*/
|
||||
router.get('/websocket', (req, res) => {
|
||||
try {
|
||||
const io = getWebSocketServer();
|
||||
const stats = getConnectionStats();
|
||||
|
||||
if (!io) {
|
||||
return res.json({
|
||||
status: 'not_initialized',
|
||||
message: 'WebSocket server not available',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: 'running',
|
||||
stats,
|
||||
sockets: {
|
||||
count: io.sockets.sockets.size,
|
||||
rooms: Array.from(io.sockets.adapter.rooms.keys())
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebSocket debug error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* System Debug Information
|
||||
*/
|
||||
router.get('/system', (req, res) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
const cpuUsage = process.cpuUsage();
|
||||
|
||||
res.json({
|
||||
process: {
|
||||
pid: process.pid,
|
||||
uptime: process.uptime(),
|
||||
version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
memory: {
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
external: Math.round(memUsage.external / 1024 / 1024)
|
||||
},
|
||||
cpu: {
|
||||
user: cpuUsage.user,
|
||||
system: cpuUsage.system
|
||||
},
|
||||
environment: {
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
port: process.env.PORT,
|
||||
logLevel: process.env.LOG_LEVEL
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Recent Logs Debug Information
|
||||
*/
|
||||
router.get('/logs', (req, res) => {
|
||||
const { level = 'info', limit = 50 } = req.query;
|
||||
|
||||
// Note: This is a placeholder. In a real implementation,
|
||||
// you'd want to read from your log files or log storage system
|
||||
res.json({
|
||||
message: 'Log retrieval not implemented',
|
||||
note: 'This would show recent log entries filtered by level',
|
||||
requested: {
|
||||
level,
|
||||
limit: parseInt(limit)
|
||||
},
|
||||
suggestion: 'Check log files directly in logs/ directory',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Player Debug Information
|
||||
*/
|
||||
router.get('/player/:playerId', async (req, res) => {
|
||||
try {
|
||||
const playerId = parseInt(req.params.playerId);
|
||||
|
||||
if (isNaN(playerId)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid player ID',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
// Get comprehensive player information
|
||||
const player = await db('players')
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
|
||||
if (!player) {
|
||||
return res.status(404).json({
|
||||
error: 'Player not found',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
|
||||
const resources = await db('player_resources')
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
const stats = await db('player_stats')
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
const colonies = await db('colonies')
|
||||
.where('player_id', playerId)
|
||||
.select(['id', 'name', 'coordinates', 'created_at']);
|
||||
|
||||
const fleets = await db('fleets')
|
||||
.where('player_id', playerId)
|
||||
.select(['id', 'name', 'status', 'created_at']);
|
||||
|
||||
// Remove sensitive information
|
||||
delete player.password_hash;
|
||||
|
||||
res.json({
|
||||
player,
|
||||
resources,
|
||||
stats,
|
||||
colonies,
|
||||
fleets,
|
||||
summary: {
|
||||
totalColonies: colonies.length,
|
||||
totalFleets: fleets.length,
|
||||
accountAge: Math.floor((Date.now() - new Date(player.created_at).getTime()) / (1000 * 60 * 60 * 24))
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player debug error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Test Endpoint for Various Scenarios
|
||||
*/
|
||||
router.get('/test/:scenario', (req, res) => {
|
||||
const { scenario } = req.params;
|
||||
|
||||
switch (scenario) {
|
||||
case 'error':
|
||||
throw new Error('Test error for debugging');
|
||||
|
||||
case 'slow':
|
||||
setTimeout(() => {
|
||||
res.json({
|
||||
message: 'Slow response test completed',
|
||||
delay: '3 seconds',
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}, 3000);
|
||||
break;
|
||||
|
||||
case 'memory':
|
||||
// Create a large object to test memory usage
|
||||
const largeArray = new Array(1000000).fill('test data');
|
||||
res.json({
|
||||
message: 'Memory test completed',
|
||||
arrayLength: largeArray.length,
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
res.json({
|
||||
message: 'Test scenario not recognized',
|
||||
availableScenarios: ['error', 'slow', 'memory'],
|
||||
correlationId: req.correlationId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
144
src/routes/index.js
Normal file
144
src/routes/index.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Main Routes Index
|
||||
* Central routing configuration that imports and organizes all route modules
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Import route modules
|
||||
const apiRoutes = require('./api');
|
||||
const adminRoutes = require('./admin');
|
||||
|
||||
/**
|
||||
* Root endpoint - API information
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const apiInfo = {
|
||||
name: 'Shattered Void MMO API',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
status: 'operational',
|
||||
timestamp: new Date().toISOString(),
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
api: '/api',
|
||||
admin: '/api/admin'
|
||||
},
|
||||
documentation: {
|
||||
api: '/docs/api',
|
||||
admin: '/docs/admin'
|
||||
},
|
||||
correlationId: req.correlationId
|
||||
};
|
||||
|
||||
res.json(apiInfo);
|
||||
});
|
||||
|
||||
/**
|
||||
* API Documentation endpoint (placeholder)
|
||||
*/
|
||||
router.get('/docs', (req, res) => {
|
||||
res.json({
|
||||
message: 'API Documentation',
|
||||
note: 'Interactive API documentation will be available here',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
correlationId: req.correlationId,
|
||||
links: {
|
||||
playerAPI: '/docs/api',
|
||||
adminAPI: '/docs/admin'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Player API Documentation (placeholder)
|
||||
*/
|
||||
router.get('/docs/api', (req, res) => {
|
||||
res.json({
|
||||
title: 'Shattered Void - Player API Documentation',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
description: 'API endpoints for player operations',
|
||||
baseUrl: '/api',
|
||||
correlationId: req.correlationId,
|
||||
endpoints: {
|
||||
authentication: {
|
||||
register: 'POST /api/auth/register',
|
||||
login: 'POST /api/auth/login',
|
||||
logout: 'POST /api/auth/logout',
|
||||
profile: 'GET /api/auth/me',
|
||||
updateProfile: 'PUT /api/auth/me',
|
||||
verify: 'GET /api/auth/verify'
|
||||
},
|
||||
player: {
|
||||
dashboard: 'GET /api/player/dashboard',
|
||||
resources: 'GET /api/player/resources',
|
||||
stats: 'GET /api/player/stats',
|
||||
notifications: 'GET /api/player/notifications'
|
||||
},
|
||||
game: {
|
||||
colonies: 'GET /api/colonies',
|
||||
fleets: 'GET /api/fleets',
|
||||
research: 'GET /api/research',
|
||||
galaxy: 'GET /api/galaxy'
|
||||
}
|
||||
},
|
||||
note: 'Full interactive documentation coming soon'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin API Documentation (placeholder)
|
||||
*/
|
||||
router.get('/docs/admin', (req, res) => {
|
||||
res.json({
|
||||
title: 'Shattered Void - Admin API Documentation',
|
||||
version: process.env.npm_package_version || '0.1.0',
|
||||
description: 'API endpoints for administrative operations',
|
||||
baseUrl: '/api/admin',
|
||||
correlationId: req.correlationId,
|
||||
endpoints: {
|
||||
authentication: {
|
||||
login: 'POST /api/admin/auth/login',
|
||||
logout: 'POST /api/admin/auth/logout',
|
||||
profile: 'GET /api/admin/auth/me',
|
||||
verify: 'GET /api/admin/auth/verify',
|
||||
stats: 'GET /api/admin/auth/stats'
|
||||
},
|
||||
playerManagement: {
|
||||
listPlayers: 'GET /api/admin/players',
|
||||
getPlayer: 'GET /api/admin/players/:id',
|
||||
updatePlayer: 'PUT /api/admin/players/:id',
|
||||
deactivatePlayer: 'DELETE /api/admin/players/:id'
|
||||
},
|
||||
systemManagement: {
|
||||
systemStats: 'GET /api/admin/system/stats',
|
||||
events: 'GET /api/admin/events',
|
||||
analytics: 'GET /api/admin/analytics'
|
||||
}
|
||||
},
|
||||
note: 'Full interactive documentation coming soon'
|
||||
});
|
||||
});
|
||||
|
||||
// Mount route modules
|
||||
router.use('/api', apiRoutes);
|
||||
|
||||
// Admin routes (if enabled)
|
||||
if (process.env.ENABLE_ADMIN_ROUTES !== 'false') {
|
||||
router.use('/api/admin', adminRoutes);
|
||||
logger.info('Admin routes enabled');
|
||||
} else {
|
||||
logger.info('Admin routes disabled');
|
||||
}
|
||||
|
||||
// Debug routes (development only)
|
||||
if (process.env.NODE_ENV === 'development' && process.env.ENABLE_DEBUG_ENDPOINTS === 'true') {
|
||||
const debugRoutes = require('./debug');
|
||||
router.use('/debug', debugRoutes);
|
||||
logger.info('Debug routes enabled');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
67
src/routes/player/auth.js
Normal file
67
src/routes/player/auth.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Player authentication routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { asyncHandler } = require('../../middleware/error-handler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Import authentication service
|
||||
// const authService = require('../../services/auth.service');
|
||||
|
||||
// Player registration
|
||||
router.post('/register', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement player registration
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Player registration endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
// Player login
|
||||
router.post('/login', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement player login
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Player login endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
// Player logout
|
||||
router.post('/logout', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement player logout
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Player logout endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
// Email verification
|
||||
router.post('/verify-email', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement email verification
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Email verification endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
// Password reset request
|
||||
router.post('/forgot-password', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement password reset request
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Password reset endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
// Password reset
|
||||
router.post('/reset-password', asyncHandler(async (req, res) => {
|
||||
// TODO: Implement password reset
|
||||
res.status(501).json({
|
||||
error: 'Not implemented yet',
|
||||
message: 'Password reset endpoint needs implementation',
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
0
src/routes/player/colonies.js
Normal file
0
src/routes/player/colonies.js
Normal file
0
src/routes/player/events.js
Normal file
0
src/routes/player/events.js
Normal file
0
src/routes/player/fleets.js
Normal file
0
src/routes/player/fleets.js
Normal file
0
src/routes/player/galaxy.js
Normal file
0
src/routes/player/galaxy.js
Normal file
48
src/routes/player/index.js
Normal file
48
src/routes/player/index.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Player API routes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { authenticateToken, optionalAuth } = require('../../middleware/auth');
|
||||
const { asyncHandler } = require('../../middleware/error-handler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Import route modules
|
||||
const authRoutes = require('./auth');
|
||||
const profileRoutes = require('./profile');
|
||||
const coloniesRoutes = require('./colonies');
|
||||
const fleetsRoutes = require('./fleets');
|
||||
const researchRoutes = require('./research');
|
||||
const galaxyRoutes = require('./galaxy');
|
||||
const eventsRoutes = require('./events');
|
||||
const notificationsRoutes = require('./notifications');
|
||||
|
||||
// Public routes (no authentication required)
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/galaxy', optionalAuth('player'), galaxyRoutes);
|
||||
|
||||
// Protected routes (authentication required)
|
||||
router.use('/profile', authenticateToken('player'), profileRoutes);
|
||||
router.use('/colonies', authenticateToken('player'), coloniesRoutes);
|
||||
router.use('/fleets', authenticateToken('player'), fleetsRoutes);
|
||||
router.use('/research', authenticateToken('player'), researchRoutes);
|
||||
router.use('/events', authenticateToken('player'), eventsRoutes);
|
||||
router.use('/notifications', authenticateToken('player'), notificationsRoutes);
|
||||
|
||||
// Player status endpoint
|
||||
router.get('/status', authenticateToken('player'), asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
status: 'authenticated',
|
||||
player: {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
displayName: req.user.display_name,
|
||||
userGroup: req.user.user_group,
|
||||
lastActive: req.user.last_active_at,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
0
src/routes/player/notifications.js
Normal file
0
src/routes/player/notifications.js
Normal file
0
src/routes/player/profile.js
Normal file
0
src/routes/player/profile.js
Normal file
0
src/routes/player/research.js
Normal file
0
src/routes/player/research.js
Normal file
201
src/server.js
Normal file
201
src/server.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Shattered Void MMO - Main Server Entry Point
|
||||
* Initializes Express server with all middleware, routes, and WebSocket connections
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
require('dotenv').config();
|
||||
|
||||
// Import core modules
|
||||
const createApp = require('./app');
|
||||
const logger = require('./utils/logger');
|
||||
const { initializeDatabase } = require('./database/connection');
|
||||
const { initializeRedis } = require('./config/redis');
|
||||
const { initializeWebSocket } = require('./config/websocket');
|
||||
const { initializeGameTick } = require('./services/game-tick.service');
|
||||
|
||||
// Configuration
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
// Global instances
|
||||
let app;
|
||||
let server;
|
||||
let io;
|
||||
|
||||
/**
|
||||
* Initialize all core systems
|
||||
*/
|
||||
async function initializeSystems() {
|
||||
try {
|
||||
logger.info('Initializing core systems...');
|
||||
|
||||
// Initialize database connections
|
||||
if (process.env.DISABLE_DATABASE !== 'true') {
|
||||
await initializeDatabase();
|
||||
logger.info('Database systems initialized');
|
||||
} else {
|
||||
logger.warn('Database disabled by environment variable');
|
||||
}
|
||||
|
||||
// Initialize Redis
|
||||
if (process.env.DISABLE_REDIS !== 'true') {
|
||||
await initializeRedis();
|
||||
logger.info('Redis systems initialized');
|
||||
} else {
|
||||
logger.warn('Redis disabled by environment variable');
|
||||
}
|
||||
|
||||
// Initialize WebSocket
|
||||
io = await initializeWebSocket(server);
|
||||
logger.info('WebSocket systems initialized');
|
||||
|
||||
// Initialize game systems
|
||||
await initializeGameSystems();
|
||||
logger.info('Game systems initialized');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize systems:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize game systems (tick processing, etc.)
|
||||
*/
|
||||
async function initializeGameSystems() {
|
||||
try {
|
||||
// Initialize game tick system
|
||||
if (process.env.ENABLE_GAME_TICK !== 'false') {
|
||||
await initializeGameTick();
|
||||
logger.info('Game tick system initialized');
|
||||
}
|
||||
|
||||
// Add other game system initializations here
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Game systems initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown handler
|
||||
*/
|
||||
function setupGracefulShutdown() {
|
||||
const shutdown = async (signal) => {
|
||||
logger.info(`Received ${signal}. Starting graceful shutdown...`);
|
||||
|
||||
try {
|
||||
// Stop accepting new connections
|
||||
if (server) {
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
});
|
||||
}
|
||||
|
||||
// Close WebSocket connections
|
||||
if (io) {
|
||||
io.close(() => {
|
||||
logger.info('WebSocket server closed');
|
||||
});
|
||||
}
|
||||
|
||||
// Close database connections
|
||||
const db = require('./database/connection');
|
||||
if (db) {
|
||||
await db.destroy();
|
||||
logger.info('Database connections closed');
|
||||
}
|
||||
|
||||
// Close Redis connection
|
||||
const redisConfig = require('./config/redis');
|
||||
if (redisConfig.client) {
|
||||
await redisConfig.client.quit();
|
||||
logger.info('Redis connection closed');
|
||||
}
|
||||
|
||||
logger.info('Graceful shutdown completed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle shutdown signals
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Promise Rejection:', {
|
||||
reason: reason?.message || reason,
|
||||
stack: reason?.stack,
|
||||
promise: promise?.toString()
|
||||
});
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the application server
|
||||
*/
|
||||
async function startServer() {
|
||||
try {
|
||||
logger.info(`Starting Shattered Void MMO Server in ${NODE_ENV} mode...`);
|
||||
|
||||
// Create Express app
|
||||
app = createApp();
|
||||
|
||||
// Create HTTP server
|
||||
server = http.createServer(app);
|
||||
|
||||
// Set up graceful shutdown handlers
|
||||
setupGracefulShutdown();
|
||||
|
||||
// Initialize all systems
|
||||
await initializeSystems();
|
||||
|
||||
// Start the server
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${NODE_ENV}`);
|
||||
logger.info(`Process ID: ${process.pid}`);
|
||||
|
||||
// Log memory usage
|
||||
const memUsage = process.memoryUsage();
|
||||
logger.info('Initial memory usage:', {
|
||||
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`,
|
||||
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`,
|
||||
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`,
|
||||
});
|
||||
|
||||
logger.info('Shattered Void MMO Server started successfully');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server if this file is run directly
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startServer,
|
||||
getApp: () => app,
|
||||
getServer: () => server,
|
||||
getIO: () => io
|
||||
};
|
||||
412
src/services/game-tick.service.js
Normal file
412
src/services/game-tick.service.js
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/**
|
||||
* Game tick processing service
|
||||
* Handles the core game loop for resource production, fleet movements, etc.
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const logger = require('../utils/logger');
|
||||
const db = require('../database/connection');
|
||||
const redisClient = require('../utils/redis');
|
||||
|
||||
class GameTickService {
|
||||
constructor() {
|
||||
this.isInitialized = false;
|
||||
this.currentTick = 0;
|
||||
this.cronJob = null;
|
||||
this.config = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the game tick system
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Load configuration
|
||||
await this.loadConfig();
|
||||
|
||||
// Get current tick number
|
||||
await this.loadCurrentTick();
|
||||
|
||||
// Start the cron job
|
||||
this.startTickScheduler();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info('Game tick service initialized', {
|
||||
tickInterval: this.config.tick_interval_ms,
|
||||
maxUserGroups: this.config.max_user_groups,
|
||||
currentTick: this.currentTick,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize game tick service:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from database
|
||||
*/
|
||||
async loadConfig() {
|
||||
const config = await db('game_tick_config')
|
||||
.where('is_active', true)
|
||||
.first();
|
||||
|
||||
if (!config) {
|
||||
throw new Error('No active game tick configuration found');
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current tick number from logs
|
||||
*/
|
||||
async loadCurrentTick() {
|
||||
const lastTick = await db('game_tick_log')
|
||||
.select('tick_number')
|
||||
.orderBy('tick_number', 'desc')
|
||||
.first();
|
||||
|
||||
this.currentTick = lastTick ? lastTick.tick_number : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cron job scheduler
|
||||
*/
|
||||
startTickScheduler() {
|
||||
const intervalMs = this.config.tick_interval_ms;
|
||||
const cronPattern = this.createCronPattern(intervalMs);
|
||||
|
||||
this.cronJob = cron.schedule(cronPattern, async () => {
|
||||
await this.processTick();
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
logger.info('Game tick scheduler started', {
|
||||
pattern: cronPattern,
|
||||
intervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cron pattern from milliseconds
|
||||
* @param {number} intervalMs - Interval in milliseconds
|
||||
* @returns {string} Cron pattern
|
||||
*/
|
||||
createCronPattern(intervalMs) {
|
||||
const intervalMinutes = Math.floor(intervalMs / 60000);
|
||||
|
||||
if (intervalMinutes === 1) {
|
||||
return '* * * * *'; // Every minute
|
||||
} else if (intervalMinutes === 5) {
|
||||
return '*/5 * * * *'; // Every 5 minutes
|
||||
} else if (intervalMinutes === 10) {
|
||||
return '*/10 * * * *'; // Every 10 minutes
|
||||
} else if (intervalMinutes === 30) {
|
||||
return '*/30 * * * *'; // Every 30 minutes
|
||||
} else if (intervalMinutes === 60) {
|
||||
return '0 * * * *'; // Every hour
|
||||
} else {
|
||||
// Default to every minute for non-standard intervals
|
||||
return '* * * * *';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single game tick
|
||||
*/
|
||||
async processTick() {
|
||||
const tickNumber = ++this.currentTick;
|
||||
const startTime = new Date();
|
||||
|
||||
logger.info('Starting game tick', { tickNumber });
|
||||
|
||||
// Process each user group
|
||||
for (let userGroup = 1; userGroup <= this.config.max_user_groups; userGroup++) {
|
||||
await this.processUserGroupTick(tickNumber, userGroup);
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
logger.info('Game tick completed', {
|
||||
tickNumber,
|
||||
duration: `${duration}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tick for a specific user group
|
||||
* @param {number} tickNumber - Current tick number
|
||||
* @param {number} userGroup - User group to process
|
||||
*/
|
||||
async processUserGroupTick(tickNumber, userGroup) {
|
||||
const startTime = new Date();
|
||||
let processedPlayers = 0;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < this.config.max_retry_attempts) {
|
||||
try {
|
||||
// Log tick start
|
||||
const logId = await this.logTickStart(tickNumber, userGroup, attempt);
|
||||
|
||||
// Get players in this user group
|
||||
const players = await db('players')
|
||||
.where('user_group', userGroup)
|
||||
.where('account_status', 'active')
|
||||
.select('id');
|
||||
|
||||
// Process each player
|
||||
for (const player of players) {
|
||||
await this.processPlayerTick(tickNumber, player.id);
|
||||
processedPlayers++;
|
||||
}
|
||||
|
||||
// Log successful completion
|
||||
await this.logTickComplete(logId, processedPlayers);
|
||||
|
||||
logger.debug('User group tick completed', {
|
||||
tickNumber,
|
||||
userGroup,
|
||||
processedPlayers,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
|
||||
break; // Success, exit retry loop
|
||||
|
||||
} catch (error) {
|
||||
attempt++;
|
||||
logger.error('User group tick failed', {
|
||||
tickNumber,
|
||||
userGroup,
|
||||
attempt,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
if (attempt >= this.config.max_retry_attempts) {
|
||||
// Max retries reached, log failure
|
||||
await this.logTickFailure(tickNumber, userGroup, attempt, error.message);
|
||||
|
||||
// Apply bonus tick if configured
|
||||
if (attempt >= this.config.bonus_tick_threshold) {
|
||||
await this.applyBonusTick(tickNumber, userGroup);
|
||||
}
|
||||
} else {
|
||||
// Wait before retry
|
||||
await this.sleep(this.config.retry_delay_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tick for a single player
|
||||
* @param {number} tickNumber - Current tick number
|
||||
* @param {number} playerId - Player ID
|
||||
*/
|
||||
async processPlayerTick(tickNumber, playerId) {
|
||||
try {
|
||||
// Use lock to prevent concurrent processing
|
||||
const lockKey = `player_tick:${playerId}`;
|
||||
const lockToken = await redisClient.lock.acquire(lockKey, 30);
|
||||
|
||||
if (!lockToken) {
|
||||
logger.warn('Could not acquire player tick lock', { playerId, tickNumber });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process resource production
|
||||
await this.processResourceProduction(playerId, tickNumber);
|
||||
|
||||
// Process building construction
|
||||
await this.processBuildingConstruction(playerId, tickNumber);
|
||||
|
||||
// Process research
|
||||
await this.processResearch(playerId, tickNumber);
|
||||
|
||||
// Process fleet movements
|
||||
await this.processFleetMovements(playerId, tickNumber);
|
||||
|
||||
// Update player last tick processed
|
||||
await db('players')
|
||||
.where('id', playerId)
|
||||
.update({ last_tick_processed: tickNumber });
|
||||
|
||||
} finally {
|
||||
await redisClient.lock.release(lockKey, lockToken);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player tick processing failed', {
|
||||
playerId,
|
||||
tickNumber,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process resource production for player colonies
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {number} tickNumber - Current tick number
|
||||
*/
|
||||
async processResourceProduction(playerId, tickNumber) {
|
||||
// TODO: Implement resource production logic
|
||||
logger.debug('Processing resource production', { playerId, tickNumber });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process building construction
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {number} tickNumber - Current tick number
|
||||
*/
|
||||
async processBuildingConstruction(playerId, tickNumber) {
|
||||
// TODO: Implement building construction logic
|
||||
logger.debug('Processing building construction', { playerId, tickNumber });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process research progress
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {number} tickNumber - Current tick number
|
||||
*/
|
||||
async processResearch(playerId, tickNumber) {
|
||||
// TODO: Implement research progress logic
|
||||
logger.debug('Processing research', { playerId, tickNumber });
|
||||
}
|
||||
|
||||
/**
|
||||
* Process fleet movements
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {number} tickNumber - Current tick number
|
||||
*/
|
||||
async processFleetMovements(playerId, tickNumber) {
|
||||
// TODO: Implement fleet movement logic
|
||||
logger.debug('Processing fleet movements', { playerId, tickNumber });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log tick start
|
||||
* @param {number} tickNumber - Tick number
|
||||
* @param {number} userGroup - User group
|
||||
* @param {number} attempt - Attempt number
|
||||
* @returns {Promise<number>} Log entry ID
|
||||
*/
|
||||
async logTickStart(tickNumber, userGroup, attempt) {
|
||||
const [logEntry] = await db('game_tick_log')
|
||||
.insert({
|
||||
tick_number: tickNumber,
|
||||
user_group: userGroup,
|
||||
started_at: new Date(),
|
||||
status: 'running',
|
||||
retry_attempt: attempt,
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
return logEntry.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log tick completion
|
||||
* @param {number} logId - Log entry ID
|
||||
* @param {number} processedPlayers - Number of players processed
|
||||
*/
|
||||
async logTickComplete(logId, processedPlayers) {
|
||||
const completedAt = new Date();
|
||||
|
||||
await db('game_tick_log')
|
||||
.where('id', logId)
|
||||
.update({
|
||||
completed_at: completedAt,
|
||||
status: 'completed',
|
||||
processed_players: processedPlayers,
|
||||
execution_time_ms: completedAt.getTime() - new Date().getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log tick failure
|
||||
* @param {number} tickNumber - Tick number
|
||||
* @param {number} userGroup - User group
|
||||
* @param {number} attempt - Final attempt number
|
||||
* @param {string} errorMessage - Error message
|
||||
*/
|
||||
async logTickFailure(tickNumber, userGroup, attempt, errorMessage) {
|
||||
await db('game_tick_log')
|
||||
.insert({
|
||||
tick_number: tickNumber,
|
||||
user_group: userGroup,
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(),
|
||||
status: 'failed',
|
||||
retry_attempt: attempt,
|
||||
error_message: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply bonus tick for failed processing
|
||||
* @param {number} tickNumber - Tick number
|
||||
* @param {number} userGroup - User group
|
||||
*/
|
||||
async applyBonusTick(tickNumber, userGroup) {
|
||||
logger.info('Applying bonus tick', { tickNumber, userGroup });
|
||||
|
||||
await db('game_tick_log')
|
||||
.where('tick_number', tickNumber)
|
||||
.where('user_group', userGroup)
|
||||
.update({ bonus_tick_applied: true });
|
||||
|
||||
// TODO: Implement actual bonus tick logic
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility sleep function
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
*/
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the game tick service
|
||||
*/
|
||||
stop() {
|
||||
if (this.cronJob) {
|
||||
this.cronJob.stop();
|
||||
this.cronJob = null;
|
||||
logger.info('Game tick service stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status
|
||||
* @returns {Object} Service status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
currentTick: this.currentTick,
|
||||
running: this.cronJob ? this.cronJob.running : false,
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const gameTickService = new GameTickService();
|
||||
|
||||
/**
|
||||
* Initialize game tick service
|
||||
*/
|
||||
async function initializeGameTick() {
|
||||
await gameTickService.initialize();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
gameTickService,
|
||||
initializeGameTick,
|
||||
};
|
||||
570
src/services/user/AdminService.js
Normal file
570
src/services/user/AdminService.js
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
/**
|
||||
* Admin Service
|
||||
* Handles all admin-related business logic including authentication, user management, and system operations
|
||||
*/
|
||||
|
||||
const db = require('../../database/connection');
|
||||
const { hashPassword, verifyPassword } = require('../../utils/password');
|
||||
const { generateAdminToken, generateRefreshToken } = require('../../utils/jwt');
|
||||
const { validateEmail } = require('../../utils/validation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { ValidationError, ConflictError, NotFoundError, AuthenticationError, AuthorizationError } = require('../../middleware/error.middleware');
|
||||
|
||||
class AdminService {
|
||||
/**
|
||||
* Authenticate admin login
|
||||
* @param {Object} loginData - Login credentials
|
||||
* @param {string} loginData.email - Admin email
|
||||
* @param {string} loginData.password - Admin password
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Authentication result with tokens
|
||||
*/
|
||||
async authenticateAdmin(loginData, correlationId) {
|
||||
try {
|
||||
const { email, password } = loginData;
|
||||
|
||||
logger.info('Admin authentication initiated', {
|
||||
correlationId,
|
||||
email
|
||||
});
|
||||
|
||||
// Find admin by email
|
||||
const admin = await this.findAdminByEmail(email);
|
||||
if (!admin) {
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
// Check if admin is active
|
||||
if (!admin.is_active) {
|
||||
throw new AuthenticationError('Account has been deactivated');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await verifyPassword(password, admin.password_hash);
|
||||
if (!isPasswordValid) {
|
||||
logger.warn('Admin authentication failed - invalid password', {
|
||||
correlationId,
|
||||
adminId: admin.id,
|
||||
email: admin.email
|
||||
});
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
// Get admin permissions
|
||||
const permissions = await this.getAdminPermissions(admin.id);
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generateAdminToken({
|
||||
adminId: admin.id,
|
||||
email: admin.email,
|
||||
username: admin.username,
|
||||
permissions: permissions
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: admin.id,
|
||||
type: 'admin'
|
||||
});
|
||||
|
||||
// Update last login timestamp
|
||||
await db('admins')
|
||||
.where('id', admin.id)
|
||||
.update({
|
||||
last_login_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
logger.audit('Admin authenticated successfully', {
|
||||
correlationId,
|
||||
adminId: admin.id,
|
||||
email: admin.email,
|
||||
username: admin.username,
|
||||
permissions: permissions
|
||||
});
|
||||
|
||||
return {
|
||||
admin: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
username: admin.username,
|
||||
permissions: permissions,
|
||||
isActive: admin.is_active
|
||||
},
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Admin authentication failed', {
|
||||
correlationId,
|
||||
email: loginData.email,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin profile by ID
|
||||
* @param {number} adminId - Admin ID
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Admin profile data
|
||||
*/
|
||||
async getAdminProfile(adminId, correlationId) {
|
||||
try {
|
||||
logger.info('Fetching admin profile', {
|
||||
correlationId,
|
||||
adminId
|
||||
});
|
||||
|
||||
const admin = await db('admins')
|
||||
.select([
|
||||
'id',
|
||||
'email',
|
||||
'username',
|
||||
'is_active',
|
||||
'created_at',
|
||||
'last_login_at'
|
||||
])
|
||||
.where('id', adminId)
|
||||
.first();
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundError('Admin not found');
|
||||
}
|
||||
|
||||
// Get admin permissions
|
||||
const permissions = await this.getAdminPermissions(adminId);
|
||||
|
||||
const profile = {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
username: admin.username,
|
||||
permissions: permissions,
|
||||
isActive: admin.is_active,
|
||||
createdAt: admin.created_at,
|
||||
lastLoginAt: admin.last_login_at
|
||||
};
|
||||
|
||||
logger.info('Admin profile retrieved successfully', {
|
||||
correlationId,
|
||||
adminId,
|
||||
username: admin.username
|
||||
});
|
||||
|
||||
return profile;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch admin profile', {
|
||||
correlationId,
|
||||
adminId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to retrieve admin profile');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get players list with pagination and filtering
|
||||
* @param {Object} options - Query options
|
||||
* @param {number} options.page - Page number
|
||||
* @param {number} options.limit - Items per page
|
||||
* @param {string} options.sortBy - Sort field
|
||||
* @param {string} options.sortOrder - Sort order (asc/desc)
|
||||
* @param {string} options.search - Search term
|
||||
* @param {boolean} options.activeOnly - Filter active players only
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Players list with pagination info
|
||||
*/
|
||||
async getPlayersList(options, correlationId) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sortBy = 'created_at',
|
||||
sortOrder = 'desc',
|
||||
search = '',
|
||||
activeOnly = null
|
||||
} = options;
|
||||
|
||||
logger.info('Fetching players list', {
|
||||
correlationId,
|
||||
page,
|
||||
limit,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
search,
|
||||
activeOnly
|
||||
});
|
||||
|
||||
let query = db('players')
|
||||
.select([
|
||||
'id',
|
||||
'email',
|
||||
'username',
|
||||
'is_active',
|
||||
'is_verified',
|
||||
'created_at',
|
||||
'last_login_at'
|
||||
]);
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
query = query.where(function() {
|
||||
this.whereILike('username', `%${search}%`)
|
||||
.orWhereILike('email', `%${search}%`);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply active filter
|
||||
if (activeOnly !== null) {
|
||||
query = query.where('is_active', activeOnly);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const totalQuery = query.clone();
|
||||
const totalCount = await totalQuery.count('* as count').first();
|
||||
const total = parseInt(totalCount.count);
|
||||
|
||||
// Apply pagination and sorting
|
||||
const offset = (page - 1) * limit;
|
||||
const players = await query
|
||||
.orderBy(sortBy, sortOrder)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const result = {
|
||||
players,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasNext: page * limit < total,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Players list retrieved successfully', {
|
||||
correlationId,
|
||||
playersCount: players.length,
|
||||
total,
|
||||
page
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch players list', {
|
||||
correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
throw new Error('Failed to retrieve players list');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed player information for admin view
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Detailed player information
|
||||
*/
|
||||
async getPlayerDetails(playerId, correlationId) {
|
||||
try {
|
||||
logger.info('Fetching player details for admin', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
// Get basic player info
|
||||
const player = await db('players')
|
||||
.select([
|
||||
'id',
|
||||
'email',
|
||||
'username',
|
||||
'is_active',
|
||||
'is_verified',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'last_login_at'
|
||||
])
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
|
||||
if (!player) {
|
||||
throw new NotFoundError('Player not found');
|
||||
}
|
||||
|
||||
// Get player resources
|
||||
const resources = await db('player_resources')
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
// Get player stats
|
||||
const stats = await db('player_stats')
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
// Get colonies count
|
||||
const coloniesCount = await db('colonies')
|
||||
.where('player_id', playerId)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
// Get fleets count
|
||||
const fleetsCount = await db('fleets')
|
||||
.where('player_id', playerId)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const playerDetails = {
|
||||
...player,
|
||||
resources: resources || {
|
||||
scrap: 0,
|
||||
energy: 0,
|
||||
research_points: 0
|
||||
},
|
||||
stats: stats || {
|
||||
colonies_count: 0,
|
||||
fleets_count: 0,
|
||||
total_battles: 0,
|
||||
battles_won: 0
|
||||
},
|
||||
currentCounts: {
|
||||
colonies: parseInt(coloniesCount.count),
|
||||
fleets: parseInt(fleetsCount.count)
|
||||
}
|
||||
};
|
||||
|
||||
logger.audit('Player details accessed by admin', {
|
||||
correlationId,
|
||||
playerId,
|
||||
playerUsername: player.username
|
||||
});
|
||||
|
||||
return playerDetails;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch player details', {
|
||||
correlationId,
|
||||
playerId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to retrieve player details');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player status (activate/deactivate)
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {boolean} isActive - New active status
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Updated player data
|
||||
*/
|
||||
async updatePlayerStatus(playerId, isActive, correlationId) {
|
||||
try {
|
||||
logger.info('Updating player status', {
|
||||
correlationId,
|
||||
playerId,
|
||||
isActive
|
||||
});
|
||||
|
||||
// Check if player exists
|
||||
const player = await db('players')
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
|
||||
if (!player) {
|
||||
throw new NotFoundError('Player not found');
|
||||
}
|
||||
|
||||
// Update player status
|
||||
await db('players')
|
||||
.where('id', playerId)
|
||||
.update({
|
||||
is_active: isActive,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const updatedPlayer = await db('players')
|
||||
.select(['id', 'email', 'username', 'is_active', 'updated_at'])
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
|
||||
logger.audit('Player status updated by admin', {
|
||||
correlationId,
|
||||
playerId,
|
||||
playerUsername: player.username,
|
||||
previousStatus: player.is_active,
|
||||
newStatus: isActive
|
||||
});
|
||||
|
||||
return updatedPlayer;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to update player status', {
|
||||
correlationId,
|
||||
playerId,
|
||||
isActive,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to update player status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system statistics for admin dashboard
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} System statistics
|
||||
*/
|
||||
async getSystemStats(correlationId) {
|
||||
try {
|
||||
logger.info('Fetching system statistics', { correlationId });
|
||||
|
||||
// Get player counts
|
||||
const playerStats = await db('players')
|
||||
.select([
|
||||
db.raw('COUNT(*) as total_players'),
|
||||
db.raw('COUNT(CASE WHEN is_active = true THEN 1 END) as active_players'),
|
||||
db.raw('COUNT(CASE WHEN is_verified = true THEN 1 END) as verified_players'),
|
||||
db.raw('COUNT(CASE WHEN created_at >= NOW() - INTERVAL \'24 hours\' THEN 1 END) as new_players_24h')
|
||||
])
|
||||
.first();
|
||||
|
||||
// Get colony and fleet counts
|
||||
const gameStats = await db.raw(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM colonies) as total_colonies,
|
||||
(SELECT COUNT(*) FROM fleets) as total_fleets,
|
||||
(SELECT COUNT(*) FROM research_queue) as active_research
|
||||
`);
|
||||
|
||||
// Get recent activity (last 24 hours)
|
||||
const recentActivity = await db('players')
|
||||
.select([
|
||||
db.raw('COUNT(CASE WHEN last_login_at >= NOW() - INTERVAL \'24 hours\' THEN 1 END) as active_24h'),
|
||||
db.raw('COUNT(CASE WHEN last_login_at >= NOW() - INTERVAL \'7 days\' THEN 1 END) as active_7d')
|
||||
])
|
||||
.first();
|
||||
|
||||
const stats = {
|
||||
players: {
|
||||
total: parseInt(playerStats.total_players),
|
||||
active: parseInt(playerStats.active_players),
|
||||
verified: parseInt(playerStats.verified_players),
|
||||
newToday: parseInt(playerStats.new_players_24h)
|
||||
},
|
||||
game: {
|
||||
totalColonies: parseInt(gameStats.rows[0].total_colonies),
|
||||
totalFleets: parseInt(gameStats.rows[0].total_fleets),
|
||||
activeResearch: parseInt(gameStats.rows[0].active_research)
|
||||
},
|
||||
activity: {
|
||||
active24h: parseInt(recentActivity.active_24h),
|
||||
active7d: parseInt(recentActivity.active_7d)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
logger.info('System statistics retrieved', {
|
||||
correlationId,
|
||||
totalPlayers: stats.players.total,
|
||||
activePlayers: stats.players.active
|
||||
});
|
||||
|
||||
return stats;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch system statistics', {
|
||||
correlationId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
throw new Error('Failed to retrieve system statistics');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find admin by email
|
||||
* @param {string} email - Admin email
|
||||
* @returns {Promise<Object|null>} Admin data or null
|
||||
*/
|
||||
async findAdminByEmail(email) {
|
||||
try {
|
||||
return await db('admins')
|
||||
.where('email', email.toLowerCase().trim())
|
||||
.first();
|
||||
} catch (error) {
|
||||
logger.error('Failed to find admin by email', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin permissions
|
||||
* @param {number} adminId - Admin ID
|
||||
* @returns {Promise<Array>} Array of permission strings
|
||||
*/
|
||||
async getAdminPermissions(adminId) {
|
||||
try {
|
||||
const permissions = await db('admin_permissions as ap')
|
||||
.join('permissions as p', 'ap.permission_id', 'p.id')
|
||||
.select('p.name')
|
||||
.where('ap.admin_id', adminId);
|
||||
|
||||
return permissions.map(p => p.name);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch admin permissions', {
|
||||
adminId,
|
||||
error: error.message
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if admin has specific permission
|
||||
* @param {number} adminId - Admin ID
|
||||
* @param {string} permission - Permission to check
|
||||
* @returns {Promise<boolean>} True if admin has permission
|
||||
*/
|
||||
async hasPermission(adminId, permission) {
|
||||
try {
|
||||
const permissions = await this.getAdminPermissions(adminId);
|
||||
return permissions.includes(permission) || permissions.includes('super_admin');
|
||||
} catch (error) {
|
||||
logger.error('Failed to check admin permission', {
|
||||
adminId,
|
||||
permission,
|
||||
error: error.message
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdminService;
|
||||
472
src/services/user/PlayerService.js
Normal file
472
src/services/user/PlayerService.js
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
/**
|
||||
* Player Service
|
||||
* Handles all player-related business logic including registration, authentication, and profile management
|
||||
*/
|
||||
|
||||
const db = require('../../database/connection');
|
||||
const { hashPassword, verifyPassword, validatePasswordStrength } = require('../../utils/password');
|
||||
const { generatePlayerToken, generateRefreshToken } = require('../../utils/jwt');
|
||||
const { validateEmail, validateUsername } = require('../../utils/validation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { ValidationError, ConflictError, NotFoundError, AuthenticationError } = require('../../middleware/error.middleware');
|
||||
|
||||
class PlayerService {
|
||||
/**
|
||||
* Register a new player
|
||||
* @param {Object} playerData - Player registration data
|
||||
* @param {string} playerData.email - Player email
|
||||
* @param {string} playerData.username - Player username
|
||||
* @param {string} playerData.password - Player password
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Registered player data
|
||||
*/
|
||||
async registerPlayer(playerData, correlationId) {
|
||||
try {
|
||||
const { email, username, password } = playerData;
|
||||
|
||||
logger.info('Player registration initiated', {
|
||||
correlationId,
|
||||
email,
|
||||
username
|
||||
});
|
||||
|
||||
// Validate input data
|
||||
await this.validateRegistrationData({ email, username, password });
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await this.findPlayerByEmail(email);
|
||||
if (existingEmail) {
|
||||
throw new ConflictError('Email address is already registered');
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await this.findPlayerByUsername(username);
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username is already taken');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create player in database transaction
|
||||
const player = await db.transaction(async (trx) => {
|
||||
const [newPlayer] = await trx('players')
|
||||
.insert({
|
||||
email: email.toLowerCase().trim(),
|
||||
username: username.trim(),
|
||||
password_hash: hashedPassword,
|
||||
is_active: true,
|
||||
is_verified: false, // Email verification required
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
})
|
||||
.returning(['id', 'email', 'username', 'is_active', 'is_verified', 'created_at']);
|
||||
|
||||
// Create initial player resources
|
||||
await trx('player_resources').insert({
|
||||
player_id: newPlayer.id,
|
||||
scrap: parseInt(process.env.STARTING_RESOURCES_SCRAP) || 1000,
|
||||
energy: parseInt(process.env.STARTING_RESOURCES_ENERGY) || 500,
|
||||
research_points: 0,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Create initial player stats
|
||||
await trx('player_stats').insert({
|
||||
player_id: newPlayer.id,
|
||||
colonies_count: 0,
|
||||
fleets_count: 0,
|
||||
total_battles: 0,
|
||||
battles_won: 0,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
logger.info('Player registered successfully', {
|
||||
correlationId,
|
||||
playerId: newPlayer.id,
|
||||
email: newPlayer.email,
|
||||
username: newPlayer.username
|
||||
});
|
||||
|
||||
return newPlayer;
|
||||
});
|
||||
|
||||
// Return player data without sensitive information
|
||||
return {
|
||||
id: player.id,
|
||||
email: player.email,
|
||||
username: player.username,
|
||||
isActive: player.is_active,
|
||||
isVerified: player.is_verified,
|
||||
createdAt: player.created_at
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player registration failed', {
|
||||
correlationId,
|
||||
email: playerData.email,
|
||||
username: playerData.username,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Player registration failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate player login
|
||||
* @param {Object} loginData - Login credentials
|
||||
* @param {string} loginData.email - Player email
|
||||
* @param {string} loginData.password - Player password
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Authentication result with tokens
|
||||
*/
|
||||
async authenticatePlayer(loginData, correlationId) {
|
||||
try {
|
||||
const { email, password } = loginData;
|
||||
|
||||
logger.info('Player authentication initiated', {
|
||||
correlationId,
|
||||
email
|
||||
});
|
||||
|
||||
// Find player by email
|
||||
const player = await this.findPlayerByEmail(email);
|
||||
if (!player) {
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
// Check if player is active
|
||||
if (!player.is_active) {
|
||||
throw new AuthenticationError('Account has been deactivated');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await verifyPassword(password, player.password_hash);
|
||||
if (!isPasswordValid) {
|
||||
logger.warn('Player authentication failed - invalid password', {
|
||||
correlationId,
|
||||
playerId: player.id,
|
||||
email: player.email
|
||||
});
|
||||
throw new AuthenticationError('Invalid email or password');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = generatePlayerToken({
|
||||
playerId: player.id,
|
||||
email: player.email,
|
||||
username: player.username
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken({
|
||||
userId: player.id,
|
||||
type: 'player'
|
||||
});
|
||||
|
||||
// Update last login timestamp
|
||||
await db('players')
|
||||
.where('id', player.id)
|
||||
.update({
|
||||
last_login_at: new Date(),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
logger.info('Player authenticated successfully', {
|
||||
correlationId,
|
||||
playerId: player.id,
|
||||
email: player.email,
|
||||
username: player.username
|
||||
});
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id,
|
||||
email: player.email,
|
||||
username: player.username,
|
||||
isActive: player.is_active,
|
||||
isVerified: player.is_verified
|
||||
},
|
||||
tokens: {
|
||||
accessToken,
|
||||
refreshToken
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Player authentication failed', {
|
||||
correlationId,
|
||||
email: loginData.email,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
if (error instanceof AuthenticationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthenticationError('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player profile by ID
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Player profile data
|
||||
*/
|
||||
async getPlayerProfile(playerId, correlationId) {
|
||||
try {
|
||||
logger.info('Fetching player profile', {
|
||||
correlationId,
|
||||
playerId
|
||||
});
|
||||
|
||||
const player = await db('players')
|
||||
.select([
|
||||
'id',
|
||||
'email',
|
||||
'username',
|
||||
'is_active',
|
||||
'is_verified',
|
||||
'created_at',
|
||||
'last_login_at'
|
||||
])
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
|
||||
if (!player) {
|
||||
throw new NotFoundError('Player not found');
|
||||
}
|
||||
|
||||
// Get player resources
|
||||
const resources = await db('player_resources')
|
||||
.select(['scrap', 'energy', 'research_points'])
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
// Get player stats
|
||||
const stats = await db('player_stats')
|
||||
.select([
|
||||
'colonies_count',
|
||||
'fleets_count',
|
||||
'total_battles',
|
||||
'battles_won'
|
||||
])
|
||||
.where('player_id', playerId)
|
||||
.first();
|
||||
|
||||
const profile = {
|
||||
id: player.id,
|
||||
email: player.email,
|
||||
username: player.username,
|
||||
isActive: player.is_active,
|
||||
isVerified: player.is_verified,
|
||||
createdAt: player.created_at,
|
||||
lastLoginAt: player.last_login_at,
|
||||
resources: resources || {
|
||||
scrap: 0,
|
||||
energy: 0,
|
||||
researchPoints: 0
|
||||
},
|
||||
stats: stats || {
|
||||
coloniesCount: 0,
|
||||
fleetsCount: 0,
|
||||
totalBattles: 0,
|
||||
battlesWon: 0
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Player profile retrieved successfully', {
|
||||
correlationId,
|
||||
playerId,
|
||||
username: player.username
|
||||
});
|
||||
|
||||
return profile;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch player profile', {
|
||||
correlationId,
|
||||
playerId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to retrieve player profile');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player profile
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {Object} updateData - Data to update
|
||||
* @param {string} correlationId - Request correlation ID
|
||||
* @returns {Promise<Object>} Updated player profile
|
||||
*/
|
||||
async updatePlayerProfile(playerId, updateData, correlationId) {
|
||||
try {
|
||||
logger.info('Updating player profile', {
|
||||
correlationId,
|
||||
playerId,
|
||||
updateFields: Object.keys(updateData)
|
||||
});
|
||||
|
||||
// Validate player exists
|
||||
const existingPlayer = await this.findPlayerById(playerId);
|
||||
if (!existingPlayer) {
|
||||
throw new NotFoundError('Player not found');
|
||||
}
|
||||
|
||||
// Validate update data
|
||||
const allowedFields = ['username'];
|
||||
const sanitizedData = {};
|
||||
|
||||
for (const [key, value] of Object.entries(updateData)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
sanitizedData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate username if being updated
|
||||
if (sanitizedData.username) {
|
||||
const usernameValidation = validateUsername(sanitizedData.username);
|
||||
if (!usernameValidation.isValid) {
|
||||
throw new ValidationError(usernameValidation.error);
|
||||
}
|
||||
|
||||
// Check if username is already taken by another player
|
||||
const existingUsername = await db('players')
|
||||
.where('username', sanitizedData.username)
|
||||
.where('id', '!=', playerId)
|
||||
.first();
|
||||
|
||||
if (existingUsername) {
|
||||
throw new ConflictError('Username is already taken');
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(sanitizedData).length === 0) {
|
||||
throw new ValidationError('No valid fields to update');
|
||||
}
|
||||
|
||||
// Update player
|
||||
sanitizedData.updated_at = new Date();
|
||||
await db('players')
|
||||
.where('id', playerId)
|
||||
.update(sanitizedData);
|
||||
|
||||
// Return updated profile
|
||||
const updatedProfile = await this.getPlayerProfile(playerId, correlationId);
|
||||
|
||||
logger.info('Player profile updated successfully', {
|
||||
correlationId,
|
||||
playerId,
|
||||
updatedFields: Object.keys(sanitizedData)
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to update player profile', {
|
||||
correlationId,
|
||||
playerId,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
if (error instanceof ValidationError || error instanceof ConflictError || error instanceof NotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to update player profile');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find player by email
|
||||
* @param {string} email - Player email
|
||||
* @returns {Promise<Object|null>} Player data or null
|
||||
*/
|
||||
async findPlayerByEmail(email) {
|
||||
try {
|
||||
return await db('players')
|
||||
.where('email', email.toLowerCase().trim())
|
||||
.first();
|
||||
} catch (error) {
|
||||
logger.error('Failed to find player by email', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find player by username
|
||||
* @param {string} username - Player username
|
||||
* @returns {Promise<Object|null>} Player data or null
|
||||
*/
|
||||
async findPlayerByUsername(username) {
|
||||
try {
|
||||
return await db('players')
|
||||
.where('username', username.trim())
|
||||
.first();
|
||||
} catch (error) {
|
||||
logger.error('Failed to find player by username', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find player by ID
|
||||
* @param {number} playerId - Player ID
|
||||
* @returns {Promise<Object|null>} Player data or null
|
||||
*/
|
||||
async findPlayerById(playerId) {
|
||||
try {
|
||||
return await db('players')
|
||||
.where('id', playerId)
|
||||
.first();
|
||||
} catch (error) {
|
||||
logger.error('Failed to find player by ID', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate registration data
|
||||
* @param {Object} data - Registration data to validate
|
||||
* @returns {Promise<void>}
|
||||
* @throws {ValidationError} If validation fails
|
||||
*/
|
||||
async validateRegistrationData(data) {
|
||||
const { email, username, password } = data;
|
||||
|
||||
// Validate email
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.isValid) {
|
||||
throw new ValidationError(emailValidation.error);
|
||||
}
|
||||
|
||||
// Validate username
|
||||
const usernameValidation = validateUsername(username);
|
||||
if (!usernameValidation.isValid) {
|
||||
throw new ValidationError(usernameValidation.error);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new ValidationError('Password does not meet requirements', {
|
||||
requirements: passwordValidation.requirements,
|
||||
errors: passwordValidation.errors
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerService;
|
||||
342
src/utils/jwt.js
Normal file
342
src/utils/jwt.js
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* JWT Token Utilities
|
||||
* Handles JWT token generation, verification, and management for player and admin authentication
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('./logger');
|
||||
|
||||
// JWT Configuration
|
||||
const JWT_CONFIG = {
|
||||
player: {
|
||||
secret: process.env.JWT_PLAYER_SECRET || 'player-secret-change-in-production',
|
||||
expiresIn: process.env.JWT_PLAYER_EXPIRES_IN || '24h',
|
||||
issuer: process.env.JWT_ISSUER || 'shattered-void-mmo',
|
||||
audience: 'player'
|
||||
},
|
||||
admin: {
|
||||
secret: process.env.JWT_ADMIN_SECRET || 'admin-secret-change-in-production',
|
||||
expiresIn: process.env.JWT_ADMIN_EXPIRES_IN || '8h',
|
||||
issuer: process.env.JWT_ISSUER || 'shattered-void-mmo',
|
||||
audience: 'admin'
|
||||
},
|
||||
refresh: {
|
||||
secret: process.env.JWT_REFRESH_SECRET || 'refresh-secret-change-in-production',
|
||||
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||
issuer: process.env.JWT_ISSUER || 'shattered-void-mmo'
|
||||
}
|
||||
};
|
||||
|
||||
// Validate JWT secrets in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const defaultSecrets = [
|
||||
'player-secret-change-in-production',
|
||||
'admin-secret-change-in-production',
|
||||
'refresh-secret-change-in-production'
|
||||
];
|
||||
|
||||
if (defaultSecrets.includes(JWT_CONFIG.player.secret) ||
|
||||
defaultSecrets.includes(JWT_CONFIG.admin.secret) ||
|
||||
defaultSecrets.includes(JWT_CONFIG.refresh.secret)) {
|
||||
throw new Error('Default JWT secrets detected in production environment. Please set proper JWT secrets.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for player authentication
|
||||
* @param {Object} payload - Token payload
|
||||
* @param {number} payload.playerId - Player ID
|
||||
* @param {string} payload.email - Player email
|
||||
* @param {string} payload.username - Player username
|
||||
* @param {Object} options - Additional token options
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generatePlayerToken(payload, options = {}) {
|
||||
try {
|
||||
const tokenPayload = {
|
||||
playerId: payload.playerId,
|
||||
email: payload.email,
|
||||
username: payload.username,
|
||||
type: 'player',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
...options.extraPayload
|
||||
};
|
||||
|
||||
const tokenOptions = {
|
||||
expiresIn: options.expiresIn || JWT_CONFIG.player.expiresIn,
|
||||
issuer: JWT_CONFIG.player.issuer,
|
||||
audience: JWT_CONFIG.player.audience,
|
||||
subject: payload.playerId.toString()
|
||||
};
|
||||
|
||||
const token = jwt.sign(tokenPayload, JWT_CONFIG.player.secret, tokenOptions);
|
||||
|
||||
logger.info('Player JWT token generated', {
|
||||
playerId: payload.playerId,
|
||||
username: payload.username,
|
||||
expiresIn: tokenOptions.expiresIn
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate player JWT token:', {
|
||||
playerId: payload.playerId,
|
||||
error: error.message
|
||||
});
|
||||
throw new Error('Token generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for admin authentication
|
||||
* @param {Object} payload - Token payload
|
||||
* @param {number} payload.adminId - Admin ID
|
||||
* @param {string} payload.email - Admin email
|
||||
* @param {string} payload.username - Admin username
|
||||
* @param {Array} payload.permissions - Admin permissions
|
||||
* @param {Object} options - Additional token options
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
function generateAdminToken(payload, options = {}) {
|
||||
try {
|
||||
const tokenPayload = {
|
||||
adminId: payload.adminId,
|
||||
email: payload.email,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions || [],
|
||||
type: 'admin',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
...options.extraPayload
|
||||
};
|
||||
|
||||
const tokenOptions = {
|
||||
expiresIn: options.expiresIn || JWT_CONFIG.admin.expiresIn,
|
||||
issuer: JWT_CONFIG.admin.issuer,
|
||||
audience: JWT_CONFIG.admin.audience,
|
||||
subject: payload.adminId.toString()
|
||||
};
|
||||
|
||||
const token = jwt.sign(tokenPayload, JWT_CONFIG.admin.secret, tokenOptions);
|
||||
|
||||
logger.info('Admin JWT token generated', {
|
||||
adminId: payload.adminId,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions,
|
||||
expiresIn: tokenOptions.expiresIn
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate admin JWT token:', {
|
||||
adminId: payload.adminId,
|
||||
error: error.message
|
||||
});
|
||||
throw new Error('Token generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token
|
||||
* @param {Object} payload - Token payload
|
||||
* @param {number} payload.userId - User ID
|
||||
* @param {string} payload.type - Token type ('player' or 'admin')
|
||||
* @param {Object} options - Additional token options
|
||||
* @returns {string} Refresh token
|
||||
*/
|
||||
function generateRefreshToken(payload, options = {}) {
|
||||
try {
|
||||
const tokenPayload = {
|
||||
userId: payload.userId,
|
||||
type: payload.type,
|
||||
tokenId: require('uuid').v4(),
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const tokenOptions = {
|
||||
expiresIn: options.expiresIn || JWT_CONFIG.refresh.expiresIn,
|
||||
issuer: JWT_CONFIG.refresh.issuer,
|
||||
audience: payload.type,
|
||||
subject: payload.userId.toString()
|
||||
};
|
||||
|
||||
const token = jwt.sign(tokenPayload, JWT_CONFIG.refresh.secret, tokenOptions);
|
||||
|
||||
logger.info('Refresh token generated', {
|
||||
userId: payload.userId,
|
||||
type: payload.type,
|
||||
tokenId: tokenPayload.tokenId,
|
||||
expiresIn: tokenOptions.expiresIn
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate refresh token:', {
|
||||
userId: payload.userId,
|
||||
type: payload.type,
|
||||
error: error.message
|
||||
});
|
||||
throw new Error('Refresh token generation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a player JWT token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function verifyPlayerToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_CONFIG.player.secret, {
|
||||
issuer: JWT_CONFIG.player.issuer,
|
||||
audience: JWT_CONFIG.player.audience
|
||||
});
|
||||
|
||||
if (decoded.type !== 'player') {
|
||||
throw new Error('Invalid token type');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn('Player JWT token verification failed:', {
|
||||
error: error.message,
|
||||
tokenPrefix: token ? token.substring(0, 20) + '...' : 'null'
|
||||
});
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new Error('Token expired');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
throw new Error('Invalid token');
|
||||
} else {
|
||||
throw new Error('Token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an admin JWT token
|
||||
* @param {string} token - JWT token to verify
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function verifyAdminToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_CONFIG.admin.secret, {
|
||||
issuer: JWT_CONFIG.admin.issuer,
|
||||
audience: JWT_CONFIG.admin.audience
|
||||
});
|
||||
|
||||
if (decoded.type !== 'admin') {
|
||||
throw new Error('Invalid token type');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn('Admin JWT token verification failed:', {
|
||||
error: error.message,
|
||||
tokenPrefix: token ? token.substring(0, 20) + '...' : 'null'
|
||||
});
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new Error('Token expired');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
throw new Error('Invalid token');
|
||||
} else {
|
||||
throw new Error('Token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a refresh token
|
||||
* @param {string} token - Refresh token to verify
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function verifyRefreshToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_CONFIG.refresh.secret, {
|
||||
issuer: JWT_CONFIG.refresh.issuer
|
||||
});
|
||||
|
||||
return decoded;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn('Refresh token verification failed:', {
|
||||
error: error.message,
|
||||
tokenPrefix: token ? token.substring(0, 20) + '...' : 'null'
|
||||
});
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new Error('Refresh token expired');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
throw new Error('Invalid refresh token');
|
||||
} else {
|
||||
throw new Error('Refresh token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token without verification (for debugging)
|
||||
* @param {string} token - JWT token to decode
|
||||
* @returns {Object} Decoded token payload
|
||||
*/
|
||||
function decodeToken(token) {
|
||||
try {
|
||||
return jwt.decode(token, { complete: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to decode JWT token:', {
|
||||
error: error.message,
|
||||
tokenPrefix: token ? token.substring(0, 20) + '...' : 'null'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
* @param {string} token - JWT token to check
|
||||
* @returns {boolean} True if token is expired
|
||||
*/
|
||||
function isTokenExpired(token) {
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
if (!decoded || !decoded.exp) return true;
|
||||
|
||||
return Date.now() >= decoded.exp * 1000;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from Authorization header
|
||||
* @param {string} authHeader - Authorization header value
|
||||
* @returns {string|null} JWT token or null if not found
|
||||
*/
|
||||
function extractTokenFromHeader(authHeader) {
|
||||
if (!authHeader) return null;
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generatePlayerToken,
|
||||
generateAdminToken,
|
||||
generateRefreshToken,
|
||||
verifyPlayerToken,
|
||||
verifyAdminToken,
|
||||
verifyRefreshToken,
|
||||
decodeToken,
|
||||
isTokenExpired,
|
||||
extractTokenFromHeader,
|
||||
JWT_CONFIG
|
||||
};
|
||||
74
src/utils/logger.js
Normal file
74
src/utils/logger.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
const winston = require('winston');
|
||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||
const path = require('path');
|
||||
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
let log = `${timestamp} [${level}]: ${message}`;
|
||||
if (Object.keys(meta).length > 0) {
|
||||
log += ` ${JSON.stringify(meta)}`;
|
||||
}
|
||||
return log;
|
||||
})
|
||||
);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: { service: 'shattered-void-mmo' },
|
||||
transports: [
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'error',
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'combined-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logDir, 'audit-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
level: 'info',
|
||||
maxSize: '20m',
|
||||
maxFiles: '90d',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json(),
|
||||
winston.format((info) => {
|
||||
return info.audit ? info : false;
|
||||
})()
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: consoleFormat,
|
||||
}));
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
logger.transports.forEach((t) => (t.silent = true));
|
||||
}
|
||||
|
||||
logger.audit = (message, meta = {}) => {
|
||||
logger.info(message, { ...meta, audit: true });
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
329
src/utils/password.js
Normal file
329
src/utils/password.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* Password Hashing and Validation Utilities
|
||||
* Handles secure password operations using bcrypt with proper configuration
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Configuration
|
||||
const BCRYPT_CONFIG = {
|
||||
saltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS) || 12,
|
||||
maxPasswordLength: parseInt(process.env.MAX_PASSWORD_LENGTH) || 128,
|
||||
minPasswordLength: parseInt(process.env.MIN_PASSWORD_LENGTH) || 8
|
||||
};
|
||||
|
||||
// Validate salt rounds configuration
|
||||
if (BCRYPT_CONFIG.saltRounds < 10) {
|
||||
logger.warn('Low bcrypt salt rounds detected. Consider using 12 or higher for production.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
* @param {string} password - Plain text password to hash
|
||||
* @returns {Promise<string>} Hashed password
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
try {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('Password must be a non-empty string');
|
||||
}
|
||||
|
||||
if (password.length > BCRYPT_CONFIG.maxPasswordLength) {
|
||||
throw new Error(`Password exceeds maximum length of ${BCRYPT_CONFIG.maxPasswordLength} characters`);
|
||||
}
|
||||
|
||||
if (password.length < BCRYPT_CONFIG.minPasswordLength) {
|
||||
throw new Error(`Password must be at least ${BCRYPT_CONFIG.minPasswordLength} characters long`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const hashedPassword = await bcrypt.hash(password, BCRYPT_CONFIG.saltRounds);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.info('Password hashed successfully', {
|
||||
duration: `${duration}ms`,
|
||||
saltRounds: BCRYPT_CONFIG.saltRounds
|
||||
});
|
||||
|
||||
return hashedPassword;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Password hashing failed:', {
|
||||
error: error.message,
|
||||
passwordLength: password?.length
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against its hash
|
||||
* @param {string} password - Plain text password to verify
|
||||
* @param {string} hash - Hashed password to compare against
|
||||
* @returns {Promise<boolean>} True if password matches hash
|
||||
*/
|
||||
async function verifyPassword(password, hash) {
|
||||
try {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('Password must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!hash || typeof hash !== 'string') {
|
||||
throw new Error('Hash must be a non-empty string');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const isValid = await bcrypt.compare(password, hash);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger.info('Password verification completed', {
|
||||
duration: `${duration}ms`,
|
||||
isValid
|
||||
});
|
||||
|
||||
return isValid;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Password verification failed:', {
|
||||
error: error.message,
|
||||
passwordLength: password?.length,
|
||||
hashLength: hash?.length
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hash needs to be rehashed (due to changed salt rounds)
|
||||
* @param {string} hash - Existing password hash
|
||||
* @returns {boolean} True if hash needs to be updated
|
||||
*/
|
||||
function needsRehash(hash) {
|
||||
try {
|
||||
if (!hash || typeof hash !== 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract salt rounds from hash
|
||||
const hashParts = hash.split('$');
|
||||
if (hashParts.length < 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentRounds = parseInt(hashParts[2]);
|
||||
const needsUpdate = currentRounds !== BCRYPT_CONFIG.saltRounds;
|
||||
|
||||
if (needsUpdate) {
|
||||
logger.info('Password hash needs update', {
|
||||
currentRounds,
|
||||
targetRounds: BCRYPT_CONFIG.saltRounds
|
||||
});
|
||||
}
|
||||
|
||||
return needsUpdate;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error checking if hash needs rehash:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehash password with current salt rounds if needed
|
||||
* @param {string} password - Plain text password
|
||||
* @param {string} currentHash - Current password hash
|
||||
* @returns {Promise<Object>} Result object with hash and wasRehashed flag
|
||||
*/
|
||||
async function rehashIfNeeded(password, currentHash) {
|
||||
try {
|
||||
const shouldRehash = needsRehash(currentHash);
|
||||
|
||||
if (!shouldRehash) {
|
||||
return {
|
||||
hash: currentHash,
|
||||
wasRehashed: false
|
||||
};
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(password);
|
||||
|
||||
logger.info('Password rehashed with updated salt rounds');
|
||||
|
||||
return {
|
||||
hash: newHash,
|
||||
wasRehashed: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Password rehashing failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password strength
|
||||
* @param {string} password - Password to validate
|
||||
* @returns {Object} Validation result with isValid flag and errors array
|
||||
*/
|
||||
function validatePasswordStrength(password) {
|
||||
const result = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
score: 0,
|
||||
requirements: {
|
||||
minLength: false,
|
||||
hasUppercase: false,
|
||||
hasLowercase: false,
|
||||
hasNumbers: false,
|
||||
hasSpecialChars: false,
|
||||
noCommonPatterns: true
|
||||
}
|
||||
};
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
result.isValid = false;
|
||||
result.errors.push('Password must be a string');
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if (password.length >= BCRYPT_CONFIG.minPasswordLength) {
|
||||
result.requirements.minLength = true;
|
||||
result.score += 1;
|
||||
} else {
|
||||
result.isValid = false;
|
||||
result.errors.push(`Password must be at least ${BCRYPT_CONFIG.minPasswordLength} characters long`);
|
||||
}
|
||||
|
||||
// Check maximum length
|
||||
if (password.length > BCRYPT_CONFIG.maxPasswordLength) {
|
||||
result.isValid = false;
|
||||
result.errors.push(`Password exceeds maximum length of ${BCRYPT_CONFIG.maxPasswordLength} characters`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for uppercase letters
|
||||
if (/[A-Z]/.test(password)) {
|
||||
result.requirements.hasUppercase = true;
|
||||
result.score += 1;
|
||||
} else {
|
||||
result.errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
// Check for lowercase letters
|
||||
if (/[a-z]/.test(password)) {
|
||||
result.requirements.hasLowercase = true;
|
||||
result.score += 1;
|
||||
} else {
|
||||
result.errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
// Check for numbers
|
||||
if (/\d/.test(password)) {
|
||||
result.requirements.hasNumbers = true;
|
||||
result.score += 1;
|
||||
} else {
|
||||
result.errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
// Check for special characters
|
||||
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||
result.requirements.hasSpecialChars = true;
|
||||
result.score += 1;
|
||||
} else {
|
||||
result.errors.push('Password must contain at least one special character');
|
||||
}
|
||||
|
||||
// Check for common patterns
|
||||
const commonPatterns = [
|
||||
/123456/,
|
||||
/password/i,
|
||||
/qwerty/i,
|
||||
/admin/i,
|
||||
/letmein/i
|
||||
];
|
||||
|
||||
for (const pattern of commonPatterns) {
|
||||
if (pattern.test(password)) {
|
||||
result.requirements.noCommonPatterns = false;
|
||||
result.errors.push('Password contains common patterns that are easily guessable');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for repeated characters
|
||||
if (/(.)\1{2,}/.test(password)) {
|
||||
result.errors.push('Password should not contain repeated characters');
|
||||
}
|
||||
|
||||
// Final validation based on environment
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const minimumScore = isProduction ? 4 : 3; // Stricter requirements in production
|
||||
|
||||
if (result.score < minimumScore) {
|
||||
result.isValid = false;
|
||||
if (result.errors.length === 0) {
|
||||
result.errors.push('Password does not meet strength requirements');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
* @param {number} length - Password length (default: 16)
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {string} Generated password
|
||||
*/
|
||||
function generateRandomPassword(length = 16, options = {}) {
|
||||
const defaultOptions = {
|
||||
includeUppercase: true,
|
||||
includeLowercase: true,
|
||||
includeNumbers: true,
|
||||
includeSpecialChars: true,
|
||||
excludeSimilar: true // Exclude similar looking characters
|
||||
};
|
||||
|
||||
const config = { ...defaultOptions, ...options };
|
||||
|
||||
let charset = '';
|
||||
|
||||
if (config.includeLowercase) {
|
||||
charset += config.excludeSimilar ? 'abcdefghjkmnpqrstuvwxyz' : 'abcdefghijklmnopqrstuvwxyz';
|
||||
}
|
||||
|
||||
if (config.includeUppercase) {
|
||||
charset += config.excludeSimilar ? 'ABCDEFGHJKMNPQRSTUVWXYZ' : 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
}
|
||||
|
||||
if (config.includeNumbers) {
|
||||
charset += config.excludeSimilar ? '23456789' : '0123456789';
|
||||
}
|
||||
|
||||
if (config.includeSpecialChars) {
|
||||
charset += '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
}
|
||||
|
||||
if (charset === '') {
|
||||
throw new Error('At least one character type must be included');
|
||||
}
|
||||
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
needsRehash,
|
||||
rehashIfNeeded,
|
||||
validatePasswordStrength,
|
||||
generateRandomPassword,
|
||||
BCRYPT_CONFIG
|
||||
};
|
||||
417
src/utils/redis.js
Normal file
417
src/utils/redis.js
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
/**
|
||||
* Redis client configuration and utilities
|
||||
* Handles caching, session storage, and real-time data
|
||||
*/
|
||||
|
||||
const Redis = require('redis');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Create Redis client
|
||||
const redisClient = Redis.createClient({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB) || 0,
|
||||
retryDelayOnFailover: 100,
|
||||
enableReadyCheck: true,
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
// Error handling
|
||||
redisClient.on('error', (error) => {
|
||||
logger.error('Redis connection error:', error);
|
||||
});
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('Redis client connected');
|
||||
});
|
||||
|
||||
redisClient.on('ready', () => {
|
||||
logger.info('Redis client ready');
|
||||
});
|
||||
|
||||
redisClient.on('end', () => {
|
||||
logger.info('Redis connection ended');
|
||||
});
|
||||
|
||||
redisClient.on('reconnecting', () => {
|
||||
logger.info('Redis client reconnecting...');
|
||||
});
|
||||
|
||||
// Connect to Redis
|
||||
const connectRedis = async () => {
|
||||
try {
|
||||
await redisClient.connect();
|
||||
logger.info('Connected to Redis successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to Redis:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Redis utility methods
|
||||
const redisUtils = {
|
||||
/**
|
||||
* Cache management utilities
|
||||
*/
|
||||
cache: {
|
||||
/**
|
||||
* Get cached data
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Promise<any>} Cached data or null
|
||||
*/
|
||||
get: async (key) => {
|
||||
try {
|
||||
const data = await redisClient.get(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
logger.error('Redis cache get error:', { key, error: error.message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set cache data with expiration
|
||||
* @param {string} key - Cache key
|
||||
* @param {any} data - Data to cache
|
||||
* @param {number} ttl - Time to live in seconds (default: 1 hour)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
set: async (key, data, ttl = 3600) => {
|
||||
try {
|
||||
await redisClient.setEx(key, ttl, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Redis cache set error:', { key, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete cached data
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
del: async (key) => {
|
||||
try {
|
||||
await redisClient.del(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Redis cache delete error:', { key, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Promise<boolean>} Exists status
|
||||
*/
|
||||
exists: async (key) => {
|
||||
try {
|
||||
const result = await redisClient.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error('Redis cache exists error:', { key, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Increment a numeric value
|
||||
* @param {string} key - Cache key
|
||||
* @param {number} increment - Amount to increment (default: 1)
|
||||
* @returns {Promise<number>} New value
|
||||
*/
|
||||
incr: async (key, increment = 1) => {
|
||||
try {
|
||||
return await redisClient.incrBy(key, increment);
|
||||
} catch (error) {
|
||||
logger.error('Redis cache increment error:', { key, error: error.message });
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Session management utilities
|
||||
*/
|
||||
session: {
|
||||
/**
|
||||
* Get player session data
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Promise<Object|null>} Session data
|
||||
*/
|
||||
get: async (sessionId) => {
|
||||
const key = `session:${sessionId}`;
|
||||
return redisUtils.cache.get(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set player session data
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {Object} sessionData - Session data
|
||||
* @param {number} ttl - Session TTL in seconds (default: 24 hours)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
set: async (sessionId, sessionData, ttl = 86400) => {
|
||||
const key = `session:${sessionId}`;
|
||||
return redisUtils.cache.set(key, sessionData, ttl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete player session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
destroy: async (sessionId) => {
|
||||
const key = `session:${sessionId}`;
|
||||
return redisUtils.cache.del(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Extend session TTL
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {number} ttl - New TTL in seconds
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
extend: async (sessionId, ttl = 86400) => {
|
||||
try {
|
||||
const key = `session:${sessionId}`;
|
||||
await redisClient.expire(key, ttl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Redis session extend error:', { sessionId, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Real-time event publishing
|
||||
*/
|
||||
pubsub: {
|
||||
/**
|
||||
* Publish event to channel
|
||||
* @param {string} channel - Channel name
|
||||
* @param {Object} data - Event data
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
publish: async (channel, data) => {
|
||||
try {
|
||||
await redisClient.publish(channel, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Redis publish error:', { channel, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to channel
|
||||
* @param {string} channel - Channel name
|
||||
* @param {Function} callback - Message handler
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
subscribe: async (channel, callback) => {
|
||||
try {
|
||||
const subscriber = redisClient.duplicate();
|
||||
await subscriber.connect();
|
||||
|
||||
subscriber.on('message', (receivedChannel, message) => {
|
||||
if (receivedChannel === channel) {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('Redis message parse error:', { channel, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await subscriber.subscribe(channel);
|
||||
logger.debug('Subscribed to Redis channel:', channel);
|
||||
} catch (error) {
|
||||
logger.error('Redis subscribe error:', { channel, error: error.message });
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Rate limiting utilities
|
||||
*/
|
||||
rateLimit: {
|
||||
/**
|
||||
* Check and increment rate limit counter
|
||||
* @param {string} key - Rate limit key (e.g., IP address)
|
||||
* @param {number} limit - Request limit
|
||||
* @param {number} window - Time window in seconds
|
||||
* @returns {Promise<Object>} Rate limit status
|
||||
*/
|
||||
check: async (key, limit, window) => {
|
||||
try {
|
||||
const rateLimitKey = `ratelimit:${key}`;
|
||||
const current = await redisClient.incr(rateLimitKey);
|
||||
|
||||
if (current === 1) {
|
||||
await redisClient.expire(rateLimitKey, window);
|
||||
}
|
||||
|
||||
const ttl = await redisClient.ttl(rateLimitKey);
|
||||
|
||||
return {
|
||||
allowed: current <= limit,
|
||||
count: current,
|
||||
remaining: Math.max(0, limit - current),
|
||||
resetTime: Date.now() + (ttl * 1000),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Redis rate limit error:', { key, error: error.message });
|
||||
return { allowed: true, count: 0, remaining: limit, resetTime: Date.now() + (window * 1000) };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Game state caching utilities
|
||||
*/
|
||||
gameState: {
|
||||
/**
|
||||
* Cache player game state
|
||||
* @param {number} playerId - Player ID
|
||||
* @param {Object} state - Game state data
|
||||
* @param {number} ttl - Cache TTL in seconds (default: 5 minutes)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
setPlayerState: async (playerId, state, ttl = 300) => {
|
||||
const key = `gamestate:player:${playerId}`;
|
||||
return redisUtils.cache.set(key, state, ttl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cached player game state
|
||||
* @param {number} playerId - Player ID
|
||||
* @returns {Promise<Object|null>} Game state data
|
||||
*/
|
||||
getPlayerState: async (playerId) => {
|
||||
const key = `gamestate:player:${playerId}`;
|
||||
return redisUtils.cache.get(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cache colony data
|
||||
* @param {number} colonyId - Colony ID
|
||||
* @param {Object} data - Colony data
|
||||
* @param {number} ttl - Cache TTL in seconds (default: 10 minutes)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
setColonyData: async (colonyId, data, ttl = 600) => {
|
||||
const key = `gamestate:colony:${colonyId}`;
|
||||
return redisUtils.cache.set(key, data, ttl);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cached colony data
|
||||
* @param {number} colonyId - Colony ID
|
||||
* @returns {Promise<Object|null>} Colony data
|
||||
*/
|
||||
getColonyData: async (colonyId) => {
|
||||
const key = `gamestate:colony:${colonyId}`;
|
||||
return redisUtils.cache.get(key);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Lock utilities for preventing race conditions
|
||||
*/
|
||||
lock: {
|
||||
/**
|
||||
* Acquire a distributed lock
|
||||
* @param {string} key - Lock key
|
||||
* @param {number} ttl - Lock TTL in seconds (default: 30)
|
||||
* @returns {Promise<string|null>} Lock token or null if failed
|
||||
*/
|
||||
acquire: async (key, ttl = 30) => {
|
||||
try {
|
||||
const lockKey = `lock:${key}`;
|
||||
const token = `${Date.now()}-${Math.random()}`;
|
||||
|
||||
const result = await redisClient.set(lockKey, token, {
|
||||
EX: ttl,
|
||||
NX: true,
|
||||
});
|
||||
|
||||
return result === 'OK' ? token : null;
|
||||
} catch (error) {
|
||||
logger.error('Redis lock acquire error:', { key, error: error.message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Release a distributed lock
|
||||
* @param {string} key - Lock key
|
||||
* @param {string} token - Lock token
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
release: async (key, token) => {
|
||||
try {
|
||||
const lockKey = `lock:${key}`;
|
||||
const luaScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
|
||||
const result = await redisClient.eval(luaScript, 1, lockKey, token);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error('Redis lock release error:', { key, error: error.message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Redis connection health stats
|
||||
* @returns {Promise<Object>} Health statistics
|
||||
*/
|
||||
getHealthStats: async () => {
|
||||
try {
|
||||
const info = await redisClient.info();
|
||||
const memory = await redisClient.info('memory');
|
||||
|
||||
return {
|
||||
connected: redisClient.isReady,
|
||||
info: info.split('\r\n').reduce((acc, line) => {
|
||||
const [key, value] = line.split(':');
|
||||
if (key && value) acc[key] = value;
|
||||
return acc;
|
||||
}, {}),
|
||||
memory: memory.split('\r\n').reduce((acc, line) => {
|
||||
const [key, value] = line.split(':');
|
||||
if (key && value) acc[key] = value;
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Redis health stats:', error);
|
||||
return { connected: false, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize Redis connection
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
connectRedis().catch((error) => {
|
||||
logger.error('Failed to initialize Redis:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach utilities to client
|
||||
Object.assign(redisClient, redisUtils);
|
||||
|
||||
module.exports = redisClient;
|
||||
432
src/utils/validation.js
Normal file
432
src/utils/validation.js
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
/**
|
||||
* Common Validation Utilities
|
||||
* Provides reusable validation functions for various data types and formats
|
||||
*/
|
||||
|
||||
const validator = require('validator');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Email validation
|
||||
* @param {string} email - Email address to validate
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateEmail(email) {
|
||||
const result = { isValid: false, error: null };
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
result.error = 'Email must be a non-empty string';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Normalize email
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
// Check format
|
||||
if (!validator.isEmail(normalizedEmail)) {
|
||||
result.error = 'Invalid email format';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (normalizedEmail.length > 320) { // RFC 5321 limit
|
||||
result.error = 'Email address too long';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for common disposable email domains (optional)
|
||||
const disposableDomains = [
|
||||
'10minutemail.com',
|
||||
'guerrillamail.com',
|
||||
'mailinator.com',
|
||||
'tempmail.org'
|
||||
];
|
||||
|
||||
const domain = normalizedEmail.split('@')[1];
|
||||
if (process.env.BLOCK_DISPOSABLE_EMAILS === 'true' && disposableDomains.includes(domain)) {
|
||||
result.error = 'Disposable email addresses are not allowed';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Username validation
|
||||
* @param {string} username - Username to validate
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateUsername(username) {
|
||||
const result = { isValid: false, error: null };
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
result.error = 'Username must be a non-empty string';
|
||||
return result;
|
||||
}
|
||||
|
||||
const trimmedUsername = username.trim();
|
||||
|
||||
// Check length
|
||||
const minLength = parseInt(process.env.MIN_USERNAME_LENGTH) || 3;
|
||||
const maxLength = parseInt(process.env.MAX_USERNAME_LENGTH) || 20;
|
||||
|
||||
if (trimmedUsername.length < minLength) {
|
||||
result.error = `Username must be at least ${minLength} characters long`;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (trimmedUsername.length > maxLength) {
|
||||
result.error = `Username must not exceed ${maxLength} characters`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check format (alphanumeric, underscores, hyphens)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedUsername)) {
|
||||
result.error = 'Username can only contain letters, numbers, underscores, and hyphens';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Must start with a letter or number
|
||||
if (!/^[a-zA-Z0-9]/.test(trimmedUsername)) {
|
||||
result.error = 'Username must start with a letter or number';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Must end with a letter or number
|
||||
if (!/[a-zA-Z0-9]$/.test(trimmedUsername)) {
|
||||
result.error = 'Username must end with a letter or number';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for reserved usernames
|
||||
const reservedUsernames = [
|
||||
'admin', 'administrator', 'root', 'system', 'api', 'www',
|
||||
'mail', 'email', 'support', 'help', 'info', 'contact',
|
||||
'null', 'undefined', 'anonymous', 'guest', 'test', 'demo'
|
||||
];
|
||||
|
||||
if (reservedUsernames.includes(trimmedUsername.toLowerCase())) {
|
||||
result.error = 'Username is reserved and cannot be used';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game coordinates validation (format: A3-91-X)
|
||||
* @param {string} coordinates - Coordinates string to validate
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateCoordinates(coordinates) {
|
||||
const result = { isValid: false, error: null };
|
||||
|
||||
if (!coordinates || typeof coordinates !== 'string') {
|
||||
result.error = 'Coordinates must be a non-empty string';
|
||||
return result;
|
||||
}
|
||||
|
||||
const trimmedCoords = coordinates.trim().toUpperCase();
|
||||
|
||||
// Check format: Letter + Numbers + Hyphen + Numbers + Hyphen + Letter
|
||||
const coordPattern = /^[A-Z]\d+-\d+-[A-Z]$/;
|
||||
|
||||
if (!coordPattern.test(trimmedCoords)) {
|
||||
result.error = 'Invalid coordinates format. Expected format: A3-91-X';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Parse components
|
||||
const parts = trimmedCoords.split('-');
|
||||
const sector = parts[0]; // A3
|
||||
const system = parts[1]; // 91
|
||||
const planet = parts[2]; // X
|
||||
|
||||
// Validate sector (Letter followed by 1-2 digits)
|
||||
if (!/^[A-Z]\d{1,2}$/.test(sector)) {
|
||||
result.error = 'Invalid sector format in coordinates';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate system (1-3 digits)
|
||||
const systemNum = parseInt(system);
|
||||
if (isNaN(systemNum) || systemNum < 1 || systemNum > 999) {
|
||||
result.error = 'System number must be between 1 and 999';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate planet (single letter)
|
||||
if (!/^[A-Z]$/.test(planet)) {
|
||||
result.error = 'Planet identifier must be a single letter';
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer validation with range checking
|
||||
* @param {any} value - Value to validate
|
||||
* @param {Object} options - Validation options
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateInteger(value, options = {}) {
|
||||
const result = { isValid: false, error: null };
|
||||
const { min, max, fieldName = 'Value' } = options;
|
||||
|
||||
// Check if value exists
|
||||
if (value === null || value === undefined || value === '') {
|
||||
result.error = `${fieldName} is required`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convert to number
|
||||
const numValue = Number(value);
|
||||
|
||||
// Check if it's a valid number
|
||||
if (isNaN(numValue)) {
|
||||
result.error = `${fieldName} must be a valid number`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if it's an integer
|
||||
if (!Number.isInteger(numValue)) {
|
||||
result.error = `${fieldName} must be an integer`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check minimum value
|
||||
if (min !== undefined && numValue < min) {
|
||||
result.error = `${fieldName} must be at least ${min}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check maximum value
|
||||
if (max !== undefined && numValue > max) {
|
||||
result.error = `${fieldName} must not exceed ${max}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
result.value = numValue;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* String validation with length checking
|
||||
* @param {string} value - String to validate
|
||||
* @param {Object} options - Validation options
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateString(value, options = {}) {
|
||||
const result = { isValid: false, error: null };
|
||||
const {
|
||||
minLength = 0,
|
||||
maxLength = 1000,
|
||||
fieldName = 'Value',
|
||||
required = true,
|
||||
trim = true,
|
||||
allowEmpty = false
|
||||
} = options;
|
||||
|
||||
// Check if value exists
|
||||
if (value === null || value === undefined) {
|
||||
if (required) {
|
||||
result.error = `${fieldName} is required`;
|
||||
return result;
|
||||
} else {
|
||||
result.isValid = true;
|
||||
result.value = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a string
|
||||
if (typeof value !== 'string') {
|
||||
result.error = `${fieldName} must be a string`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Trim if requested
|
||||
const processedValue = trim ? value.trim() : value;
|
||||
|
||||
// Check for empty string
|
||||
if (!allowEmpty && processedValue === '') {
|
||||
if (required) {
|
||||
result.error = `${fieldName} cannot be empty`;
|
||||
return result;
|
||||
} else {
|
||||
result.isValid = true;
|
||||
result.value = processedValue;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if (processedValue.length < minLength) {
|
||||
result.error = `${fieldName} must be at least ${minLength} characters long`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check maximum length
|
||||
if (processedValue.length > maxLength) {
|
||||
result.error = `${fieldName} must not exceed ${maxLength} characters`;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
result.value = processedValue;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array validation
|
||||
* @param {any} value - Value to validate as array
|
||||
* @param {Object} options - Validation options
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateArray(value, options = {}) {
|
||||
const result = { isValid: false, error: null };
|
||||
const {
|
||||
minLength = 0,
|
||||
maxLength = 100,
|
||||
fieldName = 'Array',
|
||||
required = true,
|
||||
itemValidator = null
|
||||
} = options;
|
||||
|
||||
// Check if value exists
|
||||
if (value === null || value === undefined) {
|
||||
if (required) {
|
||||
result.error = `${fieldName} is required`;
|
||||
return result;
|
||||
} else {
|
||||
result.isValid = true;
|
||||
result.value = [];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an array
|
||||
if (!Array.isArray(value)) {
|
||||
result.error = `${fieldName} must be an array`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if (value.length < minLength) {
|
||||
result.error = `${fieldName} must contain at least ${minLength} items`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check maximum length
|
||||
if (value.length > maxLength) {
|
||||
result.error = `${fieldName} must not contain more than ${maxLength} items`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate individual items if validator provided
|
||||
if (itemValidator && typeof itemValidator === 'function') {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const itemResult = itemValidator(value[i], i);
|
||||
if (!itemResult.isValid) {
|
||||
result.error = `${fieldName}[${i}]: ${itemResult.error}`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
result.value = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* UUID validation
|
||||
* @param {string} uuid - UUID string to validate
|
||||
* @param {Object} options - Validation options
|
||||
* @returns {Object} Validation result with isValid flag and error message
|
||||
*/
|
||||
function validateUUID(uuid, options = {}) {
|
||||
const result = { isValid: false, error: null };
|
||||
const { fieldName = 'UUID', required = true, version = null } = options;
|
||||
|
||||
// Check if value exists
|
||||
if (!uuid) {
|
||||
if (required) {
|
||||
result.error = `${fieldName} is required`;
|
||||
return result;
|
||||
} else {
|
||||
result.isValid = true;
|
||||
result.value = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a string
|
||||
if (typeof uuid !== 'string') {
|
||||
result.error = `${fieldName} must be a string`;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate UUID format
|
||||
const uuidValidation = version ?
|
||||
validator.isUUID(uuid, version) :
|
||||
validator.isUUID(uuid);
|
||||
|
||||
if (!uuidValidation) {
|
||||
result.error = `Invalid ${fieldName} format`;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isValid = true;
|
||||
result.value = uuid;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML content
|
||||
* @param {string} html - HTML content to sanitize
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
function sanitizeHTML(html) {
|
||||
if (!html || typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Basic HTML sanitization - remove script tags and dangerous attributes
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/on\w+="[^"]*"/gi, '')
|
||||
.replace(/on\w+='[^']*'/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/vbscript:/gi, '')
|
||||
.replace(/data:/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting key generator
|
||||
* @param {Object} req - Express request object
|
||||
* @param {string} action - Action being rate limited
|
||||
* @returns {string} Rate limiting key
|
||||
*/
|
||||
function generateRateLimitKey(req, action) {
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const userId = req.user?.playerId || req.user?.adminId || 'anonymous';
|
||||
return `ratelimit:${action}:${userId}:${ip}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateEmail,
|
||||
validateUsername,
|
||||
validateCoordinates,
|
||||
validateInteger,
|
||||
validateString,
|
||||
validateArray,
|
||||
validateUUID,
|
||||
sanitizeHTML,
|
||||
generateRateLimitKey
|
||||
};
|
||||
431
src/utils/websocket.js
Normal file
431
src/utils/websocket.js
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
/**
|
||||
* WebSocket connection and event handling
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('./logger');
|
||||
const db = require('../database/connection');
|
||||
|
||||
/**
|
||||
* Initialize WebSocket handlers
|
||||
* @param {Object} io - Socket.io instance
|
||||
*/
|
||||
function initializeWebSocketHandlers(io) {
|
||||
// Middleware for authentication
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth.token;
|
||||
|
||||
if (!token) {
|
||||
return next(new Error('Authentication token required'));
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Get user from database
|
||||
const tableName = decoded.type === 'admin' ? 'admin_users' : 'players';
|
||||
const user = await db(tableName)
|
||||
.where('id', decoded.userId)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return next(new Error('User not found'));
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (decoded.type === 'player' && user.account_status !== 'active') {
|
||||
return next(new Error('Account is not active'));
|
||||
}
|
||||
|
||||
if (decoded.type === 'admin' && !user.is_active) {
|
||||
return next(new Error('Admin account is not active'));
|
||||
}
|
||||
|
||||
// Attach user info to socket
|
||||
socket.user = user;
|
||||
socket.userType = decoded.type;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.warn('WebSocket authentication failed', {
|
||||
error: error.message,
|
||||
socketId: socket.id,
|
||||
});
|
||||
next(new Error('Authentication failed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connections
|
||||
io.on('connection', async (socket) => {
|
||||
try {
|
||||
// Store connection in database
|
||||
await storeConnection(socket);
|
||||
|
||||
logger.info('WebSocket connection established', {
|
||||
socketId: socket.id,
|
||||
userId: socket.user.id,
|
||||
userType: socket.userType,
|
||||
username: socket.user.username,
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers(socket, io);
|
||||
|
||||
// Handle disconnection
|
||||
socket.on('disconnect', async (reason) => {
|
||||
await handleDisconnection(socket, reason);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error handling WebSocket connection:', error);
|
||||
socket.disconnect(true);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('WebSocket handlers initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store WebSocket connection in database
|
||||
* @param {Object} socket - Socket instance
|
||||
*/
|
||||
async function storeConnection(socket) {
|
||||
try {
|
||||
await db('websocket_connections').insert({
|
||||
connection_id: socket.id,
|
||||
player_id: socket.userType === 'player' ? socket.user.id : null,
|
||||
admin_id: socket.userType === 'admin' ? socket.user.id : null,
|
||||
user_type: socket.userType,
|
||||
connected_at: new Date(),
|
||||
last_ping: new Date(),
|
||||
ip_address: socket.handshake.address,
|
||||
user_agent: socket.handshake.headers['user-agent'],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store WebSocket connection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event handlers for socket
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {Object} io - Socket.io instance
|
||||
*/
|
||||
function setupEventHandlers(socket, io) {
|
||||
// Ping/pong for connection health
|
||||
socket.on('ping', (callback) => {
|
||||
updateLastPing(socket.id);
|
||||
if (callback) callback('pong');
|
||||
});
|
||||
|
||||
// Subscribe to specific event types
|
||||
socket.on('subscribe', async (data, callback) => {
|
||||
try {
|
||||
await handleSubscription(socket, data);
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Subscription error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe from event types
|
||||
socket.on('unsubscribe', async (data, callback) => {
|
||||
try {
|
||||
await handleUnsubscription(socket, data);
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Unsubscription error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Player-specific events
|
||||
if (socket.userType === 'player') {
|
||||
setupPlayerEventHandlers(socket, io);
|
||||
}
|
||||
|
||||
// Admin-specific events
|
||||
if (socket.userType === 'admin') {
|
||||
setupAdminEventHandlers(socket, io);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up player-specific event handlers
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {Object} io - Socket.io instance
|
||||
*/
|
||||
function setupPlayerEventHandlers(socket, io) {
|
||||
// Join player-specific room
|
||||
socket.join(`player:${socket.user.id}`);
|
||||
|
||||
// Game action events
|
||||
socket.on('game:action', async (data, callback) => {
|
||||
try {
|
||||
// TODO: Handle game actions
|
||||
logger.debug('Game action received', {
|
||||
playerId: socket.user.id,
|
||||
action: data.action,
|
||||
data: data,
|
||||
});
|
||||
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Game action error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Colony events
|
||||
socket.on('colony:update', async (data, callback) => {
|
||||
try {
|
||||
// TODO: Handle colony updates
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Colony update error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Fleet events
|
||||
socket.on('fleet:move', async (data, callback) => {
|
||||
try {
|
||||
// TODO: Handle fleet movements
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Fleet move error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up admin-specific event handlers
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {Object} io - Socket.io instance
|
||||
*/
|
||||
function setupAdminEventHandlers(socket, io) {
|
||||
// Join admin room
|
||||
socket.join('admin');
|
||||
|
||||
// System events
|
||||
socket.on('admin:system', async (data, callback) => {
|
||||
try {
|
||||
// TODO: Handle admin system commands
|
||||
logger.info('Admin system command', {
|
||||
adminId: socket.user.id,
|
||||
command: data.command,
|
||||
});
|
||||
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Admin system error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Player management events
|
||||
socket.on('admin:player', async (data, callback) => {
|
||||
try {
|
||||
// TODO: Handle admin player management
|
||||
if (callback) callback({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Admin player error:', error);
|
||||
if (callback) callback({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle subscription requests
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {Object} data - Subscription data
|
||||
*/
|
||||
async function handleSubscription(socket, data) {
|
||||
const { subscriptionType, scopeType, scopeId, filters } = data;
|
||||
|
||||
// Validate subscription request
|
||||
if (!subscriptionType) {
|
||||
throw new Error('Subscription type required');
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!canSubscribe(socket, subscriptionType, scopeType, scopeId)) {
|
||||
throw new Error('Insufficient permissions for subscription');
|
||||
}
|
||||
|
||||
// Store subscription in database
|
||||
await db('websocket_subscriptions')
|
||||
.insert({
|
||||
connection_id: socket.id,
|
||||
subscription_type: subscriptionType,
|
||||
scope_type: scopeType || null,
|
||||
scope_id: scopeId || null,
|
||||
filters: filters ? JSON.stringify(filters) : '{}',
|
||||
})
|
||||
.onConflict(['connection_id', 'subscription_type', 'scope_type', 'scope_id'])
|
||||
.merge();
|
||||
|
||||
// Join appropriate socket room
|
||||
const roomName = getRoomName(subscriptionType, scopeType, scopeId);
|
||||
socket.join(roomName);
|
||||
|
||||
logger.debug('WebSocket subscription added', {
|
||||
socketId: socket.id,
|
||||
userId: socket.user.id,
|
||||
subscriptionType,
|
||||
scopeType,
|
||||
scopeId,
|
||||
room: roomName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unsubscription requests
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {Object} data - Unsubscription data
|
||||
*/
|
||||
async function handleUnsubscription(socket, data) {
|
||||
const { subscriptionType, scopeType, scopeId } = data;
|
||||
|
||||
// Remove subscription from database
|
||||
await db('websocket_subscriptions')
|
||||
.where('connection_id', socket.id)
|
||||
.where('subscription_type', subscriptionType)
|
||||
.where('scope_type', scopeType || null)
|
||||
.where('scope_id', scopeId || null)
|
||||
.del();
|
||||
|
||||
// Leave socket room
|
||||
const roomName = getRoomName(subscriptionType, scopeType, scopeId);
|
||||
socket.leave(roomName);
|
||||
|
||||
logger.debug('WebSocket subscription removed', {
|
||||
socketId: socket.id,
|
||||
subscriptionType,
|
||||
scopeType,
|
||||
scopeId,
|
||||
room: roomName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can subscribe to event type
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {string} subscriptionType - Subscription type
|
||||
* @param {string} scopeType - Scope type
|
||||
* @param {number} scopeId - Scope ID
|
||||
* @returns {boolean} Can subscribe
|
||||
*/
|
||||
function canSubscribe(socket, subscriptionType, scopeType, scopeId) {
|
||||
// Global events - anyone can subscribe
|
||||
if (subscriptionType === 'global_events') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Player-specific events
|
||||
if (socket.userType === 'player') {
|
||||
if (subscriptionType === 'colony_updates' && scopeType === 'player' && scopeId === socket.user.id) {
|
||||
return true;
|
||||
}
|
||||
if (subscriptionType === 'fleet_movements' && scopeType === 'player' && scopeId === socket.user.id) {
|
||||
return true;
|
||||
}
|
||||
// TODO: Add more player subscription checks
|
||||
}
|
||||
|
||||
// Admin events
|
||||
if (socket.userType === 'admin') {
|
||||
// Admins can subscribe to most events
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate room name for subscription
|
||||
* @param {string} subscriptionType - Subscription type
|
||||
* @param {string} scopeType - Scope type
|
||||
* @param {number} scopeId - Scope ID
|
||||
* @returns {string} Room name
|
||||
*/
|
||||
function getRoomName(subscriptionType, scopeType, scopeId) {
|
||||
if (!scopeType) {
|
||||
return subscriptionType;
|
||||
}
|
||||
return `${subscriptionType}:${scopeType}:${scopeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last ping timestamp
|
||||
* @param {string} connectionId - Connection ID
|
||||
*/
|
||||
async function updateLastPing(connectionId) {
|
||||
try {
|
||||
await db('websocket_connections')
|
||||
.where('connection_id', connectionId)
|
||||
.update({ last_ping: new Date() });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update last ping:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket disconnection
|
||||
* @param {Object} socket - Socket instance
|
||||
* @param {string} reason - Disconnection reason
|
||||
*/
|
||||
async function handleDisconnection(socket, reason) {
|
||||
try {
|
||||
// Remove connection from database
|
||||
await db('websocket_connections')
|
||||
.where('connection_id', socket.id)
|
||||
.update({ is_active: false });
|
||||
|
||||
// Remove all subscriptions
|
||||
await db('websocket_subscriptions')
|
||||
.where('connection_id', socket.id)
|
||||
.del();
|
||||
|
||||
logger.info('WebSocket disconnected', {
|
||||
socketId: socket.id,
|
||||
userId: socket.user?.id,
|
||||
userType: socket.userType,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error handling WebSocket disconnection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to subscribed connections
|
||||
* @param {Object} io - Socket.io instance
|
||||
* @param {string} eventType - Event type
|
||||
* @param {Object} data - Event data
|
||||
* @param {string} scopeType - Scope type (optional)
|
||||
* @param {number} scopeId - Scope ID (optional)
|
||||
*/
|
||||
function broadcastEvent(io, eventType, data, scopeType = null, scopeId = null) {
|
||||
const roomName = getRoomName(eventType, scopeType, scopeId);
|
||||
|
||||
io.to(roomName).emit(eventType, {
|
||||
type: eventType,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('Event broadcasted', {
|
||||
eventType,
|
||||
room: roomName,
|
||||
dataKeys: Object.keys(data),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeWebSocketHandlers,
|
||||
broadcastEvent,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue