server: scheduled background jobs
Add node-cron jobs for periodic market price updates, mission generation, and world event generation; all wired through a central scheduler module. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<MissionType> = new Set([
|
||||||
|
MissionType.DELIVERY,
|
||||||
|
MissionType.SMUGGLING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Genera nuove missioni nelle città sotto la soglia minima. */
|
||||||
|
export async function generateMissions(): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
const result = await prisma.mission.deleteMany({
|
||||||
|
where: { expiresAt: { lt: new Date() }, playerMissions: { none: {} } },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
@@ -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<EventTypeBalance, { title: string; description: string }> = {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<unknown>) {
|
||||||
|
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<void> {
|
||||||
|
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)');
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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() });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user