import { MissionStatus, MissionType, type Prisma } from '@prisma/client'; import { balance } from '../config/gameBalance.js'; import { prisma } from '../db/prisma.js'; 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 }, ]; /** Tipi di missione che possono richiedere merce dall'inventario. */ const TYPES_WITH_REQUIRED_ITEM: ReadonlySet = new Set([ MissionType.DELIVERY, MissionType.SMUGGLING, ]); /** Genera nuove missioni nelle città sotto la soglia minima. */ export async function generateMissions(): Promise { const b = balance.missions; const now = new Date(); const [cities, items] = await Promise.all([ prisma.city.findMany(), prisma.item.findMany(), ]); const toCreate: Prisma.MissionCreateManyInput[] = []; for (const city of cities) { const activeCount = await prisma.mission.count({ where: { cityId: city.id, expiresAt: { gt: now } }, }); for (let i = activeCount; i < b.minActivePerCity; i++) { const template = pickRandom(MISSION_TEMPLATES); const difficulty = randomInt(b.difficultyMin, b.difficultyMax); const risk = Math.round( clamp( difficulty * b.riskPerDifficulty + (city.riskLevel - 3) * b.riskPerCityRiskLevel + randomFloat(-b.riskJitter, b.riskJitter), b.riskMin, b.riskMax, ) * 100, ) / 100; 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 ) { const item = pickRandom(items); requiredItemId = item.id; requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax); itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund; } const rewardMoney = Math.round( (b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) * city.economyModifier * (1 + risk * b.riskRewardBonus) + itemRefund, ); toCreate.push({ cityId: city.id, title: template.title, type: template.type, difficulty, durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax), rewardMoney, rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty, risk, requiredItemId, requiredItemQuantity, expiresAt: new Date( now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000, ), }); } } if (toCreate.length > 0) { await prisma.mission.createMany({ data: toCreate }); } return toCreate.length; } /** * Notifica (una sola volta, deduplicata via Redis) i giocatori le cui * missioni hanno raggiunto completesAt e sono pronte per il claim. */ export async function notifyCompletedMissions(): Promise { const due = await prisma.playerMission.findMany({ where: { status: MissionStatus.STARTED, completesAt: { lte: new Date() } }, include: { mission: { select: { title: true } } }, }); for (const playerMission of due) { const firstTime = await redis.set( `notify:mission:${playerMission.id}`, '1', 'EX', 86_400, 'NX', ); if (firstTime) { emitToPlayer(playerMission.playerId, 'mission:completed', { playerMissionId: playerMission.id, missionId: playerMission.missionId, title: playerMission.mission.title, completesAt: playerMission.completesAt, }); } } } /** Elimina le missioni scadute mai iniziate da nessun giocatore. */ export async function deleteExpiredMissions(): Promise { const result = await prisma.mission.deleteMany({ where: { expiresAt: { lt: new Date() }, playerMissions: { none: {} } }, }); return result.count; }