server: missions — type configs, level gate, loot, fine on failure, abandon

Each mission type now has a typeConfig (moneyFactor, xpFactor, repFactor,
riskShift, requiredItemChance, lootChance). startMission checks minLevel.
claimMission awards loot items on success; applies a police-scaled fine
on failure. New abandonMission endpoint (POST /:id/abandon, −1 rep).
computeSuccessChance extracted for reuse between listAvailable and claim.
Narrative template pool expanded (4 templates × 4 types).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:11:33 +02:00
parent 8804dfeb22
commit 1568520533
4 changed files with 368 additions and 55 deletions
+58 -2
View File
@@ -97,9 +97,65 @@ export const balance = {
/** Ricompensa XP: base + difficulty * perDifficulty */ /** Ricompensa XP: base + difficulty * perDifficulty */
rewardXpBase: 20, rewardXpBase: 20,
rewardXpPerDifficulty: 30, rewardXpPerDifficulty: 30,
/** probabilità che una missione DELIVERY/SMUGGLING richieda oggetti */
requiredItemChance: 0.4,
requiredItemQuantityMax: 3, requiredItemQuantityMax: 3,
/**
* Carattere meccanico di ogni tipo di missione:
* - moneyFactor/xpFactor/repFactor: moltiplicatori delle ricompense
* - riskShift: spostamento del rischio base
* - requiredItemChance: probabilità che serva merce dall'inventario
* - lootChance/lootQuantityMax: probabilità e quantità del bottino in merce
*/
typeConfig: {
DELIVERY: {
moneyFactor: 1.0,
xpFactor: 1.0,
repFactor: 1,
riskShift: -0.05,
requiredItemChance: 0.6,
lootChance: 0,
lootQuantityMax: 0,
},
THEFT: {
moneyFactor: 0.6,
xpFactor: 1.0,
repFactor: 1,
riskShift: 0.05,
requiredItemChance: 0,
lootChance: 0.8,
lootQuantityMax: 2,
},
SMUGGLING: {
moneyFactor: 1.6,
xpFactor: 0.9,
repFactor: 1,
riskShift: 0.1,
requiredItemChance: 0.5,
lootChance: 0.25,
lootQuantityMax: 1,
},
INTEL: {
moneyFactor: 0.5,
xpFactor: 1.8,
repFactor: 2,
riskShift: -0.1,
requiredItemChance: 0,
lootChance: 0,
lootQuantityMax: 0,
},
},
/** livello minimo richiesto, indicizzato per difficoltà (1-5) */
minLevelByDifficulty: [1, 1, 1, 2, 4, 6],
/**
* Multa sul fallimento: rewardMoney * rewardFactor * pressione polizia,
* mai oltre il denaro posseduto dal giocatore.
*/
failFine: {
rewardFactor: 0.3,
},
/** abbandono volontario di una missione attiva */
abandon: {
reputationLoss: 1,
},
/** /**
* Probabilità di successo al claim: * Probabilità di successo al claim:
* chance = baseChance * chance = baseChance
+126 -21
View File
@@ -5,20 +5,108 @@ import { redis } from '../db/redis.js';
import { emitToPlayer } from '../realtime/socket.js'; import { emitToPlayer } from '../realtime/socket.js';
import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js'; import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js';
/** Template missioni (blueprint §19) con il tipo associato. */ type MissionTemplate = { title: string; description: string };
const MISSION_TEMPLATES: ReadonlyArray<{ title: string; type: MissionType }> = [
{ title: 'Consegna discreta', type: MissionType.DELIVERY },
{ title: 'Recupero merce', type: MissionType.THEFT },
{ title: 'Scambio al porto', type: MissionType.SMUGGLING },
{ title: 'Infiltrazione uffici', type: MissionType.INTEL },
{ title: 'Trasporto notturno', type: MissionType.SMUGGLING },
];
/** Tipi di missione che possono richiedere merce dall'inventario. */ /**
const TYPES_WITH_REQUIRED_ITEM: ReadonlySet<MissionType> = new Set([ * Pool narrativi per tipo di missione. I numeri (ricompense, rischi, loot)
MissionType.DELIVERY, * vengono da balance.missions.typeConfig: qui vive solo il flavour.
MissionType.SMUGGLING, */
]); const MISSION_TEMPLATES: Record<MissionType, MissionTemplate[]> = {
[MissionType.DELIVERY]: [
{
title: 'Consegna discreta',
description:
'Un pacco senza mittente deve arrivare a destinazione prima dell\'alba. Nessuna domanda.',
},
{
title: 'Trasporto notturno',
description:
'Un furgone carico aspetta in un vicolo. Strade secondarie, fari spenti, bocca chiusa.',
},
{
title: 'Il corriere silenzioso',
description:
'Il cliente paga bene per la puntualità. E paga meglio per la discrezione.',
},
{
title: 'Pacco per il dottore',
description:
'Un professionista del centro ha bisogno di "forniture mediche" non registrate.',
},
],
[MissionType.THEFT]: [
{
title: 'Recupero merce',
description:
'Un magazzino mal sorvegliato custodisce roba che "appartiene" al tuo committente. Riprendila.',
},
{
title: 'Visita al deposito',
description:
'Il turno di guardia cambia alle tre. Hai dieci minuti e un piede di porco.',
},
{
title: 'Il collezionista distratto',
description:
'Un riccone lascia la villa vuota nel weekend. La cassaforte è del modello che conosci bene.',
},
{
title: 'Carico fantasma',
description:
'Un camion sosta troppo a lungo in periferia. Quello che trasporta non risulta da nessuna parte.',
},
],
[MissionType.SMUGGLING]: [
{
title: 'Scambio al porto',
description:
'Container 47, banchina est. La dogana è stata pagata, ma solo fino a mezzanotte.',
},
{
title: 'La rotta dei pescatori',
description:
'Un peschereccio attracca con più di quanto dichiara. Serve qualcuno che scarichi in fretta.',
},
{
title: 'Doppio fondo',
description:
'Un\'auto con il doppio fondo deve passare tre posti di blocco. Il carico scotta.',
},
{
title: 'Frontiera amica',
description:
'Un finanziere compiacente chiude un occhio stanotte. L\'altro costa extra.',
},
],
[MissionType.INTEL]: [
{
title: 'Infiltrazione uffici',
description:
'I documenti nel cassetto del direttore valgono più dell\'oro. Fotografa e sparisci.',
},
{
title: 'Orecchie al bar',
description:
'Due appaltatori parlano troppo dopo il terzo amaro. Siediti vicino e ascolta.',
},
{
title: 'La talpa',
description:
'Un impiegato comunale vende l\'accesso agli archivi. Verifica che la merce sia buona.',
},
{
title: 'Pedinamento',
description:
'Segui il contabile per un giorno intero. Il committente vuole sapere dove dorme.',
},
],
};
/** Livello minimo richiesto per una data difficoltà (vedi balance). */
export function minLevelForDifficulty(difficulty: number): number {
const table = balance.missions.minLevelByDifficulty;
return table[Math.min(difficulty, table.length - 1)] ?? 1;
}
/** Genera nuove missioni nelle città sotto la soglia minima. */ /** Genera nuove missioni nelle città sotto la soglia minima. */
export async function generateMissions(): Promise<number> { export async function generateMissions(): Promise<number> {
@@ -36,51 +124,68 @@ export async function generateMissions(): Promise<number> {
}); });
for (let i = activeCount; i < b.minActivePerCity; i++) { for (let i = activeCount; i < b.minActivePerCity; i++) {
const template = pickRandom(MISSION_TEMPLATES); const type = pickRandom(Object.values(MissionType));
const config = b.typeConfig[type];
const template = pickRandom(MISSION_TEMPLATES[type]);
const difficulty = randomInt(b.difficultyMin, b.difficultyMax); const difficulty = randomInt(b.difficultyMin, b.difficultyMax);
const risk = const risk =
Math.round( Math.round(
clamp( clamp(
difficulty * b.riskPerDifficulty + difficulty * b.riskPerDifficulty +
(city.riskLevel - 3) * b.riskPerCityRiskLevel + (city.riskLevel - 3) * b.riskPerCityRiskLevel +
config.riskShift +
randomFloat(-b.riskJitter, b.riskJitter), randomFloat(-b.riskJitter, b.riskJitter),
b.riskMin, b.riskMin,
b.riskMax, b.riskMax,
) * 100, ) * 100,
) / 100; ) / 100;
// Merce richiesta per partire (consumata all'avvio)
let requiredItemId: string | null = null; let requiredItemId: string | null = null;
let requiredItemQuantity: number | null = null; let requiredItemQuantity: number | null = null;
let itemRefund = 0; let itemRefund = 0;
if ( if (items.length > 0 && Math.random() < config.requiredItemChance) {
items.length > 0 &&
TYPES_WITH_REQUIRED_ITEM.has(template.type) &&
Math.random() < b.requiredItemChance
) {
const item = pickRandom(items); const item = pickRandom(items);
requiredItemId = item.id; requiredItemId = item.id;
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax); requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund; itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund;
} }
// Bottino in merce in caso di successo
let rewardItemId: string | null = null;
let rewardItemQuantity: number | null = null;
if (items.length > 0 && config.lootChance > 0 && Math.random() < config.lootChance) {
rewardItemId = pickRandom(items).id;
rewardItemQuantity = randomInt(1, Math.max(1, config.lootQuantityMax));
}
const rewardMoney = Math.round( const rewardMoney = Math.round(
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) * (b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
config.moneyFactor *
city.economyModifier * city.economyModifier *
(1 + risk * b.riskRewardBonus) + (1 + risk * b.riskRewardBonus) +
itemRefund, itemRefund,
); );
const rewardXp = Math.round(
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
);
toCreate.push({ toCreate.push({
cityId: city.id, cityId: city.id,
title: template.title, title: template.title,
type: template.type, description: template.description,
type,
difficulty, difficulty,
minLevel: minLevelForDifficulty(difficulty),
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax), durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
rewardMoney, rewardMoney,
rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty, rewardXp,
risk, risk,
requiredItemId, requiredItemId,
requiredItemQuantity, requiredItemQuantity,
rewardItemId,
rewardItemQuantity,
expiresAt: new Date( expiresAt: new Date(
now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000, now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000,
), ),
+14 -1
View File
@@ -3,7 +3,13 @@ import { z } from 'zod';
import { emitToPlayer } from '../../realtime/socket.js'; import { emitToPlayer } from '../../realtime/socket.js';
import { authGuard } from '../../shared/authGuard.js'; import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js'; import { parseOrThrow } from '../../shared/validators.js';
import { claimMission, listActive, listAvailable, startMission } from './missions.service.js'; import {
abandonMission,
claimMission,
listActive,
listAvailable,
startMission,
} from './missions.service.js';
const missionParamsSchema = z.object({ missionId: z.string().uuid() }); const missionParamsSchema = z.object({ missionId: z.string().uuid() });
const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() }); const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() });
@@ -34,6 +40,13 @@ export const missionsRoutes: FastifyPluginAsync = async (app) => {
}); });
}); });
app.post('/:playerMissionId/abandon', async (request) => {
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
const result = await abandonMission(request.user.playerId, playerMissionId);
request.log.info({ playerId: request.user.playerId, playerMissionId }, 'mission:abandon');
return result;
});
app.post('/:playerMissionId/claim', async (request) => { app.post('/:playerMissionId/claim', async (request) => {
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params); const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
const result = await claimMission(request.user.playerId, playerMissionId); const result = await claimMission(request.user.playerId, playerMissionId);
+165 -26
View File
@@ -1,4 +1,4 @@
import type { Mission, PlayerMission } from '@prisma/client'; import type { Mission, Player, PlayerMission, WorldEvent } from '@prisma/client';
import { MissionStatus } from '@prisma/client'; import { MissionStatus } from '@prisma/client';
import { balance } from '../../config/gameBalance.js'; import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js'; import { prisma } from '../../db/prisma.js';
@@ -12,6 +12,11 @@ import {
type PlayerDto, type PlayerDto,
} from '../players/players.service.js'; } from '../players/players.service.js';
type MissionWithItems = Mission & {
requiredItem?: { name: string } | null;
rewardItem?: { name: string } | null;
};
export type ClaimResult = { export type ClaimResult = {
playerMissionId: string; playerMissionId: string;
missionTitle: string; missionTitle: string;
@@ -19,18 +24,48 @@ export type ClaimResult = {
successChance: number; successChance: number;
rewardMoney: number; rewardMoney: number;
rewardXp: number; rewardXp: number;
/** negativo in caso di multa sul fallimento */
moneyChange: number;
fine: number;
lootItemName: string | null;
lootItemQuantity: number | null;
reputationChange: number; reputationChange: number;
levelsGained: number; levelsGained: number;
player: PlayerDto; player: PlayerDto;
}; };
function toMissionDto(mission: Mission & { requiredItem?: { name: string } | null }) { /**
* Probabilità di successo di una missione per un giocatore
* (formula unica, usata sia per la stima mostrata al client sia per il claim).
*/
export function computeSuccessChance(
player: Pick<Player, 'level' | 'reputation'>,
mission: Pick<Mission, 'risk' | 'cityId'>,
cityPolicePressure: number,
events: WorldEvent[],
): number {
const s = balance.missions.success;
const police = cityPolicePressure * policeModifierForCity(events, mission.cityId);
return clamp(
s.baseChance -
mission.risk * s.riskWeight +
player.level * s.levelBonus +
player.reputation * s.reputationBonus -
(police - 1) * s.policeWeight,
s.minChance,
s.maxChance,
);
}
function toMissionDto(mission: MissionWithItems) {
return { return {
id: mission.id, id: mission.id,
cityId: mission.cityId, cityId: mission.cityId,
title: mission.title, title: mission.title,
description: mission.description,
type: mission.type, type: mission.type,
difficulty: mission.difficulty, difficulty: mission.difficulty,
minLevel: mission.minLevel,
durationSeconds: mission.durationSeconds, durationSeconds: mission.durationSeconds,
rewardMoney: mission.rewardMoney, rewardMoney: mission.rewardMoney,
rewardXp: mission.rewardXp, rewardXp: mission.rewardXp,
@@ -38,30 +73,58 @@ function toMissionDto(mission: Mission & { requiredItem?: { name: string } | nul
requiredItemId: mission.requiredItemId, requiredItemId: mission.requiredItemId,
requiredItemName: mission.requiredItem?.name ?? null, requiredItemName: mission.requiredItem?.name ?? null,
requiredItemQuantity: mission.requiredItemQuantity, requiredItemQuantity: mission.requiredItemQuantity,
rewardItemName: mission.rewardItem?.name ?? null,
rewardItemQuantity: mission.rewardItemQuantity,
expiresAt: mission.expiresAt, expiresAt: mission.expiresAt,
}; };
} }
/** Missioni disponibili nella città corrente, non scadute e non già iniziate dal giocatore. */ /**
* Missioni disponibili nella città corrente, con probabilità di successo
* stimata per il giocatore richiedente.
*/
export async function listAvailable(playerId: string) { export async function listAvailable(playerId: string) {
const player = await getPlayerOrThrow(playerId); const player = await getPlayerOrThrow(playerId);
const missions = await prisma.mission.findMany({ const [missions, city, events] = await Promise.all([
prisma.mission.findMany({
where: { where: {
cityId: player.currentCityId, cityId: player.currentCityId,
expiresAt: { gt: new Date() }, expiresAt: { gt: new Date() },
playerMissions: { none: { playerId } }, playerMissions: { none: { playerId } },
}, },
include: { requiredItem: { select: { name: true } } }, include: {
orderBy: { expiresAt: 'asc' }, requiredItem: { select: { name: true } },
}); rewardItem: { select: { name: true } },
return { missions: missions.map(toMissionDto) }; },
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),
};
});
}