Add routes and services for all game domains: market buy/sell with dynamic pricing, mission generation and completion, player stats and travel, inventory queries, leaderboard, city list, and world events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
225 lines
7.5 KiB
TypeScript
225 lines
7.5 KiB
TypeScript
import type { Mission, PlayerMission } from '@prisma/client';
|
|
import { MissionStatus } from '@prisma/client';
|
|
import { balance } from '../../config/gameBalance.js';
|
|
import { prisma } from '../../db/prisma.js';
|
|
import { errors } from '../../shared/errors.js';
|
|
import { clamp } from '../../shared/validators.js';
|
|
import { getActiveEvents, policeModifierForCity } from '../events/events.service.js';
|
|
import {
|
|
applyExperience,
|
|
getPlayerOrThrow,
|
|
toPlayerDto,
|
|
type PlayerDto,
|
|
} from '../players/players.service.js';
|
|
|
|
export type ClaimResult = {
|
|
playerMissionId: string;
|
|
missionTitle: string;
|
|
success: boolean;
|
|
successChance: number;
|
|
rewardMoney: number;
|
|
rewardXp: number;
|
|
reputationChange: number;
|
|
levelsGained: number;
|
|
player: PlayerDto;
|
|
};
|
|
|
|
function toMissionDto(mission: Mission & { requiredItem?: { name: string } | null }) {
|
|
return {
|
|
id: mission.id,
|
|
cityId: mission.cityId,
|
|
title: mission.title,
|
|
type: mission.type,
|
|
difficulty: mission.difficulty,
|
|
durationSeconds: mission.durationSeconds,
|
|
rewardMoney: mission.rewardMoney,
|
|
rewardXp: mission.rewardXp,
|
|
risk: mission.risk,
|
|
requiredItemId: mission.requiredItemId,
|
|
requiredItemName: mission.requiredItem?.name ?? null,
|
|
requiredItemQuantity: mission.requiredItemQuantity,
|
|
expiresAt: mission.expiresAt,
|
|
};
|
|
}
|
|
|
|
/** Missioni disponibili nella città corrente, non scadute e non già iniziate dal giocatore. */
|
|
export async function listAvailable(playerId: string) {
|
|
const player = await getPlayerOrThrow(playerId);
|
|
const missions = await prisma.mission.findMany({
|
|
where: {
|
|
cityId: player.currentCityId,
|
|
expiresAt: { gt: new Date() },
|
|
playerMissions: { none: { playerId } },
|
|
},
|
|
include: { requiredItem: { select: { name: true } } },
|
|
orderBy: { expiresAt: 'asc' },
|
|
});
|
|
return { missions: missions.map(toMissionDto) };
|
|
}
|
|
|
|
/** Missioni del giocatore ancora in corso. */
|
|
export async function listActive(playerId: string) {
|
|
const playerMissions = await prisma.playerMission.findMany({
|
|
where: { playerId, status: MissionStatus.STARTED },
|
|
include: { mission: { include: { requiredItem: { select: { name: true } } } } },
|
|
orderBy: { completesAt: 'asc' },
|
|
});
|
|
return {
|
|
missions: playerMissions.map((pm) => ({
|
|
playerMissionId: pm.id,
|
|
status: pm.status,
|
|
startedAt: pm.startedAt,
|
|
completesAt: pm.completesAt,
|
|
mission: toMissionDto(pm.mission),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Avvia una missione: consuma subito gli eventuali oggetti richiesti
|
|
* e fissa completesAt = now + durationSeconds.
|
|
*/
|
|
export async function startMission(playerId: string, missionId: string): Promise<PlayerMission> {
|
|
return prisma.$transaction(async (tx) => {
|
|
const player = await getPlayerOrThrow(playerId, tx);
|
|
const mission = await tx.mission.findUnique({ where: { id: missionId } });
|
|
if (!mission) throw errors.notFound('Missione non trovata');
|
|
|
|
const now = new Date();
|
|
if (mission.expiresAt <= now) throw errors.badRequest('Missione scaduta');
|
|
if (mission.cityId !== player.currentCityId) {
|
|
throw errors.badRequest('La missione è in un\'altra città');
|
|
}
|
|
|
|
const alreadyStarted = await tx.playerMission.findFirst({
|
|
where: { playerId, missionId },
|
|
});
|
|
if (alreadyStarted) throw errors.conflict('Missione già iniziata');
|
|
|
|
const activeCount = await tx.playerMission.count({
|
|
where: { playerId, status: MissionStatus.STARTED },
|
|
});
|
|
if (activeCount >= balance.missions.maxActivePerPlayer) {
|
|
throw errors.badRequest(
|
|
`Hai già ${activeCount} missioni attive (massimo ${balance.missions.maxActivePerPlayer})`,
|
|
);
|
|
}
|
|
|
|
if (mission.requiredItemId && mission.requiredItemQuantity) {
|
|
const inventory = await tx.inventoryItem.findUnique({
|
|
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
|
|
});
|
|
if (!inventory || inventory.quantity < mission.requiredItemQuantity) {
|
|
throw errors.insufficientItems();
|
|
}
|
|
if (inventory.quantity === mission.requiredItemQuantity) {
|
|
await tx.inventoryItem.delete({
|
|
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
|
|
});
|
|
} else {
|
|
await tx.inventoryItem.update({
|
|
where: { playerId_itemId: { playerId, itemId: mission.requiredItemId } },
|
|
data: { quantity: { decrement: mission.requiredItemQuantity } },
|
|
});
|
|
}
|
|
}
|
|
|
|
return tx.playerMission.create({
|
|
data: {
|
|
playerId,
|
|
missionId,
|
|
completesAt: new Date(now.getTime() + mission.durationSeconds * 1000),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Riscuote una missione completata: tira il dado contro la probabilità di
|
|
* successo (rischio, livello, reputazione, pressione polizia) e applica
|
|
* ricompense o penalità.
|
|
*/
|
|
export async function claimMission(
|
|
playerId: string,
|
|
playerMissionId: string,
|
|
): Promise<ClaimResult> {
|
|
const s = balance.missions.success;
|
|
return prisma.$transaction(async (tx) => {
|
|
const playerMission = await tx.playerMission.findUnique({
|
|
where: { id: playerMissionId },
|
|
include: { mission: { include: { city: true } } },
|
|
});
|
|
if (!playerMission || playerMission.playerId !== playerId) {
|
|
throw errors.notFound('Missione non trovata');
|
|
}
|
|
if (playerMission.status !== MissionStatus.STARTED || playerMission.resolvedAt) {
|
|
throw errors.conflict('Missione già risolta');
|
|
}
|
|
const now = new Date();
|
|
if (now < playerMission.completesAt) {
|
|
throw errors.badRequest('Missione non ancora completata');
|
|
}
|
|
|
|
const player = await getPlayerOrThrow(playerId, tx);
|
|
const mission = playerMission.mission;
|
|
|
|
const events = await getActiveEvents(tx);
|
|
const police = mission.city.policePressure * policeModifierForCity(events, mission.cityId);
|
|
const successChance = clamp(
|
|
s.baseChance -
|
|
mission.risk * s.riskWeight +
|
|
player.level * s.levelBonus +
|
|
player.reputation * s.reputationBonus -
|
|
(police - 1) * s.policeWeight,
|
|
s.minChance,
|
|
s.maxChance,
|
|
);
|
|
const success = Math.random() < successChance;
|
|
|
|
let updatedPlayer = player;
|
|
let reputationChange = 0;
|
|
let levelsGained = 0;
|
|
|
|
if (success) {
|
|
reputationChange = mission.difficulty * balance.missions.reputationGainPerDifficulty;
|
|
const progress = applyExperience(player.level, player.experience, mission.rewardXp);
|
|
levelsGained = progress.levelsGained;
|
|
updatedPlayer = await tx.player.update({
|
|
where: { id: player.id },
|
|
data: {
|
|
money: { increment: mission.rewardMoney },
|
|
reputation: { increment: reputationChange },
|
|
level: progress.level,
|
|
experience: progress.experience,
|
|
},
|
|
});
|
|
} else {
|
|
reputationChange = -Math.min(balance.missions.reputationLossOnFail, player.reputation);
|
|
updatedPlayer = await tx.player.update({
|
|
where: { id: player.id },
|
|
data: { reputation: { increment: reputationChange } },
|
|
});
|
|
}
|
|
|
|
await tx.playerMission.update({
|
|
where: { id: playerMission.id },
|
|
data: {
|
|
status: success ? MissionStatus.COMPLETED : MissionStatus.FAILED,
|
|
resolvedAt: now,
|
|
},
|
|
});
|
|
|
|
return {
|
|
playerMissionId: playerMission.id,
|
|
missionTitle: mission.title,
|
|
success,
|
|
successChance: Math.round(successChance * 100) / 100,
|
|
rewardMoney: success ? mission.rewardMoney : 0,
|
|
rewardXp: success ? mission.rewardXp : 0,
|
|
reputationChange,
|
|
levelsGained,
|
|
player: toPlayerDto(updatedPlayer),
|
|
};
|
|
});
|
|
}
|