diff --git a/server/src/config/gameBalance.ts b/server/src/config/gameBalance.ts index a773043..427a194 100644 --- a/server/src/config/gameBalance.ts +++ b/server/src/config/gameBalance.ts @@ -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 diff --git a/server/src/jobs/generateMissions.job.ts b/server/src/jobs/generateMissions.job.ts index 9b168b4..67a5e44 100644 --- a/server/src/jobs/generateMissions.job.ts +++ b/server/src/jobs/generateMissions.job.ts @@ -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 = 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.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 { @@ -36,51 +124,68 @@ export async function generateMissions(): Promise { }); 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, ), diff --git a/server/src/modules/missions/missions.routes.ts b/server/src/modules/missions/missions.routes.ts index df75c44..97cd3d9 100644 --- a/server/src/modules/missions/missions.routes.ts +++ b/server/src/modules/missions/missions.routes.ts @@ -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); diff --git a/server/src/modules/missions/missions.service.ts b/server/src/modules/missions/missions.service.ts index 90d6f92..26236b6 100644 --- a/server/src/modules/missions/missions.service.ts +++ b/server/src/modules/missions/missions.service.ts @@ -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, + mission: Pick, + 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({ - where: { - cityId: player.currentCityId, - expiresAt: { gt: new Date() }, - playerMissions: { none: { playerId } }, - }, - include: { requiredItem: { select: { name: true } } }, - orderBy: { expiresAt: 'asc' }, - }); - return { missions: missions.map(toMissionDto) }; + 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 } }, + 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 { 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 { - 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), + }; + }); +}