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
parent c792f67e2d
commit fb62a39dab
13 changed files with 875 additions and 0 deletions
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { travelCost } from '../players/players.service.js';
const cityParamsSchema = z.object({ cityId: z.string().uuid() });
const citySelect = {
id: true,
name: true,
riskLevel: true,
policePressure: true,
economyModifier: true,
} as const;
export const citiesRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/', async () => {
const cities = await prisma.city.findMany({ select: citySelect, orderBy: { name: 'asc' } });
return {
cities: cities.map((city) => ({ ...city, travelCost: travelCost(city.riskLevel) })),
};
});
app.get('/:cityId', async (request) => {
const { cityId } = parseOrThrow(cityParamsSchema, request.params);
const city = await prisma.city.findUnique({ where: { id: cityId }, select: citySelect });
if (!city) throw errors.notFound('Città non trovata');
return { city: { ...city, travelCost: travelCost(city.riskLevel) } };
});
};
@@ -0,0 +1,29 @@
import type { FastifyPluginAsync } from 'fastify';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
export const eventsRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/current', async () => {
const now = new Date();
const events = await prisma.worldEvent.findMany({
where: { startsAt: { lte: now }, endsAt: { gt: now } },
include: { city: { select: { id: true, name: true } } },
orderBy: { endsAt: 'asc' },
});
return {
events: events.map((event) => ({
id: event.id,
title: event.title,
description: event.description,
type: event.type,
city: event.city,
priceModifier: event.priceModifier,
policeModifier: event.policeModifier,
startsAt: event.startsAt,
endsAt: event.endsAt,
})),
};
});
};
@@ -0,0 +1,42 @@
import type { Prisma, WorldEvent } from '@prisma/client';
import { prisma } from '../../db/prisma.js';
/** Eventi attualmente in corso (startsAt <= now < endsAt). */
export async function getActiveEvents(
tx: Prisma.TransactionClient = prisma,
): Promise<WorldEvent[]> {
const now = new Date();
return tx.worldEvent.findMany({
where: { startsAt: { lte: now }, endsAt: { gt: now } },
orderBy: { startsAt: 'desc' },
});
}
/** Eventi che riguardano una città: locali della città + globali (cityId null). */
export function eventsForCity(events: WorldEvent[], cityId: string): WorldEvent[] {
return events.filter((e) => e.cityId === null || e.cityId === cityId);
}
/** Prodotto dei priceModifier degli eventi attivi per la città. */
export function priceModifierForCity(events: WorldEvent[], cityId: string): number {
return eventsForCity(events, cityId).reduce((mod, e) => mod * e.priceModifier, 1);
}
/** Prodotto dei policeModifier degli eventi attivi per la città. */
export function policeModifierForCity(events: WorldEvent[], cityId: string): number {
return eventsForCity(events, cityId).reduce((mod, e) => mod * e.policeModifier, 1);
}
export function toEventDto(event: WorldEvent) {
return {
id: event.id,
title: event.title,
description: event.description,
type: event.type,
cityId: event.cityId,
priceModifier: event.priceModifier,
policeModifier: event.policeModifier,
startsAt: event.startsAt,
endsAt: event.endsAt,
};
}
@@ -0,0 +1,28 @@
import type { FastifyPluginAsync } from 'fastify';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
export const inventoryRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/', async (request) => {
const entries = await prisma.inventoryItem.findMany({
where: { playerId: request.user.playerId },
include: { item: true },
orderBy: { item: { name: 'asc' } },
});
const items = entries.map((entry) => ({
itemId: entry.itemId,
name: entry.item.name,
quantity: entry.quantity,
weight: entry.item.weight,
basePrice: entry.item.basePrice,
rarity: entry.item.rarity,
illegalLevel: entry.item.illegalLevel,
}));
return {
items,
totalWeight: items.reduce((sum, i) => sum + i.weight * i.quantity, 0),
};
});
};
@@ -0,0 +1,29 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { balance } from '../../config/gameBalance.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { getLeaderboard } from './leaderboard.service.js';
const querySchema = z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(balance.leaderboard.maxEntries)
.default(balance.leaderboard.defaultLimit),
});
export const leaderboardRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/money', async (request) => {
const { limit } = parseOrThrow(querySchema, request.query);
return getLeaderboard('money', limit);
});
app.get('/reputation', async (request) => {
const { limit } = parseOrThrow(querySchema, request.query);
return getLeaderboard('reputation', limit);
});
};
@@ -0,0 +1,66 @@
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { redis } from '../../db/redis.js';
import { emitGlobal } from '../../realtime/socket.js';
export type LeaderboardMetric = 'money' | 'reputation';
const KEYS: Record<LeaderboardMetric, string> = {
money: 'leaderboard:money',
reputation: 'leaderboard:reputation',
};
/** Ricostruisce le classifiche Redis (sorted set) a partire dal database. */
export async function refreshLeaderboards(): Promise<void> {
const players = await prisma.player.findMany({
select: { id: true, money: true, reputation: true },
});
const pipeline = redis.pipeline();
pipeline.del(KEYS.money);
pipeline.del(KEYS.reputation);
for (const player of players) {
pipeline.zadd(KEYS.money, player.money, player.id);
pipeline.zadd(KEYS.reputation, player.reputation, player.id);
}
pipeline.zremrangebyrank(KEYS.money, 0, -(balance.leaderboard.maxEntries + 1));
pipeline.zremrangebyrank(KEYS.reputation, 0, -(balance.leaderboard.maxEntries + 1));
await pipeline.exec();
emitGlobal('leaderboard:updated', { updatedAt: new Date().toISOString() });
}
/** Legge la classifica da Redis; se vuota la ricostruisce dal database. */
export async function getLeaderboard(metric: LeaderboardMetric, limit: number) {
const key = KEYS[metric];
let raw = await redis.zrevrange(key, 0, limit - 1, 'WITHSCORES');
if (raw.length === 0) {
await refreshLeaderboards();
raw = await redis.zrevrange(key, 0, limit - 1, 'WITHSCORES');
}
const playerIds: string[] = [];
const scores = new Map<string, number>();
for (let i = 0; i < raw.length; i += 2) {
const id = raw[i]!;
playerIds.push(id);
scores.set(id, Number(raw[i + 1]));
}
const players = await prisma.player.findMany({
where: { id: { in: playerIds } },
select: { id: true, displayName: true, level: true },
});
const byId = new Map(players.map((p) => [p.id, p]));
return {
metric,
entries: playerIds.map((id, index) => ({
rank: index + 1,
playerId: id,
displayName: byId.get(id)?.displayName ?? 'sconosciuto',
level: byId.get(id)?.level ?? 1,
value: scores.get(id) ?? 0,
})),
};
}
@@ -0,0 +1,33 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { balance } from '../../config/gameBalance.js';
import { authGuard } from '../../shared/authGuard.js';
import { parseOrThrow } from '../../shared/validators.js';
import { buyItem, getCurrentMarket, sellItem } from './market.service.js';
const tradeSchema = z.object({
itemId: z.string().uuid(),
quantity: z.coerce.number().int().min(1).max(balance.market.maxQuantityPerTrade),
});
export const marketRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/current', async (request) => {
return getCurrentMarket(request.user.playerId);
});
app.post('/buy', async (request) => {
const input = parseOrThrow(tradeSchema, request.body);
const result = await buyItem(request.user.playerId, input.itemId, input.quantity);
request.log.info({ playerId: request.user.playerId, ...result }, 'market:buy');
return result;
});
app.post('/sell', async (request) => {
const input = parseOrThrow(tradeSchema, request.body);
const result = await sellItem(request.user.playerId, input.itemId, input.quantity);
request.log.info({ playerId: request.user.playerId, ...result }, 'market:sell');
return result;
});
};
+180
View File
@@ -0,0 +1,180 @@
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { errors } from '../../shared/errors.js';
import {
getActiveEvents,
eventsForCity,
priceModifierForCity,
toEventDto,
} from '../events/events.service.js';
import { getPlayerOrThrow } from '../players/players.service.js';
import { clampDemandSupply, computePrices } from './pricing.js';
export type TradeResult = {
itemId: string;
itemName: string;
quantity: number;
unitPrice: number;
total: number;
playerMoney: number;
};
/** Listino del mercato della città in cui si trova il giocatore. */
export async function getCurrentMarket(playerId: string) {
const player = await prisma.player.findUnique({
where: { id: playerId },
include: { currentCity: true },
});
if (!player) throw errors.notFound('Giocatore non trovato');
const [prices, events] = await Promise.all([
prisma.marketPrice.findMany({
where: { cityId: player.currentCityId },
include: { item: true },
orderBy: { item: { name: 'asc' } },
}),
getActiveEvents(),
]);
return {
city: {
id: player.currentCity.id,
name: player.currentCity.name,
riskLevel: player.currentCity.riskLevel,
economyModifier: player.currentCity.economyModifier,
},
prices: prices.map((p) => ({
itemId: p.itemId,
name: p.item.name,
buyPrice: p.buyPrice,
sellPrice: p.sellPrice,
demand: p.demand,
supply: p.supply,
rarity: p.item.rarity,
illegalLevel: p.item.illegalLevel,
weight: p.item.weight,
updatedAt: p.updatedAt,
})),
activeEvents: eventsForCity(events, player.currentCityId).map(toEventDto),
};
}
/**
* Acquisto: il prezzo lo decide il server (buyPrice corrente).
* Aggiorna denaro, inventario, domanda/offerta e ricalcola subito i prezzi.
*/
export async function buyItem(
playerId: string,
itemId: string,
quantity: number,
): Promise<TradeResult> {
const m = balance.market;
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
const price = await tx.marketPrice.findUnique({
where: { cityId_itemId: { cityId: player.currentCityId, itemId } },
include: { item: true, city: true },
});
if (!price) throw errors.notFound('Oggetto non disponibile in questo mercato');
const total = price.buyPrice * quantity;
if (player.money < total) throw errors.insufficientFunds();
await tx.player.update({
where: { id: player.id },
data: { money: { decrement: total } },
});
await tx.inventoryItem.upsert({
where: { playerId_itemId: { playerId, itemId } },
create: { playerId, itemId, quantity },
update: { quantity: { increment: quantity } },
});
const demand = clampDemandSupply(price.demand + quantity * m.demandDeltaPerBuyUnit);
const supply = clampDemandSupply(price.supply + quantity * m.supplyDeltaPerBuyUnit);
const events = await getActiveEvents(tx);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
await tx.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
return {
itemId,
itemName: price.item.name,
quantity,
unitPrice: price.buyPrice,
total,
playerMoney: player.money - total,
};
});
}
/**
* Vendita: verifica l'inventario, paga sellPrice * quantity,
* aggiorna domanda/offerta e ricalcola subito i prezzi.
*/
export async function sellItem(
playerId: string,
itemId: string,
quantity: number,
): Promise<TradeResult> {
const m = balance.market;
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
const price = await tx.marketPrice.findUnique({
where: { cityId_itemId: { cityId: player.currentCityId, itemId } },
include: { item: true, city: true },
});
if (!price) throw errors.notFound('Oggetto non vendibile in questo mercato');
const inventory = await tx.inventoryItem.findUnique({
where: { playerId_itemId: { playerId, itemId } },
});
if (!inventory || inventory.quantity < quantity) throw errors.insufficientItems();
const total = price.sellPrice * quantity;
await tx.player.update({
where: { id: player.id },
data: { money: { increment: total } },
});
if (inventory.quantity === quantity) {
await tx.inventoryItem.delete({ where: { playerId_itemId: { playerId, itemId } } });
} else {
await tx.inventoryItem.update({
where: { playerId_itemId: { playerId, itemId } },
data: { quantity: { decrement: quantity } },
});
}
const demand = clampDemandSupply(price.demand + quantity * m.demandDeltaPerSellUnit);
const supply = clampDemandSupply(price.supply + quantity * m.supplyDeltaPerSellUnit);
const events = await getActiveEvents(tx);
const next = computePrices(
price.item.basePrice,
price.city.economyModifier,
demand,
supply,
priceModifierForCity(events, price.cityId),
);
await tx.marketPrice.update({
where: { cityId_itemId: { cityId: price.cityId, itemId } },
data: { demand, supply, buyPrice: next.buyPrice, sellPrice: next.sellPrice },
});
return {
itemId,
itemName: price.item.name,
quantity,
unitPrice: price.sellPrice,
total,
playerMoney: player.money + total,
};
});
}
+29
View File
@@ -0,0 +1,29 @@
import { balance } from '../../config/gameBalance.js';
import { clamp } from '../../shared/validators.js';
/**
* Formula del mercato dinamico (blueprint §9):
* rawPrice = basePrice * economyModifier * demand / max(supply, minSupplyDivisor) * eventModifier
* con rawPrice limitato a [basePrice*minPriceFactor, basePrice*maxPriceFactor].
*/
export function computePrices(
basePrice: number,
economyModifier: number,
demand: number,
supply: number,
eventModifier: number,
): { buyPrice: number; sellPrice: number } {
const m = balance.market;
const raw =
basePrice * economyModifier * (demand / Math.max(supply, m.minSupplyDivisor)) * eventModifier;
const clamped = clamp(raw, basePrice * m.minPriceFactor, basePrice * m.maxPriceFactor);
return {
buyPrice: Math.round(clamped * m.buyMargin),
sellPrice: Math.round(clamped * m.sellMargin),
};
}
/** Mantiene demand/supply nei limiti consentiti. */
export function clampDemandSupply(value: number): number {
return clamp(value, balance.market.minDemandSupply, balance.market.maxDemandSupply);
}
@@ -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),
};
});
}
@@ -0,0 +1,38 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db/prisma.js';
import { authGuard } from '../../shared/authGuard.js';
import { errors } from '../../shared/errors.js';
import { parseOrThrow } from '../../shared/validators.js';
import { toPlayerDto, travel, xpForNextLevel } from './players.service.js';
const travelSchema = z.object({
cityId: z.string().uuid(),
});
export const playersRoutes: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', authGuard);
app.get('/me', async (request) => {
const player = await prisma.player.findUnique({
where: { id: request.user.playerId },
include: { currentCity: { select: { id: true, name: true, riskLevel: true } } },
});
if (!player) throw errors.notFound('Giocatore non trovato');
return {
player: toPlayerDto(player),
currentCity: player.currentCity,
xpForNextLevel: xpForNextLevel(player.level),
};
});
app.post('/travel', async (request) => {
const input = parseOrThrow(travelSchema, request.body);
const result = await travel(request.user.playerId, input.cityId);
request.log.info(
{ playerId: request.user.playerId, cityId: input.cityId, cost: result.cost },
'player:travel',
);
return result;
});
};
@@ -0,0 +1,86 @@
import type { Player, Prisma } from '@prisma/client';
import { balance } from '../../config/gameBalance.js';
import { prisma } from '../../db/prisma.js';
import { errors } from '../../shared/errors.js';
export type PlayerDto = {
id: string;
displayName: string;
money: number;
reputation: number;
level: number;
experience: number;
currentCityId: string;
};
export function toPlayerDto(player: Player): PlayerDto {
return {
id: player.id,
displayName: player.displayName,
money: player.money,
reputation: player.reputation,
level: player.level,
experience: player.experience,
currentCityId: player.currentCityId,
};
}
/** XP necessari per passare dal livello `level` al successivo. */
export function xpForNextLevel(level: number): number {
return Math.round(balance.xp.base * Math.pow(level, balance.xp.exponent));
}
/** Applica XP guadagnati gestendo i passaggi di livello. */
export function applyExperience(
level: number,
experience: number,
gainedXp: number,
): { level: number; experience: number; levelsGained: number } {
let newLevel = level;
let newExperience = experience + gainedXp;
let levelsGained = 0;
while (newExperience >= xpForNextLevel(newLevel)) {
newExperience -= xpForNextLevel(newLevel);
newLevel += 1;
levelsGained += 1;
}
return { level: newLevel, experience: newExperience, levelsGained };
}
export async function getPlayerOrThrow(
playerId: string,
tx: Prisma.TransactionClient = prisma,
): Promise<Player> {
const player = await tx.player.findUnique({ where: { id: playerId } });
if (!player) throw errors.notFound('Giocatore non trovato');
return player;
}
/** Costo del viaggio verso una città (vedi balance.travel). */
export function travelCost(destinationRiskLevel: number): number {
return balance.travel.baseCost + destinationRiskLevel * balance.travel.costPerRiskLevel;
}
/** Sposta il giocatore in un'altra città, scalando il costo del viaggio. */
export async function travel(
playerId: string,
cityId: string,
): Promise<{ player: PlayerDto; cityName: string; cost: number }> {
return prisma.$transaction(async (tx) => {
const player = await getPlayerOrThrow(playerId, tx);
if (player.currentCityId === cityId) {
throw errors.badRequest('Sei già in questa città');
}
const city = await tx.city.findUnique({ where: { id: cityId } });
if (!city) throw errors.notFound('Città non trovata');
const cost = travelCost(city.riskLevel);
if (player.money < cost) throw errors.insufficientFunds();
const updated = await tx.player.update({
where: { id: player.id },
data: { money: { decrement: cost }, currentCityId: city.id },
});
return { player: toPlayerDto(updated), cityName: city.name, cost };
});
}