burnrate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +507 -0
- package/dist/cli/format.d.ts +13 -0
- package/dist/cli/format.js +319 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +1121 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +143 -0
- package/dist/core/async-engine.d.ts +411 -0
- package/dist/core/async-engine.js +2274 -0
- package/dist/core/async-worldgen.d.ts +19 -0
- package/dist/core/async-worldgen.js +221 -0
- package/dist/core/engine.d.ts +154 -0
- package/dist/core/engine.js +1104 -0
- package/dist/core/pathfinding.d.ts +38 -0
- package/dist/core/pathfinding.js +146 -0
- package/dist/core/types.d.ts +489 -0
- package/dist/core/types.js +359 -0
- package/dist/core/worldgen.d.ts +22 -0
- package/dist/core/worldgen.js +292 -0
- package/dist/db/database.d.ts +83 -0
- package/dist/db/database.js +829 -0
- package/dist/db/turso-database.d.ts +177 -0
- package/dist/db/turso-database.js +1586 -0
- package/dist/mcp/server.d.ts +7 -0
- package/dist/mcp/server.js +1877 -0
- package/dist/server/api.d.ts +8 -0
- package/dist/server/api.js +1234 -0
- package/dist/server/async-tick-server.d.ts +5 -0
- package/dist/server/async-tick-server.js +63 -0
- package/dist/server/errors.d.ts +78 -0
- package/dist/server/errors.js +156 -0
- package/dist/server/rate-limit.d.ts +22 -0
- package/dist/server/rate-limit.js +134 -0
- package/dist/server/tick-server.d.ts +9 -0
- package/dist/server/tick-server.js +114 -0
- package/dist/server/validation.d.ts +194 -0
- package/dist/server/validation.js +114 -0
- package/package.json +65 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE Async Tick Server
|
|
3
|
+
* Processes game ticks at regular intervals using TursoDatabase
|
|
4
|
+
*/
|
|
5
|
+
import { TursoDatabase } from '../db/turso-database.js';
|
|
6
|
+
import { AsyncGameEngine } from '../core/async-engine.js';
|
|
7
|
+
const TICK_INTERVAL = parseInt(process.env.BURNRATE_TICK_INTERVAL || '600000'); // 10 minutes
|
|
8
|
+
async function main() {
|
|
9
|
+
console.log(`
|
|
10
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
11
|
+
║ BURNRATE TICK SERVER ║
|
|
12
|
+
║ The front doesn't feed itself. ║
|
|
13
|
+
╠══════════════════════════════════════════════════════════════╣
|
|
14
|
+
║ Tick interval: ${(TICK_INTERVAL / 1000).toString().padEnd(6)}seconds ║
|
|
15
|
+
║ Press Ctrl+C to stop ║
|
|
16
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
17
|
+
`);
|
|
18
|
+
const db = new TursoDatabase();
|
|
19
|
+
await db.initialize();
|
|
20
|
+
const engine = new AsyncGameEngine(db);
|
|
21
|
+
const currentTick = await db.getCurrentTick();
|
|
22
|
+
console.log(`[TICK SERVER] Starting at tick ${currentTick}`);
|
|
23
|
+
async function processTick() {
|
|
24
|
+
const startTime = Date.now();
|
|
25
|
+
try {
|
|
26
|
+
const result = await engine.processTick();
|
|
27
|
+
const duration = Date.now() - startTime;
|
|
28
|
+
console.log(`[TICK ${result.tick}] Processed in ${duration}ms, ${result.events.length} events`);
|
|
29
|
+
// Log significant events
|
|
30
|
+
for (const event of result.events) {
|
|
31
|
+
if (event.type === 'zone_captured') {
|
|
32
|
+
console.log(` → Zone ${event.data.zoneName} captured by ${event.data.newOwner || 'neutral'}`);
|
|
33
|
+
}
|
|
34
|
+
else if (event.type === 'zone_state_changed') {
|
|
35
|
+
console.log(` → Zone ${event.data.zoneName}: ${event.data.previousState} → ${event.data.newState}`);
|
|
36
|
+
}
|
|
37
|
+
else if (event.type === 'combat_resolved') {
|
|
38
|
+
console.log(` → Combat at ${event.data.location}: ${event.data.outcome}`);
|
|
39
|
+
}
|
|
40
|
+
else if (event.type === 'shipment_intercepted') {
|
|
41
|
+
console.log(` → Shipment intercepted: ${event.data.outcome}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`[TICK ERROR] ${error}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Process first tick immediately
|
|
50
|
+
await processTick();
|
|
51
|
+
// Schedule regular ticks
|
|
52
|
+
const interval = setInterval(processTick, TICK_INTERVAL);
|
|
53
|
+
// Graceful shutdown
|
|
54
|
+
const shutdown = () => {
|
|
55
|
+
console.log('\n[TICK SERVER] Shutting down...');
|
|
56
|
+
clearInterval(interval);
|
|
57
|
+
console.log('[TICK SERVER] Stopped.');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
};
|
|
60
|
+
process.on('SIGINT', shutdown);
|
|
61
|
+
process.on('SIGTERM', shutdown);
|
|
62
|
+
}
|
|
63
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE API Error Handling
|
|
3
|
+
* Standardized error responses
|
|
4
|
+
*/
|
|
5
|
+
import { Context } from 'hono';
|
|
6
|
+
import { ZodError } from 'zod';
|
|
7
|
+
export declare const ErrorCodes: {
|
|
8
|
+
readonly MISSING_API_KEY: "MISSING_API_KEY";
|
|
9
|
+
readonly INVALID_API_KEY: "INVALID_API_KEY";
|
|
10
|
+
readonly INSUFFICIENT_TIER: "INSUFFICIENT_TIER";
|
|
11
|
+
readonly VALIDATION_ERROR: "VALIDATION_ERROR";
|
|
12
|
+
readonly INVALID_INPUT: "INVALID_INPUT";
|
|
13
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
14
|
+
readonly DAILY_LIMIT_REACHED: "DAILY_LIMIT_REACHED";
|
|
15
|
+
readonly TICK_RATE_LIMITED: "TICK_RATE_LIMITED";
|
|
16
|
+
readonly NOT_FOUND: "NOT_FOUND";
|
|
17
|
+
readonly ZONE_NOT_FOUND: "ZONE_NOT_FOUND";
|
|
18
|
+
readonly PLAYER_NOT_FOUND: "PLAYER_NOT_FOUND";
|
|
19
|
+
readonly UNIT_NOT_FOUND: "UNIT_NOT_FOUND";
|
|
20
|
+
readonly SHIPMENT_NOT_FOUND: "SHIPMENT_NOT_FOUND";
|
|
21
|
+
readonly CONTRACT_NOT_FOUND: "CONTRACT_NOT_FOUND";
|
|
22
|
+
readonly FACTION_NOT_FOUND: "FACTION_NOT_FOUND";
|
|
23
|
+
readonly NOT_YOUR_RESOURCE: "NOT_YOUR_RESOURCE";
|
|
24
|
+
readonly INSUFFICIENT_PERMISSION: "INSUFFICIENT_PERMISSION";
|
|
25
|
+
readonly NOT_IN_FACTION: "NOT_IN_FACTION";
|
|
26
|
+
readonly ALREADY_IN_FACTION: "ALREADY_IN_FACTION";
|
|
27
|
+
readonly WRONG_ZONE_TYPE: "WRONG_ZONE_TYPE";
|
|
28
|
+
readonly WRONG_LOCATION: "WRONG_LOCATION";
|
|
29
|
+
readonly INSUFFICIENT_RESOURCES: "INSUFFICIENT_RESOURCES";
|
|
30
|
+
readonly INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS";
|
|
31
|
+
readonly NO_ROUTE: "NO_ROUTE";
|
|
32
|
+
readonly UNIT_BUSY: "UNIT_BUSY";
|
|
33
|
+
readonly LICENSE_REQUIRED: "LICENSE_REQUIRED";
|
|
34
|
+
readonly NAME_TAKEN: "NAME_TAKEN";
|
|
35
|
+
readonly ALREADY_EXISTS: "ALREADY_EXISTS";
|
|
36
|
+
readonly CONFLICT: "CONFLICT";
|
|
37
|
+
readonly INVALID_STATE: "INVALID_STATE";
|
|
38
|
+
readonly INTERNAL_ERROR: "INTERNAL_ERROR";
|
|
39
|
+
readonly SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
|
|
40
|
+
};
|
|
41
|
+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
|
42
|
+
export declare class GameError extends Error {
|
|
43
|
+
code: ErrorCode;
|
|
44
|
+
statusCode: number;
|
|
45
|
+
details?: Record<string, unknown> | undefined;
|
|
46
|
+
constructor(code: ErrorCode, message: string, statusCode?: number, details?: Record<string, unknown> | undefined);
|
|
47
|
+
}
|
|
48
|
+
export declare class ValidationError extends GameError {
|
|
49
|
+
constructor(message: string, details?: Record<string, unknown>);
|
|
50
|
+
static fromZod(error: ZodError): ValidationError;
|
|
51
|
+
}
|
|
52
|
+
export declare class AuthError extends GameError {
|
|
53
|
+
constructor(code: ErrorCode, message: string);
|
|
54
|
+
}
|
|
55
|
+
export declare class NotFoundError extends GameError {
|
|
56
|
+
constructor(resource: string, id?: string);
|
|
57
|
+
}
|
|
58
|
+
export declare class RateLimitError extends GameError {
|
|
59
|
+
retryAfter?: number | undefined;
|
|
60
|
+
constructor(code: ErrorCode, message: string, retryAfter?: number | undefined);
|
|
61
|
+
}
|
|
62
|
+
export declare class PermissionError extends GameError {
|
|
63
|
+
constructor(message: string, code?: ErrorCode);
|
|
64
|
+
}
|
|
65
|
+
export declare class ConflictError extends GameError {
|
|
66
|
+
constructor(message: string, code?: ErrorCode);
|
|
67
|
+
}
|
|
68
|
+
export interface ErrorResponse {
|
|
69
|
+
error: {
|
|
70
|
+
code: ErrorCode;
|
|
71
|
+
message: string;
|
|
72
|
+
details?: Record<string, unknown>;
|
|
73
|
+
requestId: string;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export declare function errorResponse(c: Context, error: GameError | Error, requestId?: string): Response;
|
|
77
|
+
import { ZodSchema } from 'zod';
|
|
78
|
+
export declare function validateBody<T>(c: Context, schema: ZodSchema<T>): Promise<T>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE API Error Handling
|
|
3
|
+
* Standardized error responses
|
|
4
|
+
*/
|
|
5
|
+
import { ZodError } from 'zod';
|
|
6
|
+
import { v4 as uuid } from 'uuid';
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// ERROR CODES
|
|
9
|
+
// ============================================================================
|
|
10
|
+
export const ErrorCodes = {
|
|
11
|
+
// Authentication
|
|
12
|
+
MISSING_API_KEY: 'MISSING_API_KEY',
|
|
13
|
+
INVALID_API_KEY: 'INVALID_API_KEY',
|
|
14
|
+
INSUFFICIENT_TIER: 'INSUFFICIENT_TIER',
|
|
15
|
+
// Validation
|
|
16
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
17
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
18
|
+
// Rate Limiting
|
|
19
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
20
|
+
DAILY_LIMIT_REACHED: 'DAILY_LIMIT_REACHED',
|
|
21
|
+
TICK_RATE_LIMITED: 'TICK_RATE_LIMITED',
|
|
22
|
+
// Resources
|
|
23
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
24
|
+
ZONE_NOT_FOUND: 'ZONE_NOT_FOUND',
|
|
25
|
+
PLAYER_NOT_FOUND: 'PLAYER_NOT_FOUND',
|
|
26
|
+
UNIT_NOT_FOUND: 'UNIT_NOT_FOUND',
|
|
27
|
+
SHIPMENT_NOT_FOUND: 'SHIPMENT_NOT_FOUND',
|
|
28
|
+
CONTRACT_NOT_FOUND: 'CONTRACT_NOT_FOUND',
|
|
29
|
+
FACTION_NOT_FOUND: 'FACTION_NOT_FOUND',
|
|
30
|
+
// Permissions
|
|
31
|
+
NOT_YOUR_RESOURCE: 'NOT_YOUR_RESOURCE',
|
|
32
|
+
INSUFFICIENT_PERMISSION: 'INSUFFICIENT_PERMISSION',
|
|
33
|
+
NOT_IN_FACTION: 'NOT_IN_FACTION',
|
|
34
|
+
ALREADY_IN_FACTION: 'ALREADY_IN_FACTION',
|
|
35
|
+
// Game State
|
|
36
|
+
WRONG_ZONE_TYPE: 'WRONG_ZONE_TYPE',
|
|
37
|
+
WRONG_LOCATION: 'WRONG_LOCATION',
|
|
38
|
+
INSUFFICIENT_RESOURCES: 'INSUFFICIENT_RESOURCES',
|
|
39
|
+
INSUFFICIENT_CREDITS: 'INSUFFICIENT_CREDITS',
|
|
40
|
+
NO_ROUTE: 'NO_ROUTE',
|
|
41
|
+
UNIT_BUSY: 'UNIT_BUSY',
|
|
42
|
+
LICENSE_REQUIRED: 'LICENSE_REQUIRED',
|
|
43
|
+
// Conflicts
|
|
44
|
+
NAME_TAKEN: 'NAME_TAKEN',
|
|
45
|
+
ALREADY_EXISTS: 'ALREADY_EXISTS',
|
|
46
|
+
CONFLICT: 'CONFLICT',
|
|
47
|
+
// State
|
|
48
|
+
INVALID_STATE: 'INVALID_STATE',
|
|
49
|
+
// Server
|
|
50
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
51
|
+
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE'
|
|
52
|
+
};
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// ERROR CLASSES
|
|
55
|
+
// ============================================================================
|
|
56
|
+
export class GameError extends Error {
|
|
57
|
+
code;
|
|
58
|
+
statusCode;
|
|
59
|
+
details;
|
|
60
|
+
constructor(code, message, statusCode = 400, details) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.code = code;
|
|
63
|
+
this.statusCode = statusCode;
|
|
64
|
+
this.details = details;
|
|
65
|
+
this.name = 'GameError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class ValidationError extends GameError {
|
|
69
|
+
constructor(message, details) {
|
|
70
|
+
super(ErrorCodes.VALIDATION_ERROR, message, 400, details);
|
|
71
|
+
this.name = 'ValidationError';
|
|
72
|
+
}
|
|
73
|
+
static fromZod(error) {
|
|
74
|
+
const issues = error.issues.map(issue => ({
|
|
75
|
+
path: issue.path.join('.'),
|
|
76
|
+
message: issue.message
|
|
77
|
+
}));
|
|
78
|
+
return new ValidationError('Validation failed', { issues });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export class AuthError extends GameError {
|
|
82
|
+
constructor(code, message) {
|
|
83
|
+
super(code, message, 401);
|
|
84
|
+
this.name = 'AuthError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class NotFoundError extends GameError {
|
|
88
|
+
constructor(resource, id) {
|
|
89
|
+
super(ErrorCodes.NOT_FOUND, id ? `${resource} '${id}' not found` : `${resource} not found`, 404);
|
|
90
|
+
this.name = 'NotFoundError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export class RateLimitError extends GameError {
|
|
94
|
+
retryAfter;
|
|
95
|
+
constructor(code, message, retryAfter) {
|
|
96
|
+
super(code, message, 429, retryAfter ? { retryAfter } : undefined);
|
|
97
|
+
this.retryAfter = retryAfter;
|
|
98
|
+
this.name = 'RateLimitError';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export class PermissionError extends GameError {
|
|
102
|
+
constructor(message, code = ErrorCodes.INSUFFICIENT_PERMISSION) {
|
|
103
|
+
super(code, message, 403);
|
|
104
|
+
this.name = 'PermissionError';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export class ConflictError extends GameError {
|
|
108
|
+
constructor(message, code = ErrorCodes.CONFLICT) {
|
|
109
|
+
super(code, message, 409);
|
|
110
|
+
this.name = 'ConflictError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function errorResponse(c, error, requestId) {
|
|
114
|
+
const rid = requestId || uuid().slice(0, 8);
|
|
115
|
+
if (error instanceof GameError) {
|
|
116
|
+
const response = {
|
|
117
|
+
error: {
|
|
118
|
+
code: error.code,
|
|
119
|
+
message: error.message,
|
|
120
|
+
requestId: rid
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
if (error.details) {
|
|
124
|
+
response.error.details = error.details;
|
|
125
|
+
}
|
|
126
|
+
const headers = {};
|
|
127
|
+
if (error instanceof RateLimitError && error.retryAfter) {
|
|
128
|
+
headers['Retry-After'] = String(error.retryAfter);
|
|
129
|
+
}
|
|
130
|
+
return c.json(response, error.statusCode, headers);
|
|
131
|
+
}
|
|
132
|
+
// Unknown error - log it but don't expose details
|
|
133
|
+
console.error(`[${rid}] Internal error:`, error);
|
|
134
|
+
return c.json({
|
|
135
|
+
error: {
|
|
136
|
+
code: ErrorCodes.INTERNAL_ERROR,
|
|
137
|
+
message: 'An internal error occurred',
|
|
138
|
+
requestId: rid
|
|
139
|
+
}
|
|
140
|
+
}, 500);
|
|
141
|
+
}
|
|
142
|
+
export async function validateBody(c, schema) {
|
|
143
|
+
try {
|
|
144
|
+
const body = await c.req.json();
|
|
145
|
+
return schema.parse(body);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof ZodError) {
|
|
149
|
+
throw ValidationError.fromZod(error);
|
|
150
|
+
}
|
|
151
|
+
if (error instanceof SyntaxError) {
|
|
152
|
+
throw new ValidationError('Invalid JSON body');
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE Rate Limiting
|
|
3
|
+
* In-memory rate limiter with sliding window
|
|
4
|
+
*/
|
|
5
|
+
import { Context, Next } from 'hono';
|
|
6
|
+
interface RateLimitConfig {
|
|
7
|
+
windowMs: number;
|
|
8
|
+
maxRequests: number;
|
|
9
|
+
keyGenerator: (c: Context) => string;
|
|
10
|
+
}
|
|
11
|
+
export declare function rateLimitMiddleware(config?: Partial<RateLimitConfig>): (c: Context, next: Next) => Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Rate limiter that adjusts based on player tier
|
|
14
|
+
* Must be used AFTER auth middleware (requires player in context)
|
|
15
|
+
*/
|
|
16
|
+
export declare function tierRateLimitMiddleware(): (c: Context, next: Next) => Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Stricter rate limit for write operations (mutations)
|
|
19
|
+
* 10 writes per minute for freelance, scaling up
|
|
20
|
+
*/
|
|
21
|
+
export declare function writeRateLimitMiddleware(): (c: Context, next: Next) => Promise<void>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BURNRATE Rate Limiting
|
|
3
|
+
* In-memory rate limiter with sliding window
|
|
4
|
+
*/
|
|
5
|
+
import { RateLimitError, ErrorCodes } from './errors.js';
|
|
6
|
+
// In-memory store (use Redis in production for horizontal scaling)
|
|
7
|
+
const rateLimitStore = new Map();
|
|
8
|
+
// Clean up old entries periodically
|
|
9
|
+
setInterval(() => {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const windowMs = 60 * 1000; // 1 minute window
|
|
12
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
13
|
+
if (now - entry.windowStart > windowMs * 2) {
|
|
14
|
+
rateLimitStore.delete(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}, 60 * 1000);
|
|
18
|
+
const DEFAULT_CONFIG = {
|
|
19
|
+
windowMs: 60 * 1000, // 1 minute
|
|
20
|
+
maxRequests: 100, // 100 requests per minute
|
|
21
|
+
keyGenerator: (c) => {
|
|
22
|
+
// Use API key if available, otherwise IP
|
|
23
|
+
const apiKey = c.req.header('X-API-Key');
|
|
24
|
+
if (apiKey)
|
|
25
|
+
return `api:${apiKey}`;
|
|
26
|
+
const forwarded = c.req.header('X-Forwarded-For');
|
|
27
|
+
const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
|
|
28
|
+
return `ip:${ip}`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// Tier-based limits (requests per minute)
|
|
32
|
+
const TIER_RATE_LIMITS = {
|
|
33
|
+
freelance: 60,
|
|
34
|
+
operator: 120,
|
|
35
|
+
command: 300
|
|
36
|
+
};
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// RATE LIMIT MIDDLEWARE
|
|
39
|
+
// ============================================================================
|
|
40
|
+
export function rateLimitMiddleware(config = {}) {
|
|
41
|
+
const { windowMs, maxRequests, keyGenerator } = { ...DEFAULT_CONFIG, ...config };
|
|
42
|
+
return async (c, next) => {
|
|
43
|
+
const key = keyGenerator(c);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
let entry = rateLimitStore.get(key);
|
|
46
|
+
// Start new window if needed
|
|
47
|
+
if (!entry || now - entry.windowStart > windowMs) {
|
|
48
|
+
entry = { count: 0, windowStart: now };
|
|
49
|
+
}
|
|
50
|
+
entry.count++;
|
|
51
|
+
rateLimitStore.set(key, entry);
|
|
52
|
+
// Check limit
|
|
53
|
+
if (entry.count > maxRequests) {
|
|
54
|
+
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
|
|
55
|
+
throw new RateLimitError(ErrorCodes.RATE_LIMITED, `Rate limit exceeded. Try again in ${retryAfter} seconds.`, retryAfter);
|
|
56
|
+
}
|
|
57
|
+
// Add rate limit headers
|
|
58
|
+
c.header('X-RateLimit-Limit', String(maxRequests));
|
|
59
|
+
c.header('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count)));
|
|
60
|
+
c.header('X-RateLimit-Reset', String(Math.ceil((entry.windowStart + windowMs) / 1000)));
|
|
61
|
+
await next();
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// TIER-AWARE RATE LIMIT
|
|
66
|
+
// ============================================================================
|
|
67
|
+
/**
|
|
68
|
+
* Rate limiter that adjusts based on player tier
|
|
69
|
+
* Must be used AFTER auth middleware (requires player in context)
|
|
70
|
+
*/
|
|
71
|
+
export function tierRateLimitMiddleware() {
|
|
72
|
+
const windowMs = 60 * 1000; // 1 minute
|
|
73
|
+
return async (c, next) => {
|
|
74
|
+
const player = c.get('player');
|
|
75
|
+
// If no player (public endpoint), use base rate
|
|
76
|
+
if (!player) {
|
|
77
|
+
return rateLimitMiddleware({ maxRequests: 30 })(c, next);
|
|
78
|
+
}
|
|
79
|
+
const key = `player:${player.id}`;
|
|
80
|
+
const maxRequests = TIER_RATE_LIMITS[player.tier] || TIER_RATE_LIMITS.freelance;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
let entry = rateLimitStore.get(key);
|
|
83
|
+
if (!entry || now - entry.windowStart > windowMs) {
|
|
84
|
+
entry = { count: 0, windowStart: now };
|
|
85
|
+
}
|
|
86
|
+
entry.count++;
|
|
87
|
+
rateLimitStore.set(key, entry);
|
|
88
|
+
if (entry.count > maxRequests) {
|
|
89
|
+
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
|
|
90
|
+
throw new RateLimitError(ErrorCodes.RATE_LIMITED, `Rate limit exceeded (${player.tier} tier: ${maxRequests}/min). Try again in ${retryAfter}s.`, retryAfter);
|
|
91
|
+
}
|
|
92
|
+
c.header('X-RateLimit-Limit', String(maxRequests));
|
|
93
|
+
c.header('X-RateLimit-Remaining', String(Math.max(0, maxRequests - entry.count)));
|
|
94
|
+
c.header('X-RateLimit-Reset', String(Math.ceil((entry.windowStart + windowMs) / 1000)));
|
|
95
|
+
await next();
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// WRITE OPERATION LIMITER
|
|
100
|
+
// ============================================================================
|
|
101
|
+
const writeStore = new Map();
|
|
102
|
+
/**
|
|
103
|
+
* Stricter rate limit for write operations (mutations)
|
|
104
|
+
* 10 writes per minute for freelance, scaling up
|
|
105
|
+
*/
|
|
106
|
+
export function writeRateLimitMiddleware() {
|
|
107
|
+
const windowMs = 60 * 1000;
|
|
108
|
+
const baseLimits = {
|
|
109
|
+
freelance: 20,
|
|
110
|
+
operator: 40,
|
|
111
|
+
command: 100
|
|
112
|
+
};
|
|
113
|
+
return async (c, next) => {
|
|
114
|
+
const player = c.get('player');
|
|
115
|
+
if (!player) {
|
|
116
|
+
await next();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const key = `write:${player.id}`;
|
|
120
|
+
const maxRequests = baseLimits[player.tier] || baseLimits.freelance;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
let entry = writeStore.get(key);
|
|
123
|
+
if (!entry || now - entry.windowStart > windowMs) {
|
|
124
|
+
entry = { count: 0, windowStart: now };
|
|
125
|
+
}
|
|
126
|
+
entry.count++;
|
|
127
|
+
writeStore.set(key, entry);
|
|
128
|
+
if (entry.count > maxRequests) {
|
|
129
|
+
const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000);
|
|
130
|
+
throw new RateLimitError(ErrorCodes.RATE_LIMITED, `Write rate limit exceeded. Try again in ${retryAfter}s.`, retryAfter);
|
|
131
|
+
}
|
|
132
|
+
await next();
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* BURNRATE Tick Server
|
|
4
|
+
* Automatically processes game ticks at regular intervals
|
|
5
|
+
*
|
|
6
|
+
* Default: 1 tick every 10 minutes (600,000ms)
|
|
7
|
+
* For testing: Use --interval flag to speed up
|
|
8
|
+
*/
|
|
9
|
+
import { GameDatabase } from '../db/database.js';
|
|
10
|
+
import { GameEngine } from '../core/engine.js';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
// Configuration
|
|
15
|
+
const TICK_INTERVAL_MS = parseInt(process.env.BURNRATE_TICK_INTERVAL || '600000'); // 10 minutes default
|
|
16
|
+
const DB_DIR = path.join(os.homedir(), '.burnrate');
|
|
17
|
+
const DB_PATH = path.join(DB_DIR, 'game.db');
|
|
18
|
+
// Ensure directory exists
|
|
19
|
+
if (!fs.existsSync(DB_DIR)) {
|
|
20
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
// Initialize
|
|
23
|
+
const db = new GameDatabase(DB_PATH);
|
|
24
|
+
const engine = new GameEngine(db);
|
|
25
|
+
// State
|
|
26
|
+
let running = true;
|
|
27
|
+
let tickCount = 0;
|
|
28
|
+
let lastTickTime = Date.now();
|
|
29
|
+
// Logging
|
|
30
|
+
function log(message) {
|
|
31
|
+
const timestamp = new Date().toISOString();
|
|
32
|
+
console.log(`[${timestamp}] ${message}`);
|
|
33
|
+
}
|
|
34
|
+
// Process a tick
|
|
35
|
+
function processTick() {
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
const result = engine.processTick();
|
|
38
|
+
const duration = Date.now() - startTime;
|
|
39
|
+
tickCount++;
|
|
40
|
+
lastTickTime = Date.now();
|
|
41
|
+
// Log tick summary
|
|
42
|
+
const eventSummary = {};
|
|
43
|
+
for (const event of result.events) {
|
|
44
|
+
eventSummary[event.type] = (eventSummary[event.type] || 0) + 1;
|
|
45
|
+
}
|
|
46
|
+
log(`Tick ${result.tick} processed in ${duration}ms (${result.events.length} events)`);
|
|
47
|
+
// Log important events
|
|
48
|
+
for (const event of result.events) {
|
|
49
|
+
if (event.type === 'zone_state_changed') {
|
|
50
|
+
log(` Zone ${event.data.zoneName}: ${event.data.previousState} → ${event.data.newState}`);
|
|
51
|
+
}
|
|
52
|
+
if (event.type === 'zone_captured') {
|
|
53
|
+
log(` Zone ${event.data.zoneName} captured by ${event.data.newOwner || 'neutral'}`);
|
|
54
|
+
}
|
|
55
|
+
if (event.type === 'shipment_arrived') {
|
|
56
|
+
log(` Shipment ${event.data.shipmentId.slice(0, 8)} arrived at destination`);
|
|
57
|
+
}
|
|
58
|
+
if (event.type === 'shipment_intercepted') {
|
|
59
|
+
log(` Shipment ${event.data.shipmentId.slice(0, 8)} intercepted! ${event.data.outcome}`);
|
|
60
|
+
}
|
|
61
|
+
if (event.type === 'combat_resolved') {
|
|
62
|
+
log(` Combat: ${event.data.outcome} (E:${event.data.escortStrength} vs R:${event.data.raiderStrength})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Main loop
|
|
67
|
+
function startTickLoop() {
|
|
68
|
+
log(`BURNRATE Tick Server started`);
|
|
69
|
+
log(`Tick interval: ${TICK_INTERVAL_MS}ms (${TICK_INTERVAL_MS / 60000} minutes)`);
|
|
70
|
+
log(`Database: ${DB_PATH}`);
|
|
71
|
+
log(`Current tick: ${db.getCurrentTick()}`);
|
|
72
|
+
log(`Press Ctrl+C to stop\n`);
|
|
73
|
+
// Process first tick immediately
|
|
74
|
+
processTick();
|
|
75
|
+
// Schedule subsequent ticks
|
|
76
|
+
const interval = setInterval(() => {
|
|
77
|
+
if (running) {
|
|
78
|
+
processTick();
|
|
79
|
+
}
|
|
80
|
+
}, TICK_INTERVAL_MS);
|
|
81
|
+
// Graceful shutdown
|
|
82
|
+
process.on('SIGINT', () => {
|
|
83
|
+
log('\nShutting down...');
|
|
84
|
+
running = false;
|
|
85
|
+
clearInterval(interval);
|
|
86
|
+
db.close();
|
|
87
|
+
log(`Processed ${tickCount} ticks total`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
});
|
|
90
|
+
process.on('SIGTERM', () => {
|
|
91
|
+
log('\nReceived SIGTERM, shutting down...');
|
|
92
|
+
running = false;
|
|
93
|
+
clearInterval(interval);
|
|
94
|
+
db.close();
|
|
95
|
+
process.exit(0);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Status endpoint (simple file-based for CLI querying)
|
|
99
|
+
function writeStatus() {
|
|
100
|
+
const statusPath = path.join(DB_DIR, 'server-status.json');
|
|
101
|
+
const status = {
|
|
102
|
+
running,
|
|
103
|
+
tickCount,
|
|
104
|
+
currentTick: db.getCurrentTick(),
|
|
105
|
+
lastTickTime: new Date(lastTickTime).toISOString(),
|
|
106
|
+
intervalMs: TICK_INTERVAL_MS,
|
|
107
|
+
pid: process.pid
|
|
108
|
+
};
|
|
109
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
|
|
110
|
+
}
|
|
111
|
+
// Update status every tick
|
|
112
|
+
setInterval(writeStatus, 5000);
|
|
113
|
+
// Start the server
|
|
114
|
+
startTickLoop();
|