diff --git a/server/src/modules/cities/cities.routes.ts b/server/src/modules/cities/cities.routes.ts new file mode 100644 index 0000000..4edd962 --- /dev/null +++ b/server/src/modules/cities/cities.routes.ts @@ -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) } }; + }); +}; diff --git a/server/src/modules/events/events.routes.ts b/server/src/modules/events/events.routes.ts new file mode 100644 index 0000000..3534867 --- /dev/null +++ b/server/src/modules/events/events.routes.ts @@ -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, + })), + }; + }); +}; diff --git a/server/src/modules/events/events.service.ts b/server/src/modules/events/events.service.ts new file mode 100644 index 0000000..bb50357 --- /dev/null +++ b/server/src/modules/events/events.service.ts @@ -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 { + 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, + }; +} diff --git a/server/src/modules/inventory/inventory.routes.ts b/server/src/modules/inventory/inventory.routes.ts new file mode 100644 index 0000000..040f03a --- /dev/null +++ b/server/src/modules/inventory/inventory.routes.ts @@ -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), + }; + }); +}; diff --git a/server/src/modules/leaderboard/leaderboard.routes.ts b/server/src/modules/leaderboard/leaderboard.routes.ts new file mode 100644 index 0000000..59d5dc4 --- /dev/null +++ b/server/src/modules/leaderboard/leaderboard.routes.ts @@ -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); + }); +}; diff --git a/server/src/modules/leaderboard/leaderboard.service.ts b/server/src/modules/leaderboard/leaderboard.service.ts new file mode 100644 index 0000000..de65a91 --- /dev/null +++ b/server/src/modules/leaderboard/leaderboard.service.ts @@ -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 = { + money: 'leaderboard:money', + reputation: 'leaderboard:reputation', +}; + +/** Ricostruisce le classifiche Redis (sorted set) a partire dal database. */ +export async function refreshLeaderboards(): Promise { + 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(); + 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, + })), + }; +} diff --git a/server/src/modules/market/market.routes.ts b/server/src/modules/market/market.routes.ts new file mode 100644 index 0000000..6717626 --- /dev/null +++ b/server/src/modules/market/market.routes.ts @@ -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; + }); +}; diff --git a/server/src/modules/market/market.service.ts b/server/src/modules/market/market.service.ts new file mode 100644 index 0000000..55d1edf --- /dev/null +++ b/server/src/modules/market/market.service.ts @@ -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 { + 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 { + 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, + }; + }); +} diff --git a/server/src/modules/market/pricing.ts b/server/src/modules/market/pricing.ts new file mode 100644 index 0000000..d1d9a9f --- /dev/null +++ b/server/src/modules/market/pricing.ts @@ -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); +} diff --git a/server/src/modules/missions/missions.routes.ts b/server/src/modules/missions/missions.routes.ts new file mode 100644 index 0000000..df75c44 --- /dev/null +++ b/server/src/modules/missions/missions.routes.ts @@ -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; + }); +}; diff --git a/server/src/modules/missions/missions.service.ts b/server/src/modules/missions/missions.service.ts new file mode 100644 index 0000000..90d6f92 --- /dev/null +++ b/server/src/modules/missions/missions.service.ts @@ -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 { + 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 { + 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), + }; + }); +} diff --git a/server/src/modules/players/players.routes.ts b/server/src/modules/players/players.routes.ts new file mode 100644 index 0000000..53b10b8 --- /dev/null +++ b/server/src/modules/players/players.routes.ts @@ -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; + }); +}; diff --git a/server/src/modules/players/players.service.ts b/server/src/modules/players/players.service.ts new file mode 100644 index 0000000..e2faded --- /dev/null +++ b/server/src/modules/players/players.service.ts @@ -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 { + 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 }; + }); +}