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:
@@ -97,9 +97,65 @@ export const balance = {
|
||||
/** Ricompensa XP: base + difficulty * perDifficulty */
|
||||
rewardXpBase: 20,
|
||||
rewardXpPerDifficulty: 30,
|
||||
/** probabilità che una missione DELIVERY/SMUGGLING richieda oggetti */
|
||||
requiredItemChance: 0.4,
|
||||
requiredItemQuantityMax: 3,
|
||||
/**
|
||||
* Carattere meccanico di ogni tipo di missione:
|
||||
* - moneyFactor/xpFactor/repFactor: moltiplicatori delle ricompense
|
||||
* - riskShift: spostamento del rischio base
|
||||
* - requiredItemChance: probabilità che serva merce dall'inventario
|
||||
* - lootChance/lootQuantityMax: probabilità e quantità del bottino in merce
|
||||
*/
|
||||
typeConfig: {
|
||||
DELIVERY: {
|
||||
moneyFactor: 1.0,
|
||||
xpFactor: 1.0,
|
||||
repFactor: 1,
|
||||
riskShift: -0.05,
|
||||
requiredItemChance: 0.6,
|
||||
lootChance: 0,
|
||||
lootQuantityMax: 0,
|
||||
},
|
||||
THEFT: {
|
||||
moneyFactor: 0.6,
|
||||
xpFactor: 1.0,
|
||||
repFactor: 1,
|
||||
riskShift: 0.05,
|
||||
requiredItemChance: 0,
|
||||
lootChance: 0.8,
|
||||
lootQuantityMax: 2,
|
||||
},
|
||||
SMUGGLING: {
|
||||
moneyFactor: 1.6,
|
||||
xpFactor: 0.9,
|
||||
repFactor: 1,
|
||||
riskShift: 0.1,
|
||||
requiredItemChance: 0.5,
|
||||
lootChance: 0.25,
|
||||
lootQuantityMax: 1,
|
||||
},
|
||||
INTEL: {
|
||||
moneyFactor: 0.5,
|
||||
xpFactor: 1.8,
|
||||
repFactor: 2,
|
||||
riskShift: -0.1,
|
||||
requiredItemChance: 0,
|
||||
lootChance: 0,
|
||||
lootQuantityMax: 0,
|
||||
},
|
||||
},
|
||||
/** livello minimo richiesto, indicizzato per difficoltà (1-5) */
|
||||
minLevelByDifficulty: [1, 1, 1, 2, 4, 6],
|
||||
/**
|
||||
* Multa sul fallimento: rewardMoney * rewardFactor * pressione polizia,
|
||||
* mai oltre il denaro posseduto dal giocatore.
|
||||
*/
|
||||
failFine: {
|
||||
rewardFactor: 0.3,
|
||||
},
|
||||
/** abbandono volontario di una missione attiva */
|
||||
abandon: {
|
||||
reputationLoss: 1,
|
||||
},
|
||||
/**
|
||||
* Probabilità di successo al claim:
|
||||
* chance = baseChance
|
||||
|
||||
@@ -5,20 +5,108 @@ import { redis } from '../db/redis.js';
|
||||
import { emitToPlayer } from '../realtime/socket.js';
|
||||
import { clamp, pickRandom, randomFloat, randomInt } from '../shared/validators.js';
|
||||
|
||||
/** Template missioni (blueprint §19) con il tipo associato. */
|
||||
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 },
|
||||
];
|
||||
type MissionTemplate = { title: string; description: string };
|
||||
|
||||
/** Tipi di missione che possono richiedere merce dall'inventario. */
|
||||
const TYPES_WITH_REQUIRED_ITEM: ReadonlySet<MissionType> = new Set([
|
||||
MissionType.DELIVERY,
|
||||
MissionType.SMUGGLING,
|
||||
]);
|
||||
/**
|
||||
* Pool narrativi per tipo di missione. I numeri (ricompense, rischi, loot)
|
||||
* vengono da balance.missions.typeConfig: qui vive solo il flavour.
|
||||
*/
|
||||
const MISSION_TEMPLATES: Record<MissionType, MissionTemplate[]> = {
|
||||
[MissionType.DELIVERY]: [
|
||||
{
|
||||
title: 'Consegna discreta',
|
||||
description:
|
||||
'Un pacco senza mittente deve arrivare a destinazione prima dell\'alba. Nessuna domanda.',
|
||||
},
|
||||
{
|
||||
title: 'Trasporto notturno',
|
||||
description:
|
||||
'Un furgone carico aspetta in un vicolo. Strade secondarie, fari spenti, bocca chiusa.',
|
||||
},
|
||||
{
|
||||
title: 'Il corriere silenzioso',
|
||||
description:
|
||||
'Il cliente paga bene per la puntualità. E paga meglio per la discrezione.',
|
||||
},
|
||||
{
|
||||
title: 'Pacco per il dottore',
|
||||
description:
|
||||
'Un professionista del centro ha bisogno di "forniture mediche" non registrate.',
|
||||
},
|
||||
],
|
||||
[MissionType.THEFT]: [
|
||||
{
|
||||
title: 'Recupero merce',
|
||||
description:
|
||||
'Un magazzino mal sorvegliato custodisce roba che "appartiene" al tuo committente. Riprendila.',
|
||||
},
|
||||
{
|
||||
title: 'Visita al deposito',
|
||||
description:
|
||||
'Il turno di guardia cambia alle tre. Hai dieci minuti e un piede di porco.',
|
||||
},
|
||||
{
|
||||
title: 'Il collezionista distratto',
|
||||
description:
|
||||
'Un riccone lascia la villa vuota nel weekend. La cassaforte è del modello che conosci bene.',
|
||||
},
|
||||
{
|
||||
title: 'Carico fantasma',
|
||||
description:
|
||||
'Un camion sosta troppo a lungo in periferia. Quello che trasporta non risulta da nessuna parte.',
|
||||
},
|
||||
],
|
||||
[MissionType.SMUGGLING]: [
|
||||
{
|
||||
title: 'Scambio al porto',
|
||||
description:
|
||||
'Container 47, banchina est. La dogana è stata pagata, ma solo fino a mezzanotte.',
|
||||
},
|
||||
{
|
||||
title: 'La rotta dei pescatori',
|
||||
description:
|
||||
'Un peschereccio attracca con più di quanto dichiara. Serve qualcuno che scarichi in fretta.',
|
||||
},
|
||||
{
|
||||
title: 'Doppio fondo',
|
||||
description:
|
||||
'Un\'auto con il doppio fondo deve passare tre posti di blocco. Il carico scotta.',
|
||||
},
|
||||
{
|
||||
title: 'Frontiera amica',
|
||||
description:
|
||||
'Un finanziere compiacente chiude un occhio stanotte. L\'altro costa extra.',
|
||||
},
|
||||
],
|
||||
[MissionType.INTEL]: [
|
||||
{
|
||||
title: 'Infiltrazione uffici',
|
||||
description:
|
||||
'I documenti nel cassetto del direttore valgono più dell\'oro. Fotografa e sparisci.',
|
||||
},
|
||||
{
|
||||
title: 'Orecchie al bar',
|
||||
description:
|
||||
'Due appaltatori parlano troppo dopo il terzo amaro. Siediti vicino e ascolta.',
|
||||
},
|
||||
{
|
||||
title: 'La talpa',
|
||||
description:
|
||||
'Un impiegato comunale vende l\'accesso agli archivi. Verifica che la merce sia buona.',
|
||||
},
|
||||
{
|
||||
title: 'Pedinamento',
|
||||
description:
|
||||
'Segui il contabile per un giorno intero. Il committente vuole sapere dove dorme.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Livello minimo richiesto per una data difficoltà (vedi balance). */
|
||||
export function minLevelForDifficulty(difficulty: number): number {
|
||||
const table = balance.missions.minLevelByDifficulty;
|
||||
return table[Math.min(difficulty, table.length - 1)] ?? 1;
|
||||
}
|
||||
|
||||
/** Genera nuove missioni nelle città sotto la soglia minima. */
|
||||
export async function generateMissions(): Promise<number> {
|
||||
@@ -36,51 +124,68 @@ export async function generateMissions(): Promise<number> {
|
||||
});
|
||||
|
||||
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 risk =
|
||||
Math.round(
|
||||
clamp(
|
||||
difficulty * b.riskPerDifficulty +
|
||||
(city.riskLevel - 3) * b.riskPerCityRiskLevel +
|
||||
config.riskShift +
|
||||
randomFloat(-b.riskJitter, b.riskJitter),
|
||||
b.riskMin,
|
||||
b.riskMax,
|
||||
) * 100,
|
||||
) / 100;
|
||||
|
||||
// Merce richiesta per partire (consumata all'avvio)
|
||||
let requiredItemId: string | null = null;
|
||||
let requiredItemQuantity: number | null = null;
|
||||
let itemRefund = 0;
|
||||
if (
|
||||
items.length > 0 &&
|
||||
TYPES_WITH_REQUIRED_ITEM.has(template.type) &&
|
||||
Math.random() < b.requiredItemChance
|
||||
) {
|
||||
if (items.length > 0 && Math.random() < config.requiredItemChance) {
|
||||
const item = pickRandom(items);
|
||||
requiredItemId = item.id;
|
||||
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
|
||||
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund;
|
||||
}
|
||||
|
||||
// Bottino in merce in caso di successo
|
||||
let rewardItemId: string | null = null;
|
||||
let rewardItemQuantity: number | null = null;
|
||||
if (items.length > 0 && config.lootChance > 0 && Math.random() < config.lootChance) {
|
||||
rewardItemId = pickRandom(items).id;
|
||||
rewardItemQuantity = randomInt(1, Math.max(1, config.lootQuantityMax));
|
||||
}
|
||||
|
||||
const rewardMoney = Math.round(
|
||||
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
|
||||
config.moneyFactor *
|
||||
city.economyModifier *
|
||||
(1 + risk * b.riskRewardBonus) +
|
||||
itemRefund,
|
||||
);
|
||||
const rewardXp = Math.round(
|
||||
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
|
||||
);
|
||||
|
||||
toCreate.push({
|
||||
cityId: city.id,
|
||||
title: template.title,
|
||||
type: template.type,
|
||||
description: template.description,
|
||||
type,
|
||||
difficulty,
|
||||
minLevel: minLevelForDifficulty(difficulty),
|
||||
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
|
||||
rewardMoney,
|
||||
rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty,
|
||||
rewardXp,
|
||||
risk,
|
||||
requiredItemId,
|
||||
requiredItemQuantity,
|
||||
rewardItemId,
|
||||
rewardItemQuantity,
|
||||
expiresAt: new Date(
|
||||
now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000,
|
||||
),
|
||||
|
||||
@@ -3,7 +3,13 @@ import { z } from 'zod';
|
||||
import { emitToPlayer } from '../../realtime/socket.js';
|
||||
import { authGuard } from '../../shared/authGuard.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 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) => {
|
||||
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
|
||||
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 { balance } from '../../config/gameBalance.js';
|
||||
import { prisma } from '../../db/prisma.js';
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
type PlayerDto,
|
||||
} from '../players/players.service.js';
|
||||
|
||||
type MissionWithItems = Mission & {
|
||||
requiredItem?: { name: string } | null;
|
||||
rewardItem?: { name: string } | null;
|
||||
};
|
||||
|
||||
export type ClaimResult = {
|
||||
playerMissionId: string;
|
||||
missionTitle: string;
|
||||
@@ -19,18 +24,48 @@ export type ClaimResult = {
|
||||
successChance: number;
|
||||
rewardMoney: number;
|
||||
rewardXp: number;
|
||||
/** negativo in caso di multa sul fallimento */
|
||||
moneyChange: number;
|
||||
fine: number;
|
||||
lootItemName: string | null;
|
||||
lootItemQuantity: number | null;
|
||||
reputationChange: number;
|
||||
levelsGained: number;
|
||||
player: PlayerDto;
|
||||
};
|
||||
|
||||
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 {
|
||||
id: mission.id,
|
||||
cityId: mission.cityId,
|
||||
title: mission.title,
|
||||
description: mission.description,
|
||||
type: mission.type,
|
||||
difficulty: mission.difficulty,
|
||||
minLevel: mission.minLevel,
|
||||
durationSeconds: mission.durationSeconds,
|
||||
rewardMoney: mission.rewardMoney,
|
||||
rewardXp: mission.rewardXp,
|
||||
@@ -38,30 +73,58 @@ function toMissionDto(mission: Mission & { requiredItem?: { name: string } | nul
|
||||
requiredItemId: mission.requiredItemId,
|
||||
requiredItemName: mission.requiredItem?.name ?? null,
|
||||
requiredItemQuantity: mission.requiredItemQuantity,
|
||||
rewardItemName: mission.rewardItem?.name ?? null,
|
||||
rewardItemQuantity: mission.rewardItemQuantity,
|
||||
expiresAt: mission.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Missioni disponibili nella città corrente, 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) {
|
||||
const player = await getPlayerOrThrow(playerId);
|
||||
const missions = await prisma.mission.findMany({
|
||||
const [missions, city, events] = await Promise.all([
|
||||
prisma.mission.findMany({
|
||||
where: {
|
||||
cityId: player.currentCityId,
|
||||
expiresAt: { gt: new Date() },
|
||||
playerMissions: { none: { playerId } },
|
||||
},
|
||||
include: { requiredItem: { select: { name: true } } },
|
||||
orderBy: { expiresAt: 'asc' },
|
||||
});
|
||||
return { missions: missions.map(toMissionDto) };
|
||||
include: {
|
||||
requiredItem: { select: { name: true } },
|
||||
rewardItem: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ difficulty: 'asc' }, { expiresAt: 'asc' }],
|
||||
}),
|
||||
prisma.city.findUnique({ where: { id: player.currentCityId } }),
|
||||
getActiveEvents(),
|
||||
]);
|
||||
|
||||
const policePressure = city?.policePressure ?? 1;
|
||||
return {
|
||||
missions: missions.map((mission) => ({
|
||||
...toMissionDto(mission),
|
||||
estimatedSuccessChance:
|
||||
Math.round(computeSuccessChance(player, mission, policePressure, events) * 100) / 100,
|
||||
canStart: player.level >= mission.minLevel,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Missioni del giocatore ancora in corso. */
|
||||
export async function listActive(playerId: string) {
|
||||
const playerMissions = await prisma.playerMission.findMany({
|
||||
where: { playerId, status: MissionStatus.STARTED },
|
||||
include: { mission: { include: { requiredItem: { select: { name: true } } } } },
|
||||
include: {
|
||||
mission: {
|
||||
include: {
|
||||
requiredItem: { select: { name: true } },
|
||||
rewardItem: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { completesAt: 'asc' },
|
||||
});
|
||||
return {
|
||||
@@ -76,8 +139,8 @@ export async function listActive(playerId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvia una missione: consuma subito gli eventuali oggetti richiesti
|
||||
* e fissa completesAt = now + durationSeconds.
|
||||
* Avvia una missione: verifica il livello minimo, consuma subito gli
|
||||
* eventuali oggetti richiesti e fissa completesAt = now + durationSeconds.
|
||||
*/
|
||||
export async function startMission(playerId: string, missionId: string): Promise<PlayerMission> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
@@ -90,6 +153,9 @@ export async function startMission(playerId: string, missionId: string): Promise
|
||||
if (mission.cityId !== player.currentCityId) {
|
||||
throw errors.badRequest('La missione è in un\'altra città');
|
||||
}
|
||||
if (player.level < mission.minLevel) {
|
||||
throw errors.badRequest(`Richiede livello ${mission.minLevel}`);
|
||||
}
|
||||
|
||||
const alreadyStarted = await tx.playerMission.findFirst({
|
||||
where: { playerId, missionId },
|
||||
@@ -136,18 +202,19 @@ export async function startMission(playerId: string, missionId: string): Promise
|
||||
|
||||
/**
|
||||
* Riscuote una missione completata: tira il dado contro la probabilità di
|
||||
* successo (rischio, livello, reputazione, pressione polizia) e applica
|
||||
* ricompense o penalità.
|
||||
* successo. Successo: denaro, XP, reputazione ed eventuale bottino in merce.
|
||||
* Fallimento: perdita di reputazione e multa in denaro (scalata dalla polizia).
|
||||
*/
|
||||
export async function claimMission(
|
||||
playerId: string,
|
||||
playerMissionId: string,
|
||||
): Promise<ClaimResult> {
|
||||
const s = balance.missions.success;
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const playerMission = await tx.playerMission.findUnique({
|
||||
where: { id: playerMissionId },
|
||||
include: { mission: { include: { city: true } } },
|
||||
include: {
|
||||
mission: { include: { city: true, rewardItem: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!playerMission || playerMission.playerId !== playerId) {
|
||||
throw errors.notFound('Missione non trovata');
|
||||
@@ -164,24 +231,27 @@ export async function claimMission(
|
||||
const mission = playerMission.mission;
|
||||
|
||||
const events = await getActiveEvents(tx);
|
||||
const police = mission.city.policePressure * policeModifierForCity(events, mission.cityId);
|
||||
const successChance = clamp(
|
||||
s.baseChance -
|
||||
mission.risk * s.riskWeight +
|
||||
player.level * s.levelBonus +
|
||||
player.reputation * s.reputationBonus -
|
||||
(police - 1) * s.policeWeight,
|
||||
s.minChance,
|
||||
s.maxChance,
|
||||
const successChance = computeSuccessChance(
|
||||
player,
|
||||
mission,
|
||||
mission.city.policePressure,
|
||||
events,
|
||||
);
|
||||
const success = Math.random() < successChance;
|
||||
|
||||
let updatedPlayer = player;
|
||||
let reputationChange = 0;
|
||||
let levelsGained = 0;
|
||||
let moneyChange = 0;
|
||||
let fine = 0;
|
||||
let lootItemName: string | null = null;
|
||||
let lootItemQuantity: number | null = null;
|
||||
|
||||
if (success) {
|
||||
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);
|
||||
levelsGained = progress.levelsGained;
|
||||
updatedPlayer = await tx.player.update({
|
||||
@@ -193,11 +263,35 @@ export async function claimMission(
|
||||
experience: progress.experience,
|
||||
},
|
||||
});
|
||||
|
||||
if (mission.rewardItemId && mission.rewardItemQuantity) {
|
||||
await tx.inventoryItem.upsert({
|
||||
where: { playerId_itemId: { playerId, itemId: mission.rewardItemId } },
|
||||
create: {
|
||||
playerId,
|
||||
itemId: mission.rewardItemId,
|
||||
quantity: mission.rewardItemQuantity,
|
||||
},
|
||||
update: { quantity: { increment: mission.rewardItemQuantity } },
|
||||
});
|
||||
lootItemName = mission.rewardItem?.name ?? null;
|
||||
lootItemQuantity = mission.rewardItemQuantity;
|
||||
}
|
||||
} else {
|
||||
const police =
|
||||
mission.city.policePressure * policeModifierForCity(events, mission.cityId);
|
||||
fine = Math.min(
|
||||
Math.round(mission.rewardMoney * balance.missions.failFine.rewardFactor * police),
|
||||
player.money,
|
||||
);
|
||||
moneyChange = -fine;
|
||||
reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation);
|
||||
updatedPlayer = await tx.player.update({
|
||||
where: { id: player.id },
|
||||
data: { reputation: { increment: reputationChange } },
|
||||
data: {
|
||||
money: { decrement: fine },
|
||||
reputation: { increment: reputationChange },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,9 +310,54 @@ export async function claimMission(
|
||||
successChance: Math.round(successChance * 100) / 100,
|
||||
rewardMoney: success ? mission.rewardMoney : 0,
|
||||
rewardXp: success ? mission.rewardXp : 0,
|
||||
moneyChange,
|
||||
fine,
|
||||
lootItemName,
|
||||
lootItemQuantity,
|
||||
reputationChange,
|
||||
levelsGained,
|
||||
player: toPlayerDto(updatedPlayer),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbandona una missione in corso: piccola perdita di reputazione,
|
||||
* la merce già consumata all'avvio non viene restituita.
|
||||
*/
|
||||
export async function abandonMission(
|
||||
playerId: string,
|
||||
playerMissionId: string,
|
||||
): Promise<{ playerMissionId: string; reputationChange: number; player: PlayerDto }> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const playerMission = await tx.playerMission.findUnique({
|
||||
where: { id: playerMissionId },
|
||||
});
|
||||
if (!playerMission || playerMission.playerId !== playerId) {
|
||||
throw errors.notFound('Missione non trovata');
|
||||
}
|
||||
if (playerMission.status !== MissionStatus.STARTED || playerMission.resolvedAt) {
|
||||
throw errors.conflict('Missione già risolta');
|
||||
}
|
||||
|
||||
const player = await getPlayerOrThrow(playerId, tx);
|
||||
const reputationChange = -Math.min(
|
||||
balance.missions.abandon.reputationLoss,
|
||||
player.reputation,
|
||||
);
|
||||
const updatedPlayer = await tx.player.update({
|
||||
where: { id: player.id },
|
||||
data: { reputation: { increment: reputationChange } },
|
||||
});
|
||||
await tx.playerMission.update({
|
||||
where: { id: playerMission.id },
|
||||
data: { status: MissionStatus.FAILED, resolvedAt: new Date() },
|
||||
});
|
||||
|
||||
return {
|
||||
playerMissionId: playerMission.id,
|
||||
reputationChange,
|
||||
player: toPlayerDto(updatedPlayer),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user