Compare commits

...
10 Commits
Author SHA1 Message Date
davideandClaude Sonnet 4.6 0f91ace43e client: screens and app routing
Add all game screens: Login, Dashboard, Market (buy/sell), Inventory,
Missions, Travel (city selection), Leaderboard; wired via react-router
in App.tsx with protected routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:35 +02:00
davideandClaude Sonnet 4.6 95864ce392 client: realtime socket and shared UI components
Add Socket.IO client wrapper (auto-reconnect, auth token), and
reusable components: Layout (nav bar), Toast notifications, and
Countdown timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:30 +02:00
davideandClaude Sonnet 4.6 9e65fe5a3c client: auth context and API layer
Add JWT-based AuthContext (login/logout, token persistence), typed
HTTP client with auth header injection, REST API functions for all
game endpoints, and shared TypeScript types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:22 +02:00
davideandClaude Sonnet 4.6 ad02b274d2 client: project config and bootstrap
Add Vite + React + TypeScript setup, package.json (react-router-dom,
socket.io-client), global CSS, app entry point and environment
config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:14 +02:00
davideandClaude Sonnet 4.6 0cd396ac1d server: Socket.IO realtime layer
Set up authenticated Socket.IO server; broadcasts market updates,
world events, and mission notifications to connected clients in
real time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:09 +02:00
davideandClaude Sonnet 4.6 a998bb9704 server: scheduled background jobs
Add node-cron jobs for periodic market price updates, mission
generation, and world event generation; all wired through a central
scheduler module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:42:04 +02:00
davideandClaude Sonnet 4.6 fb62a39dab server: game API modules — market, missions, players, inventory, leaderboard, cities, events
Add routes and services for all game domains: market buy/sell with
dynamic pricing, mission generation and completion, player stats and
travel, inventory queries, leaderboard, city list, and world events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:44 +02:00
davideandClaude Sonnet 4.6 c792f67e2d server: auth module — register, login, JWT
Implement registration and login endpoints with bcrypt password
hashing and JWT token issuance; all game routes depend on this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:39 +02:00
davideandClaude Sonnet 4.6 8ad83348f4 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>
2026-06-09 23:41:33 +02:00
davideandClaude Sonnet 4.6 8a91a298a7 server: database schema, migrations and seed
Define Prisma schema (Player, City, Good, InventoryItem, Transaction,
Mission, WorldEvent), initial migration SQL, and seed script to
populate cities and goods with base game data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:27 +02:00
62 changed files with 6189 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# URL del server di gioco (senza /api/v1)
VITE_API_URL=http://localhost:3000
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+58
View File
@@ -0,0 +1,58 @@
# Contrabbandieri MMO — Client
Prototipo web mobile-first del client di gioco. Parla con il server (`../server`) via REST (`/api/v1`) e Socket.IO (`/ws`).
> Nota: `Blueprint_Client.md` conteneva una copia del blueprint server, quindi le scelte client (Vite + React, tema noir) sono state concordate a parte. Il client è volutamente "stupido": tutte le regole di gioco vivono sul server.
## Stack
Vite · React 19 · TypeScript · React Router · socket.io-client · CSS puro (nessuna UI library)
## Avvio
Il server deve essere attivo (vedi `../server/README.md`). Poi:
```bash
npm install
npm run dev
```
Apri `http://localhost:5173` (per provarlo da telefono sulla stessa rete: `http://<ip-del-pc>:5173`).
Se il server non è su `localhost:3000`, crea un `.env` da `.env.example` con `VITE_API_URL`.
## Schermate
- **Login/Registrazione** — JWT salvato in localStorage.
- **Covo** (home) — profilo, denaro, reputazione, barra XP, città corrente, eventi attivi, accesso alle classifiche, logout.
- **Mercato** — listino della città corrente (prezzo acquisto/vendita), eventi che influenzano i prezzi, modale compra/vendi con quantità; si auto-aggiorna a ogni tick del server via `market:updated`.
- **Missioni** — missioni attive con countdown e riscossione (esito successo/fallimento con ricompense), missioni disponibili con difficoltà, rischio, durata e merce richiesta.
- **Zaino** — inventario con quantità e peso totale.
- **Città** — elenco città con rischio, economia e costo viaggio.
- **Classifiche** — denaro e reputazione.
## Realtime
Alla login il client apre la connessione Socket.IO autenticata col JWT. Eventi gestiti: `market:updated` (refresh listino), `mission:completed`, `worldEvent:started`, `worldEvent:ended` (toast di notifica).
## Struttura
```
src/
├── main.tsx / App.tsx # bootstrap, routing, route guard
├── config.ts # VITE_API_URL
├── api/ # http client, tipi DTO, endpoint tipizzati
├── auth/AuthContext.tsx # sessione (token+player), localStorage, socket lifecycle
├── realtime/socket.ts # singleton socket.io-client
├── components/ # Layout (topbar+bottom nav), Toast, Countdown
├── screens/ # una schermata per route
├── shared/format.ts # formattazione denaro/durate/etichette
└── styles/global.css # tema noir (variabili CSS)
```
## Build di produzione
```bash
npm run build # typecheck + bundle in dist/
npm run preview
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f13" />
<title>Contrabbandieri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1965
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "contrabbandieri-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from './auth/AuthContext';
import { Layout } from './components/Layout';
import { DashboardScreen } from './screens/DashboardScreen';
import { InventoryScreen } from './screens/InventoryScreen';
import { LeaderboardScreen } from './screens/LeaderboardScreen';
import { LoginScreen } from './screens/LoginScreen';
import { MarketScreen } from './screens/MarketScreen';
import { MissionsScreen } from './screens/MissionsScreen';
import { TravelScreen } from './screens/TravelScreen';
export function App() {
const { token } = useAuth();
if (!token) {
return (
<Routes>
<Route path="*" element={<LoginScreen />} />
</Routes>
);
}
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<DashboardScreen />} />
<Route path="/market" element={<MarketScreen />} />
<Route path="/missions" element={<MissionsScreen />} />
<Route path="/inventory" element={<InventoryScreen />} />
<Route path="/travel" element={<TravelScreen />} />
<Route path="/leaderboard" element={<LeaderboardScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { request } from './http';
import type {
ActiveMission,
AuthResponse,
City,
ClaimMissionResponse,
InventoryResponse,
LeaderboardResponse,
MarketResponse,
Mission,
PlayerMeResponse,
StartMissionResponse,
TradeResponse,
TravelResponse,
WorldEvent,
} from './types';
export const api = {
// Auth
register: (email: string, password: string, displayName: string) =>
request<AuthResponse>('/auth/register', {
method: 'POST',
body: { email, password, displayName },
}),
login: (email: string, password: string) =>
request<AuthResponse>('/auth/login', { method: 'POST', body: { email, password } }),
// Player
playerMe: () => request<PlayerMeResponse>('/player/me'),
travel: (cityId: string) =>
request<TravelResponse>('/player/travel', { method: 'POST', body: { cityId } }),
// Cities
cities: () => request<{ cities: City[] }>('/cities'),
// Market
market: () => request<MarketResponse>('/market/current'),
buy: (itemId: string, quantity: number) =>
request<TradeResponse>('/market/buy', { method: 'POST', body: { itemId, quantity } }),
sell: (itemId: string, quantity: number) =>
request<TradeResponse>('/market/sell', { method: 'POST', body: { itemId, quantity } }),
// Inventory
inventory: () => request<InventoryResponse>('/inventory'),
// Missions
missionsAvailable: () => request<{ missions: Mission[] }>('/missions/available'),
missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'),
startMission: (missionId: string) =>
request<StartMissionResponse>(`/missions/${missionId}/start`, { method: 'POST' }),
claimMission: (playerMissionId: string) =>
request<ClaimMissionResponse>(`/missions/${playerMissionId}/claim`, { method: 'POST' }),
// Events
events: () => request<{ events: WorldEvent[] }>('/events/current'),
// Leaderboard
leaderboard: (metric: 'money' | 'reputation', limit = 20) =>
request<LeaderboardResponse>(`/leaderboard/${metric}?limit=${limit}`),
};
+56
View File
@@ -0,0 +1,56 @@
import { API_URL } from '../config';
/** Errore restituito dal server nella forma { error: { code, message } }. */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
let accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
accessToken = token;
}
type RequestOptions = {
method?: 'GET' | 'POST';
body?: unknown;
};
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
let response: Response;
try {
response = await fetch(`${API_URL}/api/v1${path}`, {
method: options.method ?? 'GET',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new ApiError(0, 'NETWORK_ERROR', 'Server non raggiungibile');
}
if (!response.ok) {
let code = 'REQUEST_ERROR';
let message = `Errore ${response.status}`;
try {
const payload = (await response.json()) as { error?: { code?: string; message?: string } };
code = payload.error?.code ?? code;
message = payload.error?.message ?? message;
} catch {
// risposta non JSON: si tengono i default
}
throw new ApiError(response.status, code, message);
}
return (await response.json()) as T;
}
+149
View File
@@ -0,0 +1,149 @@
/** DTO speculari alle risposte del server (server/src/modules/*). */
export type Player = {
id: string;
displayName: string;
money: number;
reputation: number;
level: number;
experience: number;
currentCityId: string;
};
export type AuthResponse = {
accessToken: string;
player: Player;
};
export type PlayerMeResponse = {
player: Player;
currentCity: { id: string; name: string; riskLevel: number };
xpForNextLevel: number;
};
export type City = {
id: string;
name: string;
riskLevel: number;
policePressure: number;
economyModifier: number;
travelCost: number;
};
export type TravelResponse = {
player: Player;
cityName: string;
cost: number;
};
export type WorldEvent = {
id: string;
title: string;
description: string;
type: 'MARKET_BOOM' | 'POLICE_RAID' | 'SHORTAGE' | 'BLACKOUT';
cityId?: string | null;
city?: { id: string; name: string } | null;
priceModifier: number;
policeModifier: number;
startsAt: string;
endsAt: string;
};
export type MarketEntry = {
itemId: string;
name: string;
buyPrice: number;
sellPrice: number;
demand: number;
supply: number;
rarity: number;
illegalLevel: number;
weight: number;
updatedAt: string;
};
export type MarketResponse = {
city: { id: string; name: string; riskLevel: number; economyModifier: number };
prices: MarketEntry[];
activeEvents: WorldEvent[];
};
export type TradeResponse = {
itemId: string;
itemName: string;
quantity: number;
unitPrice: number;
total: number;
playerMoney: number;
};
export type InventoryEntry = {
itemId: string;
name: string;
quantity: number;
weight: number;
basePrice: number;
rarity: number;
illegalLevel: number;
};
export type InventoryResponse = {
items: InventoryEntry[];
totalWeight: number;
};
export type Mission = {
id: string;
cityId: string;
title: string;
type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL';
difficulty: number;
durationSeconds: number;
rewardMoney: number;
rewardXp: number;
risk: number;
requiredItemId: string | null;
requiredItemName: string | null;
requiredItemQuantity: number | null;
expiresAt: string;
};
export type ActiveMission = {
playerMissionId: string;
status: 'STARTED' | 'COMPLETED' | 'FAILED';
startedAt: string;
completesAt: string;
mission: Mission;
};
export type StartMissionResponse = {
playerMissionId: string;
status: string;
startedAt: string;
completesAt: string;
};
export type ClaimMissionResponse = {
playerMissionId: string;
missionTitle: string;
success: boolean;
successChance: number;
rewardMoney: number;
rewardXp: number;
reputationChange: number;
levelsGained: number;
player: Player;
};
export type LeaderboardEntry = {
rank: number;
playerId: string;
displayName: string;
level: number;
value: number;
};
export type LeaderboardResponse = {
metric: 'money' | 'reputation';
entries: LeaderboardEntry[];
};
+106
View File
@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { api } from '../api/api';
import { setAccessToken } from '../api/http';
import type { Player } from '../api/types';
import { connectSocket, disconnectSocket } from '../realtime/socket';
const STORAGE_KEY = 'contrabbandieri.auth';
type AuthState = { token: string; player: Player } | null;
type AuthContextValue = {
token: string | null;
player: Player | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
/** Aggiorna i dati del giocatore dopo un'azione (acquisto, viaggio, claim...). */
setPlayer: (player: Player) => void;
/** Ricarica il profilo dal server. */
refreshPlayer: () => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function loadStoredAuth(): AuthState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as AuthState) : null;
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthState>(() => {
const stored = loadStoredAuth();
// il token va impostato prima del primo render per le fetch iniziali
setAccessToken(stored?.token ?? null);
return stored;
});
useEffect(() => {
setAccessToken(auth?.token ?? null);
if (auth) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
connectSocket(auth.token);
} else {
localStorage.removeItem(STORAGE_KEY);
disconnectSocket();
}
return () => disconnectSocket();
}, [auth?.token]);
const login = useCallback(async (email: string, password: string) => {
const res = await api.login(email, password);
setAuth({ token: res.accessToken, player: res.player });
}, []);
const register = useCallback(
async (email: string, password: string, displayName: string) => {
const res = await api.register(email, password, displayName);
setAuth({ token: res.accessToken, player: res.player });
},
[],
);
const setPlayer = useCallback((player: Player) => {
setAuth((prev) => (prev ? { ...prev, player } : prev));
}, []);
const refreshPlayer = useCallback(async () => {
const res = await api.playerMe();
setPlayer(res.player);
}, [setPlayer]);
const logout = useCallback(() => setAuth(null), []);
const value = useMemo<AuthContextValue>(
() => ({
token: auth?.token ?? null,
player: auth?.player ?? null,
login,
register,
setPlayer,
refreshPlayer,
logout,
}),
[auth, login, register, setPlayer, refreshPlayer, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth deve essere usato dentro AuthProvider');
return ctx;
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
function remainingSeconds(target: string): number {
return Math.max(0, Math.ceil((new Date(target).getTime() - Date.now()) / 1000));
}
/** Conto alla rovescia verso `target`; chiama onDone una volta arrivato a zero. */
export function Countdown({ target, onDone }: { target: string; onDone?: () => void }) {
const [seconds, setSeconds] = useState(() => remainingSeconds(target));
useEffect(() => {
setSeconds(remainingSeconds(target));
const interval = setInterval(() => {
const left = remainingSeconds(target);
setSeconds(left);
if (left === 0) {
clearInterval(interval);
onDone?.();
}
}, 1000);
return () => clearInterval(interval);
}, [target]);
if (seconds === 0) return <span className="countdown countdown--done">pronta</span>;
const mm = Math.floor(seconds / 60);
const ss = String(seconds % 60).padStart(2, '0');
return (
<span className="countdown">
{mm}:{ss}
</span>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format';
import { useToast } from './Toast';
const NAV_ITEMS = [
{ to: '/', label: 'Covo', icon: '🏠' },
{ to: '/market', label: 'Mercato', icon: '⚖️' },
{ to: '/missions', label: 'Missioni', icon: '🎯' },
{ to: '/inventory', label: 'Zaino', icon: '🎒' },
{ to: '/travel', label: 'Città', icon: '🗺️' },
];
export function Layout() {
const { player } = useAuth();
const { toast } = useToast();
// Notifiche realtime globali dal server
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const onMissionCompleted = (payload: { title?: string }) => {
toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success');
};
const onEventStarted = (payload: { title?: string }) => {
toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info');
};
const onEventEnded = (payload: { title?: string }) => {
toast(`Evento terminato: ${payload.title ?? ''}`, 'info');
};
socket.on('mission:completed', onMissionCompleted);
socket.on('worldEvent:started', onEventStarted);
socket.on('worldEvent:ended', onEventEnded);
return () => {
socket.off('mission:completed', onMissionCompleted);
socket.off('worldEvent:started', onEventStarted);
socket.off('worldEvent:ended', onEventEnded);
};
}, [toast]);
return (
<div className="app">
<header className="topbar">
<span className="topbar__brand">CONTRABBANDIERI</span>
{player && (
<span className="topbar__stats">
<span className="topbar__money">{formatMoney(player.money)}</span>
<span className="topbar__level">liv. {player.level}</span>
</span>
)}
</header>
<main className="content">
<Outlet />
</main>
<nav className="bottomnav">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`}
>
<span className="bottomnav__icon">{item.icon}</span>
<span className="bottomnav__label">{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
type ToastKind = 'info' | 'success' | 'error';
type Toast = { id: number; kind: ToastKind; message: string };
type ToastContextValue = {
toast: (message: string, kind?: ToastKind) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId.current++;
setToasts((prev) => [...prev.slice(-3), { id, kind, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.kind}`}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast deve essere usato dentro ToastProvider');
return ctx;
}
+2
View File
@@ -0,0 +1,2 @@
/** URL base del server di gioco (REST su /api/v1, Socket.IO su /ws). */
export const API_URL: string = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { AuthProvider } from './auth/AuthContext';
import { ToastProvider } from './components/Toast';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../config';
let socket: Socket | null = null;
/** Connette il socket realtime (path /ws) autenticandosi con il JWT. */
export function connectSocket(token: string): Socket {
disconnectSocket();
socket = io(API_URL, { path: '/ws', auth: { token } });
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectSocket(): void {
socket?.disconnect();
socket = null;
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/api';
import type { PlayerMeResponse, WorldEvent } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { EVENT_TYPE_LABELS, formatMoney, riskLabel } from '../shared/format';
export function DashboardScreen() {
const { player, setPlayer, logout } = useAuth();
const [me, setMe] = useState<PlayerMeResponse | null>(null);
const [events, setEvents] = useState<WorldEvent[]>([]);
useEffect(() => {
void api.playerMe().then((res) => {
setMe(res);
setPlayer(res.player);
});
void api.events().then((res) => setEvents(res.events));
}, [setPlayer]);
if (!player) return null;
const xpNext = me?.xpForNextLevel ?? null;
const xpRatio = xpNext ? Math.min(1, player.experience / xpNext) : 0;
return (
<div className="screen">
<section className="card profile">
<div className="profile__header">
<h2>{player.displayName}</h2>
<span className="badge">liv. {player.level}</span>
</div>
{me && (
<p className="profile__city">
📍 {me.currentCity.name} · rischio {riskLabel(me.currentCity.riskLevel)}
</p>
)}
<div className="profile__stats">
<div className="stat">
<span className="stat__label">Denaro</span>
<span className="stat__value stat__value--money">{formatMoney(player.money)}</span>
</div>
<div className="stat">
<span className="stat__label">Reputazione</span>
<span className="stat__value">{player.reputation}</span>
</div>
</div>
{xpNext !== null && (
<div className="xpbar" title={`${player.experience}/${xpNext} XP`}>
<div className="xpbar__fill" style={{ width: `${xpRatio * 100}%` }} />
<span className="xpbar__text">
{player.experience}/{xpNext} XP
</span>
</div>
)}
</section>
<section className="card">
<h3 className="card__title">Eventi in corso</h3>
{events.length === 0 ? (
<p className="muted">Tutto tranquillo... per ora.</p>
) : (
<ul className="list">
{events.map((event) => (
<li key={event.id} className="list__row">
<div>
<strong>{event.title}</strong>
<span className="muted">
{' '}
· {event.city?.name ?? 'tutte le città'} ·{' '}
{EVENT_TYPE_LABELS[event.type] ?? event.type}
</span>
<p className="muted small">{event.description}</p>
</div>
</li>
))}
</ul>
)}
</section>
<Link to="/leaderboard" className="btn btn--secondary">
🏆 Classifiche
</Link>
<button type="button" className="btn btn--ghost" onClick={logout}>
Esci
</button>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/api';
import type { InventoryResponse } from '../api/types';
import { formatMoney } from '../shared/format';
export function InventoryScreen() {
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
useEffect(() => {
void api.inventory().then(setInventory);
}, []);
if (!inventory) return <p className="muted screen">Caricamento zaino...</p>;
return (
<div className="screen">
<h2 className="screen__title">Zaino</h2>
{inventory.items.length === 0 ? (
<div className="card">
<p className="muted">Lo zaino è vuoto.</p>
<Link to="/market" className="btn btn--secondary">
Vai al mercato
</Link>
</div>
) : (
<>
<ul className="list">
{inventory.items.map((item) => (
<li key={item.itemId} className="list__row">
<div>
<strong>{item.name}</strong>
<p className="muted small">
valore base {formatMoney(item.basePrice)} · peso {item.weight}/unità
</p>
</div>
<span className="badge">×{item.quantity}</span>
</li>
))}
</ul>
<p className="muted small">Peso totale: {inventory.totalWeight}</p>
<Link to="/market" className="btn btn--secondary">
Vendi al mercato
</Link>
</>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { api } from '../api/api';
import type { LeaderboardResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { formatMoney } from '../shared/format';
type Metric = 'money' | 'reputation';
export function LeaderboardScreen() {
const { player } = useAuth();
const [metric, setMetric] = useState<Metric>('money');
const [board, setBoard] = useState<LeaderboardResponse | null>(null);
useEffect(() => {
setBoard(null);
void api.leaderboard(metric).then(setBoard);
}, [metric]);
return (
<div className="screen">
<h2 className="screen__title">Classifiche</h2>
<div className="login__tabs">
<button
type="button"
className={metric === 'money' ? 'is-active' : ''}
onClick={() => setMetric('money')}
>
Denaro
</button>
<button
type="button"
className={metric === 'reputation' ? 'is-active' : ''}
onClick={() => setMetric('reputation')}
>
Reputazione
</button>
</div>
{!board ? (
<p className="muted">Caricamento...</p>
) : board.entries.length === 0 ? (
<p className="muted">Classifica vuota.</p>
) : (
<ul className="list">
{board.entries.map((entry) => (
<li
key={entry.playerId}
className={`list__row${entry.playerId === player?.id ? ' is-current' : ''}`}
>
<div>
<span className="rank">#{entry.rank}</span>
<strong>{entry.displayName}</strong>
<span className="muted small"> · liv. {entry.level}</span>
</div>
<span className="stat__value--money">
{metric === 'money' ? formatMoney(entry.value) : entry.value}
</span>
</li>
))}
</ul>
)}
<p className="muted small">Le classifiche si aggiornano ogni 15 minuti.</p>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, type FormEvent } from 'react';
import { ApiError } from '../api/http';
import { useAuth } from '../auth/AuthContext';
export function LoginScreen() {
const { login, register } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (mode === 'login') {
await login(email, password);
} else {
await register(email, password, displayName);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Errore imprevisto');
} finally {
setBusy(false);
}
}
return (
<div className="login">
<h1 className="login__title">CONTRABBANDIERI</h1>
<p className="login__subtitle">La città non dorme. Nemmeno tu.</p>
<div className="login__tabs">
<button
type="button"
className={mode === 'login' ? 'is-active' : ''}
onClick={() => setMode('login')}
>
Accedi
</button>
<button
type="button"
className={mode === 'register' ? 'is-active' : ''}
onClick={() => setMode('register')}
>
Registrati
</button>
</div>
<form className="login__form" onSubmit={onSubmit}>
{mode === 'register' && (
<label>
Nome in codice
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="ShadowFox"
minLength={3}
maxLength={20}
required
/>
</label>
)}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@esempio.com"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === 'register' ? 'minimo 8 caratteri' : ''}
minLength={mode === 'register' ? 8 : 1}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn btn--primary" disabled={busy}>
{busy ? '...' : mode === 'login' ? 'Entra in città' : 'Inizia la carriera'}
</button>
</form>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/api';
import { ApiError } from '../api/http';
import type { InventoryResponse, MarketEntry, MarketResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format';
export function MarketScreen() {
const { player, refreshPlayer } = useAuth();
const { toast } = useToast();
const [market, setMarket] = useState<MarketResponse | null>(null);
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
const [selected, setSelected] = useState<MarketEntry | null>(null);
const [quantity, setQuantity] = useState(1);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const [m, inv] = await Promise.all([api.market(), api.inventory()]);
setMarket(m);
setInventory(inv);
}, []);
useEffect(() => {
void reload();
}, [reload]);
// Il mercato si aggiorna a ogni tick del server
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const onUpdate = () => void reload();
socket.on('market:updated', onUpdate);
return () => {
socket.off('market:updated', onUpdate);
};
}, [reload]);
const owned = (itemId: string) =>
inventory?.items.find((i) => i.itemId === itemId)?.quantity ?? 0;
async function trade(kind: 'buy' | 'sell') {
if (!selected) return;
setBusy(true);
try {
const fn = kind === 'buy' ? api.buy : api.sell;
const res = await fn(selected.itemId, quantity);
toast(
kind === 'buy'
? `Comprati ${res.quantity}× ${res.itemName} per ${formatMoney(res.total)}`
: `Venduti ${res.quantity}× ${res.itemName} per ${formatMoney(res.total)}`,
'success',
);
setSelected(null);
setQuantity(1);
await Promise.all([reload(), refreshPlayer()]);
} catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally {
setBusy(false);
}
}
if (!market) return <p className="muted screen">Caricamento mercato...</p>;
return (
<div className="screen">
<h2 className="screen__title">Mercato di {market.city.name}</h2>
{market.activeEvents.length > 0 && (
<div className="banner">
{market.activeEvents.map((e) => (
<span key={e.id}> {e.title}</span>
))}
</div>
)}
<ul className="list">
{market.prices.map((entry) => (
<li
key={entry.itemId}
className="list__row list__row--clickable"
onClick={() => {
setSelected(entry);
setQuantity(1);
}}
>
<div>
<strong>{entry.name}</strong>
{owned(entry.itemId) > 0 && (
<span className="badge badge--small">×{owned(entry.itemId)}</span>
)}
</div>
<div className="prices">
<span className="price price--buy">{formatMoney(entry.buyPrice)}</span>
<span className="price price--sell">{formatMoney(entry.sellPrice)}</span>
</div>
</li>
))}
</ul>
<p className="muted small">
Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto.
</p>
{selected && (
<div className="modal-backdrop" onClick={() => setSelected(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{selected.name}</h3>
<p className="muted small">
Possiedi: {owned(selected.itemId)} · peso {selected.weight}/unità
</p>
<label className="quantity">
Quantità
<div className="quantity__controls">
<button type="button" onClick={() => setQuantity((q) => Math.max(1, q - 1))}>
</button>
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={(e) =>
setQuantity(Math.max(1, Math.min(100, Number(e.target.value) || 1)))
}
/>
<button type="button" onClick={() => setQuantity((q) => Math.min(100, q + 1))}>
+
</button>
</div>
</label>
<div className="modal__actions">
<button
type="button"
className="btn btn--primary"
disabled={busy || (player?.money ?? 0) < selected.buyPrice * quantity}
onClick={() => void trade('buy')}
>
Compra · {formatMoney(selected.buyPrice * quantity)}
</button>
<button
type="button"
className="btn btn--secondary"
disabled={busy || owned(selected.itemId) < quantity}
onClick={() => void trade('sell')}
>
Vendi · {formatMoney(selected.sellPrice * quantity)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/api';
import { ApiError } from '../api/http';
import type { ActiveMission, ClaimMissionResponse, Mission } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { Countdown } from '../components/Countdown';
import { useToast } from '../components/Toast';
import {
difficultyStars,
formatDuration,
formatMoney,
formatPercent,
MISSION_TYPE_LABELS,
} from '../shared/format';
export function MissionsScreen() {
const { setPlayer } = useAuth();
const { toast } = useToast();
const [available, setAvailable] = useState<Mission[]>([]);
const [active, setActive] = useState<ActiveMission[]>([]);
const [result, setResult] = useState<ClaimMissionResponse | null>(null);
const [busy, setBusy] = useState(false);
// tick fittizio per ri-renderizzare quando un countdown arriva a zero
const [, setTick] = useState(0);
const reload = useCallback(async () => {
const [av, ac] = await Promise.all([api.missionsAvailable(), api.missionsActive()]);
setAvailable(av.missions);
setActive(ac.missions);
}, []);
useEffect(() => {
void reload();
}, [reload]);
async function start(mission: Mission) {
setBusy(true);
try {
await api.startMission(mission.id);
toast(`Missione "${mission.title}" avviata`, 'success');
await reload();
} catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally {
setBusy(false);
}
}
async function claim(playerMissionId: string) {
setBusy(true);
try {
const res = await api.claimMission(playerMissionId);
setResult(res);
setPlayer(res.player);
await reload();
} catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally {
setBusy(false);
}
}
const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now();
return (
<div className="screen">
<h2 className="screen__title">Missioni</h2>
{active.length > 0 && (
<section className="card">
<h3 className="card__title">In corso</h3>
<ul className="list">
{active.map((m) => (
<li key={m.playerMissionId} className="list__row">
<div>
<strong>{m.mission.title}</strong>
<p className="muted small">
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
</p>
</div>
{isReady(m) ? (
<button
type="button"
className="btn btn--primary btn--small"
disabled={busy}
onClick={() => void claim(m.playerMissionId)}
>
Riscuoti
</button>
) : (
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
)}
</li>
))}
</ul>
</section>
)}
<section className="card">
<h3 className="card__title">Disponibili in città</h3>
{available.length === 0 ? (
<p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p>
) : (
<ul className="list">
{available.map((mission) => (
<li key={mission.id} className="mission">
<div className="mission__head">
<strong>{mission.title}</strong>
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
</div>
<p className="muted small">
{MISSION_TYPE_LABELS[mission.type]} · durata{' '}
{formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)}
</p>
{mission.requiredItemName && (
<p className="mission__required">
Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName}
</p>
)}
<div className="mission__footer">
<span className="mission__reward">
{formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP
</span>
<button
type="button"
className="btn btn--secondary btn--small"
disabled={busy}
onClick={() => void start(mission)}
>
Avvia
</button>
</div>
</li>
))}
</ul>
)}
</section>
{result && (
<div className="modal-backdrop" onClick={() => setResult(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>{result.success ? '✅ Colpo riuscito!' : '❌ Missione fallita'}</h3>
<p className="muted">"{result.missionTitle}"</p>
{result.success ? (
<ul className="result-list">
<li>+{formatMoney(result.rewardMoney)}</li>
<li>+{result.rewardXp} XP</li>
<li>+{result.reputationChange} reputazione</li>
{result.levelsGained > 0 && (
<li className="levelup">
Sei salito al livello {result.player.level}!
</li>
)}
</ul>
) : (
<ul className="result-list">
<li>{result.reputationChange} reputazione</li>
<li className="muted small">
Probabilità di successo: {formatPercent(result.successChance)}
</li>
</ul>
)}
<button type="button" className="btn btn--primary" onClick={() => setResult(null)}>
Continua
</button>
</div>
</div>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/api';
import { ApiError } from '../api/http';
import type { City } from '../api/types';
import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast';
import { formatMoney, riskLabel } from '../shared/format';
export function TravelScreen() {
const { player, setPlayer } = useAuth();
const { toast } = useToast();
const [cities, setCities] = useState<City[]>([]);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const res = await api.cities();
setCities(res.cities);
}, []);
useEffect(() => {
void reload();
}, [reload]);
async function travelTo(city: City) {
setBusy(true);
try {
const res = await api.travel(city.id);
setPlayer(res.player);
toast(`Benvenuto a ${res.cityName} (${formatMoney(res.cost)})`, 'success');
} catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally {
setBusy(false);
}
}
return (
<div className="screen">
<h2 className="screen__title">Città</h2>
<ul className="list">
{cities.map((city) => {
const isCurrent = player?.currentCityId === city.id;
const canAfford = (player?.money ?? 0) >= city.travelCost;
return (
<li key={city.id} className={`list__row${isCurrent ? ' is-current' : ''}`}>
<div>
<strong>{city.name}</strong>
{isCurrent && <span className="badge badge--small">sei qui</span>}
<p className="muted small">
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
</p>
</div>
{!isCurrent && (
<button
type="button"
className="btn btn--secondary btn--small"
disabled={busy || !canAfford}
onClick={() => void travelTo(city)}
>
Viaggia · {formatMoney(city.travelCost)}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
const euro = new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
export function formatMoney(value: number): string {
return euro.format(value);
}
export function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
/** Stelle di difficoltà (1-5). */
export function difficultyStars(difficulty: number): string {
return '★'.repeat(difficulty) + '☆'.repeat(Math.max(0, 5 - difficulty));
}
export function formatDuration(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes === 0) return `${seconds}s`;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
export const MISSION_TYPE_LABELS: Record<string, string> = {
DELIVERY: 'Consegna',
THEFT: 'Furto',
SMUGGLING: 'Contrabbando',
INTEL: 'Informazioni',
};
export const EVENT_TYPE_LABELS: Record<string, string> = {
MARKET_BOOM: 'Boom di mercato',
POLICE_RAID: 'Retata',
SHORTAGE: 'Carenza merci',
BLACKOUT: 'Blackout',
};
/** Etichetta del rischio città (1-5). */
export function riskLabel(riskLevel: number): string {
if (riskLevel <= 2) return 'basso';
if (riskLevel === 3) return 'medio';
return 'alto';
}
+622
View File
@@ -0,0 +1,622 @@
/* ===== Tema noir "Contrabbandieri" ===== */
:root {
--bg: #0f0f13;
--bg-raised: #1a1a21;
--bg-card: #17171d;
--border: #2a2a33;
--text: #e8e4da;
--text-muted: #8d8a80;
--accent: #d4a017; /* oro sporco */
--accent-dim: #8a6a10;
--success: #4caf6e;
--danger: #d9534f;
--info: #5b8bd9;
--radius: 10px;
--nav-height: 64px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
}
#root {
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
}
/* ===== Struttura app ===== */
.app {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(15, 15, 19, 0.95);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(6px);
}
.topbar__brand {
font-weight: 800;
letter-spacing: 0.18em;
font-size: 0.85rem;
color: var(--accent);
}
.topbar__stats {
display: flex;
gap: 10px;
align-items: baseline;
}
.topbar__money {
font-weight: 700;
color: var(--success);
}
.topbar__level {
font-size: 0.8rem;
color: var(--text-muted);
}
.content {
flex: 1;
padding: 16px 16px calc(var(--nav-height) + 24px);
}
.bottomnav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
height: var(--nav-height);
display: flex;
background: var(--bg-raised);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom);
}
.bottomnav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
text-decoration: none;
color: var(--text-muted);
font-size: 0.65rem;
letter-spacing: 0.04em;
}
.bottomnav__item.is-active {
color: var(--accent);
}
.bottomnav__icon {
font-size: 1.25rem;
}
/* ===== Schermate e card ===== */
.screen {
display: flex;
flex-direction: column;
gap: 14px;
}
.screen__title {
margin: 4px 0 0;
font-size: 1.2rem;
letter-spacing: 0.04em;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card__title {
margin: 0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
margin: 0;
}
.small {
font-size: 0.8rem;
}
.badge {
background: var(--accent-dim);
color: #fff;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
}
.badge--small {
font-size: 0.65rem;
padding: 1px 8px;
margin-left: 8px;
}
.banner {
background: rgba(212, 160, 23, 0.12);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
}
/* ===== Liste ===== */
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.list__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
}
.list__row--clickable {
cursor: pointer;
}
.list__row--clickable:hover {
border-color: var(--accent-dim);
}
.list__row.is-current {
border-color: var(--accent);
}
.prices {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.price {
font-weight: 700;
font-size: 0.9rem;
}
.price--buy {
color: var(--danger);
}
.price--sell {
color: var(--success);
}
.rank {
display: inline-block;
min-width: 2.2em;
color: var(--accent);
font-weight: 700;
}
/* ===== Profilo ===== */
.profile__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.profile__header h2 {
margin: 0;
}
.profile__city {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.profile__stats {
display: flex;
gap: 20px;
}
.stat {
display: flex;
flex-direction: column;
}
.stat__label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.stat__value {
font-size: 1.15rem;
font-weight: 700;
}
.stat__value--money {
color: var(--success);
font-weight: 700;
}
.xpbar {
position: relative;
height: 18px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
}
.xpbar__fill {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
transition: width 0.4s ease;
}
.xpbar__text {
position: relative;
display: block;
text-align: center;
font-size: 0.7rem;
line-height: 18px;
color: var(--text);
}
/* ===== Missioni ===== */
.mission {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mission__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.mission__stars {
color: var(--accent);
font-size: 0.85rem;
letter-spacing: 0.1em;
}
.mission__required {
margin: 0;
font-size: 0.8rem;
color: var(--accent);
}
.mission__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.mission__reward {
font-weight: 700;
color: var(--success);
font-size: 0.9rem;
}
.countdown {
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--accent);
}
.countdown--done {
color: var(--success);
}
.result-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 600;
}
.levelup {
color: var(--accent);
}
/* ===== Bottoni e form ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: var(--radius);
padding: 12px 16px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: opacity 0.15s ease;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent);
color: #16130a;
}
.btn--secondary {
background: var(--bg-raised);
color: var(--text);
border: 1px solid var(--border);
}
.btn--ghost {
background: transparent;
color: var(--text-muted);
}
.btn--small {
padding: 8px 12px;
font-size: 0.8rem;
}
.form-error {
color: var(--danger);
margin: 0;
font-size: 0.85rem;
}
/* ===== Login ===== */
.login {
min-height: 100dvh;
display: flex;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 24px;
}
.login__title {
margin: 0;
text-align: center;
letter-spacing: 0.25em;
color: var(--accent);
font-size: 1.4rem;
}
.login__subtitle {
margin: 0;
text-align: center;
color: var(--text-muted);
font-style: italic;
font-size: 0.9rem;
}
.login__tabs {
display: flex;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.login__tabs button {
flex: 1;
padding: 10px;
background: transparent;
border: none;
color: var(--text-muted);
font-weight: 700;
cursor: pointer;
}
.login__tabs button.is-active {
background: var(--accent);
color: #16130a;
}
.login__form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login__form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
input {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 12px;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: var(--accent);
}
/* ===== Modal ===== */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: flex-end;
justify-content: center;
}
.modal {
width: 100%;
max-width: 480px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 12px;
}
.modal h3 {
margin: 0;
}
.modal__actions {
display: flex;
gap: 10px;
}
.modal__actions .btn {
flex: 1;
}
.quantity {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.quantity__controls {
display: flex;
gap: 8px;
}
.quantity__controls button {
width: 48px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 1.2rem;
cursor: pointer;
}
.quantity__controls input {
flex: 1;
text-align: center;
}
/* ===== Toast ===== */
.toast-stack {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 8px;
width: calc(100% - 32px);
max-width: 448px;
pointer-events: none;
}
.toast {
padding: 12px 14px;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--border);
background: var(--bg-raised);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
animation: toast-in 0.2s ease;
}
.toast--success {
border-color: var(--success);
}
.toast--error {
border-color: var(--danger);
}
.toast--info {
border-color: var(--info);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
});
@@ -0,0 +1,178 @@
-- CreateEnum
CREATE TYPE "MissionStatus" AS ENUM ('STARTED', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "MissionType" AS ENUM ('DELIVERY', 'THEFT', 'SMUGGLING', 'INTEL');
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('MARKET_BOOM', 'POLICE_RAID', 'SHORTAGE', 'BLACKOUT');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"money" INTEGER NOT NULL DEFAULT 1000,
"reputation" INTEGER NOT NULL DEFAULT 0,
"level" INTEGER NOT NULL DEFAULT 1,
"experience" INTEGER NOT NULL DEFAULT 0,
"currentCityId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "City" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"riskLevel" INTEGER NOT NULL,
"policePressure" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"economyModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "City_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"basePrice" INTEGER NOT NULL,
"rarity" INTEGER NOT NULL,
"illegalLevel" INTEGER NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InventoryItem" (
"playerId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("playerId","itemId")
);
-- CreateTable
CREATE TABLE "MarketPrice" (
"cityId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"buyPrice" INTEGER NOT NULL,
"sellPrice" INTEGER NOT NULL,
"demand" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"supply" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketPrice_pkey" PRIMARY KEY ("cityId","itemId")
);
-- CreateTable
CREATE TABLE "Mission" (
"id" TEXT NOT NULL,
"cityId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "MissionType" NOT NULL,
"difficulty" INTEGER NOT NULL,
"durationSeconds" INTEGER NOT NULL,
"rewardMoney" INTEGER NOT NULL,
"rewardXp" INTEGER NOT NULL,
"risk" DOUBLE PRECISION NOT NULL,
"requiredItemId" TEXT,
"requiredItemQuantity" INTEGER,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Mission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlayerMission" (
"id" TEXT NOT NULL,
"playerId" TEXT NOT NULL,
"missionId" TEXT NOT NULL,
"status" "MissionStatus" NOT NULL DEFAULT 'STARTED',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completesAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "PlayerMission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorldEvent" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"cityId" TEXT,
"priceModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"policeModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorldEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Player_userId_key" ON "Player"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Player_displayName_key" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "City_name_key" ON "City"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Item_name_key" ON "Item"("name");
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_currentCityId_fkey" FOREIGN KEY ("currentCityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_requiredItemId_fkey" FOREIGN KEY ("requiredItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_missionId_fkey" FOREIGN KEY ("missionId") REFERENCES "Mission"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorldEvent" ADD CONSTRAINT "WorldEvent_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+157
View File
@@ -0,0 +1,157 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum MissionStatus {
STARTED
COMPLETED
FAILED
}
enum MissionType {
DELIVERY
THEFT
SMUGGLING
INTEL
}
enum EventType {
MARKET_BOOM
POLICE_RAID
SHORTAGE
BLACKOUT
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
player Player?
}
model Player {
id String @id @default(uuid())
userId String @unique
displayName String @unique
money Int @default(1000)
reputation Int @default(0)
level Int @default(1)
experience Int @default(0)
currentCityId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
currentCity City @relation(fields: [currentCityId], references: [id])
inventory InventoryItem[]
missions PlayerMission[]
}
model City {
id String @id @default(uuid())
name String @unique
riskLevel Int
policePressure Float @default(1.0)
economyModifier Float @default(1.0)
createdAt DateTime @default(now())
players Player[]
prices MarketPrice[]
missions Mission[]
events WorldEvent[]
}
model Item {
id String @id @default(uuid())
name String @unique
basePrice Int
rarity Int
illegalLevel Int
weight Int @default(1)
createdAt DateTime @default(now())
inventory InventoryItem[]
prices MarketPrice[]
missions Mission[]
}
model InventoryItem {
playerId String
itemId String
quantity Int
player Player @relation(fields: [playerId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@id([playerId, itemId])
}
model MarketPrice {
cityId String
itemId String
buyPrice Int
sellPrice Int
demand Float @default(1.0)
supply Float @default(1.0)
updatedAt DateTime @updatedAt
city City @relation(fields: [cityId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@id([cityId, itemId])
}
model Mission {
id String @id @default(uuid())
cityId String
title String
type MissionType
difficulty Int
durationSeconds Int
rewardMoney Int
rewardXp Int
risk Float
requiredItemId String?
requiredItemQuantity Int?
expiresAt DateTime
createdAt DateTime @default(now())
city City @relation(fields: [cityId], references: [id])
requiredItem Item? @relation(fields: [requiredItemId], references: [id])
playerMissions PlayerMission[]
}
model PlayerMission {
id String @id @default(uuid())
playerId String
missionId String
status MissionStatus @default(STARTED)
startedAt DateTime @default(now())
completesAt DateTime
resolvedAt DateTime?
player Player @relation(fields: [playerId], references: [id])
mission Mission @relation(fields: [missionId], references: [id])
}
model WorldEvent {
id String @id @default(uuid())
title String
description String
type EventType
cityId String?
priceModifier Float @default(1.0)
policeModifier Float @default(1.0)
startsAt DateTime
endsAt DateTime
createdAt DateTime @default(now())
city City? @relation(fields: [cityId], references: [id])
}
+81
View File
@@ -0,0 +1,81 @@
import { PrismaClient } from '@prisma/client';
import { computePrices } from '../src/modules/market/pricing.js';
import { randomFloat } from '../src/shared/validators.js';
const prisma = new PrismaClient();
/** Città iniziali (blueprint §19): economia e rischio differenziati. */
const CITIES = [
{ name: 'Milano', riskLevel: 3, policePressure: 1.1, economyModifier: 1.25 },
{ name: 'Napoli', riskLevel: 4, policePressure: 1.3, economyModifier: 1.0 },
{ name: 'Palermo', riskLevel: 4, policePressure: 1.25, economyModifier: 0.85 },
{ name: 'Torino', riskLevel: 2, policePressure: 0.9, economyModifier: 1.0 },
{ name: 'Bari', riskLevel: 3, policePressure: 1.0, economyModifier: 0.85 },
];
/** Item commerciabili iniziali (blueprint §19). */
const ITEMS = [
{ name: 'Gioielli', basePrice: 500, rarity: 2, illegalLevel: 2, weight: 1 },
{ name: 'Componenti elettronici', basePrice: 350, rarity: 1, illegalLevel: 1, weight: 2 },
{ name: 'Documenti falsi', basePrice: 700, rarity: 3, illegalLevel: 4, weight: 1 },
{ name: 'Auto rubate', basePrice: 1800, rarity: 4, illegalLevel: 5, weight: 10 },
{ name: 'Informazioni riservate', basePrice: 1200, rarity: 4, illegalLevel: 3, weight: 1 },
{ name: 'Armi leggere', basePrice: 1500, rarity: 3, illegalLevel: 5, weight: 4 },
];
async function main(): Promise<void> {
for (const city of CITIES) {
await prisma.city.upsert({
where: { name: city.name },
create: city,
update: {
riskLevel: city.riskLevel,
policePressure: city.policePressure,
economyModifier: city.economyModifier,
},
});
}
console.log(`Città: ${CITIES.length}`);
for (const item of ITEMS) {
await prisma.item.upsert({
where: { name: item.name },
create: item,
update: {
basePrice: item.basePrice,
rarity: item.rarity,
illegalLevel: item.illegalLevel,
weight: item.weight,
},
});
}
console.log(`Item: ${ITEMS.length}`);
// Listino iniziale per ogni coppia città/item, con domanda e offerta
// leggermente casuali. I prezzi già esistenti non vengono toccati.
const cities = await prisma.city.findMany();
const items = await prisma.item.findMany();
let createdPrices = 0;
for (const city of cities) {
for (const item of items) {
const demand = Math.round(randomFloat(0.8, 1.2) * 100) / 100;
const supply = Math.round(randomFloat(0.8, 1.2) * 100) / 100;
const prices = computePrices(item.basePrice, city.economyModifier, demand, supply, 1);
const result = await prisma.marketPrice.upsert({
where: { cityId_itemId: { cityId: city.id, itemId: item.id } },
create: { cityId: city.id, itemId: item.id, demand, supply, ...prices },
update: {},
});
if (result) createdPrices += 1;
}
}
console.log(`Prezzi di mercato: ${createdPrices}`);
console.log('Seed completato.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+65
View File
@@ -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;
}
+13
View File
@@ -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);
+156
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+6
View File
@@ -0,0 +1,6 @@
import { Redis } from 'ioredis';
import { env } from '../config/env.js';
export const redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
});
+132
View File
@@ -0,0 +1,132 @@
import { MissionStatus, MissionType, type Prisma } from '@prisma/client';
import { balance } from '../config/gameBalance.js';
import { prisma } from '../db/prisma.js';
import { redis } from '../db/redis.js';
import { emitToPlayer } from '../realtime/socket.js';
import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js';
/** Template missioni (blueprint §19) con il tipo associato. */
const MISSION_TEMPLATES: ReadonlyArray<{ title: string; type: MissionType }> = [
{ title: 'Consegna discreta', type: MissionType.DELIVERY },
{ title: 'Recupero merce', type: MissionType.THEFT },
{ title: 'Scambio al porto', type: MissionType.SMUGGLING },
{ title: 'Infiltrazione uffici', type: MissionType.INTEL },
{ title: 'Trasporto notturno', type: MissionType.SMUGGLING },
];
/** Tipi di missione che possono richiedere merce dall'inventario. */
const TYPES_WITH_REQUIRED_ITEM: ReadonlySet<MissionType> = new Set([
MissionType.DELIVERY,
MissionType.SMUGGLING,
]);
/** Genera nuove missioni nelle città sotto la soglia minima. */
export async function generateMissions(): Promise<number> {
const b = balance.missions;
const now = new Date();
const [cities, items] = await Promise.all([
prisma.city.findMany(),
prisma.item.findMany(),
]);
const toCreate: Prisma.MissionCreateManyInput[] = [];
for (const city of cities) {
const activeCount = await prisma.mission.count({
where: { cityId: city.id, expiresAt: { gt: now } },
});
for (let i = activeCount; i < b.minActivePerCity; i++) {
const template = pickRandom(MISSION_TEMPLATES);
const difficulty = randomInt(b.difficultyMin, b.difficultyMax);
const risk =
Math.round(
clamp(
difficulty * b.riskPerDifficulty +
(city.riskLevel - 3) * b.riskPerCityRiskLevel +
randomFloat(-b.riskJitter, b.riskJitter),
b.riskMin,
b.riskMax,
) * 100,
) / 100;
let requiredItemId: string | null = null;
let requiredItemQuantity: number | null = null;
let itemRefund = 0;
if (
items.length > 0 &&
TYPES_WITH_REQUIRED_ITEM.has(template.type) &&
Math.random() < b.requiredItemChance
) {
const item = pickRandom(items);
requiredItemId = item.id;
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund;
}
const rewardMoney = Math.round(
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
city.economyModifier *
(1 + risk * b.riskRewardBonus) +
itemRefund,
);
toCreate.push({
cityId: city.id,
title: template.title,
type: template.type,
difficulty,
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
rewardMoney,
rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty,
risk,
requiredItemId,
requiredItemQuantity,
expiresAt: new Date(
now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000,
),
});
}
}
if (toCreate.length > 0) {
await prisma.mission.createMany({ data: toCreate });
}
return toCreate.length;
}
/**
* Notifica (una sola volta, deduplicata via Redis) i giocatori le cui
* missioni hanno raggiunto completesAt e sono pronte per il claim.
*/
export async function notifyCompletedMissions(): Promise<void> {
const due = await prisma.playerMission.findMany({
where: { status: MissionStatus.STARTED, completesAt: { lte: new Date() } },
include: { mission: { select: { title: true } } },
});
for (const playerMission of due) {
const firstTime = await redis.set(
`notify:mission:${playerMission.id}`,
'1',
'EX',
86_400,
'NX',
);
if (firstTime) {
emitToPlayer(playerMission.playerId, 'mission:completed', {
playerMissionId: playerMission.id,
missionId: playerMission.missionId,
title: playerMission.mission.title,
completesAt: playerMission.completesAt,
});
}
}
}
/** Elimina le missioni scadute mai iniziate da nessun giocatore. */
export async function deleteExpiredMissions(): Promise<number> {
const result = await prisma.mission.deleteMany({
where: { expiresAt: { lt: new Date() }, playerMissions: { none: {} } },
});
return result.count;
}
+88
View File
@@ -0,0 +1,88 @@
import { EventType } from '@prisma/client';
import { balance, type EventTypeBalance } from '../config/gameBalance.js';
import { prisma } from '../db/prisma.js';
import { redis } from '../db/redis.js';
import { toEventDto } from '../modules/events/events.service.js';
import { emitGlobal } from '../realtime/socket.js';
import { pickRandom, randomFloat, randomInt } from '../shared/validators.js';
/** Testi degli eventi per tipo (i moltiplicatori numerici sono in gameBalance). */
const EVENT_TEXTS: Record<EventTypeBalance, { title: string; description: string }> = {
MARKET_BOOM: {
title: 'Boom di mercato',
description: 'La domanda esplode: i prezzi salgono in tutta la zona.',
},
POLICE_RAID: {
title: 'Retata della polizia',
description: 'Controlli ovunque: muoversi è molto più rischioso.',
},
SHORTAGE: {
title: 'Carenza di merci',
description: 'Le scorte scarseggiano e i prezzi schizzano alle stelle.',
},
BLACKOUT: {
title: 'Blackout',
description: 'La città è al buio: il mercato rallenta, la polizia pure.',
},
};
async function createEvent(cityId: string | null, durationMinutes: number): Promise<void> {
const type = pickRandom(Object.values(EventType));
const ranges = balance.events.types[type];
const texts = EVENT_TEXTS[type];
const now = new Date();
const event = await prisma.worldEvent.create({
data: {
title: texts.title,
description: texts.description,
type,
cityId,
priceModifier:
Math.round(randomFloat(ranges.priceModifier[0], ranges.priceModifier[1]) * 100) / 100,
policeModifier:
Math.round(randomFloat(ranges.policeModifier[0], ranges.policeModifier[1]) * 100) / 100,
startsAt: now,
endsAt: new Date(now.getTime() + durationMinutes * 60_000),
},
});
emitGlobal('worldEvent:started', toEventDto(event));
}
/** Ogni 15 minuti: possibile evento locale in una città casuale. */
export async function maybeGenerateLocalEvent(): Promise<void> {
if (Math.random() >= balance.events.localChance) return;
const cities = await prisma.city.findMany({ select: { id: true } });
if (cities.length === 0) return;
await createEvent(
pickRandom(cities).id,
randomInt(balance.events.localDurationMinutesMin, balance.events.localDurationMinutesMax),
);
}
/** Ogni ora: possibile evento globale (cityId null). */
export async function maybeGenerateGlobalEvent(): Promise<void> {
if (Math.random() >= balance.events.globalChance) return;
await createEvent(
null,
randomInt(balance.events.globalDurationMinutesMin, balance.events.globalDurationMinutesMax),
);
}
/** Notifica (una sola volta) la fine degli eventi terminati di recente. */
export async function notifyEndedEvents(): Promise<void> {
const now = new Date();
const recentlyEnded = await prisma.worldEvent.findMany({
where: {
endsAt: { lt: now, gt: new Date(now.getTime() - 2 * 60 * 60_000) },
},
});
for (const event of recentlyEnded) {
const firstTime = await redis.set(`notify:event-end:${event.id}`, '1', 'EX', 86_400, 'NX');
if (firstTime) {
emitGlobal('worldEvent:ended', toEventDto(event));
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import cron from 'node-cron';
import type { FastifyBaseLogger } from 'fastify';
import { refreshLeaderboards } from '../modules/leaderboard/leaderboard.service.js';
import {
deleteExpiredMissions,
generateMissions,
notifyCompletedMissions,
} from './generateMissions.job.js';
import {
maybeGenerateGlobalEvent,
maybeGenerateLocalEvent,
notifyEndedEvents,
} from './generateWorldEvent.job.js';
import { updateMarketPrices } from './updateMarketPrices.job.js';
/** Esegue un job loggando eventuali errori senza far cadere lo scheduler. */
async function safeRun(log: FastifyBaseLogger, name: string, job: () => Promise<unknown>) {
try {
await job();
} catch (err) {
log.error({ err }, `job ${name} fallito`);
}
}
/** Job eseguiti subito all'avvio per avere un mondo "vivo" senza attendere il cron. */
export async function runStartupJobs(log: FastifyBaseLogger): Promise<void> {
await safeRun(log, 'generateMissions', generateMissions);
await safeRun(log, 'updateMarketPrices', updateMarketPrices);
await safeRun(log, 'refreshLeaderboards', refreshLeaderboards);
log.info('job di avvio completati');
}
/** Avvia i cicli periodici (blueprint §10). */
export function startScheduler(log: FastifyBaseLogger): void {
// Ogni minuto: prezzi, notifiche missioni completate, pulizia e rigenerazione missioni.
cron.schedule('* * * * *', async () => {
await safeRun(log, 'updateMarketPrices', updateMarketPrices);
await safeRun(log, 'notifyCompletedMissions', notifyCompletedMissions);
await safeRun(log, 'deleteExpiredMissions', deleteExpiredMissions);
await safeRun(log, 'generateMissions', generateMissions);
});
// Ogni 15 minuti: possibile evento locale e aggiornamento classifiche Redis.
cron.schedule('*/15 * * * *', async () => {
await safeRun(log, 'maybeGenerateLocalEvent', maybeGenerateLocalEvent);
await safeRun(log, 'refreshLeaderboards', refreshLeaderboards);
});
// Ogni ora: possibile evento globale e chiusura/notifica eventi terminati.
cron.schedule('0 * * * *', async () => {
await safeRun(log, 'maybeGenerateGlobalEvent', maybeGenerateGlobalEvent);
await safeRun(log, 'notifyEndedEvents', notifyEndedEvents);
});
log.info('scheduler avviato (1m / 15m / 1h)');
}
+41
View File
@@ -0,0 +1,41 @@
import { balance } from '../config/gameBalance.js';
import { prisma } from '../db/prisma.js';
import { getActiveEvents, priceModifierForCity } from '../modules/events/events.service.js';
import { clampDemandSupply, computePrices } from '../modules/market/pricing.js';
import { emitGlobal } from '../realtime/socket.js';
/**
* Tick del mercato (ogni minuto): domanda e offerta tornano lentamente
* verso l'equilibrio e i prezzi vengono ricalcolati con i modificatori
* degli eventi attivi.
*/
export async function updateMarketPrices(): Promise<void> {
const m = balance.market;
const [events, prices] = await Promise.all([
getActiveEvents(),
prisma.marketPrice.findMany({ include: { item: true, city: true } }),
]);
const updates = prices.map((price) => {
const demand = clampDemandSupply(
price.demand * m.decayFactor + m.equilibrium * (1 - m.decayFactor),
);
const supply = clampDemandSupply(
price.supply * m.decayFactor + m.equilibrium * (1 - m.decayFactor),
);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
return prisma.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId: price.itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
});
await prisma.$transaction(updates);
emitGlobal('market:updated', { updatedAt: new Date().toISOString() });
}
+54
View File
@@ -0,0 +1,54 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { toPlayerDto } from '../players/players.service.js';
import { loginUser, registerUser } from './auth.service.js';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
displayName: z.string().min(3).max(20),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
/** Rate limit dedicato agli endpoint di autenticazione (vedi blueprint §11). */
const authRateLimit = {
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
};
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/register', authRateLimit, async (request, reply) => {
const input = parseOrThrow(registerSchema, request.body);
const user = await registerUser(input);
const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id });
request.log.info({ userId: user.id, playerId: user.player.id }, 'auth:register');
return reply.status(201).send({ accessToken, player: toPlayerDto(user.player) });
});
app.post('/login', authRateLimit, async (request) => {
const input = parseOrThrow(loginSchema, request.body);
const user = await loginUser(input);
const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id });
request.log.info({ userId: user.id }, 'auth:login');
return { accessToken, player: toPlayerDto(user.player) };
});
app.get('/me', { preHandler: [authGuard] }, async (request) => {
const user = await prisma.user.findUnique({
where: { id: request.user.sub },
include: { player: true },
});
if (!user?.player) throw errors.notFound('Utente non trovato');
return {
user: { id: user.id, email: user.email, createdAt: user.createdAt },
player: toPlayerDto(user.player),
};
});
};
+57
View File
@@ -0,0 +1,57 @@
import argon2 from 'argon2';
import type { Player, User } from '@prisma/client';
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { AppError, errors } from '../../shared/errors.js';
export type UserWithPlayer = User & { player: Player };
export async function registerUser(input: {
email: string;
password: string;
displayName: string;
}): Promise<UserWithPlayer> {
const existingEmail = await prisma.user.findUnique({ where: { email: input.email } });
if (existingEmail) throw errors.conflict('Email già registrata');
const existingName = await prisma.player.findUnique({
where: { displayName: input.displayName },
});
if (existingName) throw errors.conflict('Nome giocatore già in uso');
const startingCity =
(await prisma.city.findUnique({ where: { name: balance.player.startingCityName } })) ??
(await prisma.city.findFirst({ orderBy: { name: 'asc' } }));
if (!startingCity) {
throw new AppError(503, 'WORLD_NOT_SEEDED', 'Mondo di gioco non inizializzato: eseguire il seed');
}
const passwordHash = await argon2.hash(input.password);
const user = await prisma.user.create({
data: {
email: input.email,
passwordHash,
player: {
create: { displayName: input.displayName, currentCityId: startingCity.id },
},
},
include: { player: true },
});
return user as UserWithPlayer;
}
export async function loginUser(input: {
email: string;
password: string;
}): Promise<UserWithPlayer> {
const user = await prisma.user.findUnique({
where: { email: input.email },
include: { player: true },
});
if (!user || !user.player) throw errors.unauthorized('Credenziali non valide');
const passwordOk = await argon2.verify(user.passwordHash, input.password);
if (!passwordOk) throw errors.unauthorized('Credenziali non valide');
return user as UserWithPlayer;
}
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { travelCost } from '../players/players.service.js';
const cityParamsSchema = z.object({ cityId: z.string().uuid() });
const citySelect = {
id: true,
name: true,
riskLevel: true,
policePressure: true,
economyModifier: true,
} as const;
export const citiesRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/', async () => {
const cities = await prisma.city.findMany({ select: citySelect, orderBy: { name: 'asc' } });
return {
cities: cities.map((city) => ({ ...city, travelCost: travelCost(city.riskLevel) })),
};
});
app.get('/:cityId', async (request) => {
const { cityId } = parseOrThrow(cityParamsSchema, request.params);
const city = await prisma.city.findUnique({ where: { id: cityId }, select: citySelect });
if (!city) throw errors.notFound('Città non trovata');
return { city: { ...city, travelCost: travelCost(city.riskLevel) } };
});
};
@@ -0,0 +1,29 @@
import type { FastifyPluginAsync } from 'fastify';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
export const eventsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/current', async () => {
const now = new Date();
const events = await prisma.worldEvent.findMany({
where: { startsAt: { lte: now }, endsAt: { gt: now } },
include: { city: { select: { id: true, name: true } } },
orderBy: { endsAt: 'asc' },
});
return {
events: events.map((event) => ({
id: event.id,
title: event.title,
description: event.description,
type: event.type,
city: event.city,
priceModifier: event.priceModifier,
policeModifier: event.policeModifier,
startsAt: event.startsAt,
endsAt: event.endsAt,
})),
};
});
};
@@ -0,0 +1,42 @@
import type { Prisma, WorldEvent } from '@prisma/client';
import { prisma } from '../../db/prisma.js';
/** Eventi attualmente in corso (startsAt <= now < endsAt). */
export async function getActiveEvents(
tx: Prisma.TransactionClient = prisma,
): Promise<WorldEvent[]> {
const now = new Date();
return tx.worldEvent.findMany({
where: { startsAt: { lte: now }, endsAt: { gt: now } },
orderBy: { startsAt: 'desc' },
});
}
/** Eventi che riguardano una città: locali della città + globali (cityId null). */
export function eventsForCity(events: WorldEvent[], cityId: string): WorldEvent[] {
return events.filter((e) => e.cityId === null || e.cityId === cityId);
}
/** Prodotto dei priceModifier degli eventi attivi per la città. */
export function priceModifierForCity(events: WorldEvent[], cityId: string): number {
return eventsForCity(events, cityId).reduce((mod, e) => mod * e.priceModifier, 1);
}
/** Prodotto dei policeModifier degli eventi attivi per la città. */
export function policeModifierForCity(events: WorldEvent[], cityId: string): number {
return eventsForCity(events, cityId).reduce((mod, e) => mod * e.policeModifier, 1);
}
export function toEventDto(event: WorldEvent) {
return {
id: event.id,
title: event.title,
description: event.description,
type: event.type,
cityId: event.cityId,
priceModifier: event.priceModifier,
policeModifier: event.policeModifier,
startsAt: event.startsAt,
endsAt: event.endsAt,
};
}
@@ -0,0 +1,28 @@
import type { FastifyPluginAsync } from 'fastify';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
export const inventoryRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/', async (request) => {
const entries = await prisma.inventoryItem.findMany({
where: { playerId: request.user.playerId },
include: { item: true },
orderBy: { item: { name: 'asc' } },
});
const items = entries.map((entry) => ({
itemId: entry.itemId,
name: entry.item.name,
quantity: entry.quantity,
weight: entry.item.weight,
basePrice: entry.item.basePrice,
rarity: entry.item.rarity,
illegalLevel: entry.item.illegalLevel,
}));
return {
items,
totalWeight: items.reduce((sum, i) => sum + i.weight * i.quantity, 0),
};
});
};
@@ -0,0 +1,29 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { balance } from '../../config/gameBalance.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { getLeaderboard } from './leaderboard.service.js';
const querySchema = z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(balance.leaderboard.maxEntries)
.default(balance.leaderboard.defaultLimit),
});
export const leaderboardRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/money', async (request) => {
const { limit } = parseOrThrow(querySchema, request.query);
return getLeaderboard('money', limit);
});
app.get('/reputation', async (request) => {
const { limit } = parseOrThrow(querySchema, request.query);
return getLeaderboard('reputation', limit);
});
};
@@ -0,0 +1,66 @@
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { redis } from '../../db/redis.js';
import { emitGlobal } from '../../realtime/socket.js';
export type LeaderboardMetric = 'money' | 'reputation';
const KEYS: Record<LeaderboardMetric, string> = {
money: 'leaderboard:money',
reputation: 'leaderboard:reputation',
};
/** Ricostruisce le classifiche Redis (sorted set) a partire dal database. */
export async function refreshLeaderboards(): Promise<void> {
const players = await prisma.player.findMany({
select: { id: true, money: true, reputation: true },
});
const pipeline = redis.pipeline();
pipeline.del(KEYS.money);
pipeline.del(KEYS.reputation);
for (const player of players) {
pipeline.zadd(KEYS.money, player.money, player.id);
pipeline.zadd(KEYS.reputation, player.reputation, player.id);
}
pipeline.zremrangebyrank(KEYS.money, 0, -(balance.leaderboard.maxEntries + 1));
pipeline.zremrangebyrank(KEYS.reputation, 0, -(balance.leaderboard.maxEntries + 1));
await pipeline.exec();
emitGlobal('leaderboard:updated', { updatedAt: new Date().toISOString() });
}
/** Legge la classifica da Redis; se vuota la ricostruisce dal database. */
export async function getLeaderboard(metric: LeaderboardMetric, limit: number) {
const key = KEYS[metric];
let raw = await redis.zrevrange(key, 0, limit - 1, 'WITHSCORES');
if (raw.length === 0) {
await refreshLeaderboards();
raw = await redis.zrevrange(key, 0, limit - 1, 'WITHSCORES');
}
const playerIds: string[] = [];
const scores = new Map<string, number>();
for (let i = 0; i < raw.length; i += 2) {
const id = raw[i]!;
playerIds.push(id);
scores.set(id, Number(raw[i + 1]));
}
const players = await prisma.player.findMany({
where: { id: { in: playerIds } },
select: { id: true, displayName: true, level: true },
});
const byId = new Map(players.map((p) => [p.id, p]));
return {
metric,
entries: playerIds.map((id, index) => ({
rank: index + 1,
playerId: id,
displayName: byId.get(id)?.displayName ?? 'sconosciuto',
level: byId.get(id)?.level ?? 1,
value: scores.get(id) ?? 0,
})),
};
}
@@ -0,0 +1,33 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { balance } from '../../config/gameBalance.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { buyItem, getCurrentMarket, sellItem } from './market.service.js';
const tradeSchema = z.object({
itemId: z.string().uuid(),
quantity: z.coerce.number().int().min(1).max(balance.market.maxQuantityPerTrade),
});
export const marketRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/current', async (request) => {
return getCurrentMarket(request.user.playerId);
});
app.post('/buy', async (request) => {
const input = parseOrThrow(tradeSchema, request.body);
const result = await buyItem(request.user.playerId, input.itemId, input.quantity);
request.log.info({ playerId: request.user.playerId, ...result }, 'market:buy');
return result;
});
app.post('/sell', async (request) => {
const input = parseOrThrow(tradeSchema, request.body);
const result = await sellItem(request.user.playerId, input.itemId, input.quantity);
request.log.info({ playerId: request.user.playerId, ...result }, 'market:sell');
return result;
});
};
+180
View File
@@ -0,0 +1,180 @@
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { errors } from '../../shared/errors.js';
import {
getActiveEvents,
eventsForCity,
priceModifierForCity,
toEventDto,
} from '../events/events.service.js';
import { getPlayerOrThrow } from '../players/players.service.js';
import { clampDemandSupply, computePrices } from './pricing.js';
export type TradeResult = {
itemId: string;
itemName: string;
quantity: number;
unitPrice: number;
total: number;
playerMoney: number;
};
/** Listino del mercato della città in cui si trova il giocatore. */
export async function getCurrentMarket(playerId: string) {
const player = await prisma.player.findUnique({
where: { id: playerId },
include: { currentCity: true },
});
if (!player) throw errors.notFound('Giocatore non trovato');
const [prices, events] = await Promise.all([
prisma.marketPrice.findMany({
where: { cityId: player.currentCityId },
include: { item: true },
orderBy: { item: { name: 'asc' } },
}),
getActiveEvents(),
]);
return {
city: {
id: player.currentCity.id,
name: player.currentCity.name,
riskLevel: player.currentCity.riskLevel,
economyModifier: player.currentCity.economyModifier,
},
prices: prices.map((p) => ({
itemId: p.itemId,
name: p.item.name,
buyPrice: p.buyPrice,
sellPrice: p.sellPrice,
demand: p.demand,
supply: p.supply,
rarity: p.item.rarity,
illegalLevel: p.item.illegalLevel,
weight: p.item.weight,
updatedAt: p.updatedAt,
})),
activeEvents: eventsForCity(events, player.currentCityId).map(toEventDto),
};
}
/**
* Acquisto: il prezzo lo decide il server (buyPrice corrente).
* Aggiorna denaro, inventario, domanda/offerta e ricalcola subito i prezzi.
*/
export async function buyItem(
playerId: string,
itemId: string,
quantity: number,
): Promise<TradeResult> {
const m = balance.market;
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
const price = await tx.marketPrice.findUnique({
where: { cityId_itemId: { cityId: player.currentCityId, itemId } },
include: { item: true, city: true },
});
if (!price) throw errors.notFound('Oggetto non disponibile in questo mercato');
const total = price.buyPrice * quantity;
if (player.money < total) throw errors.insufficientFunds();
await tx.player.update({
where: { id: player.id },
data: { money: { decrement: total } },
});
await tx.inventoryItem.upsert({
where: { playerId_itemId: { playerId, itemId } },
create: { playerId, itemId, quantity },
update: { quantity: { increment: quantity } },
});
const demand = clampDemandSupply(price.demand + quantity * m.demandDeltaPerBuyUnit);
const supply = clampDemandSupply(price.supply + quantity * m.supplyDeltaPerBuyUnit);
const events = await getActiveEvents(tx);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
await tx.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
return {
itemId,
itemName: price.item.name,
quantity,
unitPrice: price.buyPrice,
total,
playerMoney: player.money - total,
};
});
}
/**
* Vendita: verifica l'inventario, paga sellPrice * quantity,
* aggiorna domanda/offerta e ricalcola subito i prezzi.
*/
export async function sellItem(
playerId: string,
itemId: string,
quantity: number,
): Promise<TradeResult> {
const m = balance.market;
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
const price = await tx.marketPrice.findUnique({
where: { cityId_itemId: { cityId: player.currentCityId, itemId } },
include: { item: true, city: true },
});
if (!price) throw errors.notFound('Oggetto non vendibile in questo mercato');
const inventory = await tx.inventoryItem.findUnique({
where: { playerId_itemId: { playerId, itemId } },
});
if (!inventory || inventory.quantity < quantity) throw errors.insufficientItems();
const total = price.sellPrice * quantity;
await tx.player.update({
where: { id: player.id },
data: { money: { increment: total } },
});
if (inventory.quantity === quantity) {
await tx.inventoryItem.delete({ where: { playerId_itemId: { playerId, itemId } } });
} else {
await tx.inventoryItem.update({
where: { playerId_itemId: { playerId, itemId } },
data: { quantity: { decrement: quantity } },
});
}
const demand = clampDemandSupply(price.demand + quantity * m.demandDeltaPerSellUnit);
const supply = clampDemandSupply(price.supply + quantity * m.supplyDeltaPerSellUnit);
const events = await getActiveEvents(tx);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
await tx.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
return {
itemId,
itemName: price.item.name,
quantity,
unitPrice: price.sellPrice,
total,
playerMoney: player.money + total,
};
});
}
+29
View File
@@ -0,0 +1,29 @@
import { balance } from '../../config/gameBalance.js';
import { clamp } from '../../shared/validators.js';
/**
* Formula del mercato dinamico (blueprint §9):
* rawPrice = basePrice * economyModifier * demand / max(supply, minSupplyDivisor) * eventModifier
* con rawPrice limitato a [basePrice*minPriceFactor, basePrice*maxPriceFactor].
*/
export function computePrices(
basePrice: number,
economyModifier: number,
demand: number,
supply: number,
eventModifier: number,
): { buyPrice: number; sellPrice: number } {
const m = balance.market;
const raw =
basePrice * economyModifier * (demand / Math.max(supply, m.minSupplyDivisor)) * eventModifier;
const clamped = clamp(raw, basePrice * m.minPriceFactor, basePrice * m.maxPriceFactor);
return {
buyPrice: Math.round(clamped * m.buyMargin),
sellPrice: Math.round(clamped * m.sellMargin),
};
}
/** Mantiene demand/supply nei limiti consentiti. */
export function clampDemandSupply(value: number): number {
return clamp(value, balance.market.minDemandSupply, balance.market.maxDemandSupply);
}
@@ -0,0 +1,56 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { emitToPlayer } from '../../realtime/socket.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { claimMission, listActive, listAvailable, startMission } from './missions.service.js';
const missionParamsSchema = z.object({ missionId: z.string().uuid() });
const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() });
export const missionsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/available', async (request) => {
return listAvailable(request.user.playerId);
});
app.get('/active', async (request) => {
return listActive(request.user.playerId);
});
app.post('/:missionId/start', async (request, reply) => {
const { missionId } = parseOrThrow(missionParamsSchema, request.params);
const playerMission = await startMission(request.user.playerId, missionId);
request.log.info(
{ playerId: request.user.playerId, missionId, playerMissionId: playerMission.id },
'mission:start',
);
return reply.status(201).send({
playerMissionId: playerMission.id,
status: playerMission.status,
startedAt: playerMission.startedAt,
completesAt: playerMission.completesAt,
});
});
app.post('/:playerMissionId/claim', async (request) => {
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
const result = await claimMission(request.user.playerId, playerMissionId);
request.log.info(
{
playerId: request.user.playerId,
playerMissionId,
success: result.success,
rewardMoney: result.rewardMoney,
},
'mission:claim',
);
emitToPlayer(request.user.playerId, 'player:notification', {
type: 'mission:result',
playerMissionId,
success: result.success,
});
return result;
});
};
@@ -0,0 +1,224 @@
import type { Mission, PlayerMission } from '@prisma/client';
import { MissionStatus } from '@prisma/client';
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { errors } from '../../shared/errors.js';
import { clamp } from '../../shared/validators.js';
import { getActiveEvents, policeModifierForCity } from '../events/events.service.js';
import {
applyExperience,
getPlayerOrThrow,
toPlayerDto,
type PlayerDto,
} from '../players/players.service.js';
export type ClaimResult = {
playerMissionId: string;
missionTitle: string;
success: boolean;
successChance: number;
rewardMoney: number;
rewardXp: number;
reputationChange: number;
levelsGained: number;
player: PlayerDto;
};
function toMissionDto(mission: Mission & { requiredItem?: { name: string } | null }) {
return {
id: mission.id,
cityId: mission.cityId,
title: mission.title,
type: mission.type,
difficulty: mission.difficulty,
durationSeconds: mission.durationSeconds,
rewardMoney: mission.rewardMoney,
rewardXp: mission.rewardXp,
risk: mission.risk,
requiredItemId: mission.requiredItemId,
requiredItemName: mission.requiredItem?.name ?? null,
requiredItemQuantity: mission.requiredItemQuantity,
expiresAt: mission.expiresAt,
};
}
/** Missioni disponibili nella città corrente, non scadute e non già iniziate dal giocatore. */
export async function listAvailable(playerId: string) {
const player = await getPlayerOrThrow(playerId);
const missions = await prisma.mission.findMany({
where: {
cityId: player.currentCityId,
expiresAt: { gt: new Date() },
playerMissions: { none: { playerId } },
},
include: { requiredItem: { select: { name: true } } },
orderBy: { expiresAt: 'asc' },
});
return { missions: missions.map(toMissionDto) };
}
/** Missioni del giocatore ancora in corso. */
export async function listActive(playerId: string) {
const playerMissions = await prisma.playerMission.findMany({
where: { playerId, status: MissionStatus.STARTED },
include: { mission: { include: { requiredItem: { select: { name: true } } } } },
orderBy: { completesAt: 'asc' },
});
return {
missions: playerMissions.map((pm) => ({
playerMissionId: pm.id,
status: pm.status,
startedAt: pm.startedAt,
completesAt: pm.completesAt,
mission: toMissionDto(pm.mission),
})),
};
}
/**
* Avvia una missione: consuma subito gli eventuali oggetti richiesti
* e fissa completesAt = now + durationSeconds.
*/
export async function startMission(playerId: string, missionId: string): Promise<PlayerMission> {
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
const mission = await tx.mission.findUnique({ where: { id: missionId } });
if (!mission) throw errors.notFound('Missione non trovata');
const now = new Date();
if (mission.expiresAt <= now) throw errors.badRequest('Missione scaduta');
if (mission.cityId !== player.currentCityId) {
throw errors.badRequest('La missione è in un\'altra città');
}
const alreadyStarted = await tx.playerMission.findFirst({
where: { playerId, missionId },
});
if (alreadyStarted) throw errors.conflict('Missione già iniziata');
const activeCount = await tx.playerMission.count({
where: { playerId, status: MissionStatus.STARTED },
});
if (activeCount >= balance.missions.maxActivePerPlayer) {
throw errors.badRequest(
`Hai già ${activeCount} missioni attive (massimo ${balance.missions.maxActivePerPlayer})`,
);
}
if (mission.requiredItemId && mission.requiredItemQuantity) {
const inventory = await tx.inventoryItem.findUnique({
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
});
if (!inventory || inventory.quantity < mission.requiredItemQuantity) {
throw errors.insufficientItems();
}
if (inventory.quantity === mission.requiredItemQuantity) {
await tx.inventoryItem.delete({
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
});
} else {
await tx.inventoryItem.update({
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
data: { quantity: { decrement: mission.requiredItemQuantity } },
});
}
}
return tx.playerMission.create({
data: {
playerId,
missionId,
completesAt: new Date(now.getTime() + mission.durationSeconds * 1000),
},
});
});
}
/**
* Riscuote una missione completata: tira il dado contro la probabilità di
* successo (rischio, livello, reputazione, pressione polizia) e applica
* ricompense o penalità.
*/
export async function claimMission(
playerId: string,
playerMissionId: string,
): Promise<ClaimResult> {
const s = balance.missions.success;
return prisma.$transaction(async (tx) => {
const playerMission = await tx.playerMission.findUnique({
where: { id: playerMissionId },
include: { mission: { include: { city: true } } },
});
if (!playerMission || playerMission.playerId !== playerId) {
throw errors.notFound('Missione non trovata');
}
if (playerMission.status !== MissionStatus.STARTED || playerMission.resolvedAt) {
throw errors.conflict('Missione già risolta');
}
const now = new Date();
if (now < playerMission.completesAt) {
throw errors.badRequest('Missione non ancora completata');
}
const player = await getPlayerOrThrow(playerId, tx);
const mission = playerMission.mission;
const events = await getActiveEvents(tx);
const police = mission.city.policePressure * policeModifierForCity(events, mission.cityId);
const successChance = clamp(
s.baseChance -
mission.risk * s.riskWeight +
player.level * s.levelBonus +
player.reputation * s.reputationBonus -
(police - 1) * s.policeWeight,
s.minChance,
s.maxChance,
);
const success = Math.random() < successChance;
let updatedPlayer = player;
let reputationChange = 0;
let levelsGained = 0;
if (success) {
reputationChange = mission.difficulty * balance.missions.reputationGainPerDifficulty;
const progress = applyExperience(player.level, player.experience, mission.rewardXp);
levelsGained = progress.levelsGained;
updatedPlayer = await tx.player.update({
where: { id: player.id },
data: {
money: { increment: mission.rewardMoney },
reputation: { increment: reputationChange },
level: progress.level,
experience: progress.experience,
},
});
} else {
reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation);
updatedPlayer = await tx.player.update({
where: { id: player.id },
data: { reputation: { increment: reputationChange } },
});
}
await tx.playerMission.update({
where: { id: playerMission.id },
data: {
status: success ? MissionStatus.COMPLETED : MissionStatus.FAILED,
resolvedAt: now,
},
});
return {
playerMissionId: playerMission.id,
missionTitle: mission.title,
success,
successChance: Math.round(successChance * 100) / 100,
rewardMoney: success ? mission.rewardMoney : 0,
rewardXp: success ? mission.rewardXp : 0,
reputationChange,
levelsGained,
player: toPlayerDto(updatedPlayer),
};
});
}
@@ -0,0 +1,38 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { toPlayerDto, travel, xpForNextLevel } from './players.service.js';
const travelSchema = z.object({
cityId: z.string().uuid(),
});
export const playersRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/me', async (request) => {
const player = await prisma.player.findUnique({
where: { id: request.user.playerId },
include: { currentCity: { select: { id: true, name: true, riskLevel: true } } },
});
if (!player) throw errors.notFound('Giocatore non trovato');
return {
player: toPlayerDto(player),
currentCity: player.currentCity,
xpForNextLevel: xpForNextLevel(player.level),
};
});
app.post('/travel', async (request) => {
const input = parseOrThrow(travelSchema, request.body);
const result = await travel(request.user.playerId, input.cityId);
request.log.info(
{ playerId: request.user.playerId, cityId: input.cityId, cost: result.cost },
'player:travel',
);
return result;
});
};
@@ -0,0 +1,86 @@
import type { Player, Prisma } from '@prisma/client';
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { errors } from '../../shared/errors.js';
export type PlayerDto = {
id: string;
displayName: string;
money: number;
reputation: number;
level: number;
experience: number;
currentCityId: string;
};
export function toPlayerDto(player: Player): PlayerDto {
return {
id: player.id,
displayName: player.displayName,
money: player.money,
reputation: player.reputation,
level: player.level,
experience: player.experience,
currentCityId: player.currentCityId,
};
}
/** XP necessari per passare dal livello `level` al successivo. */
export function xpForNextLevel(level: number): number {
return Math.round(balance.xp.base * Math.pow(level, balance.xp.exponent));
}
/** Applica XP guadagnati gestendo i passaggi di livello. */
export function applyExperience(
level: number,
experience: number,
gainedXp: number,
): { level: number; experience: number; levelsGained: number } {
let newLevel = level;
let newExperience = experience + gainedXp;
let levelsGained = 0;
while (newExperience >= xpForNextLevel(newLevel)) {
newExperience -= xpForNextLevel(newLevel);
newLevel += 1;
levelsGained += 1;
}
return { level: newLevel, experience: newExperience, levelsGained };
}
export async function getPlayerOrThrow(
playerId: string,
tx: Prisma.TransactionClient = prisma,
): Promise<Player> {
const player = await tx.player.findUnique({ where: { id: playerId } });
if (!player) throw errors.notFound('Giocatore non trovato');
return player;
}
/** Costo del viaggio verso una città (vedi balance.travel). */
export function travelCost(destinationRiskLevel: number): number {
return balance.travel.baseCost + destinationRiskLevel * balance.travel.costPerRiskLevel;
}
/** Sposta il giocatore in un'altra città, scalando il costo del viaggio. */
export async function travel(
playerId: string,
cityId: string,
): Promise<{ player: PlayerDto; cityName: string; cost: number }> {
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
if (player.currentCityId === cityId) {
throw errors.badRequest('Sei già in questa città');
}
const city = await tx.city.findUnique({ where: { id: cityId } });
if (!city) throw errors.notFound('Città non trovata');
const cost = travelCost(city.riskLevel);
if (player.money < cost) throw errors.insufficientFunds();
const updated = await tx.player.update({
where: { id: player.id },
data: { money: { decrement: cost }, currentCityId: city.id },
});
return { player: toPlayerDto(updated), cityName: city.name, cost };
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { Server } from 'socket.io';
let io: Server | null = null;
/**
* Inizializza Socket.IO sullo stesso server HTTP di Fastify (path /ws).
* L'handshake richiede un JWT valido in `auth.token`.
*/
export function initSocket(app: FastifyInstance): Server {
io = new Server(app.server, {
path: '/ws',
cors: { origin: '*' },
});
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined;
if (!token) {
next(new Error('UNAUTHORIZED'));
return;
}
try {
const payload = app.jwt.verify<{ sub: string; playerId: string }>(token);
socket.data.playerId = payload.playerId;
next();
} catch {
next(new Error('UNAUTHORIZED'));
}
});
io.on('connection', (socket) => {
const playerId = socket.data.playerId as string;
void socket.join(`player:${playerId}`);
socket.on('client:ping', () => {
socket.emit('player:notification', { type: 'pong', at: new Date().toISOString() });
});
});
return io;
}
/** Notifica un singolo giocatore (room player:{id}). */
export function emitToPlayer(playerId: string, event: string, payload: unknown): void {
io?.to(`player:${playerId}`).emit(event, payload);
}
/** Notifica tutti i client connessi. */
export function emitGlobal(event: string, payload: unknown): void {
io?.emit(event, payload);
}
+31
View File
@@ -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);
});
+11
View File
@@ -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');
}
}
+23
View File
@@ -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'),
};
+30
View File
@@ -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)]!;
}
+8
View File
@@ -0,0 +1,8 @@
import '@fastify/jwt';
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: string; playerId: string };
user: { sub: string; playerId: string };
}
}