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:
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user