import type { Mission, PlayerMission } from '@prisma/client'; import { MissionStatus } from '@prisma/client'; import { balance } from '../../config/gameBalance.js'; import { prisma } from '../../db/prisma.js'; import { errors } from '../../shared/errors.js'; import { clamp } from '../../shared/validators.js'; import { getActiveEvents, policeModifierForCity } from '../events/events.service.js'; import { applyExperience, getPlayerOrThrow, toPlayerDto, type PlayerDto, } from '../players/players.service.js'; export type ClaimResult = { playerMissionId: string; missionTitle: string; success: boolean; successChance: number; rewardMoney: number; rewardXp: number; reputationChange: number; levelsGained: number; player: PlayerDto; }; function toMissionDto(mission: Mission & { requiredItem?: { name: string } | null }) { return { id: mission.id, cityId: mission.cityId, title: mission.title, type: mission.type, difficulty: mission.difficulty, durationSeconds: mission.durationSeconds, rewardMoney: mission.rewardMoney, rewardXp: mission.rewardXp, risk: mission.risk, requiredItemId: mission.requiredItemId, requiredItemName: mission.requiredItem?.name ?? null, requiredItemQuantity: mission.requiredItemQuantity, expiresAt: mission.expiresAt, }; } /** Missioni disponibili nella città corrente, non scadute e non già iniziate dal giocatore. */ 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) }; } /** 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 } } } } }, orderBy: { completesAt: 'asc' }, }); return { missions: playerMissions.map((pm) => ({ playerMissionId: pm.id, status: pm.status, startedAt: pm.startedAt, completesAt: pm.completesAt, mission: toMissionDto(pm.mission), })), }; } /** * Avvia una missione: 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) => { const player = await getPlayerOrThrow(playerId, tx); const mission = await tx.mission.findUnique({ where: { id: missionId } }); if (!mission) throw errors.notFound('Missione non trovata'); const now = new Date(); if (mission.expiresAt <= now) throw errors.badRequest('Missione scaduta'); if (mission.cityId !== player.currentCityId) { throw errors.badRequest('La missione è in un\'altra città'); } const alreadyStarted = await tx.playerMission.findFirst({ where: { playerId, missionId }, }); if (alreadyStarted) throw errors.conflict('Missione già iniziata'); const activeCount = await tx.playerMission.count({ where: { playerId, status: MissionStatus.STARTED }, }); if (activeCount >= balance.missions.maxActivePerPlayer) { throw errors.badRequest( `Hai già ${activeCount} missioni attive (massimo ${balance.missions.maxActivePerPlayer})`, ); } if (mission.requiredItemId && mission.requiredItemQuantity) { const inventory = await tx.inventoryItem.findUnique({ where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } }, }); if (!inventory || inventory.quantity < mission.requiredItemQuantity) { throw errors.insufficientItems(); } if (inventory.quantity === mission.requiredItemQuantity) { await tx.inventoryItem.delete({ where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } }, }); } else { await tx.inventoryItem.update({ where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } }, data: { quantity: { decrement: mission.requiredItemQuantity } }, }); } } return tx.playerMission.create({ data: { playerId, missionId, completesAt: new Date(now.getTime() + mission.durationSeconds * 1000), }, }); }); } /** * Riscuote una missione completata: tira il dado contro la probabilità di * successo (rischio, livello, reputazione, pressione polizia) e applica * ricompense o penalità. */ 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 } } }, }); 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 now = new Date(); if (now < playerMission.completesAt) { throw errors.badRequest('Missione non ancora completata'); } const player = await getPlayerOrThrow(playerId, tx); 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 success = Math.random() < successChance; let updatedPlayer = player; let reputationChange = 0; let levelsGained = 0; if (success) { reputationChange = mission.difficulty * balance.missions.reputationGainPerDifficulty; const progress = applyExperience(player.level, player.experience, mission.rewardXp); levelsGained = progress.levelsGained; updatedPlayer = await tx.player.update({ where: { id: player.id }, data: { money: { increment: mission.rewardMoney }, reputation: { increment: reputationChange }, level: progress.level, experience: progress.experience, }, }); } else { reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation); updatedPlayer = await tx.player.update({ where: { id: player.id }, data: { reputation: { increment: reputationChange } }, }); } await tx.playerMission.update({ where: { id: playerMission.id }, data: { status: success ? MissionStatus.COMPLETED : MissionStatus.FAILED, resolvedAt: now, }, }); return { playerMissionId: playerMission.id, missionTitle: mission.title, success, successChance: Math.round(successChance * 100) / 100, rewardMoney: success ? mission.rewardMoney : 0, rewardXp: success ? mission.rewardXp : 0, reputationChange, levelsGained, player: toPlayerDto(updatedPlayer), }; }); }