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 */
|
/** 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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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([
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user