Compare commits

...
20 Commits
Author SHA1 Message Date
davideandClaude Sonnet 4.6 a3c0892fea docs: update READMEs for new features
Document SVG map, mission improvements (chips, chance bar, abandon,
loot, fine), market trend tags, leaderboard live updates, and the
new abandon API endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:12:29 +02:00
davideandClaude Sonnet 4.6 48e618d543 client: nav mission badge + CSS for map, missions, market, dashboard
Layout polls active missions every 20s and on socket events; shows a
red badge on the Missioni nav tab when missions are ready to claim.
CSS additions: SVG map styles with pulse animation, chance bar,
mission type chips, lock/abandon styles, market trend tags,
quicklinks row, nav badge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:12:21 +02:00
davideandClaude Sonnet 4.6 6abd115c81 client: market trend tags + leaderboard live updates via Socket.IO
Market rows show a TrendTag: 💰 affare when buyPrice < basePrice,
📈 vendi when sellPrice > basePrice. Leaderboard subscribes to
leaderboard:updated socket event to refresh without polling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:12:14 +02:00
davideandClaude Sonnet 4.6 18de7fda21 client: dashboard — active missions widget and quicklinks
Home screen now loads active missions alongside player/events data.
Shows a "Missioni in corso" card with per-mission countdown and
inline Riscuoti button (with toast feedback). Quick-access buttons
to Missioni, Mercato, Classifiche replace the single leaderboard link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:12:08 +02:00
davideandClaude Sonnet 4.6 bdee84244f client: missions screen — narrative, chips, chance bar, loot, abandon
Available missions now show description, type chip (colored by type),
success probability bar (green/gold/red), minLevel lock with 🔒.
Reward loot item displayed in footer. Active missions show reward
loot and an Abandon button (confirm dialog, −1 rep warning).
Result modal shows loot and fine details.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:12:03 +02:00
davideandClaude Sonnet 4.6 bf5cfdb4ed client: travel screen — SVG Italy map with clickable city markers
Replace plain list with a styled SVG Italy outline (mainland, Sicily,
Sardinia). Cities rendered as markers: color by risk level, pulse
animation for current city,  icon for active events. Clicking a
marker or a list row opens a travel modal with city details and
confirm button. List remains as compact fallback below the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:53 +02:00
davideandClaude Sonnet 4.6 25376553e7 client: API layer — types and client updates for new server fields
Add AvailableMission (extends Mission with estimatedSuccessChance,
canStart), AbandonMissionResponse type. Extend Mission with description,
minLevel, rewardItemName/Quantity; ClaimMissionResponse with moneyChange,
fine, lootItem fields; City with mapX/mapY; MarketEntry with basePrice.
Add abandonMission() API call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:44 +02:00
davideandClaude Sonnet 4.6 123b67952e server: API — expose mapX/mapY in cities and basePrice in market
Cities endpoint now returns map coordinates for the SVG map renderer.
Market endpoint includes basePrice so the client can compute price
trend deltas relative to the item base value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:38 +02:00
davideandClaude Sonnet 4.6 1568520533 server: missions — type configs, level gate, loot, fine on failure, abandon
Each mission type now has a typeConfig (moneyFactor, xpFactor, repFactor,
riskShift, requiredItemChance, lootChance). startMission checks minLevel.
claimMission awards loot items on success; applies a police-scaled fine
on failure. New abandonMission endpoint (POST /:id/abandon, −1 rep).
computeSuccessChance extracted for reuse between listAvailable and claim.
Narrative template pool expanded (4 templates × 4 types).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:33 +02:00
davideandClaude Sonnet 4.6 8804dfeb22 server: schema — City map coords, Mission narrative/level/loot fields
Add mapX/mapY to City for SVG map positioning; add description,
minLevel, rewardItemId/Quantity to Mission; add named relations
MissionRequiredItem and MissionRewardItem on Item. Seed updated
with Italian city coordinates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:11:26 +02:00
davideandClaude Sonnet 4.6 0f91ace43e client: screens and app routing
Add all game screens: Login, Dashboard, Market (buy/sell), Inventory,
Missions, Travel (city selection), Leaderboard; wired via react-router
in App.tsx with protected routes.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:39 +02:00
davideandClaude Sonnet 4.6 8ad83348f4 server: core bootstrap — config, db clients, shared utilities
Add env config (zod validation), game balance constants, Prisma and
Redis client singletons, auth guard middleware, error helpers,
request validators, Fastify JWT type augmentation, and app/server
entry points.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:27 +02:00
64 changed files with 7049 additions and 4 deletions
+2
View File
@@ -0,0 +1,2 @@
# URL del server di gioco (senza /api/v1)
VITE_API_URL=http://localhost:3000
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+58
View File
@@ -0,0 +1,58 @@
# Contrabbandieri MMO — Client
Prototipo web mobile-first del client di gioco. Parla con il server (`../server`) via REST (`/api/v1`) e Socket.IO (`/ws`).
> Nota: `Blueprint_Client.md` conteneva una copia del blueprint server, quindi le scelte client (Vite + React, tema noir) sono state concordate a parte. Il client è volutamente "stupido": tutte le regole di gioco vivono sul server.
## Stack
Vite · React 19 · TypeScript · React Router · socket.io-client · CSS puro (nessuna UI library)
## Avvio
Il server deve essere attivo (vedi `../server/README.md`). Poi:
```bash
npm install
npm run dev
```
Apri `http://localhost:5173` (per provarlo da telefono sulla stessa rete: `http://<ip-del-pc>:5173`).
Se il server non è su `localhost:3000`, crea un `.env` da `.env.example` con `VITE_API_URL`.
## Schermate
- **Login/Registrazione** — JWT salvato in localStorage.
- **Covo** (home) — profilo, denaro, reputazione, barra XP, città corrente, eventi attivi, accesso alle classifiche, logout.
- **Mercato** — listino della città corrente (prezzo acquisto/vendita), eventi che influenzano i prezzi, modale compra/vendi con quantità; si auto-aggiorna a ogni tick del server via `market:updated`.
- **Missioni** — 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
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f0f13" />
<title>Contrabbandieri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1965
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "contrabbandieri-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from './auth/AuthContext';
import { Layout } from './components/Layout';
import { DashboardScreen } from './screens/DashboardScreen';
import { InventoryScreen } from './screens/InventoryScreen';
import { LeaderboardScreen } from './screens/LeaderboardScreen';
import { LoginScreen } from './screens/LoginScreen';
import { MarketScreen } from './screens/MarketScreen';
import { MissionsScreen } from './screens/MissionsScreen';
import { TravelScreen } from './screens/TravelScreen';
export function App() {
const { token } = useAuth();
if (!token) {
return (
<Routes>
<Route path="*" element={<LoginScreen />} />
</Routes>
);
}
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<DashboardScreen />} />
<Route path="/market" element={<MarketScreen />} />
<Route path="/missions" element={<MissionsScreen />} />
<Route path="/inventory" element={<InventoryScreen />} />
<Route path="/travel" element={<TravelScreen />} />
<Route path="/leaderboard" element={<LeaderboardScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
+63
View File
@@ -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}`),
};
+56
View File
@@ -0,0 +1,56 @@
import { API_URL } from '../config';
/** Errore restituito dal server nella forma { error: { code, message } }. */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
let accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
accessToken = token;
}
type RequestOptions = {
method?: 'GET' | 'POST';
body?: unknown;
};
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
let response: Response;
try {
response = await fetch(`${API_URL}/api/v1${path}`, {
method: options.method ?? 'GET',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new ApiError(0, 'NETWORK_ERROR', 'Server non raggiungibile');
}
if (!response.ok) {
let code = 'REQUEST_ERROR';
let message = `Errore ${response.status}`;
try {
const payload = (await response.json()) as { error?: { code?: string; message?: string } };
code = payload.error?.code ?? code;
message = payload.error?.message ?? message;
} catch {
// risposta non JSON: si tengono i default
}
throw new ApiError(response.status, code, message);
}
return (await response.json()) as T;
}
+172
View File
@@ -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[];
};
+106
View File
@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { api } from '../api/api';
import { setAccessToken } from '../api/http';
import type { Player } from '../api/types';
import { connectSocket, disconnectSocket } from '../realtime/socket';
const STORAGE_KEY = 'contrabbandieri.auth';
type AuthState = { token: string; player: Player } | null;
type AuthContextValue = {
token: string | null;
player: Player | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, displayName: string) => Promise<void>;
/** Aggiorna i dati del giocatore dopo un'azione (acquisto, viaggio, claim...). */
setPlayer: (player: Player) => void;
/** Ricarica il profilo dal server. */
refreshPlayer: () => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
function loadStoredAuth(): AuthState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as AuthState) : null;
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [auth, setAuth] = useState<AuthState>(() => {
const stored = loadStoredAuth();
// il token va impostato prima del primo render per le fetch iniziali
setAccessToken(stored?.token ?? null);
return stored;
});
useEffect(() => {
setAccessToken(auth?.token ?? null);
if (auth) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
connectSocket(auth.token);
} else {
localStorage.removeItem(STORAGE_KEY);
disconnectSocket();
}
return () => disconnectSocket();
}, [auth?.token]);
const login = useCallback(async (email: string, password: string) => {
const res = await api.login(email, password);
setAuth({ token: res.accessToken, player: res.player });
}, []);
const register = useCallback(
async (email: string, password: string, displayName: string) => {
const res = await api.register(email, password, displayName);
setAuth({ token: res.accessToken, player: res.player });
},
[],
);
const setPlayer = useCallback((player: Player) => {
setAuth((prev) => (prev ? { ...prev, player } : prev));
}, []);
const refreshPlayer = useCallback(async () => {
const res = await api.playerMe();
setPlayer(res.player);
}, [setPlayer]);
const logout = useCallback(() => setAuth(null), []);
const value = useMemo<AuthContextValue>(
() => ({
token: auth?.token ?? null,
player: auth?.player ?? null,
login,
register,
setPlayer,
refreshPlayer,
logout,
}),
[auth, login, register, setPlayer, refreshPlayer, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth deve essere usato dentro AuthProvider');
return ctx;
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
function remainingSeconds(target: string): number {
return Math.max(0, Math.ceil((new Date(target).getTime() - Date.now()) / 1000));
}
/** Conto alla rovescia verso `target`; chiama onDone una volta arrivato a zero. */
export function Countdown({ target, onDone }: { target: string; onDone?: () => void }) {
const [seconds, setSeconds] = useState(() => remainingSeconds(target));
useEffect(() => {
setSeconds(remainingSeconds(target));
const interval = setInterval(() => {
const left = remainingSeconds(target);
setSeconds(left);
if (left === 0) {
clearInterval(interval);
onDone?.();
}
}, 1000);
return () => clearInterval(interval);
}, [target]);
if (seconds === 0) return <span className="countdown countdown--done">pronta</span>;
const mm = Math.floor(seconds / 60);
const ss = String(seconds % 60).padStart(2, '0');
return (
<span className="countdown">
{mm}:{ss}
</span>
);
}
+105
View File
@@ -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>
);
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
type ToastKind = 'info' | 'success' | 'error';
type Toast = { id: number; kind: ToastKind; message: string };
type ToastContextValue = {
toast: (message: string, kind?: ToastKind) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId.current++;
setToasts((prev) => [...prev.slice(-3), { id, kind, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const value = useMemo(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.kind}`}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast deve essere usato dentro ToastProvider');
return ctx;
}
+2
View File
@@ -0,0 +1,2 @@
/** URL base del server di gioco (REST su /api/v1, Socket.IO su /ws). */
export const API_URL: string = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { AuthProvider } from './auth/AuthContext';
import { ToastProvider } from './components/Toast';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+20
View File
@@ -0,0 +1,20 @@
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../config';
let socket: Socket | null = null;
/** Connette il socket realtime (path /ws) autenticandosi con il JWT. */
export function connectSocket(token: string): Socket {
disconnectSocket();
socket = io(API_URL, { path: '/ws', auth: { token } });
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectSocket(): void {
socket?.disconnect();
socket = null;
}
+164
View File
@@ -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>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/api';
import type { InventoryResponse } from '../api/types';
import { formatMoney } from '../shared/format';
export function InventoryScreen() {
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
useEffect(() => {
void api.inventory().then(setInventory);
}, []);
if (!inventory) return <p className="muted screen">Caricamento zaino...</p>;
return (
<div className="screen">
<h2 className="screen__title">Zaino</h2>
{inventory.items.length === 0 ? (
<div className="card">
<p className="muted">Lo zaino è vuoto.</p>
<Link to="/market" className="btn btn--secondary">
Vai al mercato
</Link>
</div>
) : (
<>
<ul className="list">
{inventory.items.map((item) => (
<li key={item.itemId} className="list__row">
<div>
<strong>{item.name}</strong>
<p className="muted small">
valore base {formatMoney(item.basePrice)} · peso {item.weight}/unità
</p>
</div>
<span className="badge">×{item.quantity}</span>
</li>
))}
</ul>
<p className="muted small">Peso totale: {inventory.totalWeight}</p>
<Link to="/market" className="btn btn--secondary">
Vendi al mercato
</Link>
</>
)}
</div>
);
}
+78
View File
@@ -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>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, type FormEvent } from 'react';
import { ApiError } from '../api/http';
import { useAuth } from '../auth/AuthContext';
export function LoginScreen() {
const { login, register } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
if (mode === 'login') {
await login(email, password);
} else {
await register(email, password, displayName);
}
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Errore imprevisto');
} finally {
setBusy(false);
}
}
return (
<div className="login">
<h1 className="login__title">CONTRABBANDIERI</h1>
<p className="login__subtitle">La città non dorme. Nemmeno tu.</p>
<div className="login__tabs">
<button
type="button"
className={mode === 'login' ? 'is-active' : ''}
onClick={() => setMode('login')}
>
Accedi
</button>
<button
type="button"
className={mode === 'register' ? 'is-active' : ''}
onClick={() => setMode('register')}
>
Registrati
</button>
</div>
<form className="login__form" onSubmit={onSubmit}>
{mode === 'register' && (
<label>
Nome in codice
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="ShadowFox"
minLength={3}
maxLength={20}
required
/>
</label>
)}
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tu@esempio.com"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === 'register' ? 'minimo 8 caratteri' : ''}
minLength={mode === 'register' ? 8 : 1}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn btn--primary" disabled={busy}>
{busy ? '...' : mode === 'login' ? 'Entra in città' : 'Inizia la carriera'}
</button>
</form>
</div>
);
}
+174
View File
@@ -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>
);
}
+240
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+46
View File
@@ -0,0 +1,46 @@
const euro = new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
export function formatMoney(value: number): string {
return euro.format(value);
}
export function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
/** Stelle di difficoltà (1-5). */
export function difficultyStars(difficulty: number): string {
return '★'.repeat(difficulty) + '☆'.repeat(Math.max(0, 5 - difficulty));
}
export function formatDuration(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes === 0) return `${seconds}s`;
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
export const MISSION_TYPE_LABELS: Record<string, string> = {
DELIVERY: 'Consegna',
THEFT: 'Furto',
SMUGGLING: 'Contrabbando',
INTEL: 'Informazioni',
};
export const EVENT_TYPE_LABELS: Record<string, string> = {
MARKET_BOOM: 'Boom di mercato',
POLICE_RAID: 'Retata',
SHORTAGE: 'Carenza merci',
BLACKOUT: 'Blackout',
};
/** Etichetta del rischio città (1-5). */
export function riskLabel(riskLevel: number): string {
if (riskLevel <= 2) return 'basso';
if (riskLevel === 3) return 'medio';
return 'alto';
}
+810
View File
@@ -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);
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
});
+5 -4
View File
@@ -61,9 +61,10 @@ Tutte le route tranne register/login richiedono `Authorization: Bearer <jwt>`.
| GET | `/market/current` | listino della città corrente + eventi attivi | | GET | `/market/current` | listino della città corrente + eventi attivi |
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server | | POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
| GET | `/inventory` | inventario con peso totale | | GET | `/inventory` | inventario con peso totale |
| GET | `/missions/available` | missioni nella città corrente | | GET | `/missions/available` | missioni nella città corrente, con probabilità di successo stimata e requisito di livello |
| POST | `/missions/:missionId/start` | avvia (consuma subito gli item richiesti) | | POST | `/missions/:missionId/start` | avvia (verifica livello, consuma subito gli item richiesti) |
| POST | `/missions/:playerMissionId/claim` | riscuote: successo/fallimento calcolato dal server | | POST | `/missions/:playerMissionId/claim` | riscuote: successo (premi + bottino) o fallimento (multa) |
| POST | `/missions/:playerMissionId/abandon` | abbandona una missione attiva (1 reputazione) |
| GET | `/missions/active` | missioni in corso | | GET | `/missions/active` | missioni in corso |
| GET | `/events/current` | eventi mondo attivi | | GET | `/events/current` | eventi mondo attivi |
| GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` | | GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` |
@@ -82,7 +83,7 @@ I parametri citati sono tutti in `gameBalance.ts`.
- **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98). - **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98).
- **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`. - **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`.
- **Missioni**: `successo = 0.95 risk×0.6 + livello×0.01 + reputazione×0.0005 (pressione polizia1)×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 polizia1)×0.05`, limitato a [5%, 95%]; la stessa stima è esposta al client in `/missions/available`. Ogni tipo ha un carattere (`typeConfig`): Consegna trasporta merce, Furto paga meno ma dà bottino in merce, Contrabbando paga molto e rischia di più, Informazioni dà XP e reputazione doppia. Difficoltà alte richiedono un livello minimo. Successo: denaro + XP + reputazione + eventuale bottino. Fallimento: 3 reputazione e multa (30% del premio × pressione polizia, mai oltre il denaro posseduto). Una missione attiva può essere abbandonata (`POST /missions/:id/abandon`, 1 reputazione, merce consumata persa). Massimo 3 missioni attive per giocatore.
- **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP. - **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP.
- **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi. - **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi.
@@ -0,0 +1,178 @@
-- CreateEnum
CREATE TYPE "MissionStatus" AS ENUM ('STARTED', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "MissionType" AS ENUM ('DELIVERY', 'THEFT', 'SMUGGLING', 'INTEL');
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('MARKET_BOOM', 'POLICE_RAID', 'SHORTAGE', 'BLACKOUT');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Player" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"money" INTEGER NOT NULL DEFAULT 1000,
"reputation" INTEGER NOT NULL DEFAULT 0,
"level" INTEGER NOT NULL DEFAULT 1,
"experience" INTEGER NOT NULL DEFAULT 0,
"currentCityId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "City" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"riskLevel" INTEGER NOT NULL,
"policePressure" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"economyModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "City_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"basePrice" INTEGER NOT NULL,
"rarity" INTEGER NOT NULL,
"illegalLevel" INTEGER NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InventoryItem" (
"playerId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
CONSTRAINT "InventoryItem_pkey" PRIMARY KEY ("playerId","itemId")
);
-- CreateTable
CREATE TABLE "MarketPrice" (
"cityId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"buyPrice" INTEGER NOT NULL,
"sellPrice" INTEGER NOT NULL,
"demand" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"supply" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketPrice_pkey" PRIMARY KEY ("cityId","itemId")
);
-- CreateTable
CREATE TABLE "Mission" (
"id" TEXT NOT NULL,
"cityId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"type" "MissionType" NOT NULL,
"difficulty" INTEGER NOT NULL,
"durationSeconds" INTEGER NOT NULL,
"rewardMoney" INTEGER NOT NULL,
"rewardXp" INTEGER NOT NULL,
"risk" DOUBLE PRECISION NOT NULL,
"requiredItemId" TEXT,
"requiredItemQuantity" INTEGER,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Mission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlayerMission" (
"id" TEXT NOT NULL,
"playerId" TEXT NOT NULL,
"missionId" TEXT NOT NULL,
"status" "MissionStatus" NOT NULL DEFAULT 'STARTED',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completesAt" TIMESTAMP(3) NOT NULL,
"resolvedAt" TIMESTAMP(3),
CONSTRAINT "PlayerMission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorldEvent" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"cityId" TEXT,
"priceModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"policeModifier" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"startsAt" TIMESTAMP(3) NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorldEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Player_userId_key" ON "Player"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Player_displayName_key" ON "Player"("displayName");
-- CreateIndex
CREATE UNIQUE INDEX "City_name_key" ON "City"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Item_name_key" ON "Item"("name");
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Player" ADD CONSTRAINT "Player_currentCityId_fkey" FOREIGN KEY ("currentCityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InventoryItem" ADD CONSTRAINT "InventoryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MarketPrice" ADD CONSTRAINT "MarketPrice_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_requiredItemId_fkey" FOREIGN KEY ("requiredItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PlayerMission" ADD CONSTRAINT "PlayerMission_missionId_fkey" FOREIGN KEY ("missionId") REFERENCES "Mission"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorldEvent" ADD CONSTRAINT "WorldEvent_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "City" ADD COLUMN "mapX" DOUBLE PRECISION NOT NULL DEFAULT 50,
ADD COLUMN "mapY" DOUBLE PRECISION NOT NULL DEFAULT 50;
-- AlterTable
ALTER TABLE "Mission" ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "rewardItemId" TEXT,
ADD COLUMN "rewardItemQuantity" INTEGER;
-- AddForeignKey
ALTER TABLE "Mission" ADD CONSTRAINT "Mission_rewardItemId_fkey" FOREIGN KEY ("rewardItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+165
View File
@@ -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])
}
+86
View File
@@ -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());
+65
View File
@@ -0,0 +1,65 @@
import Fastify, { type FastifyError, type FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import { env } from './config/env.js';
import { authRoutes } from './modules/auth/auth.routes.js';
import { citiesRoutes } from './modules/cities/cities.routes.js';
import { eventsRoutes } from './modules/events/events.routes.js';
import { inventoryRoutes } from './modules/inventory/inventory.routes.js';
import { leaderboardRoutes } from './modules/leaderboard/leaderboard.routes.js';
import { marketRoutes } from './modules/market/market.routes.js';
import { missionsRoutes } from './modules/missions/missions.routes.js';
import { playersRoutes } from './modules/players/players.routes.js';
import { AppError } from './shared/errors.js';
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: { level: env.NODE_ENV === 'production' ? 'info' : 'debug' },
});
await app.register(cors, { origin: true });
await app.register(jwt, {
secret: env.JWT_SECRET,
sign: { expiresIn: env.JWT_EXPIRES_IN },
});
// global: false — il rate limit si applica solo alle route che lo richiedono (auth).
await app.register(rateLimit, { global: false });
app.setErrorHandler((error: unknown, request, reply) => {
if (error instanceof AppError) {
return reply
.status(error.statusCode)
.send({ error: { code: error.code, message: error.message } });
}
// Errori generati da Fastify/plugin (404, 429, body malformato, ...)
const fastifyError = error as FastifyError;
if (fastifyError.statusCode && fastifyError.statusCode < 500) {
return reply.status(fastifyError.statusCode).send({
error: { code: fastifyError.code ?? 'REQUEST_ERROR', message: fastifyError.message },
});
}
request.log.error({ err: error }, 'errore non gestito');
return reply
.status(500)
.send({ error: { code: 'INTERNAL_ERROR', message: 'Errore interno del server' } });
});
app.get('/healthz', async () => ({ status: 'ok' }));
await app.register(
async (api) => {
await api.register(authRoutes, { prefix: '/auth' });
await api.register(playersRoutes, { prefix: '/player' });
await api.register(citiesRoutes, { prefix: '/cities' });
await api.register(marketRoutes, { prefix: '/market' });
await api.register(inventoryRoutes, { prefix: '/inventory' });
await api.register(missionsRoutes, { prefix: '/missions' });
await api.register(eventsRoutes, { prefix: '/events' });
await api.register(leaderboardRoutes, { prefix: '/leaderboard' });
},
{ prefix: '/api/v1' },
);
return app;
}
+13
View File
@@ -0,0 +1,13 @@
import 'dotenv/config';
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().min(1, 'DATABASE_URL mancante'),
REDIS_URL: z.string().min(1, 'REDIS_URL mancante'),
JWT_SECRET: z.string().min(8, 'JWT_SECRET troppo corto (min 8 caratteri)'),
JWT_EXPIRES_IN: z.string().default('7d'),
});
export const env = EnvSchema.parse(process.env);
+212
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+6
View File
@@ -0,0 +1,6 @@
import { Redis } from 'ioredis';
import { env } from '../config/env.js';
export const redis = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
});
+237
View File
@@ -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;
}
+88
View File
@@ -0,0 +1,88 @@
import { EventType } from '@prisma/client';
import { balance, type EventTypeBalance } from '../config/gameBalance.js';
import { prisma } from '../db/prisma.js';
import { redis } from '../db/redis.js';
import { toEventDto } from '../modules/events/events.service.js';
import { emitGlobal } from '../realtime/socket.js';
import { pickRandom, randomFloat, randomInt } from '../shared/validators.js';
/** Testi degli eventi per tipo (i moltiplicatori numerici sono in gameBalance). */
const EVENT_TEXTS: Record<EventTypeBalance, { title: string; description: string }> = {
MARKET_BOOM: {
title: 'Boom di mercato',
description: 'La domanda esplode: i prezzi salgono in tutta la zona.',
},
POLICE_RAID: {
title: 'Retata della polizia',
description: 'Controlli ovunque: muoversi è molto più rischioso.',
},
SHORTAGE: {
title: 'Carenza di merci',
description: 'Le scorte scarseggiano e i prezzi schizzano alle stelle.',
},
BLACKOUT: {
title: 'Blackout',
description: 'La città è al buio: il mercato rallenta, la polizia pure.',
},
};
async function createEvent(cityId: string | null, durationMinutes: number): Promise<void> {
const type = pickRandom(Object.values(EventType));
const ranges = balance.events.types[type];
const texts = EVENT_TEXTS[type];
const now = new Date();
const event = await prisma.worldEvent.create({
data: {
title: texts.title,
description: texts.description,
type,
cityId,
priceModifier:
Math.round(randomFloat(ranges.priceModifier[0], ranges.priceModifier[1]) * 100) / 100,
policeModifier:
Math.round(randomFloat(ranges.policeModifier[0], ranges.policeModifier[1]) * 100) / 100,
startsAt: now,
endsAt: new Date(now.getTime() + durationMinutes * 60_000),
},
});
emitGlobal('worldEvent:started', toEventDto(event));
}
/** Ogni 15 minuti: possibile evento locale in una città casuale. */
export async function maybeGenerateLocalEvent(): Promise<void> {
if (Math.random() >= balance.events.localChance) return;
const cities = await prisma.city.findMany({ select: { id: true } });
if (cities.length === 0) return;
await createEvent(
pickRandom(cities).id,
randomInt(balance.events.localDurationMinutesMin, balance.events.localDurationMinutesMax),
);
}
/** Ogni ora: possibile evento globale (cityId null). */
export async function maybeGenerateGlobalEvent(): Promise<void> {
if (Math.random() >= balance.events.globalChance) return;
await createEvent(
null,
randomInt(balance.events.globalDurationMinutesMin, balance.events.globalDurationMinutesMax),
);
}
/** Notifica (una sola volta) la fine degli eventi terminati di recente. */
export async function notifyEndedEvents(): Promise<void> {
const now = new Date();
const recentlyEnded = await prisma.worldEvent.findMany({
where: {
endsAt: { lt: now, gt: new Date(now.getTime() - 2 * 60 * 60_000) },
},
});
for (const event of recentlyEnded) {
const firstTime = await redis.set(`notify:event-end:${event.id}`, '1', 'EX', 86_400, 'NX');
if (firstTime) {
emitGlobal('worldEvent:ended', toEventDto(event));
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import cron from 'node-cron';
import type { FastifyBaseLogger } from 'fastify';
import { refreshLeaderboards } from '../modules/leaderboard/leaderboard.service.js';
import {
deleteExpiredMissions,
generateMissions,
notifyCompletedMissions,
} from './generateMissions.job.js';
import {
maybeGenerateGlobalEvent,
maybeGenerateLocalEvent,
notifyEndedEvents,
} from './generateWorldEvent.job.js';
import { updateMarketPrices } from './updateMarketPrices.job.js';
/** Esegue un job loggando eventuali errori senza far cadere lo scheduler. */
async function safeRun(log: FastifyBaseLogger, name: string, job: () => Promise<unknown>) {
try {
await job();
} catch (err) {
log.error({ err }, `job ${name} fallito`);
}
}
/** Job eseguiti subito all'avvio per avere un mondo "vivo" senza attendere il cron. */
export async function runStartupJobs(log: FastifyBaseLogger): Promise<void> {
await safeRun(log, 'generateMissions', generateMissions);
await safeRun(log, 'updateMarketPrices', updateMarketPrices);
await safeRun(log, 'refreshLeaderboards', refreshLeaderboards);
log.info('job di avvio completati');
}
/** Avvia i cicli periodici (blueprint §10). */
export function startScheduler(log: FastifyBaseLogger): void {
// Ogni minuto: prezzi, notifiche missioni completate, pulizia e rigenerazione missioni.
cron.schedule('* * * * *', async () => {
await safeRun(log, 'updateMarketPrices', updateMarketPrices);
await safeRun(log, 'notifyCompletedMissions', notifyCompletedMissions);
await safeRun(log, 'deleteExpiredMissions', deleteExpiredMissions);
await safeRun(log, 'generateMissions', generateMissions);
});
// Ogni 15 minuti: possibile evento locale e aggiornamento classifiche Redis.
cron.schedule('*/15 * * * *', async () => {
await safeRun(log, 'maybeGenerateLocalEvent', maybeGenerateLocalEvent);
await safeRun(log, 'refreshLeaderboards', refreshLeaderboards);
});
// Ogni ora: possibile evento globale e chiusura/notifica eventi terminati.
cron.schedule('0 * * * *', async () => {
await safeRun(log, 'maybeGenerateGlobalEvent', maybeGenerateGlobalEvent);
await safeRun(log, 'notifyEndedEvents', notifyEndedEvents);
});
log.info('scheduler avviato (1m / 15m / 1h)');
}
+41
View File
@@ -0,0 +1,41 @@
import { balance } from '../config/gameBalance.js';
import { prisma } from '../db/prisma.js';
import { getActiveEvents, priceModifierForCity } from '../modules/events/events.service.js';
import { clampDemandSupply, computePrices } from '../modules/market/pricing.js';
import { emitGlobal } from '../realtime/socket.js';
/**
* Tick del mercato (ogni minuto): domanda e offerta tornano lentamente
* verso l'equilibrio e i prezzi vengono ricalcolati con i modificatori
* degli eventi attivi.
*/
export async function updateMarketPrices(): Promise<void> {
const m = balance.market;
const [events, prices] = await Promise.all([
getActiveEvents(),
prisma.marketPrice.findMany({ include: { item: true, city: true } }),
]);
const updates = prices.map((price) => {
const demand = clampDemandSupply(
price.demand * m.decayFactor + m.equilibrium * (1 - m.decayFactor),
);
const supply = clampDemandSupply(
price.supply * m.decayFactor + m.equilibrium * (1 - m.decayFactor),
);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
return prisma.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId: price.itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
});
await prisma.$transaction(updates);
emitGlobal('market:updated', { updatedAt: new Date().toISOString() });
}
+54
View File
@@ -0,0 +1,54 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { toPlayerDto } from '../players/players.service.js';
import { loginUser, registerUser } from './auth.service.js';
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
displayName: z.string().min(3).max(20),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
/** Rate limit dedicato agli endpoint di autenticazione (vedi blueprint §11). */
const authRateLimit = {
config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
};
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/register', authRateLimit, async (request, reply) => {
const input = parseOrThrow(registerSchema, request.body);
const user = await registerUser(input);
const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id });
request.log.info({ userId: user.id, playerId: user.player.id }, 'auth:register');
return reply.status(201).send({ accessToken, player: toPlayerDto(user.player) });
});
app.post('/login', authRateLimit, async (request) => {
const input = parseOrThrow(loginSchema, request.body);
const user = await loginUser(input);
const accessToken = app.jwt.sign({ sub: user.id, playerId: user.player.id });
request.log.info({ userId: user.id }, 'auth:login');
return { accessToken, player: toPlayerDto(user.player) };
});
app.get('/me', { preHandler: [authGuard] }, async (request) => {
const user = await prisma.user.findUnique({
where: { id: request.user.sub },
include: { player: true },
});
if (!user?.player) throw errors.notFound('Utente non trovato');
return {
user: { id: user.id, email: user.email, createdAt: user.createdAt },
player: toPlayerDto(user.player),
};
});
};
+57
View File
@@ -0,0 +1,57 @@
import argon2 from 'argon2';
import type { Player, User } from '@prisma/client';
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { AppError, errors } from '../../shared/errors.js';
export type UserWithPlayer = User & { player: Player };
export async function registerUser(input: {
email: string;
password: string;
displayName: string;
}): Promise<UserWithPlayer> {
const existingEmail = await prisma.user.findUnique({ where: { email: input.email } });
if (existingEmail) throw errors.conflict('Email già registrata');
const existingName = await prisma.player.findUnique({
where: { displayName: input.displayName },
});
if (existingName) throw errors.conflict('Nome giocatore già in uso');
const startingCity =
(await prisma.city.findUnique({ where: { name: balance.player.startingCityName } })) ??
(await prisma.city.findFirst({ orderBy: { name: 'asc' } }));
if (!startingCity) {
throw new AppError(503, 'WORLD_NOT_SEEDED', 'Mondo di gioco non inizializzato: eseguire il seed');
}
const passwordHash = await argon2.hash(input.password);
const user = await prisma.user.create({
data: {
email: input.email,
passwordHash,
player: {
create: { displayName: input.displayName, currentCityId: startingCity.id },
},
},
include: { player: true },
});
return user as UserWithPlayer;
}
export async function loginUser(input: {
email: string;
password: string;
}): Promise<UserWithPlayer> {
const user = await prisma.user.findUnique({
where: { email: input.email },
include: { player: true },
});
if (!user || !user.player) throw errors.unauthorized('Credenziali non valide');
const passwordOk = await argon2.verify(user.passwordHash, input.password);
if (!passwordOk) throw errors.unauthorized('Credenziali non valide');
return user as UserWithPlayer;
}
@@ -0,0 +1,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;
});
};
+181
View File
@@ -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,
};
});
}
+29
View File
@@ -0,0 +1,29 @@
import { balance } from '../../config/gameBalance.js';
import { clamp } from '../../shared/validators.js';
/**
* Formula del mercato dinamico (blueprint §9):
* rawPrice = basePrice * economyModifier * demand / max(supply, minSupplyDivisor) * eventModifier
* con rawPrice limitato a [basePrice*minPriceFactor, basePrice*maxPriceFactor].
*/
export function computePrices(
basePrice: number,
economyModifier: number,
demand: number,
supply: number,
eventModifier: number,
): { buyPrice: number; sellPrice: number } {
const m = balance.market;
const raw =
basePrice * economyModifier * (demand / Math.max(supply, m.minSupplyDivisor)) * eventModifier;
const clamped = clamp(raw, basePrice * m.minPriceFactor, basePrice * m.maxPriceFactor);
return {
buyPrice: Math.round(clamped * m.buyMargin),
sellPrice: Math.round(clamped * m.sellMargin),
};
}
/** Mantiene demand/supply nei limiti consentiti. */
export function clampDemandSupply(value: number): number {
return clamp(value, balance.market.minDemandSupply, balance.market.maxDemandSupply);
}
@@ -0,0 +1,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 };
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { Server } from 'socket.io';
let io: Server | null = null;
/**
* Inizializza Socket.IO sullo stesso server HTTP di Fastify (path /ws).
* L'handshake richiede un JWT valido in `auth.token`.
*/
export function initSocket(app: FastifyInstance): Server {
io = new Server(app.server, {
path: '/ws',
cors: { origin: '*' },
});
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined;
if (!token) {
next(new Error('UNAUTHORIZED'));
return;
}
try {
const payload = app.jwt.verify<{ sub: string; playerId: string }>(token);
socket.data.playerId = payload.playerId;
next();
} catch {
next(new Error('UNAUTHORIZED'));
}
});
io.on('connection', (socket) => {
const playerId = socket.data.playerId as string;
void socket.join(`player:${playerId}`);
socket.on('client:ping', () => {
socket.emit('player:notification', { type: 'pong', at: new Date().toISOString() });
});
});
return io;
}
/** Notifica un singolo giocatore (room player:{id}). */
export function emitToPlayer(playerId: string, event: string, payload: unknown): void {
io?.to(`player:${playerId}`).emit(event, payload);
}
/** Notifica tutti i client connessi. */
export function emitGlobal(event: string, payload: unknown): void {
io?.emit(event, payload);
}
+31
View File
@@ -0,0 +1,31 @@
import { buildApp } from './app.js';
import { env } from './config/env.js';
import { prisma } from './db/prisma.js';
import { redis } from './db/redis.js';
import { runStartupJobs, startScheduler } from './jobs/scheduler.js';
import { initSocket } from './realtime/socket.js';
async function main(): Promise<void> {
const app = await buildApp();
initSocket(app);
await app.listen({ port: env.PORT, host: '0.0.0.0' });
await runStartupJobs(app.log);
startScheduler(app.log);
const shutdown = async (signal: string) => {
app.log.info({ signal }, 'arresto del server');
await app.close();
await prisma.$disconnect();
redis.disconnect();
process.exit(0);
};
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { errors } from './errors.js';
/** preHandler che richiede un JWT valido; popola request.user con { sub, playerId }. */
export async function authGuard(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
try {
await request.jwtVerify();
} catch {
throw errors.unauthorized('Token mancante o non valido');
}
}
+23
View File
@@ -0,0 +1,23 @@
/** Errore applicativo con status HTTP e codice macchina-leggibile. */
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export const errors = {
badRequest: (message: string) => new AppError(400, 'BAD_REQUEST', message),
validation: (message: string) => new AppError(400, 'VALIDATION_ERROR', message),
unauthorized: (message = 'Non autenticato') => new AppError(401, 'UNAUTHORIZED', message),
forbidden: (message = 'Operazione non consentita') => new AppError(403, 'FORBIDDEN', message),
notFound: (message = 'Risorsa non trovata') => new AppError(404, 'NOT_FOUND', message),
conflict: (message: string) => new AppError(409, 'CONFLICT', message),
insufficientFunds: () => new AppError(400, 'INSUFFICIENT_FUNDS', 'Denaro insufficiente'),
insufficientItems: () =>
new AppError(400, 'INSUFFICIENT_ITEMS', 'Quantità insufficiente in inventario'),
};
+30
View File
@@ -0,0 +1,30 @@
import type { ZodTypeAny, output } from 'zod';
import { AppError } from './errors.js';
/** Valida `data` con lo schema Zod; lancia AppError 400 con i dettagli in caso di errore. */
export function parseOrThrow<S extends ZodTypeAny>(schema: S, data: unknown): output<S> {
const result = schema.safeParse(data);
if (!result.success) {
const detail = result.error.issues
.map((issue) => `${issue.path.join('.') || 'body'}: ${issue.message}`)
.join('; ');
throw new AppError(400, 'VALIDATION_ERROR', detail);
}
return result.data;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function randomFloat(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
export function pickRandom<T>(values: readonly T[]): T {
return values[Math.floor(Math.random() * values.length)]!;
}
+8
View File
@@ -0,0 +1,8 @@
import '@fastify/jwt';
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: string; playerId: string };
user: { sub: string; playerId: string };
}
}