server: missions — type configs, level gate, loot, fine on failure, abandon
Each mission type now has a typeConfig (moneyFactor, xpFactor, repFactor, riskShift, requiredItemChance, lootChance). startMission checks minLevel. claimMission awards loot items on success; applies a police-scaled fine on failure. New abandonMission endpoint (POST /:id/abandon, −1 rep). computeSuccessChance extracted for reuse between listAvailable and claim. Narrative template pool expanded (4 templates × 4 types). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,20 +5,108 @@ 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 },
|
||||
];
|
||||
type MissionTemplate = { title: string; description: string };
|
||||
|
||||
/** Tipi di missione che possono richiedere merce dall'inventario. */
|
||||
const TYPES_WITH_REQUIRED_ITEM: ReadonlySet<MissionType> = new Set([
|
||||
MissionType.DELIVERY,
|
||||
MissionType.SMUGGLING,
|
||||
]);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Genera nuove missioni nelle città sotto la soglia minima. */
|
||||
export async function generateMissions(): Promise<number> {
|
||||
@@ -36,51 +124,68 @@ export async function generateMissions(): Promise<number> {
|
||||
});
|
||||
|
||||
for (let i = activeCount; i < b.minActivePerCity; i++) {
|
||||
const template = pickRandom(MISSION_TEMPLATES);
|
||||
const type = pickRandom(Object.values(MissionType));
|
||||
const config = b.typeConfig[type];
|
||||
const template = pickRandom(MISSION_TEMPLATES[type]);
|
||||
const difficulty = randomInt(b.difficultyMin, b.difficultyMax);
|
||||
|
||||
const risk =
|
||||
Math.round(
|
||||
clamp(
|
||||
difficulty * b.riskPerDifficulty +
|
||||
(city.riskLevel - 3) * b.riskPerCityRiskLevel +
|
||||
config.riskShift +
|
||||
randomFloat(-b.riskJitter, b.riskJitter),
|
||||
b.riskMin,
|
||||
b.riskMax,
|
||||
) * 100,
|
||||
) / 100;
|
||||
|
||||
// Merce richiesta per partire (consumata all'avvio)
|
||||
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
|
||||
) {
|
||||
if (items.length > 0 && Math.random() < config.requiredItemChance) {
|
||||
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));
|
||||
}
|
||||
|
||||
const rewardMoney = Math.round(
|
||||
(b.rewardMoneyBase + difficulty * b.rewardMoneyPerDifficulty) *
|
||||
config.moneyFactor *
|
||||
city.economyModifier *
|
||||
(1 + risk * b.riskRewardBonus) +
|
||||
itemRefund,
|
||||
);
|
||||
const rewardXp = Math.round(
|
||||
(b.rewardXpBase + difficulty * b.rewardXpPerDifficulty) * config.xpFactor,
|
||||
);
|
||||
|
||||
toCreate.push({
|
||||
cityId: city.id,
|
||||
title: template.title,
|
||||
type: template.type,
|
||||
description: template.description,
|
||||
type,
|
||||
difficulty,
|
||||
minLevel: minLevelForDifficulty(difficulty),
|
||||
durationSeconds: randomInt(b.durationSecondsMin, b.durationSecondsMax),
|
||||
rewardMoney,
|
||||
rewardXp: b.rewardXpBase + difficulty * b.rewardXpPerDifficulty,
|
||||
rewardXp,
|
||||
risk,
|
||||
requiredItemId,
|
||||
requiredItemQuantity,
|
||||
rewardItemId,
|
||||
rewardItemQuantity,
|
||||
expiresAt: new Date(
|
||||
now.getTime() + randomInt(b.expiryMinutesMin, b.expiryMinutesMax) * 60_000,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user