server: core bootstrap — config, db clients, shared utilities
Add env config (zod validation), game balance constants, Prisma and Redis client singletons, auth guard middleware, error helpers, request validators, Fastify JWT type augmentation, and app/server entry points. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { authRoutes } from './modules/auth/auth.routes.js';
|
||||||
|
import { citiesRoutes } from './modules/cities/cities.routes.js';
|
||||||
|
import { eventsRoutes } from './modules/events/events.routes.js';
|
||||||
|
import { inventoryRoutes } from './modules/inventory/inventory.routes.js';
|
||||||
|
import { leaderboardRoutes } from './modules/leaderboard/leaderboard.routes.js';
|
||||||
|
import { marketRoutes } from './modules/market/market.routes.js';
|
||||||
|
import { missionsRoutes } from './modules/missions/missions.routes.js';
|
||||||
|
import { playersRoutes } from './modules/players/players.routes.js';
|
||||||
|
import { AppError } from './shared/errors.js';
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: env.NODE_ENV === 'production' ? 'info' : 'debug' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
await app.register(jwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
sign: { expiresIn: env.JWT_EXPIRES_IN },
|
||||||
|
});
|
||||||
|
// global: false — il rate limit si applica solo alle route che lo richiedono (auth).
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
|
||||||
|
app.setErrorHandler((error: unknown, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply
|
||||||
|
.status(error.statusCode)
|
||||||
|
.send({ error: { code: error.code, message: error.message } });
|
||||||
|
}
|
||||||
|
// Errori generati da Fastify/plugin (404, 429, body malformato, ...)
|
||||||
|
const fastifyError = error as FastifyError;
|
||||||
|
if (fastifyError.statusCode && fastifyError.statusCode < 500) {
|
||||||
|
return reply.status(fastifyError.statusCode).send({
|
||||||
|
error: { code: fastifyError.code ?? 'REQUEST_ERROR', message: fastifyError.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request.log.error({ err: error }, 'errore non gestito');
|
||||||
|
return reply
|
||||||
|
.status(500)
|
||||||
|
.send({ error: { code: 'INTERNAL_ERROR', message: 'Errore interno del server' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/healthz', async () => ({ status: 'ok' }));
|
||||||
|
|
||||||
|
await app.register(
|
||||||
|
async (api) => {
|
||||||
|
await api.register(authRoutes, { prefix: '/auth' });
|
||||||
|
await api.register(playersRoutes, { prefix: '/player' });
|
||||||
|
await api.register(citiesRoutes, { prefix: '/cities' });
|
||||||
|
await api.register(marketRoutes, { prefix: '/market' });
|
||||||
|
await api.register(inventoryRoutes, { prefix: '/inventory' });
|
||||||
|
await api.register(missionsRoutes, { prefix: '/missions' });
|
||||||
|
await api.register(eventsRoutes, { prefix: '/events' });
|
||||||
|
await api.register(leaderboardRoutes, { prefix: '/leaderboard' });
|
||||||
|
},
|
||||||
|
{ prefix: '/api/v1' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL mancante'),
|
||||||
|
REDIS_URL: z.string().min(1, 'REDIS_URL mancante'),
|
||||||
|
JWT_SECRET: z.string().min(8, 'JWT_SECRET troppo corto (min 8 caratteri)'),
|
||||||
|
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = EnvSchema.parse(process.env);
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Bilanciamento di gioco — TUTTI i parametri numerici delle regole vivono qui.
|
||||||
|
*
|
||||||
|
* Nessuna formula nel resto del codice deve usare costanti "magiche":
|
||||||
|
* se serve un numero, va aggiunto in questo file e importato.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const balance = {
|
||||||
|
/** Parametri del giocatore */
|
||||||
|
player: {
|
||||||
|
/** Città di partenza dei nuovi giocatori (deve esistere nel seed) */
|
||||||
|
startingCityName: 'Milano',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progressione esperienza.
|
||||||
|
* XP necessari per passare dal livello L al livello L+1:
|
||||||
|
* xpForNextLevel(L) = base * L ^ exponent
|
||||||
|
* Es. livello 1→2: 100 XP, 2→3: ~283 XP, 3→4: ~520 XP.
|
||||||
|
*/
|
||||||
|
xp: {
|
||||||
|
base: 100,
|
||||||
|
exponent: 1.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viaggio tra città.
|
||||||
|
* Costo = baseCost + riskLevel(città di destinazione) * costPerRiskLevel
|
||||||
|
* Es. città con riskLevel 3: 50 + 3*25 = 125.
|
||||||
|
*/
|
||||||
|
travel: {
|
||||||
|
baseCost: 50,
|
||||||
|
costPerRiskLevel: 25,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Mercato dinamico (vedi blueprint §9) */
|
||||||
|
market: {
|
||||||
|
/** buyPrice = round(rawPrice * buyMargin) */
|
||||||
|
buyMargin: 1.12,
|
||||||
|
/** sellPrice = round(rawPrice * sellMargin) */
|
||||||
|
sellMargin: 0.88,
|
||||||
|
/** rawPrice è limitato a [basePrice*minPriceFactor, basePrice*maxPriceFactor] */
|
||||||
|
minPriceFactor: 0.35,
|
||||||
|
maxPriceFactor: 3.5,
|
||||||
|
/** divisore minimo dell'offerta nella formula: demand / max(supply, minSupplyDivisor) */
|
||||||
|
minSupplyDivisor: 0.25,
|
||||||
|
/** demand e supply restano sempre in [min, max] */
|
||||||
|
minDemandSupply: 0.25,
|
||||||
|
maxDemandSupply: 4.0,
|
||||||
|
/** variazioni per unità comprata */
|
||||||
|
demandDeltaPerBuyUnit: 0.02,
|
||||||
|
supplyDeltaPerBuyUnit: -0.01,
|
||||||
|
/** variazioni per unità venduta */
|
||||||
|
demandDeltaPerSellUnit: -0.01,
|
||||||
|
supplyDeltaPerSellUnit: 0.02,
|
||||||
|
/** ritorno verso l'equilibrio a ogni tick: v = v*decayFactor + equilibrium*(1-decayFactor) */
|
||||||
|
decayFactor: 0.98,
|
||||||
|
equilibrium: 1.0,
|
||||||
|
/** massimo numero di unità per singola transazione */
|
||||||
|
maxQuantityPerTrade: 100,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Generazione e risoluzione missioni */
|
||||||
|
missions: {
|
||||||
|
/** numero minimo di missioni attive (non scadute) per città */
|
||||||
|
minActivePerCity: 5,
|
||||||
|
/** missioni attive contemporanee per giocatore */
|
||||||
|
maxActivePerPlayer: 3,
|
||||||
|
/** scadenza missione generata: minuti casuali in [min, max] */
|
||||||
|
expiryMinutesMin: 30,
|
||||||
|
expiryMinutesMax: 60,
|
||||||
|
/** durata missione: secondi casuali in [min, max] */
|
||||||
|
durationSecondsMin: 60,
|
||||||
|
durationSecondsMax: 900,
|
||||||
|
/** difficoltà casuale in [min, max] */
|
||||||
|
difficultyMin: 1,
|
||||||
|
difficultyMax: 5,
|
||||||
|
/**
|
||||||
|
* Rischio della missione (0..1):
|
||||||
|
* risk = difficulty*riskPerDifficulty + (cityRiskLevel-3)*riskPerCityRiskLevel ± riskJitter
|
||||||
|
* limitato a [riskMin, riskMax].
|
||||||
|
*/
|
||||||
|
riskPerDifficulty: 0.12,
|
||||||
|
riskPerCityRiskLevel: 0.03,
|
||||||
|
riskJitter: 0.05,
|
||||||
|
riskMin: 0.05,
|
||||||
|
riskMax: 0.9,
|
||||||
|
/**
|
||||||
|
* Ricompensa in denaro:
|
||||||
|
* reward = (base + difficulty*perDifficulty) * economyModifier * (1 + risk*riskRewardBonus)
|
||||||
|
* Se la missione richiede oggetti, si aggiunge il loro valore * requiredItemRefund.
|
||||||
|
*/
|
||||||
|
rewardMoneyBase: 150,
|
||||||
|
rewardMoneyPerDifficulty: 250,
|
||||||
|
riskRewardBonus: 0.5,
|
||||||
|
requiredItemRefund: 1.15,
|
||||||
|
/** Ricompensa XP: base + difficulty * perDifficulty */
|
||||||
|
rewardXpBase: 20,
|
||||||
|
rewardXpPerDifficulty: 30,
|
||||||
|
/** probabilità che una missione DELIVERY/SMUGGLING richieda oggetti */
|
||||||
|
requiredItemChance: 0.4,
|
||||||
|
requiredItemQuantityMax: 3,
|
||||||
|
/**
|
||||||
|
* Probabilità di successo al claim:
|
||||||
|
* chance = baseChance
|
||||||
|
* - risk * riskWeight
|
||||||
|
* + level * levelBonus
|
||||||
|
* + reputation * reputationBonus
|
||||||
|
* - (policePressure*eventPoliceModifier - 1) * policeWeight
|
||||||
|
* limitata a [minChance, maxChance].
|
||||||
|
*/
|
||||||
|
success: {
|
||||||
|
baseChance: 0.95,
|
||||||
|
riskWeight: 0.6,
|
||||||
|
levelBonus: 0.01,
|
||||||
|
reputationBonus: 0.0005,
|
||||||
|
policeWeight: 0.05,
|
||||||
|
minChance: 0.05,
|
||||||
|
maxChance: 0.95,
|
||||||
|
},
|
||||||
|
/** reputazione: +difficulty*gain in caso di successo, -loss in caso di fallimento (mai sotto 0) */
|
||||||
|
reputationGainPerDifficulty: 2,
|
||||||
|
reputationLossOnFail: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Eventi mondo (locali e globali) */
|
||||||
|
events: {
|
||||||
|
/** probabilità di generare un evento locale a ogni ciclo da 15 minuti */
|
||||||
|
localChance: 0.25,
|
||||||
|
/** probabilità di generare un evento globale a ogni ciclo orario */
|
||||||
|
globalChance: 0.2,
|
||||||
|
/** durata eventi locali: minuti casuali in [min, max] */
|
||||||
|
localDurationMinutesMin: 15,
|
||||||
|
localDurationMinutesMax: 45,
|
||||||
|
/** durata eventi globali: minuti casuali in [min, max] */
|
||||||
|
globalDurationMinutesMin: 30,
|
||||||
|
globalDurationMinutesMax: 90,
|
||||||
|
/** range [min, max] dei moltiplicatori per tipo di evento */
|
||||||
|
types: {
|
||||||
|
MARKET_BOOM: { priceModifier: [1.2, 1.5], policeModifier: [1.0, 1.0] },
|
||||||
|
POLICE_RAID: { priceModifier: [0.9, 1.1], policeModifier: [1.3, 1.8] },
|
||||||
|
SHORTAGE: { priceModifier: [1.3, 1.8], policeModifier: [1.0, 1.2] },
|
||||||
|
BLACKOUT: { priceModifier: [0.7, 0.9], policeModifier: [0.8, 1.0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Classifiche */
|
||||||
|
leaderboard: {
|
||||||
|
/** numero massimo di posizioni restituite/memorizzate */
|
||||||
|
maxEntries: 100,
|
||||||
|
/** limite di default per le richieste GET */
|
||||||
|
defaultLimit: 20,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventTypeBalance = keyof typeof balance.events.types;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const redis = new Redis(env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { prisma } from './db/prisma.js';
|
||||||
|
import { redis } from './db/redis.js';
|
||||||
|
import { runStartupJobs, startScheduler } from './jobs/scheduler.js';
|
||||||
|
import { initSocket } from './realtime/socket.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = await buildApp();
|
||||||
|
initSocket(app);
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
|
|
||||||
|
await runStartupJobs(app.log);
|
||||||
|
startScheduler(app.log);
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
app.log.info({ signal }, 'arresto del server');
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
redis.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { errors } from './errors.js';
|
||||||
|
|
||||||
|
/** preHandler che richiede un JWT valido; popola request.user con { sub, playerId }. */
|
||||||
|
export async function authGuard(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
throw errors.unauthorized('Token mancante o non valido');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Errore applicativo con status HTTP e codice macchina-leggibile. */
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errors = {
|
||||||
|
badRequest: (message: string) => new AppError(400, 'BAD_REQUEST', message),
|
||||||
|
validation: (message: string) => new AppError(400, 'VALIDATION_ERROR', message),
|
||||||
|
unauthorized: (message = 'Non autenticato') => new AppError(401, 'UNAUTHORIZED', message),
|
||||||
|
forbidden: (message = 'Operazione non consentita') => new AppError(403, 'FORBIDDEN', message),
|
||||||
|
notFound: (message = 'Risorsa non trovata') => new AppError(404, 'NOT_FOUND', message),
|
||||||
|
conflict: (message: string) => new AppError(409, 'CONFLICT', message),
|
||||||
|
insufficientFunds: () => new AppError(400, 'INSUFFICIENT_FUNDS', 'Denaro insufficiente'),
|
||||||
|
insufficientItems: () =>
|
||||||
|
new AppError(400, 'INSUFFICIENT_ITEMS', 'Quantità insufficiente in inventario'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ZodTypeAny, output } from 'zod';
|
||||||
|
import { AppError } from './errors.js';
|
||||||
|
|
||||||
|
/** Valida `data` con lo schema Zod; lancia AppError 400 con i dettagli in caso di errore. */
|
||||||
|
export function parseOrThrow<S extends ZodTypeAny>(schema: S, data: unknown): output<S> {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (!result.success) {
|
||||||
|
const detail = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', detail);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomFloat(min: number, max: number): number {
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRandom<T>(values: readonly T[]): T {
|
||||||
|
return values[Math.floor(Math.random() * values.length)]!;
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import '@fastify/jwt';
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { sub: string; playerId: string };
|
||||||
|
user: { sub: string; playerId: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user