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;
|
||||
}
|
||||
Reference in New Issue
Block a user