diff --git a/server/src/jobs/generateMissions.job.ts b/server/src/jobs/generateMissions.job.ts new file mode 100644 index 0000000..9b168b4 --- /dev/null +++ b/server/src/jobs/generateMissions.job.ts @@ -0,0 +1,132 @@ +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; +} diff --git a/server/src/jobs/generateWorldEvent.job.ts b/server/src/jobs/generateWorldEvent.job.ts new file mode 100644 index 0000000..1554756 --- /dev/null +++ b/server/src/jobs/generateWorldEvent.job.ts @@ -0,0 +1,88 @@ +import { EventType } from '@prisma/client'; +import { balance, type EventTypeBalance } from '../config/gameBalance.js'; +import { prisma } from '../db/prisma.js'; +import { redis } from '../db/redis.js'; +import { toEventDto } from '../modules/events/events.service.js'; +import { emitGlobal } from '../realtime/socket.js'; +import { pickRandom, randomFloat, randomInt } from '../shared/validators.js'; + +/** Testi degli eventi per tipo (i moltiplicatori numerici sono in gameBalance). */ +const EVENT_TEXTS: Record = { + MARKET_BOOM: { + title: 'Boom di mercato', + description: 'La domanda esplode: i prezzi salgono in tutta la zona.', + }, + POLICE_RAID: { + title: 'Retata della polizia', + description: 'Controlli ovunque: muoversi è molto più rischioso.', + }, + SHORTAGE: { + title: 'Carenza di merci', + description: 'Le scorte scarseggiano e i prezzi schizzano alle stelle.', + }, + BLACKOUT: { + title: 'Blackout', + description: 'La città è al buio: il mercato rallenta, la polizia pure.', + }, +}; + +async function createEvent(cityId: string | null, durationMinutes: number): Promise { + const type = pickRandom(Object.values(EventType)); + const ranges = balance.events.types[type]; + const texts = EVENT_TEXTS[type]; + const now = new Date(); + + const event = await prisma.worldEvent.create({ + data: { + title: texts.title, + description: texts.description, + type, + cityId, + priceModifier: + Math.round(randomFloat(ranges.priceModifier[0], ranges.priceModifier[1]) * 100) / 100, + policeModifier: + Math.round(randomFloat(ranges.policeModifier[0], ranges.policeModifier[1]) * 100) / 100, + startsAt: now, + endsAt: new Date(now.getTime() + durationMinutes * 60_000), + }, + }); + + emitGlobal('worldEvent:started', toEventDto(event)); +} + +/** Ogni 15 minuti: possibile evento locale in una città casuale. */ +export async function maybeGenerateLocalEvent(): Promise { + if (Math.random() >= balance.events.localChance) return; + const cities = await prisma.city.findMany({ select: { id: true } }); + if (cities.length === 0) return; + await createEvent( + pickRandom(cities).id, + randomInt(balance.events.localDurationMinutesMin, balance.events.localDurationMinutesMax), + ); +} + +/** Ogni ora: possibile evento globale (cityId null). */ +export async function maybeGenerateGlobalEvent(): Promise { + if (Math.random() >= balance.events.globalChance) return; + await createEvent( + null, + randomInt(balance.events.globalDurationMinutesMin, balance.events.globalDurationMinutesMax), + ); +} + +/** Notifica (una sola volta) la fine degli eventi terminati di recente. */ +export async function notifyEndedEvents(): Promise { + const now = new Date(); + const recentlyEnded = await prisma.worldEvent.findMany({ + where: { + endsAt: { lt: now, gt: new Date(now.getTime() - 2 * 60 * 60_000) }, + }, + }); + + for (const event of recentlyEnded) { + const firstTime = await redis.set(`notify:event-end:${event.id}`, '1', 'EX', 86_400, 'NX'); + if (firstTime) { + emitGlobal('worldEvent:ended', toEventDto(event)); + } + } +} diff --git a/server/src/jobs/scheduler.ts b/server/src/jobs/scheduler.ts new file mode 100644 index 0000000..9c1ba25 --- /dev/null +++ b/server/src/jobs/scheduler.ts @@ -0,0 +1,56 @@ +import cron from 'node-cron'; +import type { FastifyBaseLogger } from 'fastify'; +import { refreshLeaderboards } from '../modules/leaderboard/leaderboard.service.js'; +import { + deleteExpiredMissions, + generateMissions, + notifyCompletedMissions, +} from './generateMissions.job.js'; +import { + maybeGenerateGlobalEvent, + maybeGenerateLocalEvent, + notifyEndedEvents, +} from './generateWorldEvent.job.js'; +import { updateMarketPrices } from './updateMarketPrices.job.js'; + +/** Esegue un job loggando eventuali errori senza far cadere lo scheduler. */ +async function safeRun(log: FastifyBaseLogger, name: string, job: () => Promise) { + try { + await job(); + } catch (err) { + log.error({ err }, `job ${name} fallito`); + } +} + +/** Job eseguiti subito all'avvio per avere un mondo "vivo" senza attendere il cron. */ +export async function runStartupJobs(log: FastifyBaseLogger): Promise { + await safeRun(log, 'generateMissions', generateMissions); + await safeRun(log, 'updateMarketPrices', updateMarketPrices); + await safeRun(log, 'refreshLeaderboards', refreshLeaderboards); + log.info('job di avvio completati'); +} + +/** Avvia i cicli periodici (blueprint §10). */ +export function startScheduler(log: FastifyBaseLogger): void { + // Ogni minuto: prezzi, notifiche missioni completate, pulizia e rigenerazione missioni. + cron.schedule('* * * * *', async () => { + await safeRun(log, 'updateMarketPrices', updateMarketPrices); + await safeRun(log, 'notifyCompletedMissions', notifyCompletedMissions); + await safeRun(log, 'deleteExpiredMissions', deleteExpiredMissions); + await safeRun(log, 'generateMissions', generateMissions); + }); + + // Ogni 15 minuti: possibile evento locale e aggiornamento classifiche Redis. + cron.schedule('*/15 * * * *', async () => { + await safeRun(log, 'maybeGenerateLocalEvent', maybeGenerateLocalEvent); + await safeRun(log, 'refreshLeaderboards', refreshLeaderboards); + }); + + // Ogni ora: possibile evento globale e chiusura/notifica eventi terminati. + cron.schedule('0 * * * *', async () => { + await safeRun(log, 'maybeGenerateGlobalEvent', maybeGenerateGlobalEvent); + await safeRun(log, 'notifyEndedEvents', notifyEndedEvents); + }); + + log.info('scheduler avviato (1m / 15m / 1h)'); +} diff --git a/server/src/jobs/updateMarketPrices.job.ts b/server/src/jobs/updateMarketPrices.job.ts new file mode 100644 index 0000000..4262333 --- /dev/null +++ b/server/src/jobs/updateMarketPrices.job.ts @@ -0,0 +1,41 @@ +import { balance } from '../config/gameBalance.js'; +import { prisma } from '../db/prisma.js'; +import { getActiveEvents, priceModifierForCity } from '../modules/events/events.service.js'; +import { clampDemandSupply, computePrices } from '../modules/market/pricing.js'; +import { emitGlobal } from '../realtime/socket.js'; + +/** + * Tick del mercato (ogni minuto): domanda e offerta tornano lentamente + * verso l'equilibrio e i prezzi vengono ricalcolati con i modificatori + * degli eventi attivi. + */ +export async function updateMarketPrices(): Promise { + const m = balance.market; + const [events, prices] = await Promise.all([ + getActiveEvents(), + prisma.marketPrice.findMany({ include: { item: true, city: true } }), + ]); + + const updates = prices.map((price) => { + const demand = clampDemandSupply( + price.demand * m.decayFactor + m.equilibrium * (1 - m.decayFactor), + ); + const supply = clampDemandSupply( + price.supply * m.decayFactor + m.equilibrium * (1 - m.decayFactor), + ); + const next = computePrices( + price.item.basePrice, + price.city.economyModifier, + demand, + supply, + priceModifierForCity(events, price.cityId), + ); + return prisma.marketPrice.update({ + where: { cityId_itemId: { cityId: price.cityId, itemId: price.itemId } }, + data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice }, + }); + }); + + await prisma.$transaction(updates); + emitGlobal('market:updated', { updatedAt: new Date().toISOString() }); +}