Files
gioco-mobile/server/src/jobs/generateMissions.job.ts
T

238 lines
7.7 KiB
TypeScript
Raw Normal View History

2026-06-09 23:42:04 +02:00
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';
type MissionTemplate = { title: string; description: string };
2026-06-09 23:42:04 +02:00
/**
* Pool narrativi per tipo di missione. I numeri (ricompense, rischi, loot)
* vengono da balance.missions.typeConfig: qui vive solo il flavour.
*/
const MISSION_TEMPLATES: Record<MissionType, MissionTemplate[]> = {
[MissionType.DELIVERY]: [
{
title: 'Consegna discreta',
description:
'Un pacco senza mittente deve arrivare a destinazione prima dell\'alba. Nessuna domanda.',
},
{
title: 'Trasporto notturno',
description:
'Un furgone carico aspetta in un vicolo. Strade secondarie, fari spenti, bocca chiusa.',
},
{
title: 'Il corriere silenzioso',
description:
'Il cliente paga bene per la puntualità. E paga meglio per la discrezione.',
},
{
title: 'Pacco per il dottore',
description:
'Un professionista del centro ha bisogno di "forniture mediche" non registrate.',
},
],
[MissionType.THEFT]: [
{
title: 'Recupero merce',
description:
'Un magazzino mal sorvegliato custodisce roba che "appartiene" al tuo committente. Riprendila.',
},
{
title: 'Visita al deposito',
description:
'Il turno di guardia cambia alle tre. Hai dieci minuti e un piede di porco.',
},
{
title: 'Il collezionista distratto',
description:
'Un riccone lascia la villa vuota nel weekend. La cassaforte è del modello che conosci bene.',
},
{
title: 'Carico fantasma',
description:
'Un camion sosta troppo a lungo in periferia. Quello che trasporta non risulta da nessuna parte.',
},
],
[MissionType.SMUGGLING]: [
{
title: 'Scambio al porto',
description:
'Container 47, banchina est. La dogana è stata pagata, ma solo fino a mezzanotte.',
},
{
title: 'La rotta dei pescatori',
description:
'Un peschereccio attracca con più di quanto dichiara. Serve qualcuno che scarichi in fretta.',
},
{
title: 'Doppio fondo',
description:
'Un\'auto con il doppio fondo deve passare tre posti di blocco. Il carico scotta.',
},
{
title: 'Frontiera amica',
description:
'Un finanziere compiacente chiude un occhio stanotte. L\'altro costa extra.',
},
],
[MissionType.INTEL]: [
{
title: 'Infiltrazione uffici',
description:
'I documenti nel cassetto del direttore valgono più dell\'oro. Fotografa e sparisci.',
},
{
title: 'Orecchie al bar',
description:
'Due appaltatori parlano troppo dopo il terzo amaro. Siediti vicino e ascolta.',
},
{
title: 'La talpa',
description:
'Un impiegato comunale vende l\'accesso agli archivi. Verifica che la merce sia buona.',
},
{
title: 'Pedinamento',
description:
'Segui il contabile per un giorno intero. Il committente vuole sapere dove dorme.',
},
],
};
/** Livello minimo richiesto per una data difficoltà (vedi balance). */
export function minLevelForDifficulty(difficulty: number): number {
const table = balance.missions.minLevelByDifficulty;
return table[Math.min(difficulty, table.length - 1)] ?? 1;
}
2026-06-09 23:42:04 +02:00
/** 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 type = pickRandom(Object.values(MissionType));
const config = b.typeConfig[type];
const template = pickRandom(MISSION_TEMPLATES[type]);
2026-06-09 23:42:04 +02:00
const difficulty = randomInt(b.difficultyMin, b.difficultyMax);
2026-06-09 23:42:04 +02:00
const risk =
Math.round(
clamp(
difficulty * b.riskPerDifficulty +
(city.riskLevel - 3) * b.riskPerCityRiskLevel +
config.riskShift +
2026-06-09 23:42:04 +02:00
randomFloat(-b.riskJitter, b.riskJitter),
b.riskMin,
b.riskMax,
) * 100,
) / 100;
// Merce richiesta per partire (consumata all'avvio)
2026-06-09 23:42:04 +02:00
let requiredItemId: string | null = null;
let requiredItemQuantity: number | null = null;
let itemRefund = 0;
if (items.length > 0 && Math.random() < config.requiredItemChance) {
2026-06-09 23:42:04 +02:00
const item = pickRandom(items);
requiredItemId = item.id;
requiredItemQuantity = randomInt(1, b.requiredItemQuantityMax);
itemRefund = item.basePrice * requiredItemQuantity * b.requiredItemRefund;
}
// Bottino in merce in caso di successo
let rewardItemId: string | null = null;
let rewardItemQuantity: number | null = null;
if (items.length > 0 && config.lootChance > 0 && Math.random() < config.lootChance) {
rewardItemId = pickRandom(items).id;
rewardItemQuantity = randomInt(1, Math.max(1, config.lootQuantityMax));
}
2026-06-09 23:42:04 +02:00
const rewardMoney = Math.round(
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
config.moneyFactor *
2026-06-09 23:42:04 +02:00
city.economyModifier *
(1 + risk * b.riskRewardBonus) +
itemRefund,
);
const rewardXp = Math.round(
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
);
2026-06-09 23:42:04 +02:00
toCreate.push({
cityId: city.id,
title: template.title,
description: template.description,
type,
2026-06-09 23:42:04 +02:00
difficulty,
minLevel: minLevelForDifficulty(difficulty),
2026-06-09 23:42:04 +02:00
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
rewardMoney,
rewardXp,
2026-06-09 23:42:04 +02:00
risk,
requiredItemId,
requiredItemQuantity,
rewardItemId,
rewardItemQuantity,
2026-06-09 23:42:04 +02:00
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;
}