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>
This commit is contained in:
@@ -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,14 +95,9 @@ 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">
|
|
||||||
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
|
|
||||||
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isReady(m) ? (
|
{isReady(m) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -91,6 +110,23 @@ export function MissionsScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
|
<Countdown target={m.completesAt} onDone={() => setTick((t) => t + 1)} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
{MISSION_TYPE_LABELS[m.mission.type]} · premio{' '}
|
||||||
|
{formatMoney(m.mission.rewardMoney)} + {m.mission.rewardXp} XP
|
||||||
|
{m.mission.rewardItemName &&
|
||||||
|
` + ${m.mission.rewardItemQuantity}× ${m.mission.rewardItemName}`}
|
||||||
|
</p>
|
||||||
|
{!isReady(m) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost btn--small mission__abandon"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void abandon(m)}
|
||||||
|
>
|
||||||
|
Abbandona
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -103,25 +139,51 @@ 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;
|
||||||
|
return (
|
||||||
|
<li key={mission.id} className={`mission${locked ? ' mission--locked' : ''}`}>
|
||||||
<div className="mission__head">
|
<div className="mission__head">
|
||||||
<strong>{mission.title}</strong>
|
<strong>{mission.title}</strong>
|
||||||
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
|
<span className="mission__stars">{difficultyStars(mission.difficulty)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className={`chip chip--${mission.type.toLowerCase()}`}>
|
||||||
|
{MISSION_TYPE_LABELS[mission.type]}
|
||||||
|
</span>
|
||||||
|
<p className="mission__description">{mission.description}</p>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
{MISSION_TYPE_LABELS[mission.type]} · durata{' '}
|
durata {formatDuration(mission.durationSeconds)} · rischio{' '}
|
||||||
{formatDuration(mission.durationSeconds)} · rischio {formatPercent(mission.risk)}
|
{formatPercent(mission.risk)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="chancebar" title="Probabilità di successo stimata">
|
||||||
|
<div
|
||||||
|
className="chancebar__fill"
|
||||||
|
style={{
|
||||||
|
width: `${mission.estimatedSuccessChance * 100}%`,
|
||||||
|
background: chanceColor(mission.estimatedSuccessChance),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="chancebar__text">
|
||||||
|
successo {formatPercent(mission.estimatedSuccessChance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{mission.requiredItemName && (
|
{mission.requiredItemName && (
|
||||||
<p className="mission__required">
|
<p className="mission__required">
|
||||||
Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName}
|
Richiede: {mission.requiredItemQuantity}× {mission.requiredItemName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mission__footer">
|
<div className="mission__footer">
|
||||||
<span className="mission__reward">
|
<span className="mission__reward">
|
||||||
{formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP
|
{formatMoney(mission.rewardMoney)} · {mission.rewardXp} XP
|
||||||
|
{mission.rewardItemName &&
|
||||||
|
` · 🎁 ${mission.rewardItemQuantity}× ${mission.rewardItemName}`}
|
||||||
</span>
|
</span>
|
||||||
|
{locked ? (
|
||||||
|
<span className="mission__locked">🔒 liv. {mission.minLevel}</span>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--secondary btn--small"
|
className="btn btn--secondary btn--small"
|
||||||
@@ -130,9 +192,11 @@ export function MissionsScreen() {
|
|||||||
>
|
>
|
||||||
Avvia
|
Avvia
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user