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 |
|
||||
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
|
||||
| GET | `/inventory` | inventario con peso totale |
|
||||
| GET | `/missions/available` | missioni nella città corrente |
|
||||
| POST | `/missions/:missionId/start` | avvia (consuma subito gli item richiesti) |
|
||||
| POST | `/missions/:playerMissionId/claim` | riscuote: successo/fallimento calcolato dal server |
|
||||
| GET | `/missions/available` | missioni nella città corrente, con probabilità di successo stimata e requisito di livello |
|
||||
| POST | `/missions/:missionId/start` | avvia (verifica livello, consuma subito gli item richiesti) |
|
||||
| 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 | `/events/current` | eventi mondo attivi |
|
||||
| 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).
|
||||
- **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.
|
||||
- **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