Compare commits

..
10 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
20 changed files with 1011 additions and 155 deletions
+2 -2
View File
@@ -26,9 +26,9 @@ Se il server non è su `localhost:3000`, crea un `.env` da `.env.example` con `V
- **Login/Registrazione** — JWT salvato in localStorage. - **Login/Registrazione** — JWT salvato in localStorage.
- **Covo** (home) — profilo, denaro, reputazione, barra XP, città corrente, eventi attivi, accesso alle classifiche, logout. - **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`. - **Mercato** — listino della città corrente (prezzo acquisto/vendita), eventi che influenzano i prezzi, modale compra/vendi con quantità; si auto-aggiorna a ogni tick del server via `market:updated`.
- **Missioni** — missioni attive con countdown e riscossione (esito successo/fallimento con ricompense), missioni disponibili con difficoltà, rischio, durata e merce richiesta. - **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. - **Zaino** — inventario con quantità e peso totale.
- **Città** — elenco città con rischio, economia e costo viaggio. - **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. - **Classifiche** — denaro e reputazione.
## Realtime ## Realtime
+5 -2
View File
@@ -1,13 +1,14 @@
import { request } from './http'; import { request } from './http';
import type { import type {
AbandonMissionResponse,
ActiveMission, ActiveMission,
AuthResponse, AuthResponse,
AvailableMission,
City, City,
ClaimMissionResponse, ClaimMissionResponse,
InventoryResponse, InventoryResponse,
LeaderboardResponse, LeaderboardResponse,
MarketResponse, MarketResponse,
Mission,
PlayerMeResponse, PlayerMeResponse,
StartMissionResponse, StartMissionResponse,
TradeResponse, TradeResponse,
@@ -44,12 +45,14 @@ export const api = {
inventory: () => request<InventoryResponse>('/inventory'), inventory: () => request<InventoryResponse>('/inventory'),
// Missions // Missions
missionsAvailable: () => request<{ missions: Mission[] }>('/missions/available'), missionsAvailable: () => request<{ missions: AvailableMission[] }>('/missions/available'),
missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'), missionsActive: () => request<{ missions: ActiveMission[] }>('/missions/active'),
startMission: (missionId: string) => startMission: (missionId: string) =>
request<StartMissionResponse>(`/missions/${missionId}/start`, { method: 'POST' }), request<StartMissionResponse>(`/missions/${missionId}/start`, { method: 'POST' }),
claimMission: (playerMissionId: string) => claimMission: (playerMissionId: string) =>
request<ClaimMissionResponse>(`/missions/${playerMissionId}/claim`, { method: 'POST' }), request<ClaimMissionResponse>(`/missions/${playerMissionId}/claim`, { method: 'POST' }),
abandonMission: (playerMissionId: string) =>
request<AbandonMissionResponse>(`/missions/${playerMissionId}/abandon`, { method: 'POST' }),
// Events // Events
events: () => request<{ events: WorldEvent[] }>('/events/current'), events: () => request<{ events: WorldEvent[] }>('/events/current'),
+23
View File
@@ -27,6 +27,8 @@ export type City = {
riskLevel: number; riskLevel: number;
policePressure: number; policePressure: number;
economyModifier: number; economyModifier: number;
mapX: number;
mapY: number;
travelCost: number; travelCost: number;
}; };
@@ -54,6 +56,7 @@ export type MarketEntry = {
name: string; name: string;
buyPrice: number; buyPrice: number;
sellPrice: number; sellPrice: number;
basePrice: number;
demand: number; demand: number;
supply: number; supply: number;
rarity: number; rarity: number;
@@ -96,8 +99,10 @@ export type Mission = {
id: string; id: string;
cityId: string; cityId: string;
title: string; title: string;
description: string;
type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL'; type: 'DELIVERY' | 'THEFT' | 'SMUGGLING' | 'INTEL';
difficulty: number; difficulty: number;
minLevel: number;
durationSeconds: number; durationSeconds: number;
rewardMoney: number; rewardMoney: number;
rewardXp: number; rewardXp: number;
@@ -105,9 +110,17 @@ export type Mission = {
requiredItemId: string | null; requiredItemId: string | null;
requiredItemName: string | null; requiredItemName: string | null;
requiredItemQuantity: number | null; requiredItemQuantity: number | null;
rewardItemName: string | null;
rewardItemQuantity: number | null;
expiresAt: string; expiresAt: string;
}; };
/** Missione disponibile, arricchita con la stima calcolata per il giocatore. */
export type AvailableMission = Mission & {
estimatedSuccessChance: number;
canStart: boolean;
};
export type ActiveMission = { export type ActiveMission = {
playerMissionId: string; playerMissionId: string;
status: 'STARTED' | 'COMPLETED' | 'FAILED'; status: 'STARTED' | 'COMPLETED' | 'FAILED';
@@ -130,11 +143,21 @@ export type ClaimMissionResponse = {
successChance: number; successChance: number;
rewardMoney: number; rewardMoney: number;
rewardXp: number; rewardXp: number;
moneyChange: number;
fine: number;
lootItemName: string | null;
lootItemQuantity: number | null;
reputationChange: number; reputationChange: number;
levelsGained: number; levelsGained: number;
player: Player; player: Player;
}; };
export type AbandonMissionResponse = {
playerMissionId: string;
reputationChange: number;
player: Player;
};
export type LeaderboardEntry = { export type LeaderboardEntry = {
rank: number; rank: number;
playerId: string; playerId: string;
+33 -4
View File
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { api } from '../api/api';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { getSocket } from '../realtime/socket'; import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format'; import { formatMoney } from '../shared/format';
@@ -16,6 +17,28 @@ const NAV_ITEMS = [
export function Layout() { export function Layout() {
const { player } = useAuth(); const { player } = useAuth();
const { toast } = useToast(); 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 // Notifiche realtime globali dal server
useEffect(() => { useEffect(() => {
@@ -24,6 +47,7 @@ export function Layout() {
const onMissionCompleted = (payload: { title?: string }) => { const onMissionCompleted = (payload: { title?: string }) => {
toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success'); toast(`Missione "${payload.title ?? ''}" pronta da riscuotere`, 'success');
void refreshReadyCount();
}; };
const onEventStarted = (payload: { title?: string }) => { const onEventStarted = (payload: { title?: string }) => {
toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info'); toast(`Nuovo evento: ${payload.title ?? 'evento mondo'}`, 'info');
@@ -40,7 +64,7 @@ export function Layout() {
socket.off('worldEvent:started', onEventStarted); socket.off('worldEvent:started', onEventStarted);
socket.off('worldEvent:ended', onEventEnded); socket.off('worldEvent:ended', onEventEnded);
}; };
}, [toast]); }, [toast, refreshReadyCount]);
return ( return (
<div className="app"> <div className="app">
@@ -66,7 +90,12 @@ export function Layout() {
end={item.to === '/'} end={item.to === '/'}
className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`} className={({ isActive }) => `bottomnav__item${isActive ? ' is-active' : ''}`}
> >
<span className="bottomnav__icon">{item.icon}</span> <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> <span className="bottomnav__label">{item.label}</span>
</NavLink> </NavLink>
))} ))}
+85 -10
View File
@@ -1,27 +1,65 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../api/api'; import { api } from '../api/api';
import type { PlayerMeResponse, WorldEvent } from '../api/types'; import { ApiError } from '../api/http';
import type { ActiveMission, PlayerMeResponse, WorldEvent } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Countdown } from '../components/Countdown';
import { useToast } from '../components/Toast';
import { EVENT_TYPE_LABELS, formatMoney, riskLabel } from '../shared/format'; import { EVENT_TYPE_LABELS, formatMoney, riskLabel } from '../shared/format';
export function DashboardScreen() { export function DashboardScreen() {
const { player, setPlayer, logout } = useAuth(); const { player, setPlayer, logout } = useAuth();
const { toast } = useToast();
const [me, setMe] = useState<PlayerMeResponse | null>(null); const [me, setMe] = useState<PlayerMeResponse | null>(null);
const [events, setEvents] = useState<WorldEvent[]>([]); 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(() => { useEffect(() => {
void api.playerMe().then((res) => { void reload();
setMe(res); }, [reload]);
async function quickClaim(playerMissionId: string) {
setBusy(true);
try {
const res = await api.claimMission(playerMissionId);
setPlayer(res.player); setPlayer(res.player);
}); toast(
void api.events().then((res) => setEvents(res.events)); res.success
}, [setPlayer]); ? `✅ "${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; if (!player) return null;
const xpNext = me?.xpForNextLevel ?? null; const xpNext = me?.xpForNextLevel ?? null;
const xpRatio = xpNext ? Math.min(1, player.experience / xpNext) : 0; const xpRatio = xpNext ? Math.min(1, player.experience / xpNext) : 0;
const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now();
return ( return (
<div className="screen"> <div className="screen">
@@ -55,6 +93,46 @@ export function DashboardScreen() {
)} )}
</section> </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"> <section className="card">
<h3 className="card__title">Eventi in corso</h3> <h3 className="card__title">Eventi in corso</h3>
{events.length === 0 ? ( {events.length === 0 ? (
@@ -78,9 +156,6 @@ export function DashboardScreen() {
)} )}
</section> </section>
<Link to="/leaderboard" className="btn btn--secondary">
🏆 Classifiche
</Link>
<button type="button" className="btn btn--ghost" onClick={logout}> <button type="button" className="btn btn--ghost" onClick={logout}>
Esci Esci
</button> </button>
+12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { api } from '../api/api'; import { api } from '../api/api';
import type { LeaderboardResponse } from '../api/types'; import type { LeaderboardResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format'; import { formatMoney } from '../shared/format';
type Metric = 'money' | 'reputation'; type Metric = 'money' | 'reputation';
@@ -16,6 +17,17 @@ export function LeaderboardScreen() {
void api.leaderboard(metric).then(setBoard); void api.leaderboard(metric).then(setBoard);
}, [metric]); }, [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 ( return (
<div className="screen"> <div className="screen">
<h2 className="screen__title">Classifiche</h2> <h2 className="screen__title">Classifiche</h2>
+19 -1
View File
@@ -7,6 +7,22 @@ import { useToast } from '../components/Toast';
import { getSocket } from '../realtime/socket'; import { getSocket } from '../realtime/socket';
import { formatMoney } from '../shared/format'; 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() { export function MarketScreen() {
const { player, refreshPlayer } = useAuth(); const { player, refreshPlayer } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
@@ -95,12 +111,14 @@ export function MarketScreen() {
<div className="prices"> <div className="prices">
<span className="price price--buy">{formatMoney(entry.buyPrice)}</span> <span className="price price--buy">{formatMoney(entry.buyPrice)}</span>
<span className="price price--sell">{formatMoney(entry.sellPrice)}</span> <span className="price price--sell">{formatMoney(entry.sellPrice)}</span>
<TrendTag entry={entry} />
</div> </div>
</li> </li>
))} ))}
</ul> </ul>
<p className="muted small"> <p className="muted small">
Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto. Prezzo a sinistra: acquisto · a destra: vendita. I prezzi cambiano ogni minuto: compra
dove c'è l'affare, vendi dove rende.
</p> </p>
{selected && ( {selected && (
+115 -47
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { api } from '../api/api'; import { api } from '../api/api';
import { ApiError } from '../api/http'; import { ApiError } from '../api/http';
import type { ActiveMission, ClaimMissionResponse, Mission } from '../api/types'; import type { ActiveMission, AvailableMission, ClaimMissionResponse } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Countdown } from '../components/Countdown'; import { Countdown } from '../components/Countdown';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
@@ -13,10 +13,17 @@ import {
MISSION_TYPE_LABELS, MISSION_TYPE_LABELS,
} from '../shared/format'; } 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() { export function MissionsScreen() {
const { setPlayer } = useAuth(); const { setPlayer } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [available, setAvailable] = useState<Mission[]>([]); const [available, setAvailable] = useState<AvailableMission[]>([]);
const [active, setActive] = useState<ActiveMission[]>([]); const [active, setActive] = useState<ActiveMission[]>([]);
const [result, setResult] = useState<ClaimMissionResponse | null>(null); const [result, setResult] = useState<ClaimMissionResponse | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -33,7 +40,7 @@ export function MissionsScreen() {
void reload(); void reload();
}, [reload]); }, [reload]);
async function start(mission: Mission) { async function start(mission: AvailableMission) {
setBusy(true); setBusy(true);
try { try {
await api.startMission(mission.id); await api.startMission(mission.id);
@@ -60,6 +67,23 @@ export function MissionsScreen() {
} }
} }
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(); const isReady = (m: ActiveMission) => new Date(m.completesAt).getTime() <= Date.now();
return ( return (
@@ -71,25 +95,37 @@ export function MissionsScreen() {
<h3 className="card__title">In corso</h3> <h3 className="card__title">In corso</h3>
<ul className="list"> <ul className="list">
{active.map((m) => ( {active.map((m) => (
<li key={m.playerMissionId} className="list__row"> <li key={m.playerMissionId} className="mission">
<div> <div className="mission__head">
<strong>{m.mission.title}</strong> <strong>{m.mission.title}</strong>
<p className="muted small"> {isReady(m) ? (
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '} <button
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP type="button"
</p> 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> </div>
{isReady(m) ? ( <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 <button
type="button" type="button"
className="btn btn--primary btn--small" className="btn btn--ghost btn--small mission__abandon"
disabled={busy} disabled={busy}
onClick={() => void claim(m.playerMissionId)} onClick={() => void abandon(m)}
> >
Riscuoti Abbandona
</button> </button>
) : (
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
)} )}
</li> </li>
))} ))}
@@ -103,36 +139,64 @@ export function MissionsScreen() {
<p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p> <p className="muted">Nessuna missione al momento. Riprova tra un minuto.</p>
) : ( ) : (
<ul className="list"> <ul className="list">
{available.map((mission) => ( {available.map((mission) => {
<li key={mission.id} className="mission"> const locked = !mission.canStart;
<div className="mission__head"> return (
<strong>{mission.title}</strong> <li key={mission.id} className={`mission${locked ? ' mission--locked' : ''}`}>
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span> <div className="mission__head">
</div> <strong>{mission.title}</strong>
<p className="muted small"> <span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
{MISSION_TYPE_LABELS[mission.type]} · durata{' '} </div>
{formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)} <span className={`chip chip--${mission.type.toLowerCase()}`}>
</p> {MISSION_TYPE_LABELS[mission.type]}
{mission.requiredItemName && (
<p className="mission__required">
Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName}
</p>
)}
<div className="mission__footer">
<span className="mission__reward">
{formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP
</span> </span>
<button <p className="mission__description">{mission.description}</p>
type="button" <p className="muted small">
className="btn btn--secondary btn--small" durata {formatDuration(mission.durationSeconds)} · rischio{' '}
disabled={busy} {formatPercent(mission.risk)}
onClick={() => void start(mission)} </p>
>
Avvia <div className="chancebar" title="Probabilità di successo stimata">
</button> <div
</div> className="chancebar__fill"
</li> 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> </ul>
)} )}
</section> </section>
@@ -147,14 +211,18 @@ export function MissionsScreen() {
<li>+{formatMoney(result.rewardMoney)}</li> <li>+{formatMoney(result.rewardMoney)}</li>
<li>+{result.rewardXp} XP</li> <li>+{result.rewardXp} XP</li>
<li>+{result.reputationChange} reputazione</li> <li>+{result.reputationChange} reputazione</li>
{result.levelsGained > 0 && ( {result.lootItemName && (
<li className="levelup"> <li>
Sei salito al livello {result.player.level}! 🎁 +{result.lootItemQuantity}× {result.lootItemName}
</li> </li>
)} )}
{result.levelsGained > 0 && (
<li className="levelup">Sei salito al livello {result.player.level}!</li>
)}
</ul> </ul>
) : ( ) : (
<ul className="result-list"> <ul className="result-list">
{result.fine > 0 && <li>💸 multa: {formatMoney(result.fine)}</li>}
<li>{result.reputationChange} reputazione</li> <li>{result.reputationChange} reputazione</li>
<li className="muted small"> <li className="muted small">
Probabilità di successo: {formatPercent(result.successChance)} Probabilità di successo: {formatPercent(result.successChance)}
+118 -20
View File
@@ -1,32 +1,63 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/api'; import { api } from '../api/api';
import { ApiError } from '../api/http'; import { ApiError } from '../api/http';
import type { City } from '../api/types'; import type { City, WorldEvent } from '../api/types';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { formatMoney, riskLabel } from '../shared/format'; 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() { export function TravelScreen() {
const { player, setPlayer } = useAuth(); const { player, setPlayer } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [cities, setCities] = useState<City[]>([]); const [cities, setCities] = useState<City[]>([]);
const [events, setEvents] = useState<WorldEvent[]>([]);
const [selected, setSelected] = useState<City | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const res = await api.cities(); const [c, e] = await Promise.all([api.cities(), api.events()]);
setCities(res.cities); setCities(c.cities);
setEvents(e.events);
}, []); }, []);
useEffect(() => { useEffect(() => {
void reload(); void reload();
}, [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) { async function travelTo(city: City) {
setBusy(true); setBusy(true);
try { try {
const res = await api.travel(city.id); const res = await api.travel(city.id);
setPlayer(res.player); setPlayer(res.player);
toast(`Benvenuto a ${res.cityName} (${formatMoney(res.cost)})`, 'success'); toast(`Benvenuto a ${res.cityName} (${formatMoney(res.cost)})`, 'success');
setSelected(null);
} catch (err) { } catch (err) {
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error'); toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
} finally { } finally {
@@ -36,34 +67,101 @@ export function TravelScreen() {
return ( return (
<div className="screen"> <div className="screen">
<h2 className="screen__title">Città</h2> <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"> <ul className="list">
{cities.map((city) => { {cities.map((city) => {
const isCurrent = player?.currentCityId === city.id; const isCurrent = player?.currentCityId === city.id;
const canAfford = (player?.money ?? 0) >= city.travelCost;
return ( return (
<li key={city.id} className={`list__row${isCurrent ? ' is-current' : ''}`}> <li
key={city.id}
className={`list__row list__row--clickable${isCurrent ? ' is-current' : ''}`}
onClick={() => setSelected(city)}
>
<div> <div>
<strong>{city.name}</strong> <strong>{city.name}</strong>
{isCurrent && <span className="badge badge--small">sei qui</span>} {isCurrent && <span className="badge badge--small">sei qui</span>}
<p className="muted small"> {(eventsByCity.get(city.id) ?? []).length > 0 && <span> </span>}
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
</p>
</div> </div>
{!isCurrent && ( <span className="muted small">
<button {isCurrent ? '—' : formatMoney(city.travelCost)}
type="button" </span>
className="btn btn--secondary btn--small"
disabled={busy || !canAfford}
onClick={() => void travelTo(city)}
>
Viaggia · {formatMoney(city.travelCost)}
</button>
)}
</li> </li>
); );
})} })}
</ul> </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> </div>
); );
} }
+188
View File
@@ -572,6 +572,194 @@ input:focus {
text-align: center; 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 ===== */
.toast-stack { .toast-stack {
position: fixed; position: fixed;
+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,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;
+12 -4
View File
@@ -60,6 +60,8 @@ model City {
riskLevel Int riskLevel Int
policePressure Float @default(1.0) policePressure Float @default(1.0)
economyModifier Float @default(1.0) economyModifier Float @default(1.0)
mapX Float @default(50)
mapY Float @default(50)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
players Player[] players Player[]
@@ -77,9 +79,10 @@ model Item {
weight Int @default(1) weight Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
inventory InventoryItem[] inventory InventoryItem[]
prices MarketPrice[] prices MarketPrice[]
missions Mission[] requiredInMissions Mission[] @relation("MissionRequiredItem")
rewardInMissions Mission[] @relation("MissionRewardItem")
} }
model InventoryItem { model InventoryItem {
@@ -112,19 +115,24 @@ model Mission {
id String @id @default(uuid()) id String @id @default(uuid())
cityId String cityId String
title String title String
description String @default("")
type MissionType type MissionType
difficulty Int difficulty Int
minLevel Int @default(1)
durationSeconds Int durationSeconds Int
rewardMoney Int rewardMoney Int
rewardXp Int rewardXp Int
risk Float risk Float
requiredItemId String? requiredItemId String?
requiredItemQuantity Int? requiredItemQuantity Int?
rewardItemId String?
rewardItemQuantity Int?
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
city City @relation(fields: [cityId], references: [id]) city City @relation(fields: [cityId], references: [id])
requiredItem Item? @relation(fields: [requiredItemId], references: [id]) requiredItem Item? @relation("MissionRequiredItem", fields: [requiredItemId], references: [id])
rewardItem Item? @relation("MissionRewardItem", fields: [rewardItemId], references: [id])
playerMissions PlayerMission[] playerMissions PlayerMission[]
} }
+11 -6
View File
@@ -4,13 +4,16 @@ import { randomFloat } from '../src/shared/validators.js';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
/** Città iniziali (blueprint §19): economia e rischio differenziati. */ /**
* Città iniziali (blueprint §19): economia e rischio differenziati.
* mapX/mapY sono coordinate percentuali (0-100) sulla mappa stilizzata dell'Italia.
*/
const CITIES = [ const CITIES = [
{ name: 'Milano', riskLevel: 3, policePressure: 1.1, economyModifier: 1.25 }, { name: 'Milano', riskLevel: 3, policePressure: 1.1, economyModifier: 1.25, mapX: 32, mapY: 12 },
{ name: 'Napoli', riskLevel: 4, policePressure: 1.3, economyModifier: 1.0 }, { name: 'Napoli', riskLevel: 4, policePressure: 1.3, economyModifier: 1.0, mapX: 60, mapY: 58 },
{ name: 'Palermo', riskLevel: 4, policePressure: 1.25, economyModifier: 0.85 }, { name: 'Palermo', riskLevel: 4, policePressure: 1.25, economyModifier: 0.85, mapX: 52, mapY: 86 },
{ name: 'Torino', riskLevel: 2, policePressure: 0.9, economyModifier: 1.0 }, { name: 'Torino', riskLevel: 2, policePressure: 0.9, economyModifier: 1.0, mapX: 16, mapY: 16 },
{ name: 'Bari', riskLevel: 3, policePressure: 1.0, economyModifier: 0.85 }, { name: 'Bari', riskLevel: 3, policePressure: 1.0, economyModifier: 0.85, mapX: 78, mapY: 52 },
]; ];
/** Item commerciabili iniziali (blueprint §19). */ /** Item commerciabili iniziali (blueprint §19). */
@@ -32,6 +35,8 @@ async function main(): Promise<void> {
riskLevel: city.riskLevel, riskLevel: city.riskLevel,
policePressure: city.policePressure, policePressure: city.policePressure,
economyModifier: city.economyModifier, economyModifier: city.economyModifier,
mapX: city.mapX,
mapY: city.mapY,
}, },
}); });
} }
+58 -2
View File
@@ -97,9 +97,65 @@ export const balance = {
/** Ricompensa XP: base + difficulty * perDifficulty */ /** Ricompensa XP: base + difficulty * perDifficulty */
rewardXpBase: 20, rewardXpBase: 20,
rewardXpPerDifficulty: 30, rewardXpPerDifficulty: 30,
/** probabilità che una missione DELIVERY/SMUGGLING richieda oggetti */
requiredItemChance: 0.4,
requiredItemQuantityMax: 3, 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: * Probabilità di successo al claim:
* chance = baseChance * chance = baseChance
+126 -21
View File
@@ -5,20 +5,108 @@ import { redis } from '../db/redis.js';
import { emitToPlayer } from '../realtime/socket.js'; import { emitToPlayer } from '../realtime/socket.js';
import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js'; import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js';
/** Template missioni (blueprint §19) con il tipo associato. */ type MissionTemplate = { title: string; description: string };
const MISSION_TEMPLATES: ReadonlyArray<{ title: string; type: MissionType }> = [
{ title: 'Consegna discreta', type: MissionType.DELIVERY },
{ title: 'Recupero merce', type: MissionType.THEFT },
{ title: 'Scambio al porto', type: MissionType.SMUGGLING },
{ title: 'Infiltrazione uffici', type: MissionType.INTEL },
{ title: 'Trasporto notturno', type: MissionType.SMUGGLING },
];
/** Tipi di missione che possono richiedere merce dall'inventario. */ /**
const TYPES_WITH_REQUIRED_ITEM: ReadonlySet<MissionType> = new Set([ * Pool narrativi per tipo di missione. I numeri (ricompense, rischi, loot)
MissionType.DELIVERY, * vengono da balance.missions.typeConfig: qui vive solo il flavour.
MissionType.SMUGGLING, */
]); 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. */ /** Genera nuove missioni nelle città sotto la soglia minima. */
export async function generateMissions(): Promise<number> { export async function generateMissions(): Promise<number> {
@@ -36,51 +124,68 @@ export async function generateMissions(): Promise<number> {
}); });
for (let i = activeCount; i < b.minActivePerCity; i++) { for (let i = activeCount; i < b.minActivePerCity; i++) {
const template = pickRandom(MISSION_TEMPLATES); 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 difficulty = randomInt(b.difficultyMin, b.difficultyMax);
const risk = const risk =
Math.round( Math.round(
clamp( clamp(
difficulty * b.riskPerDifficulty + difficulty * b.riskPerDifficulty +
(city.riskLevel - 3) * b.riskPerCityRiskLevel + (city.riskLevel - 3) * b.riskPerCityRiskLevel +
config.riskShift +
randomFloat(-b.riskJitter, b.riskJitter), randomFloat(-b.riskJitter, b.riskJitter),
b.riskMin, b.riskMin,
b.riskMax, b.riskMax,
) * 100, ) * 100,
) / 100; ) / 100;
// Merce richiesta per partire (consumata all'avvio)
let requiredItemId: string | null = null; let requiredItemId: string | null = null;
let requiredItemQuantity: number | null = null; let requiredItemQuantity: number | null = null;
let itemRefund = 0; let itemRefund = 0;
if ( if (items.length > 0 && Math.random() < config.requiredItemChance) {
items.length > 0 &&
TYPES_WITH_REQUIRED_ITEM.has(template.type) &&
Math.random() < b.requiredItemChance
) {
const item = pickRandom(items); const item = pickRandom(items);
requiredItemId = item.id; requiredItemId = item.id;
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax); requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund; 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( const rewardMoney = Math.round(
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) * (b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
config.moneyFactor *
city.economyModifier * city.economyModifier *
(1 + risk * b.riskRewardBonus) + (1 + risk * b.riskRewardBonus) +
itemRefund, itemRefund,
); );
const rewardXp = Math.round(
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
);
toCreate.push({ toCreate.push({
cityId: city.id, cityId: city.id,
title: template.title, title: template.title,
type: template.type, description: template.description,
type,
difficulty, difficulty,
minLevel: minLevelForDifficulty(difficulty),
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax), durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
rewardMoney, rewardMoney,
rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty, rewardXp,
risk, risk,
requiredItemId, requiredItemId,
requiredItemQuantity, requiredItemQuantity,
rewardItemId,
rewardItemQuantity,
expiresAt: new Date( expiresAt: new Date(
now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000, now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000,
), ),
@@ -14,6 +14,8 @@ const citySelect = {
riskLevel: true, riskLevel: true,
policePressure: true, policePressure: true,
economyModifier: true, economyModifier: true,
mapX: true,
mapY: true,
} as const; } as const;
export const citiesRoutes: FastifyPluginAsync = async (app) => { export const citiesRoutes: FastifyPluginAsync = async (app) => {
@@ -48,6 +48,7 @@ export async function getCurrentMarket(playerId: string) {
name: p.item.name, name: p.item.name,
buyPrice: p.buyPrice, buyPrice: p.buyPrice,
sellPrice: p.sellPrice, sellPrice: p.sellPrice,
basePrice: p.item.basePrice,
demand: p.demand, demand: p.demand,
supply: p.supply, supply: p.supply,
rarity: p.item.rarity, rarity: p.item.rarity,
+14 -1
View File
@@ -3,7 +3,13 @@ import { z } from 'zod';
import { emitToPlayer } from '../../realtime/socket.js'; import { emitToPlayer } from '../../realtime/socket.js';
import { authGuard } from '../../shared/authGuard.js'; import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js'; import { parseOrThrow } from '../../shared/validators.js';
import { claimMission, listActive, listAvailable, startMission } from './missions.service.js'; import {
abandonMission,
claimMission,
listActive,
listAvailable,
startMission,
} from './missions.service.js';
const missionParamsSchema = z.object({ missionId: z.string().uuid() }); const missionParamsSchema = z.object({ missionId: z.string().uuid() });
const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() }); const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() });
@@ -34,6 +40,13 @@ export const missionsRoutes: FastifyPluginAsync = async (app) => {
}); });
}); });
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) => { app.post('/:playerMissionId/claim', async (request) => {
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params); const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
const result = await claimMission(request.user.playerId, playerMissionId); const result = await claimMission(request.user.playerId, playerMissionId);
+170 -31
View File
@@ -1,4 +1,4 @@
import type { Mission, PlayerMission } from '@prisma/client'; import type { Mission, Player, PlayerMission, WorldEvent } from '@prisma/client';
import { MissionStatus } from '@prisma/client'; import { MissionStatus } from '@prisma/client';
import { balance } from '../../config/gameBalance.js'; import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js'; import { prisma } from '../../db/prisma.js';
@@ -12,6 +12,11 @@ import {
type PlayerDto, type PlayerDto,
} from '../players/players.service.js'; } from '../players/players.service.js';
type MissionWithItems = Mission & {
requiredItem?: { name: string } | null;
rewardItem?: { name: string } | null;
};
export type ClaimResult = { export type ClaimResult = {
playerMissionId: string; playerMissionId: string;
missionTitle: string; missionTitle: string;
@@ -19,18 +24,48 @@ export type ClaimResult = {
successChance: number; successChance: number;
rewardMoney: number; rewardMoney: number;
rewardXp: number; rewardXp: number;
/** negativo in caso di multa sul fallimento */
moneyChange: number;
fine: number;
lootItemName: string | null;
lootItemQuantity: number | null;
reputationChange: number; reputationChange: number;
levelsGained: number; levelsGained: number;
player: PlayerDto; player: PlayerDto;
}; };
function toMissionDto(mission: Mission & { requiredItem?: { name: string } | null }) { /**
* 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 { return {
id: mission.id, id: mission.id,
cityId: mission.cityId, cityId: mission.cityId,
title: mission.title, title: mission.title,
description: mission.description,
type: mission.type, type: mission.type,
difficulty: mission.difficulty, difficulty: mission.difficulty,
minLevel: mission.minLevel,
durationSeconds: mission.durationSeconds, durationSeconds: mission.durationSeconds,
rewardMoney: mission.rewardMoney, rewardMoney: mission.rewardMoney,
rewardXp: mission.rewardXp, rewardXp: mission.rewardXp,
@@ -38,30 +73,58 @@ function toMissionDto(mission: Mission & { requiredItem?: { name: string } | nul
requiredItemId: mission.requiredItemId, requiredItemId: mission.requiredItemId,
requiredItemName: mission.requiredItem?.name ?? null, requiredItemName: mission.requiredItem?.name ?? null,
requiredItemQuantity: mission.requiredItemQuantity, requiredItemQuantity: mission.requiredItemQuantity,
rewardItemName: mission.rewardItem?.name ?? null,
rewardItemQuantity: mission.rewardItemQuantity,
expiresAt: mission.expiresAt, expiresAt: mission.expiresAt,
}; };
} }
/** Missioni disponibili nella città corrente, non scadute e non già iniziate dal giocatore. */ /**
* Missioni disponibili nella città corrente, con probabilità di successo
* stimata per il giocatore richiedente.
*/
export async function listAvailable(playerId: string) { export async function listAvailable(playerId: string) {
const player = await getPlayerOrThrow(playerId); const player = await getPlayerOrThrow(playerId);
const missions = await prisma.mission.findMany({ const [missions, city, events] = await Promise.all([
where: { prisma.mission.findMany({
cityId: player.currentCityId, where: {
expiresAt: { gt: new Date() }, cityId: player.currentCityId,
playerMissions: { none: { playerId } }, expiresAt: { gt: new Date() },
}, playerMissions: { none: { playerId } },
include: { requiredItem: { select: { name: true } } }, },
orderBy: { expiresAt: 'asc' }, include: {
}); requiredItem: { select: { name: true } },
return { missions: missions.map(toMissionDto) }; 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. */ /** Missioni del giocatore ancora in corso. */
export async function listActive(playerId: string) { export async function listActive(playerId: string) {
const playerMissions = await prisma.playerMission.findMany({ const playerMissions = await prisma.playerMission.findMany({
where: { playerId, status: MissionStatus.STARTED }, where: { playerId, status: MissionStatus.STARTED },
include: { mission: { include: { requiredItem: { select: { name: true } } } } }, include: {
mission: {
include: {
requiredItem: { select: { name: true } },
rewardItem: { select: { name: true } },
},
},
},
orderBy: { completesAt: 'asc' }, orderBy: { completesAt: 'asc' },
}); });
return { return {
@@ -76,8 +139,8 @@ export async function listActive(playerId: string) {
} }
/** /**
* Avvia una missione: consuma subito gli eventuali oggetti richiesti * Avvia una missione: verifica il livello minimo, consuma subito gli
* e fissa completesAt = now + durationSeconds. * eventuali oggetti richiesti e fissa completesAt = now + durationSeconds.
*/ */
export async function startMission(playerId: string, missionId: string): Promise<PlayerMission> { export async function startMission(playerId: string, missionId: string): Promise<PlayerMission> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
@@ -90,6 +153,9 @@ export async function startMission(playerId: string, missionId: string): Promise
if (mission.cityId !== player.currentCityId) { if (mission.cityId !== player.currentCityId) {
throw errors.badRequest('La missione è in un\'altra città'); 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({ const alreadyStarted = await tx.playerMission.findFirst({
where: { playerId, missionId }, where: { playerId, missionId },
@@ -136,18 +202,19 @@ export async function startMission(playerId: string, missionId: string): Promise
/** /**
* Riscuote una missione completata: tira il dado contro la probabilità di * Riscuote una missione completata: tira il dado contro la probabilità di
* successo (rischio, livello, reputazione, pressione polizia) e applica * successo. Successo: denaro, XP, reputazione ed eventuale bottino in merce.
* ricompense o penalità. * Fallimento: perdita di reputazione e multa in denaro (scalata dalla polizia).
*/ */
export async function claimMission( export async function claimMission(
playerId: string, playerId: string,
playerMissionId: string, playerMissionId: string,
): Promise<ClaimResult> { ): Promise<ClaimResult> {
const s = balance.missions.success;
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const playerMission = await tx.playerMission.findUnique({ const playerMission = await tx.playerMission.findUnique({
where: { id: playerMissionId }, where: { id: playerMissionId },
include: { mission: { include: { city: true } } }, include: {
mission: { include: { city: true, rewardItem: { select: { name: true } } } },
},
}); });
if (!playerMission || playerMission.playerId !== playerId) { if (!playerMission || playerMission.playerId !== playerId) {
throw errors.notFound('Missione non trovata'); throw errors.notFound('Missione non trovata');
@@ -164,24 +231,27 @@ export async function claimMission(
const mission = playerMission.mission; const mission = playerMission.mission;
const events = await getActiveEvents(tx); const events = await getActiveEvents(tx);
const police = mission.city.policePressure * policeModifierForCity(events, mission.cityId); const successChance = computeSuccessChance(
const successChance = clamp( player,
s.baseChance - mission,
mission.risk * s.riskWeight + mission.city.policePressure,
player.level * s.levelBonus + events,
player.reputation * s.reputationBonus -
(police - 1) * s.policeWeight,
s.minChance,
s.maxChance,
); );
const success = Math.random() < successChance; const success = Math.random() < successChance;
let updatedPlayer = player; let updatedPlayer = player;
let reputationChange = 0; let reputationChange = 0;
let levelsGained = 0; let levelsGained = 0;
let moneyChange = 0;
let fine = 0;
let lootItemName: string | null = null;
let lootItemQuantity: number | null = null;
if (success) { if (success) {
reputationChange = mission.difficulty * balance.missions.reputationGainPerDifficulty; 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); const progress = applyExperience(player.level, player.experience, mission.rewardXp);
levelsGained = progress.levelsGained; levelsGained = progress.levelsGained;
updatedPlayer = await tx.player.update({ updatedPlayer = await tx.player.update({
@@ -193,11 +263,35 @@ export async function claimMission(
experience: progress.experience, 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 { } 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); reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation);
updatedPlayer = await tx.player.update({ updatedPlayer = await tx.player.update({
where: { id: player.id }, where: { id: player.id },
data: { reputation: { increment: reputationChange } }, data: {
money: { decrement: fine },
reputation: { increment: reputationChange },
},
}); });
} }
@@ -216,9 +310,54 @@ export async function claimMission(
successChance: Math.round(successChance * 100) / 100, successChance: Math.round(successChance * 100) / 100,
rewardMoney: success ? mission.rewardMoney : 0, rewardMoney: success ? mission.rewardMoney : 0,
rewardXp: success ? mission.rewardXp : 0, rewardXp: success ? mission.rewardXp : 0,
moneyChange,
fine,
lootItemName,
lootItemQuantity,
reputationChange, reputationChange,
levelsGained, levelsGained,
player: toPlayerDto(updatedPlayer), 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),
};
});
}