server: game API modules — market, missions, players, inventory, leaderboard, cities, events

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>
This commit is contained in:
2026-06-09 23:41:44 +02:00
co-authored by Claude Sonnet 4.6
parent c792f67e2d
commit fb62a39dab
13 changed files with 875 additions and 0 deletions
@@ -0,0 +1,56 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { emitToPlayer } from '../../realtime/socket.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { claimMission, listActive, listAvailable, startMission } from './missions.service.js';
const missionParamsSchema = z.object({ missionId: z.string().uuid() });
const playerMissionParamsSchema = z.object({ playerMissionId: z.string().uuid() });
export const missionsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/available', async (request) => {
return listAvailable(request.user.playerId);
});
app.get('/active', async (request) => {
return listActive(request.user.playerId);
});
app.post('/:missionId/start', async (request, reply) => {
const { missionId } = parseOrThrow(missionParamsSchema, request.params);
const playerMission = await startMission(request.user.playerId, missionId);
request.log.info(
{ playerId: request.user.playerId, missionId, playerMissionId: playerMission.id },
'mission:start',
);
return reply.status(201).send({
playerMissionId: playerMission.id,
status: playerMission.status,
startedAt: playerMission.startedAt,
completesAt: playerMission.completesAt,
});
});
app.post('/:playerMissionId/claim', async (request) => {
const { playerMissionId } = parseOrThrow(playerMissionParamsSchema, request.params);
const result = await claimMission(request.user.playerId, playerMissionId);
request.log.info(
{
playerId: request.user.playerId,
playerMissionId,
success: result.success,
rewardMoney: result.rewardMoney,
},
'mission:claim',
);
emitToPlayer(request.user.playerId, 'player:notification', {
type: 'mission:result',
playerMissionId,
success: result.success,
});
return result;
});
};
@@ -0,0 +1,224 @@
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),
};
});
}