From 8ad83348f47a29852c6af365c55dc8f872498e97 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 9 Jun 2026 23:41:33 +0200 Subject: [PATCH] =?UTF-8?q?server:=20core=20bootstrap=20=E2=80=94=20config?= =?UTF-8?q?,=20db=20clients,=20shared=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/app.ts | 65 +++++++++++++ server/src/config/env.ts | 13 +++ server/src/config/gameBalance.ts | 156 ++++++++++++++++++++++++++++++ server/src/db/prisma.ts | 3 + server/src/db/redis.ts | 6 ++ server/src/server.ts | 31 ++++++ server/src/shared/authGuard.ts | 11 +++ server/src/shared/errors.ts | 23 +++++ server/src/shared/validators.ts | 30 ++++++ server/src/types/fastify-jwt.d.ts | 8 ++ 10 files changed, 346 insertions(+) create mode 100644 server/src/app.ts create mode 100644 server/src/config/env.ts create mode 100644 server/src/config/gameBalance.ts create mode 100644 server/src/db/prisma.ts create mode 100644 server/src/db/redis.ts create mode 100644 server/src/server.ts create mode 100644 server/src/shared/authGuard.ts create mode 100644 server/src/shared/errors.ts create mode 100644 server/src/shared/validators.ts create mode 100644 server/src/types/fastify-jwt.d.ts diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..6506803 --- /dev/null +++ b/server/src/app.ts @@ -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 { + 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; +} diff --git a/server/src/config/env.ts b/server/src/config/env.ts new file mode 100644 index 0000000..952675a --- /dev/null +++ b/server/src/config/env.ts @@ -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); diff --git a/server/src/config/gameBalance.ts b/server/src/config/gameBalance.ts new file mode 100644 index 0000000..a773043 --- /dev/null +++ b/server/src/config/gameBalance.ts @@ -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; diff --git a/server/src/db/prisma.ts b/server/src/db/prisma.ts new file mode 100644 index 0000000..9b6c4ce --- /dev/null +++ b/server/src/db/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); diff --git a/server/src/db/redis.ts b/server/src/db/redis.ts new file mode 100644 index 0000000..9b78986 --- /dev/null +++ b/server/src/db/redis.ts @@ -0,0 +1,6 @@ +import { Redis } from 'ioredis'; +import { env } from '../config/env.js'; + +export const redis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: 3, +}); diff --git a/server/src/server.ts b/server/src/server.ts new file mode 100644 index 0000000..d4b3862 --- /dev/null +++ b/server/src/server.ts @@ -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 { + 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); +}); diff --git a/server/src/shared/authGuard.ts b/server/src/shared/authGuard.ts new file mode 100644 index 0000000..6714a0a --- /dev/null +++ b/server/src/shared/authGuard.ts @@ -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 { + try { + await request.jwtVerify(); + } catch { + throw errors.unauthorized('Token mancante o non valido'); + } +} diff --git a/server/src/shared/errors.ts b/server/src/shared/errors.ts new file mode 100644 index 0000000..bc06421 --- /dev/null +++ b/server/src/shared/errors.ts @@ -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'), +}; diff --git a/server/src/shared/validators.ts b/server/src/shared/validators.ts new file mode 100644 index 0000000..5dcac40 --- /dev/null +++ b/server/src/shared/validators.ts @@ -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(schema: S, data: unknown): output { + 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(values: readonly T[]): T { + return values[Math.floor(Math.random() * values.length)]!; +} diff --git a/server/src/types/fastify-jwt.d.ts b/server/src/types/fastify-jwt.d.ts new file mode 100644 index 0000000..e179b00 --- /dev/null +++ b/server/src/types/fastify-jwt.d.ts @@ -0,0 +1,8 @@ +import '@fastify/jwt'; + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { sub: string; playerId: string }; + user: { sub: string; playerId: string }; + } +}