- 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>
685 lines
No EOL
21 KiB
Markdown
685 lines
No EOL
21 KiB
Markdown
# 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. |