Compare commits
20
Commits
34708340f1
...
main
@@ -0,0 +1,2 @@
|
|||||||
|
# URL del server di gioco (senza /api/v1)
|
||||||
|
VITE_API_URL=http://localhost:3000
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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** — descrizioni narrative, chip per tipo, barra della probabilità di successo stimata, requisito di livello (lucchetto), bottino in merce, abbandono; attive con countdown e riscossione (esito con premi, bottino o multa).
|
||||||
|
- **Zaino** — inventario con quantità e peso totale.
|
||||||
|
- **Città** — mappa SVG stilizzata dell'Italia con marker cliccabili (colore = rischio, pulsazione = città corrente, ⚡ = evento attivo) e pannello viaggio; lista compatta come fallback.
|
||||||
|
- **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
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
Generated
+1965
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { request } from './http';
|
||||||
|
import type {
|
||||||
|
AbandonMissionResponse,
|
||||||
|
ActiveMission,
|
||||||
|
AuthResponse,
|
||||||
|
AvailableMission,
|
||||||
|
City,
|
||||||
|
ClaimMissionResponse,
|
||||||
|
InventoryResponse,
|
||||||
|
LeaderboardResponse,
|
||||||
|
MarketResponse,
|
||||||
|
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: AvailableMission[] }>('/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' }),
|
||||||
|
abandonMission: (playerMissionId: string) =>
|
||||||
|
request<AbandonMissionResponse>(`/missions/${playerMissionId}/abandon`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Events
|
||||||
|
events: () => request<{ events: WorldEvent[] }>('/events/current'),
|
||||||
|
|
||||||
|
// Leaderboard
|
||||||
|
leaderboard: (metric: 'money' | 'reputation', limit = 20) =>
|
||||||
|
request<LeaderboardResponse>(`/leaderboard/${metric}?limit=${limit}`),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/** 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;
|
||||||
|
mapX: number;
|
||||||
|
mapY: 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;
|
||||||
|
basePrice: 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;
|
||||||
|
description: string;
|
||||||
|
type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL';
|
||||||
|
difficulty: number;
|
||||||
|
minLevel: number;
|
||||||
|
durationSeconds: number;
|
||||||
|
rewardMoney: number;
|
||||||
|
rewardXp: number;
|
||||||
|
risk: number;
|
||||||
|
requiredItemId: string | null;
|
||||||
|
requiredItemName: string | null;
|
||||||
|
requiredItemQuantity: number | null;
|
||||||
|
rewardItemName: string | null;
|
||||||
|
rewardItemQuantity: number | null;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Missione disponibile, arricchita con la stima calcolata per il giocatore. */
|
||||||
|
export type AvailableMission = Mission & {
|
||||||
|
estimatedSuccessChance: number;
|
||||||
|
canStart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
moneyChange: number;
|
||||||
|
fine: number;
|
||||||
|
lootItemName: string | null;
|
||||||
|
lootItemQuantity: number | null;
|
||||||
|
reputationChange: number;
|
||||||
|
levelsGained: number;
|
||||||
|
player: Player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AbandonMissionResponse = {
|
||||||
|
playerMissionId: string;
|
||||||
|
reputationChange: number;
|
||||||
|
player: Player;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeaderboardEntry = {
|
||||||
|
rank: number;
|
||||||
|
playerId: string;
|
||||||
|
displayName: string;
|
||||||
|
level: number;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeaderboardResponse = {
|
||||||
|
metric: 'money' | 'reputation';
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
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();
|
||||||
|
const location = useLocation();
|
||||||
|
const [readyCount, setReadyCount] = useState(0);
|
||||||
|
|
||||||
|
// Badge sul tab Missioni: quante missioni sono pronte da riscuotere
|
||||||
|
const refreshReadyCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.missionsActive();
|
||||||
|
const now = Date.now();
|
||||||
|
setReadyCount(
|
||||||
|
res.missions.filter((m) => new Date(m.completesAt).getTime() <= now).length,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// non bloccare la UI per un badge
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshReadyCount();
|
||||||
|
const interval = setInterval(() => void refreshReadyCount(), 20_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
// location: il claim avviene navigando, quindi al cambio pagina si riallinea
|
||||||
|
}, [refreshReadyCount, location.pathname]);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
void refreshReadyCount();
|
||||||
|
};
|
||||||
|
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, refreshReadyCount]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
{item.to === '/missions' && readyCount > 0 && (
|
||||||
|
<span className="bottomnav__badge">{readyCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="bottomnav__label">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
import { ApiError } from '../api/http';
|
||||||
|
import type { ActiveMission, PlayerMeResponse, WorldEvent } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { Countdown } from '../components/Countdown';
|
||||||
|
import { useToast } from '../components/Toast';
|
||||||
|
import { EVENT_TYPE_LABELS, formatMoney, riskLabel } from '../shared/format';
|
||||||
|
|
||||||
|
export function DashboardScreen() {
|
||||||
|
const { player, setPlayer, logout } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [me, setMe] = useState<PlayerMeResponse | null>(null);
|
||||||
|
const [events, setEvents] = useState<WorldEvent[]>([]);
|
||||||
|
const [active, setActive] = useState<ActiveMission[]>([]);
|
||||||
|
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 [meRes, eventsRes, activeRes] = await Promise.all([
|
||||||
|
api.playerMe(),
|
||||||
|
api.events(),
|
||||||
|
api.missionsActive(),
|
||||||
|
]);
|
||||||
|
setMe(meRes);
|
||||||
|
setPlayer(meRes.player);
|
||||||
|
setEvents(eventsRes.events);
|
||||||
|
setActive(activeRes.missions);
|
||||||
|
}, [setPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
async function quickClaim(playerMissionId: string) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.claimMission(playerMissionId);
|
||||||
|
setPlayer(res.player);
|
||||||
|
toast(
|
||||||
|
res.success
|
||||||
|
? `✅ "${res.missionTitle}": +${formatMoney(res.rewardMoney)}, +${res.rewardXp} XP` +
|
||||||
|
(res.lootItemName ? `, 🎁 ${res.lootItemQuantity}× ${res.lootItemName}` : '')
|
||||||
|
: `❌ "${res.missionTitle}" fallita` +
|
||||||
|
(res.fine > 0 ? `: multa −${formatMoney(res.fine)}` : ''),
|
||||||
|
res.success ? 'success' : 'error',
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player) return null;
|
||||||
|
|
||||||
|
const xpNext = me?.xpForNextLevel ?? null;
|
||||||
|
const xpRatio = xpNext ? Math.min(1, player.experience / xpNext) : 0;
|
||||||
|
const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now();
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{active.length > 0 && (
|
||||||
|
<section className="card">
|
||||||
|
<h3 className="card__title">Missioni 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">{formatMoney(m.mission.rewardMoney)}</p>
|
||||||
|
</div>
|
||||||
|
{isReady(m) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary btn--small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void quickClaim(m.playerMissionId)}
|
||||||
|
>
|
||||||
|
Riscuoti
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="quicklinks">
|
||||||
|
<Link to="/missions" className="btn btn--secondary">
|
||||||
|
🎯 Missioni
|
||||||
|
</Link>
|
||||||
|
<Link to="/market" className="btn btn--secondary">
|
||||||
|
⚖️ Mercato
|
||||||
|
</Link>
|
||||||
|
<Link to="/leaderboard" className="btn btn--secondary">
|
||||||
|
🏆 Classifiche
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button type="button" className="btn btn--ghost" onClick={logout}>
|
||||||
|
Esci
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
import type { LeaderboardResponse } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { getSocket } from '../realtime/socket';
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Aggiornamento live quando il server ricalcola le classifiche
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket) return;
|
||||||
|
const onUpdate = () => void api.leaderboard(metric).then(setBoard);
|
||||||
|
socket.on('leaderboard:updated', onUpdate);
|
||||||
|
return () => {
|
||||||
|
socket.off('leaderboard:updated', onUpdate);
|
||||||
|
};
|
||||||
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confronta i prezzi locali col valore base della merce: segnala dove
|
||||||
|
* conviene comprare (buy sotto il base) e dove conviene vendere (sell sopra).
|
||||||
|
*/
|
||||||
|
function TrendTag({ entry }: { entry: MarketEntry }) {
|
||||||
|
const buyDelta = Math.round((entry.buyPrice / entry.basePrice - 1) * 100);
|
||||||
|
const sellDelta = Math.round((entry.sellPrice / entry.basePrice - 1) * 100);
|
||||||
|
if (sellDelta > 0) {
|
||||||
|
return <span className="trend trend--sell">📈 vendi +{sellDelta}%</span>;
|
||||||
|
}
|
||||||
|
if (buyDelta < 0) {
|
||||||
|
return <span className="trend trend--buy">💰 affare {buyDelta}%</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<TrendTag entry={entry} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="muted small">
|
||||||
|
Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto: compra
|
||||||
|
dove c'è l'affare, vendi dove rende.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
import { ApiError } from '../api/http';
|
||||||
|
import type { ActiveMission, AvailableMission, ClaimMissionResponse } 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';
|
||||||
|
|
||||||
|
/** Colore della barra probabilità: verde → oro → rosso. */
|
||||||
|
function chanceColor(chance: number): string {
|
||||||
|
if (chance >= 0.7) return '#4caf6e';
|
||||||
|
if (chance >= 0.45) return '#d4a017';
|
||||||
|
return '#d9534f';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissionsScreen() {
|
||||||
|
const { setPlayer } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [available, setAvailable] = useState<AvailableMission[]>([]);
|
||||||
|
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: AvailableMission) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abandon(mission: ActiveMission) {
|
||||||
|
if (!window.confirm(`Abbandonare "${mission.mission.title}"? Perderai reputazione e la merce già consegnata.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.abandonMission(mission.playerMissionId);
|
||||||
|
setPlayer(res.player);
|
||||||
|
toast(`Missione abbandonata (${res.reputationChange} reputazione)`, 'info');
|
||||||
|
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="mission">
|
||||||
|
<div className="mission__head">
|
||||||
|
<strong>{m.mission.title}</strong>
|
||||||
|
{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)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
|
||||||
|
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
|
||||||
|
{m.mission.rewardItemName &&
|
||||||
|
` + ${m.mission.rewardItemQuantity}× ${m.mission.rewardItemName}`}
|
||||||
|
</p>
|
||||||
|
{!isReady(m) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost btn--small mission__abandon"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void abandon(m)}
|
||||||
|
>
|
||||||
|
Abbandona
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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) => {
|
||||||
|
const locked = !mission.canStart;
|
||||||
|
return (
|
||||||
|
<li key={mission.id} className={`mission${locked ? ' mission--locked' : ''}`}>
|
||||||
|
<div className="mission__head">
|
||||||
|
<strong>{mission.title}</strong>
|
||||||
|
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`chip chip--${mission.type.toLowerCase()}`}>
|
||||||
|
{MISSION_TYPE_LABELS[mission.type]}
|
||||||
|
</span>
|
||||||
|
<p className="mission__description">{mission.description}</p>
|
||||||
|
<p className="muted small">
|
||||||
|
durata {formatDuration(mission.durationSeconds)} · rischio{' '}
|
||||||
|
{formatPercent(mission.risk)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="chancebar" title="Probabilità di successo stimata">
|
||||||
|
<div
|
||||||
|
className="chancebar__fill"
|
||||||
|
style={{
|
||||||
|
width: `${mission.estimatedSuccessChance * 100}%`,
|
||||||
|
background: chanceColor(mission.estimatedSuccessChance),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="chancebar__text">
|
||||||
|
successo {formatPercent(mission.estimatedSuccessChance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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
|
||||||
|
{mission.rewardItemName &&
|
||||||
|
` · 🎁 ${mission.rewardItemQuantity}× ${mission.rewardItemName}`}
|
||||||
|
</span>
|
||||||
|
{locked ? (
|
||||||
|
<span className="mission__locked">🔒 liv. {mission.minLevel}</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.lootItemName && (
|
||||||
|
<li>
|
||||||
|
🎁 +{result.lootItemQuantity}× {result.lootItemName}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{result.levelsGained > 0 && (
|
||||||
|
<li className="levelup">Sei salito al livello {result.player.level}!</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<ul className="result-list">
|
||||||
|
{result.fine > 0 && <li>💸 multa: −{formatMoney(result.fine)}</li>}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from '../api/api';
|
||||||
|
import { ApiError } from '../api/http';
|
||||||
|
import type { City, WorldEvent } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useToast } from '../components/Toast';
|
||||||
|
import { formatMoney, riskLabel } from '../shared/format';
|
||||||
|
|
||||||
|
/** Sagoma stilizzata dell'Italia (coordinate 0-100, stesse di City.mapX/mapY). */
|
||||||
|
const ITALY_MAINLAND =
|
||||||
|
'M12,18 L18,11 L30,9 L44,8 L50,13 L54,20 L60,28 L70,40 L82,46 L91,52 L88,57 L79,58 ' +
|
||||||
|
'L71,63 L65,71 L63,79 L58,76 L57,67 L52,58 L45,46 L37,34 L25,27 L15,24 Z';
|
||||||
|
const ITALY_SICILY = 'M44,83 L54,81 L62,84 L58,91 L48,93 L43,88 Z';
|
||||||
|
const ITALY_SARDINIA = 'M13,55 L21,56 L22,66 L19,74 L12,72 L11,62 Z';
|
||||||
|
|
||||||
|
const RISK_COLORS: Record<string, string> = {
|
||||||
|
basso: '#4caf6e',
|
||||||
|
medio: '#d4a017',
|
||||||
|
alto: '#d9534f',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TravelScreen() {
|
||||||
|
const { player, setPlayer } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
|
const [events, setEvents] = useState<WorldEvent[]>([]);
|
||||||
|
const [selected, setSelected] = useState<City | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const [c, e] = await Promise.all([api.cities(), api.events()]);
|
||||||
|
setCities(c.cities);
|
||||||
|
setEvents(e.events);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const eventsByCity = useMemo(() => {
|
||||||
|
const map = new Map<string, WorldEvent[]>();
|
||||||
|
for (const event of events) {
|
||||||
|
const key = event.city?.id ?? 'global';
|
||||||
|
map.set(key, [...(map.get(key) ?? []), event]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const cityEvents = (cityId: string): WorldEvent[] => [
|
||||||
|
...(eventsByCity.get(cityId) ?? []),
|
||||||
|
...(eventsByCity.get('global') ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
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');
|
||||||
|
setSelected(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<h2 className="screen__title">Mappa</h2>
|
||||||
|
|
||||||
|
<div className="map-card">
|
||||||
|
<svg viewBox="0 0 100 100" className="map-svg" role="img" aria-label="Mappa delle città">
|
||||||
|
<path d={ITALY_MAINLAND} className="map-land" />
|
||||||
|
<path d={ITALY_SICILY} className="map-land" />
|
||||||
|
<path d={ITALY_SARDINIA} className="map-land" />
|
||||||
|
|
||||||
|
{cities.map((city) => {
|
||||||
|
const isCurrent = player?.currentCityId === city.id;
|
||||||
|
const hasEvent = (eventsByCity.get(city.id) ?? []).length > 0;
|
||||||
|
const color = RISK_COLORS[riskLabel(city.riskLevel)] ?? '#d4a017';
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={city.id}
|
||||||
|
className="map-marker"
|
||||||
|
onClick={() => setSelected(city)}
|
||||||
|
transform={`translate(${city.mapX}, ${city.mapY})`}
|
||||||
|
>
|
||||||
|
{isCurrent && <circle r="4.5" className="map-marker__pulse" fill={color} />}
|
||||||
|
<circle r="2.6" fill="#0f0f13" stroke={color} strokeWidth="0.8" />
|
||||||
|
<circle r="1" fill={isCurrent ? '#e8e4da' : color} />
|
||||||
|
{hasEvent && (
|
||||||
|
<text x="2.6" y="-2.2" fontSize="3.5">
|
||||||
|
⚡
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text x="0" y="6.8" textAnchor="middle" className="map-marker__label">
|
||||||
|
{city.name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<p className="muted small map-legend">
|
||||||
|
● colore = rischio (verde basso, oro medio, rosso alto) · ⚡ evento in corso · tocca una
|
||||||
|
città per viaggiare
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="list">
|
||||||
|
{cities.map((city) => {
|
||||||
|
const isCurrent = player?.currentCityId === city.id;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={city.id}
|
||||||
|
className={`list__row list__row--clickable${isCurrent ? ' is-current' : ''}`}
|
||||||
|
onClick={() => setSelected(city)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{city.name}</strong>
|
||||||
|
{isCurrent && <span className="badge badge--small">sei qui</span>}
|
||||||
|
{(eventsByCity.get(city.id) ?? []).length > 0 && <span> ⚡</span>}
|
||||||
|
</div>
|
||||||
|
<span className="muted small">
|
||||||
|
{isCurrent ? '—' : formatMoney(city.travelCost)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setSelected(null)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{selected.name}</h3>
|
||||||
|
<p className="muted small">
|
||||||
|
rischio {riskLabel(selected.riskLevel)} · economia ×{selected.economyModifier} ·
|
||||||
|
polizia ×{selected.policePressure}
|
||||||
|
</p>
|
||||||
|
{cityEvents(selected.id).length > 0 && (
|
||||||
|
<div className="banner">
|
||||||
|
{cityEvents(selected.id).map((event) => (
|
||||||
|
<span key={event.id}>
|
||||||
|
⚡ {event.title}
|
||||||
|
{!event.city && ' (globale)'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{player?.currentCityId === selected.id ? (
|
||||||
|
<p className="muted">Ti trovi già in questa città.</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
disabled={busy || (player?.money ?? 0) < selected.travelCost}
|
||||||
|
onClick={() => void travelTo(selected)}
|
||||||
|
>
|
||||||
|
Viaggia · {formatMoney(selected.travelCost)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
/* ===== 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mappa ===== */
|
||||||
|
.map-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-land {
|
||||||
|
fill: #1d1d26;
|
||||||
|
stroke: #34343f;
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker__label {
|
||||||
|
font-size: 3.4px;
|
||||||
|
fill: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: #0f0f13;
|
||||||
|
stroke-width: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker__pulse {
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: map-pulse 1.8s ease-out infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
transform-box: fill-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes map-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-legend {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Missioni: probabilità, chip, lock ===== */
|
||||||
|
.chancebar {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chancebar__fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chancebar__text {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--delivery {
|
||||||
|
border-color: var(--info);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--theft {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--smuggling {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--intel {
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission--locked {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission__locked {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission__abandon {
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mercato: convenienza ===== */
|
||||||
|
.trend {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend--buy {
|
||||||
|
background: rgba(76, 175, 110, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend--sell {
|
||||||
|
background: rgba(212, 160, 23, 0.18);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Home ===== */
|
||||||
|
.quicklinks {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quicklinks .btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Badge navigazione ===== */
|
||||||
|
.bottomnav__icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomnav__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -10px;
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
+5
-4
@@ -61,9 +61,10 @@ Tutte le route tranne register/login richiedono `Authorization: Bearer <jwt>`.
|
|||||||
| GET | `/market/current` | listino della città corrente + eventi attivi |
|
| GET | `/market/current` | listino della città corrente + eventi attivi |
|
||||||
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
|
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
|
||||||
| GET | `/inventory` | inventario con peso totale |
|
| GET | `/inventory` | inventario con peso totale |
|
||||||
| GET | `/missions/available` | missioni nella città corrente |
|
| GET | `/missions/available` | missioni nella città corrente, con probabilità di successo stimata e requisito di livello |
|
||||||
| POST | `/missions/:missionId/start` | avvia (consuma subito gli item richiesti) |
|
| POST | `/missions/:missionId/start` | avvia (verifica livello, consuma subito gli item richiesti) |
|
||||||
| POST | `/missions/:playerMissionId/claim` | riscuote: successo/fallimento calcolato dal server |
|
| POST | `/missions/:playerMissionId/claim` | riscuote: successo (premi + bottino) o fallimento (multa) |
|
||||||
|
| POST | `/missions/:playerMissionId/abandon` | abbandona una missione attiva (−1 reputazione) |
|
||||||
| GET | `/missions/active` | missioni in corso |
|
| GET | `/missions/active` | missioni in corso |
|
||||||
| GET | `/events/current` | eventi mondo attivi |
|
| GET | `/events/current` | eventi mondo attivi |
|
||||||
| GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` |
|
| GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` |
|
||||||
@@ -82,7 +83,7 @@ I parametri citati sono tutti in `gameBalance.ts`.
|
|||||||
|
|
||||||
- **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98).
|
- **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98).
|
||||||
- **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`.
|
- **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`.
|
||||||
- **Missioni**: `successo = 0.95 − risk×0.6 + livello×0.01 + reputazione×0.0005 − (pressione polizia−1)×0.05`, limitato a [5%, 95%]. Successo: denaro + XP + reputazione (+2×difficoltà). Fallimento: −3 reputazione (mai sotto 0). Massimo 3 missioni attive per giocatore.
|
- **Missioni**: `successo = 0.95 − risk×0.6 + livello×0.01 + reputazione×0.0005 − (pressione polizia−1)×0.05`, limitato a [5%, 95%]; la stessa stima è esposta al client in `/missions/available`. Ogni tipo ha un carattere (`typeConfig`): Consegna trasporta merce, Furto paga meno ma dà bottino in merce, Contrabbando paga molto e rischia di più, Informazioni dà XP e reputazione doppia. Difficoltà alte richiedono un livello minimo. Successo: denaro + XP + reputazione + eventuale bottino. Fallimento: −3 reputazione e multa (30% del premio × pressione polizia, mai oltre il denaro posseduto). Una missione attiva può essere abbandonata (`POST /missions/:id/abandon`, −1 reputazione, merce consumata persa). Massimo 3 missioni attive per giocatore.
|
||||||
- **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP.
|
- **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP.
|
||||||
- **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi.
|
- **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi.
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "City" ADD COLUMN "mapX" DOUBLE PRECISION NOT NULL DEFAULT 50,
|
||||||
|
ADD COLUMN "mapY" DOUBLE PRECISION NOT NULL DEFAULT 50;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Mission" ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN "rewardItemId" TEXT,
|
||||||
|
ADD COLUMN "rewardItemQuantity" INTEGER;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_rewardItemId_fkey" FOREIGN KEY ("rewardItemId") REFERENCES "Item"("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"
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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)
|
||||||
|
mapX Float @default(50)
|
||||||
|
mapY Float @default(50)
|
||||||
|
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[]
|
||||||
|
requiredInMissions Mission[] @relation("MissionRequiredItem")
|
||||||
|
rewardInMissions Mission[] @relation("MissionRewardItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
description String @default("")
|
||||||
|
type MissionType
|
||||||
|
difficulty Int
|
||||||
|
minLevel Int @default(1)
|
||||||
|
durationSeconds Int
|
||||||
|
rewardMoney Int
|
||||||
|
rewardXp Int
|
||||||
|
risk Float
|
||||||
|
requiredItemId String?
|
||||||
|
requiredItemQuantity Int?
|
||||||
|
rewardItemId String?
|
||||||
|
rewardItemQuantity Int?
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
city City @relation(fields: [cityId], references: [id])
|
||||||
|
requiredItem Item? @relation("MissionRequiredItem", fields: [requiredItemId], references: [id])
|
||||||
|
rewardItem Item? @relation("MissionRewardItem", fields: [rewardItemId], 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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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.
|
||||||
|
* mapX/mapY sono coordinate percentuali (0-100) sulla mappa stilizzata dell'Italia.
|
||||||
|
*/
|
||||||
|
const CITIES = [
|
||||||
|
{ name: 'Milano', riskLevel: 3, policePressure: 1.1, economyModifier: 1.25, mapX: 32, mapY: 12 },
|
||||||
|
{ name: 'Napoli', riskLevel: 4, policePressure: 1.3, economyModifier: 1.0, mapX: 60, mapY: 58 },
|
||||||
|
{ name: 'Palermo', riskLevel: 4, policePressure: 1.25, economyModifier: 0.85, mapX: 52, mapY: 86 },
|
||||||
|
{ name: 'Torino', riskLevel: 2, policePressure: 0.9, economyModifier: 1.0, mapX: 16, mapY: 16 },
|
||||||
|
{ name: 'Bari', riskLevel: 3, policePressure: 1.0, economyModifier: 0.85, mapX: 78, mapY: 52 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
mapX: city.mapX,
|
||||||
|
mapY: city.mapY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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());
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
|
||||||
|
import cors from '@fastify/cors';
|
||||||
|
import jwt from '@fastify/jwt';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { authRoutes } from './modules/auth/auth.routes.js';
|
||||||
|
import { citiesRoutes } from './modules/cities/cities.routes.js';
|
||||||
|
import { eventsRoutes } from './modules/events/events.routes.js';
|
||||||
|
import { inventoryRoutes } from './modules/inventory/inventory.routes.js';
|
||||||
|
import { leaderboardRoutes } from './modules/leaderboard/leaderboard.routes.js';
|
||||||
|
import { marketRoutes } from './modules/market/market.routes.js';
|
||||||
|
import { missionsRoutes } from './modules/missions/missions.routes.js';
|
||||||
|
import { playersRoutes } from './modules/players/players.routes.js';
|
||||||
|
import { AppError } from './shared/errors.js';
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: env.NODE_ENV === 'production' ? 'info' : 'debug' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
await app.register(jwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
sign: { expiresIn: env.JWT_EXPIRES_IN },
|
||||||
|
});
|
||||||
|
// global: false — il rate limit si applica solo alle route che lo richiedono (auth).
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
|
||||||
|
app.setErrorHandler((error: unknown, request, reply) => {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return reply
|
||||||
|
.status(error.statusCode)
|
||||||
|
.send({ error: { code: error.code, message: error.message } });
|
||||||
|
}
|
||||||
|
// Errori generati da Fastify/plugin (404, 429, body malformato, ...)
|
||||||
|
const fastifyError = error as FastifyError;
|
||||||
|
if (fastifyError.statusCode && fastifyError.statusCode < 500) {
|
||||||
|
return reply.status(fastifyError.statusCode).send({
|
||||||
|
error: { code: fastifyError.code ?? 'REQUEST_ERROR', message: fastifyError.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request.log.error({ err: error }, 'errore non gestito');
|
||||||
|
return reply
|
||||||
|
.status(500)
|
||||||
|
.send({ error: { code: 'INTERNAL_ERROR', message: 'Errore interno del server' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/healthz', async () => ({ status: 'ok' }));
|
||||||
|
|
||||||
|
await app.register(
|
||||||
|
async (api) => {
|
||||||
|
await api.register(authRoutes, { prefix: '/auth' });
|
||||||
|
await api.register(playersRoutes, { prefix: '/player' });
|
||||||
|
await api.register(citiesRoutes, { prefix: '/cities' });
|
||||||
|
await api.register(marketRoutes, { prefix: '/market' });
|
||||||
|
await api.register(inventoryRoutes, { prefix: '/inventory' });
|
||||||
|
await api.register(missionsRoutes, { prefix: '/missions' });
|
||||||
|
await api.register(eventsRoutes, { prefix: '/events' });
|
||||||
|
await api.register(leaderboardRoutes, { prefix: '/leaderboard' });
|
||||||
|
},
|
||||||
|
{ prefix: '/api/v1' },
|
||||||
|
);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL mancante'),
|
||||||
|
REDIS_URL: z.string().min(1, 'REDIS_URL mancante'),
|
||||||
|
JWT_SECRET: z.string().min(8, 'JWT_SECRET troppo corto (min 8 caratteri)'),
|
||||||
|
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = EnvSchema.parse(process.env);
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
requiredItemQuantityMax: 3,
|
||||||
|
/**
|
||||||
|
* Carattere meccanico di ogni tipo di missione:
|
||||||
|
* - moneyFactor/xpFactor/repFactor: moltiplicatori delle ricompense
|
||||||
|
* - riskShift: spostamento del rischio base
|
||||||
|
* - requiredItemChance: probabilità che serva merce dall'inventario
|
||||||
|
* - lootChance/lootQuantityMax: probabilità e quantità del bottino in merce
|
||||||
|
*/
|
||||||
|
typeConfig: {
|
||||||
|
DELIVERY: {
|
||||||
|
moneyFactor: 1.0,
|
||||||
|
xpFactor: 1.0,
|
||||||
|
repFactor: 1,
|
||||||
|
riskShift: -0.05,
|
||||||
|
requiredItemChance: 0.6,
|
||||||
|
lootChance: 0,
|
||||||
|
lootQuantityMax: 0,
|
||||||
|
},
|
||||||
|
THEFT: {
|
||||||
|
moneyFactor: 0.6,
|
||||||
|
xpFactor: 1.0,
|
||||||
|
repFactor: 1,
|
||||||
|
riskShift: 0.05,
|
||||||
|
requiredItemChance: 0,
|
||||||
|
lootChance: 0.8,
|
||||||
|
lootQuantityMax: 2,
|
||||||
|
},
|
||||||
|
SMUGGLING: {
|
||||||
|
moneyFactor: 1.6,
|
||||||
|
xpFactor: 0.9,
|
||||||
|
repFactor: 1,
|
||||||
|
riskShift: 0.1,
|
||||||
|
requiredItemChance: 0.5,
|
||||||
|
lootChance: 0.25,
|
||||||
|
lootQuantityMax: 1,
|
||||||
|
},
|
||||||
|
INTEL: {
|
||||||
|
moneyFactor: 0.5,
|
||||||
|
xpFactor: 1.8,
|
||||||
|
repFactor: 2,
|
||||||
|
riskShift: -0.1,
|
||||||
|
requiredItemChance: 0,
|
||||||
|
lootChance: 0,
|
||||||
|
lootQuantityMax: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** livello minimo richiesto, indicizzato per difficoltà (1-5) */
|
||||||
|
minLevelByDifficulty: [1, 1, 1, 2, 4, 6],
|
||||||
|
/**
|
||||||
|
* Multa sul fallimento: rewardMoney * rewardFactor * pressione polizia,
|
||||||
|
* mai oltre il denaro posseduto dal giocatore.
|
||||||
|
*/
|
||||||
|
failFine: {
|
||||||
|
rewardFactor: 0.3,
|
||||||
|
},
|
||||||
|
/** abbandono volontario di una missione attiva */
|
||||||
|
abandon: {
|
||||||
|
reputationLoss: 1,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Probabilità di successo al claim:
|
||||||
|
* chance = baseChance
|
||||||
|
* - risk * riskWeight
|
||||||
|
* + level * levelBonus
|
||||||
|
* + reputation * reputationBonus
|
||||||
|
* - (policePressure*eventPoliceModifier - 1) * policeWeight
|
||||||
|
* limitata a [minChance, maxChance].
|
||||||
|
*/
|
||||||
|
success: {
|
||||||
|
baseChance: 0.95,
|
||||||
|
riskWeight: 0.6,
|
||||||
|
levelBonus: 0.01,
|
||||||
|
reputationBonus: 0.0005,
|
||||||
|
policeWeight: 0.05,
|
||||||
|
minChance: 0.05,
|
||||||
|
maxChance: 0.95,
|
||||||
|
},
|
||||||
|
/** reputazione: +difficulty*gain in caso di successo, -loss in caso di fallimento (mai sotto 0) */
|
||||||
|
reputationGainPerDifficulty: 2,
|
||||||
|
reputationLossOnFail: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Eventi mondo (locali e globali) */
|
||||||
|
events: {
|
||||||
|
/** probabilità di generare un evento locale a ogni ciclo da 15 minuti */
|
||||||
|
localChance: 0.25,
|
||||||
|
/** probabilità di generare un evento globale a ogni ciclo orario */
|
||||||
|
globalChance: 0.2,
|
||||||
|
/** durata eventi locali: minuti casuali in [min, max] */
|
||||||
|
localDurationMinutesMin: 15,
|
||||||
|
localDurationMinutesMax: 45,
|
||||||
|
/** durata eventi globali: minuti casuali in [min, max] */
|
||||||
|
globalDurationMinutesMin: 30,
|
||||||
|
globalDurationMinutesMax: 90,
|
||||||
|
/** range [min, max] dei moltiplicatori per tipo di evento */
|
||||||
|
types: {
|
||||||
|
MARKET_BOOM: { priceModifier: [1.2, 1.5], policeModifier: [1.0, 1.0] },
|
||||||
|
POLICE_RAID: { priceModifier: [0.9, 1.1], policeModifier: [1.3, 1.8] },
|
||||||
|
SHORTAGE: { priceModifier: [1.3, 1.8], policeModifier: [1.0, 1.2] },
|
||||||
|
BLACKOUT: { priceModifier: [0.7, 0.9], policeModifier: [0.8, 1.0] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Classifiche */
|
||||||
|
leaderboard: {
|
||||||
|
/** numero massimo di posizioni restituite/memorizzate */
|
||||||
|
maxEntries: 100,
|
||||||
|
/** limite di default per le richieste GET */
|
||||||
|
defaultLimit: 20,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventTypeBalance = keyof typeof balance.events.types;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const redis = new Redis(env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type MissionTemplate = { title: string; description: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pool narrativi per tipo di missione. I numeri (ricompense, rischi, loot)
|
||||||
|
* vengono da balance.missions.typeConfig: qui vive solo il flavour.
|
||||||
|
*/
|
||||||
|
const MISSION_TEMPLATES: Record<MissionType, MissionTemplate[]> = {
|
||||||
|
[MissionType.DELIVERY]: [
|
||||||
|
{
|
||||||
|
title: 'Consegna discreta',
|
||||||
|
description:
|
||||||
|
'Un pacco senza mittente deve arrivare a destinazione prima dell\'alba. Nessuna domanda.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trasporto notturno',
|
||||||
|
description:
|
||||||
|
'Un furgone carico aspetta in un vicolo. Strade secondarie, fari spenti, bocca chiusa.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Il corriere silenzioso',
|
||||||
|
description:
|
||||||
|
'Il cliente paga bene per la puntualità. E paga meglio per la discrezione.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pacco per il dottore',
|
||||||
|
description:
|
||||||
|
'Un professionista del centro ha bisogno di "forniture mediche" non registrate.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[MissionType.THEFT]: [
|
||||||
|
{
|
||||||
|
title: 'Recupero merce',
|
||||||
|
description:
|
||||||
|
'Un magazzino mal sorvegliato custodisce roba che "appartiene" al tuo committente. Riprendila.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Visita al deposito',
|
||||||
|
description:
|
||||||
|
'Il turno di guardia cambia alle tre. Hai dieci minuti e un piede di porco.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Il collezionista distratto',
|
||||||
|
description:
|
||||||
|
'Un riccone lascia la villa vuota nel weekend. La cassaforte è del modello che conosci bene.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Carico fantasma',
|
||||||
|
description:
|
||||||
|
'Un camion sosta troppo a lungo in periferia. Quello che trasporta non risulta da nessuna parte.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[MissionType.SMUGGLING]: [
|
||||||
|
{
|
||||||
|
title: 'Scambio al porto',
|
||||||
|
description:
|
||||||
|
'Container 47, banchina est. La dogana è stata pagata, ma solo fino a mezzanotte.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'La rotta dei pescatori',
|
||||||
|
description:
|
||||||
|
'Un peschereccio attracca con più di quanto dichiara. Serve qualcuno che scarichi in fretta.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Doppio fondo',
|
||||||
|
description:
|
||||||
|
'Un\'auto con il doppio fondo deve passare tre posti di blocco. Il carico scotta.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Frontiera amica',
|
||||||
|
description:
|
||||||
|
'Un finanziere compiacente chiude un occhio stanotte. L\'altro costa extra.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[MissionType.INTEL]: [
|
||||||
|
{
|
||||||
|
title: 'Infiltrazione uffici',
|
||||||
|
description:
|
||||||
|
'I documenti nel cassetto del direttore valgono più dell\'oro. Fotografa e sparisci.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Orecchie al bar',
|
||||||
|
description:
|
||||||
|
'Due appaltatori parlano troppo dopo il terzo amaro. Siediti vicino e ascolta.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'La talpa',
|
||||||
|
description:
|
||||||
|
'Un impiegato comunale vende l\'accesso agli archivi. Verifica che la merce sia buona.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Pedinamento',
|
||||||
|
description:
|
||||||
|
'Segui il contabile per un giorno intero. Il committente vuole sapere dove dorme.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Livello minimo richiesto per una data difficoltà (vedi balance). */
|
||||||
|
export function minLevelForDifficulty(difficulty: number): number {
|
||||||
|
const table = balance.missions.minLevelByDifficulty;
|
||||||
|
return table[Math.min(difficulty, table.length - 1)] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 type = pickRandom(Object.values(MissionType));
|
||||||
|
const config = b.typeConfig[type];
|
||||||
|
const template = pickRandom(MISSION_TEMPLATES[type]);
|
||||||
|
const difficulty = randomInt(b.difficultyMin, b.difficultyMax);
|
||||||
|
|
||||||
|
const risk =
|
||||||
|
Math.round(
|
||||||
|
clamp(
|
||||||
|
difficulty * b.riskPerDifficulty +
|
||||||
|
(city.riskLevel - 3) * b.riskPerCityRiskLevel +
|
||||||
|
config.riskShift +
|
||||||
|
randomFloat(-b.riskJitter, b.riskJitter),
|
||||||
|
b.riskMin,
|
||||||
|
b.riskMax,
|
||||||
|
) * 100,
|
||||||
|
) / 100;
|
||||||
|
|
||||||
|
// Merce richiesta per partire (consumata all'avvio)
|
||||||
|
let requiredItemId: string | null = null;
|
||||||
|
let requiredItemQuantity: number | null = null;
|
||||||
|
let itemRefund = 0;
|
||||||
|
if (items.length > 0 && Math.random() < config.requiredItemChance) {
|
||||||
|
const item = pickRandom(items);
|
||||||
|
requiredItemId = item.id;
|
||||||
|
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
|
||||||
|
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottino in merce in caso di successo
|
||||||
|
let rewardItemId: string | null = null;
|
||||||
|
let rewardItemQuantity: number | null = null;
|
||||||
|
if (items.length > 0 && config.lootChance > 0 && Math.random() < config.lootChance) {
|
||||||
|
rewardItemId = pickRandom(items).id;
|
||||||
|
rewardItemQuantity = randomInt(1, Math.max(1, config.lootQuantityMax));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewardMoney = Math.round(
|
||||||
|
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
|
||||||
|
config.moneyFactor *
|
||||||
|
city.economyModifier *
|
||||||
|
(1 + risk * b.riskRewardBonus) +
|
||||||
|
itemRefund,
|
||||||
|
);
|
||||||
|
const rewardXp = Math.round(
|
||||||
|
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
|
||||||
|
);
|
||||||
|
|
||||||
|
toCreate.push({
|
||||||
|
cityId: city.id,
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
type,
|
||||||
|
difficulty,
|
||||||
|
minLevel: minLevelForDifficulty(difficulty),
|
||||||
|
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
|
||||||
|
rewardMoney,
|
||||||
|
rewardXp,
|
||||||
|
risk,
|
||||||
|
requiredItemId,
|
||||||
|
requiredItemQuantity,
|
||||||
|
rewardItemId,
|
||||||
|
rewardItemQuantity,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)');
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,37 @@
|
|||||||
|
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,
|
||||||
|
mapX: true,
|
||||||
|
mapY: 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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,
|
||||||
|
basePrice: p.item.basePrice,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,69 @@
|
|||||||
|
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 {
|
||||||
|
abandonMission,
|
||||||
|
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/abandon', async (request) => {
|
||||||
|
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
|
||||||
|
const result = await abandonMission(request.user.playerId, playerMissionId);
|
||||||
|
request.log.info({ playerId: request.user.playerId, playerMissionId }, 'mission:abandon');
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
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,363 @@
|
|||||||
|
import type { Mission, Player, PlayerMission, WorldEvent } 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';
|
||||||
|
|
||||||
|
type MissionWithItems = Mission & {
|
||||||
|
requiredItem?: { name: string } | null;
|
||||||
|
rewardItem?: { name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClaimResult = {
|
||||||
|
playerMissionId: string;
|
||||||
|
missionTitle: string;
|
||||||
|
success: boolean;
|
||||||
|
successChance: number;
|
||||||
|
rewardMoney: number;
|
||||||
|
rewardXp: number;
|
||||||
|
/** negativo in caso di multa sul fallimento */
|
||||||
|
moneyChange: number;
|
||||||
|
fine: number;
|
||||||
|
lootItemName: string | null;
|
||||||
|
lootItemQuantity: number | null;
|
||||||
|
reputationChange: number;
|
||||||
|
levelsGained: number;
|
||||||
|
player: PlayerDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probabilità di successo di una missione per un giocatore
|
||||||
|
* (formula unica, usata sia per la stima mostrata al client sia per il claim).
|
||||||
|
*/
|
||||||
|
export function computeSuccessChance(
|
||||||
|
player: Pick<Player, 'level' | 'reputation'>,
|
||||||
|
mission: Pick<Mission, 'risk' | 'cityId'>,
|
||||||
|
cityPolicePressure: number,
|
||||||
|
events: WorldEvent[],
|
||||||
|
): number {
|
||||||
|
const s = balance.missions.success;
|
||||||
|
const police = cityPolicePressure * policeModifierForCity(events, mission.cityId);
|
||||||
|
return clamp(
|
||||||
|
s.baseChance -
|
||||||
|
mission.risk * s.riskWeight +
|
||||||
|
player.level * s.levelBonus +
|
||||||
|
player.reputation * s.reputationBonus -
|
||||||
|
(police - 1) * s.policeWeight,
|
||||||
|
s.minChance,
|
||||||
|
s.maxChance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMissionDto(mission: MissionWithItems) {
|
||||||
|
return {
|
||||||
|
id: mission.id,
|
||||||
|
cityId: mission.cityId,
|
||||||
|
title: mission.title,
|
||||||
|
description: mission.description,
|
||||||
|
type: mission.type,
|
||||||
|
difficulty: mission.difficulty,
|
||||||
|
minLevel: mission.minLevel,
|
||||||
|
durationSeconds: mission.durationSeconds,
|
||||||
|
rewardMoney: mission.rewardMoney,
|
||||||
|
rewardXp: mission.rewardXp,
|
||||||
|
risk: mission.risk,
|
||||||
|
requiredItemId: mission.requiredItemId,
|
||||||
|
requiredItemName: mission.requiredItem?.name ?? null,
|
||||||
|
requiredItemQuantity: mission.requiredItemQuantity,
|
||||||
|
rewardItemName: mission.rewardItem?.name ?? null,
|
||||||
|
rewardItemQuantity: mission.rewardItemQuantity,
|
||||||
|
expiresAt: mission.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Missioni disponibili nella città corrente, con probabilità di successo
|
||||||
|
* stimata per il giocatore richiedente.
|
||||||
|
*/
|
||||||
|
export async function listAvailable(playerId: string) {
|
||||||
|
const player = await getPlayerOrThrow(playerId);
|
||||||
|
const [missions, city, events] = await Promise.all([
|
||||||
|
prisma.mission.findMany({
|
||||||
|
where: {
|
||||||
|
cityId: player.currentCityId,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
playerMissions: { none: { playerId } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
requiredItem: { select: { name: true } },
|
||||||
|
rewardItem: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ difficulty: 'asc' }, { expiresAt: 'asc' }],
|
||||||
|
}),
|
||||||
|
prisma.city.findUnique({ where: { id: player.currentCityId } }),
|
||||||
|
getActiveEvents(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const policePressure = city?.policePressure ?? 1;
|
||||||
|
return {
|
||||||
|
missions: missions.map((mission) => ({
|
||||||
|
...toMissionDto(mission),
|
||||||
|
estimatedSuccessChance:
|
||||||
|
Math.round(computeSuccessChance(player, mission, policePressure, events) * 100) / 100,
|
||||||
|
canStart: player.level >= mission.minLevel,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 } },
|
||||||
|
rewardItem: { 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: verifica il livello minimo, 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à');
|
||||||
|
}
|
||||||
|
if (player.level < mission.minLevel) {
|
||||||
|
throw errors.badRequest(`Richiede livello ${mission.minLevel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Successo: denaro, XP, reputazione ed eventuale bottino in merce.
|
||||||
|
* Fallimento: perdita di reputazione e multa in denaro (scalata dalla polizia).
|
||||||
|
*/
|
||||||
|
export async function claimMission(
|
||||||
|
playerId: string,
|
||||||
|
playerMissionId: string,
|
||||||
|
): Promise<ClaimResult> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const playerMission = await tx.playerMission.findUnique({
|
||||||
|
where: { id: playerMissionId },
|
||||||
|
include: {
|
||||||
|
mission: { include: { city: true, rewardItem: { select: { name: 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 successChance = computeSuccessChance(
|
||||||
|
player,
|
||||||
|
mission,
|
||||||
|
mission.city.policePressure,
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
const success = Math.random() < successChance;
|
||||||
|
|
||||||
|
let updatedPlayer = player;
|
||||||
|
let reputationChange = 0;
|
||||||
|
let levelsGained = 0;
|
||||||
|
let moneyChange = 0;
|
||||||
|
let fine = 0;
|
||||||
|
let lootItemName: string | null = null;
|
||||||
|
let lootItemQuantity: number | null = null;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const config = balance.missions.typeConfig[mission.type];
|
||||||
|
reputationChange =
|
||||||
|
mission.difficulty * balance.missions.reputationGainPerDifficulty * config.repFactor;
|
||||||
|
moneyChange = mission.rewardMoney;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mission.rewardItemId && mission.rewardItemQuantity) {
|
||||||
|
await tx.inventoryItem.upsert({
|
||||||
|
where: { playerId_itemId: { playerId, itemId: mission.rewardItemId } },
|
||||||
|
create: {
|
||||||
|
playerId,
|
||||||
|
itemId: mission.rewardItemId,
|
||||||
|
quantity: mission.rewardItemQuantity,
|
||||||
|
},
|
||||||
|
update: { quantity: { increment: mission.rewardItemQuantity } },
|
||||||
|
});
|
||||||
|
lootItemName = mission.rewardItem?.name ?? null;
|
||||||
|
lootItemQuantity = mission.rewardItemQuantity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const police =
|
||||||
|
mission.city.policePressure * policeModifierForCity(events, mission.cityId);
|
||||||
|
fine = Math.min(
|
||||||
|
Math.round(mission.rewardMoney * balance.missions.failFine.rewardFactor * police),
|
||||||
|
player.money,
|
||||||
|
);
|
||||||
|
moneyChange = -fine;
|
||||||
|
reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation);
|
||||||
|
updatedPlayer = await tx.player.update({
|
||||||
|
where: { id: player.id },
|
||||||
|
data: {
|
||||||
|
money: { decrement: fine },
|
||||||
|
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,
|
||||||
|
moneyChange,
|
||||||
|
fine,
|
||||||
|
lootItemName,
|
||||||
|
lootItemQuantity,
|
||||||
|
reputationChange,
|
||||||
|
levelsGained,
|
||||||
|
player: toPlayerDto(updatedPlayer),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abbandona una missione in corso: piccola perdita di reputazione,
|
||||||
|
* la merce già consumata all'avvio non viene restituita.
|
||||||
|
*/
|
||||||
|
export async function abandonMission(
|
||||||
|
playerId: string,
|
||||||
|
playerMissionId: string,
|
||||||
|
): Promise<{ playerMissionId: string; reputationChange: number; player: PlayerDto }> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const playerMission = await tx.playerMission.findUnique({
|
||||||
|
where: { id: playerMissionId },
|
||||||
|
});
|
||||||
|
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 player = await getPlayerOrThrow(playerId, tx);
|
||||||
|
const reputationChange = -Math.min(
|
||||||
|
balance.missions.abandon.reputationLoss,
|
||||||
|
player.reputation,
|
||||||
|
);
|
||||||
|
const updatedPlayer = await tx.player.update({
|
||||||
|
where: { id: player.id },
|
||||||
|
data: { reputation: { increment: reputationChange } },
|
||||||
|
});
|
||||||
|
await tx.playerMission.update({
|
||||||
|
where: { id: playerMission.id },
|
||||||
|
data: { status: MissionStatus.FAILED, resolvedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerMissionId: playerMission.id,
|
||||||
|
reputationChange,
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { prisma } from './db/prisma.js';
|
||||||
|
import { redis } from './db/redis.js';
|
||||||
|
import { runStartupJobs, startScheduler } from './jobs/scheduler.js';
|
||||||
|
import { initSocket } from './realtime/socket.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = await buildApp();
|
||||||
|
initSocket(app);
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: '0.0.0.0' });
|
||||||
|
|
||||||
|
await runStartupJobs(app.log);
|
||||||
|
startScheduler(app.log);
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
app.log.info({ signal }, 'arresto del server');
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
redis.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { errors } from './errors.js';
|
||||||
|
|
||||||
|
/** preHandler che richiede un JWT valido; popola request.user con { sub, playerId }. */
|
||||||
|
export async function authGuard(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch {
|
||||||
|
throw errors.unauthorized('Token mancante o non valido');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Errore applicativo con status HTTP e codice macchina-leggibile. */
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: number,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errors = {
|
||||||
|
badRequest: (message: string) => new AppError(400, 'BAD_REQUEST', message),
|
||||||
|
validation: (message: string) => new AppError(400, 'VALIDATION_ERROR', message),
|
||||||
|
unauthorized: (message = 'Non autenticato') => new AppError(401, 'UNAUTHORIZED', message),
|
||||||
|
forbidden: (message = 'Operazione non consentita') => new AppError(403, 'FORBIDDEN', message),
|
||||||
|
notFound: (message = 'Risorsa non trovata') => new AppError(404, 'NOT_FOUND', message),
|
||||||
|
conflict: (message: string) => new AppError(409, 'CONFLICT', message),
|
||||||
|
insufficientFunds: () => new AppError(400, 'INSUFFICIENT_FUNDS', 'Denaro insufficiente'),
|
||||||
|
insufficientItems: () =>
|
||||||
|
new AppError(400, 'INSUFFICIENT_ITEMS', 'Quantità insufficiente in inventario'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ZodTypeAny, output } from 'zod';
|
||||||
|
import { AppError } from './errors.js';
|
||||||
|
|
||||||
|
/** Valida `data` con lo schema Zod; lancia AppError 400 con i dettagli in caso di errore. */
|
||||||
|
export function parseOrThrow<S extends ZodTypeAny>(schema: S, data: unknown): output<S> {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (!result.success) {
|
||||||
|
const detail = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', detail);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomFloat(min: number, max: number): number {
|
||||||
|
return Math.random() * (max - min) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRandom<T>(values: readonly T[]): T {
|
||||||
|
return values[Math.floor(Math.random() * values.length)]!;
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import '@fastify/jwt';
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { sub: string; playerId: string };
|
||||||
|
user: { sub: string; playerId: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user