Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c0892fea | ||
|
|
48e618d543 | ||
|
|
6abd115c81 | ||
|
|
18de7fda21 | ||
|
|
bdee84244f | ||
|
|
bf5cfdb4ed | ||
|
|
25376553e7 | ||
|
|
123b67952e | ||
|
|
1568520533 | ||
|
|
8804dfeb22 |
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -61,9 +61,10 @@ Tutte le route tranne register/login richiedono `Authorization: Bearer <jwt>`.
|
|||||||
| GET | `/market/current` | listino della città corrente + eventi attivi |
|
| GET | `/market/current` | listino della città corrente + eventi attivi |
|
||||||
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
|
| POST | `/market/buy` · `/market/sell` | `{itemId, quantity}` — prezzi decisi dal server |
|
||||||
| GET | `/inventory` | inventario con peso totale |
|
| GET | `/inventory` | inventario con peso totale |
|
||||||
| GET | `/missions/available` | missioni nella città corrente |
|
| GET | `/missions/available` | missioni nella città corrente, con probabilità di successo stimata e requisito di livello |
|
||||||
| POST | `/missions/:missionId/start` | avvia (consuma subito gli item richiesti) |
|
| POST | `/missions/:missionId/start` | avvia (verifica livello, consuma subito gli item richiesti) |
|
||||||
| POST | `/missions/:playerMissionId/claim` | riscuote: successo/fallimento calcolato dal server |
|
| POST | `/missions/:playerMissionId/claim` | riscuote: successo (premi + bottino) o fallimento (multa) |
|
||||||
|
| POST | `/missions/:playerMissionId/abandon` | abbandona una missione attiva (−1 reputazione) |
|
||||||
| GET | `/missions/active` | missioni in corso |
|
| GET | `/missions/active` | missioni in corso |
|
||||||
| GET | `/events/current` | eventi mondo attivi |
|
| GET | `/events/current` | eventi mondo attivi |
|
||||||
| GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` |
|
| GET | `/leaderboard/money` · `/leaderboard/reputation` | classifiche (Redis), `?limit=N` |
|
||||||
@@ -82,7 +83,7 @@ I parametri citati sono tutti in `gameBalance.ts`.
|
|||||||
|
|
||||||
- **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98).
|
- **Mercato** (`rawPrice = basePrice * economyModifier * demand / max(supply, 0.25) * eventModifier`): buy = raw×1.12, sell = raw×0.88, limiti [0.35×, 3.5×] del prezzo base. Acquisti/vendite spostano domanda/offerta; ogni minuto il mercato decade verso l'equilibrio (fattore 0.98).
|
||||||
- **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`.
|
- **Viaggio**: `costo = 50 + riskLevel(destinazione) × 25`.
|
||||||
- **Missioni**: `successo = 0.95 − risk×0.6 + livello×0.01 + reputazione×0.0005 − (pressione polizia−1)×0.05`, limitato a [5%, 95%]. Successo: denaro + XP + reputazione (+2×difficoltà). Fallimento: −3 reputazione (mai sotto 0). Massimo 3 missioni attive per giocatore.
|
- **Missioni**: `successo = 0.95 − risk×0.6 + livello×0.01 + reputazione×0.0005 − (pressione polizia−1)×0.05`, limitato a [5%, 95%]; la stessa stima è esposta al client in `/missions/available`. Ogni tipo ha un carattere (`typeConfig`): Consegna trasporta merce, Furto paga meno ma dà bottino in merce, Contrabbando paga molto e rischia di più, Informazioni dà XP e reputazione doppia. Difficoltà alte richiedono un livello minimo. Successo: denaro + XP + reputazione + eventuale bottino. Fallimento: −3 reputazione e multa (30% del premio × pressione polizia, mai oltre il denaro posseduto). Una missione attiva può essere abbandonata (`POST /missions/:id/abandon`, −1 reputazione, merce consumata persa). Massimo 3 missioni attive per giocatore.
|
||||||
- **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP.
|
- **XP**: per passare dal livello L a L+1 servono `100 × L^1.5` XP.
|
||||||
- **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi.
|
- **Eventi**: ogni 15 min possibile evento locale (25%), ogni ora possibile evento globale (20%); modificano prezzi e pressione di polizia finché attivi.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user