/
kochkatech
/
CorpGame
Обзор
Документация
Войти
/
kochkatech
/
CorpGame
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
dev
backend/src/interaction/interaction.service.ts
149 строк
8 KB
kochkareal
chore: добавлен .gitattributes, нормализованы переносы строк (LF везде, кроме .bat/.cmd)
06 авг 2026, 15:18
06 авг 2026, 15:18
c25ef1b
Код
Авторство
О чём код?
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeGateway } from '../realtime/realtime.gateway'; import { EventsService } from '../events/events.service'; import { GameConfig } from '../game/default-config'; import { withTeamLock } from '../common/team-lock'; import { maxTransferAmount } from './interaction.calc'; @Injectable() export class InteractionService { constructor( private readonly prisma: PrismaService, private readonly realtime: RealtimeGateway, private readonly events: EventsService, ) {} private async loadContext(sessionId: string, actorTeamId: string, targetTeamId: string) { if (actorTeamId === targetTeamId) throw new BadRequestException('Нельзя выбрать собственную команду'); const session = await this.prisma.gameSession.findUnique({ where: { id: sessionId } }); if (!session) throw new NotFoundException('Игровая сессия не найдена'); if (session.status !== 'RUNNING') { throw new BadRequestException('Действия доступны только во время активной игры'); } const [actor, target] = await Promise.all([ this.prisma.team.findUnique({ where: { id: actorTeamId } }), this.prisma.team.findUnique({ where: { id: targetTeamId }, include: { branches: { orderBy: { createdAt: 'asc' } } }, }), ]); if (!actor || actor.sessionId !== sessionId) throw new ForbiddenException('Команда не принадлежит этой сессии'); if (!target || target.sessionId !== sessionId) throw new NotFoundException('Команда-цель не найдена'); return { session, actor, target, config: session.config as unknown as GameConfig }; } async transfer(sessionId: string, actorTeamId: string, targetTeamId: string, resource: string, amount: number) { const { session, actor, target, config } = await this.loadContext(sessionId, actorTeamId, targetTeamId); if (!session.transferEnabled) throw new BadRequestException('Передача ресурсов отключена администратором'); if (!config.resources.some((r) => r.key === resource)) throw new BadRequestException('Неизвестный ресурс'); if (!Number.isFinite(amount) || amount <= 0) throw new BadRequestException('Некорректное количество'); // Быстрая предпроверка по уже прочитанным данным; авторитетная валидация и списание — под локом. const preAvailable = (actor.resources as Record<string, number>)[resource] ?? 0; if (amount > maxTransferAmount(config, preAvailable)) { throw new BadRequestException( `За раз можно передать не более ${maxTransferAmount(config, preAvailable)} (${config.interactions.transferMaxPercent}% запаса)`, ); } // Обе команды под row-lock (в отсортированном порядке id — против deadlock со встречным // трансфером/кражей); запас отправителя перечитывается под локом и валидируется заново. await withTeamLock(this.prisma, [actorTeamId, targetTeamId], async (tx, teams) => { const actorResources = teams.get(actorTeamId)!.resources as Record<string, number>; const targetResources = teams.get(targetTeamId)!.resources as Record<string, number>; const available = actorResources[resource] ?? 0; const cap = maxTransferAmount(config, available); if (amount > cap) { throw new BadRequestException(`За раз можно передать не более ${cap} (${config.interactions.transferMaxPercent}% запаса)`); } if (amount > available) throw new BadRequestException('Недостаточно ресурса для передачи'); await tx.team.update({ where: { id: actorTeamId }, data: { resources: { ...actorResources, [resource]: available - amount } }, }); await tx.team.update({ where: { id: targetTeamId }, data: { resources: { ...targetResources, [resource]: (targetResources[resource] ?? 0) + amount } }, }); }); await this.logAndBroadcast(sessionId, 'interaction:transfer', actorTeamId, targetTeamId, { resource, amount }); this.realtime.emitTeamNotification(targetTeamId, { kind: 'transfer_received', fromTeam: actor.name, resource, amount, }); return { ok: true }; } /** Санкции понижают уровень случайного филиала цели; если понижать уже некуда — временно замедляют его */ async sanction(sessionId: string, actorTeamId: string, targetTeamId: string) { const { actor, target, config } = await this.loadContext(sessionId, actorTeamId, targetTeamId); const session = await this.prisma.gameSession.findUnique({ where: { id: sessionId } }); if (!session?.sanctionsEnabled) throw new BadRequestException('Санкции отключены администратором'); if (target.branches.length === 0) throw new BadRequestException('У цели нет филиалов для санкций'); // Тот же компромисс, что и у лимита краж в theft.service.ts: проверка читает sanctionsUsed // вне лока, инкремент ниже атомарный ({ increment: 1 }), но не сериализован с проверкой — // в редкой гонке команда может превысить лимит на 1. Не искажает ресурсы и симметрично // для всех команд, поэтому не оборачиваем весь метод в withTeamLock. if (actor.sanctionsUsed >= config.interactions.maxSanctionsPerTeam) { throw new BadRequestException('Лимит санкций для вашей команды исчерпан'); } const upgradable = target.branches.filter((b) => b.level > 1); let payload: Record<string, unknown>; if (upgradable.length > 0) { const victim = upgradable[Math.floor(Math.random() * upgradable.length)]; await this.prisma.branch.update({ where: { id: victim.id }, data: { level: { decrement: 1 } } }); payload = { effect: 'downgrade', cityKey: victim.cityKey, newLevel: victim.level - 1 }; } else { const victim = target.branches[Math.floor(Math.random() * target.branches.length)]; const slowedUntil = new Date(Date.now() + config.interactions.sanctionSlowDurationSec * 1000); await this.prisma.branch.update({ where: { id: victim.id }, data: { slowedUntil, slowMultiplier: config.interactions.sanctionSlowMultiplier }, }); payload = { effect: 'slow', cityKey: victim.cityKey, durationSec: config.interactions.sanctionSlowDurationSec, multiplier: config.interactions.sanctionSlowMultiplier, }; } await this.prisma.team.update({ where: { id: actorTeamId }, data: { sanctionsUsed: { increment: 1 } } }); await this.logAndBroadcast(sessionId, 'interaction:sanction', actorTeamId, targetTeamId, payload); this.realtime.emitTeamNotification(targetTeamId, { kind: 'sanctioned', fromTeam: actor.name, ...payload }); return { ok: true, ...payload }; } private async logAndBroadcast( sessionId: string, type: string, actorTeamId: string, targetTeamId: string, payload: Record<string, unknown>, ) { await this.events.logAndEmit({ sessionId, type, actorTeamId, targetTeamId, payload }); const [actorTeam, targetTeam] = await Promise.all([ this.prisma.team.findUnique({ where: { id: actorTeamId }, include: { branches: { orderBy: { createdAt: 'asc' } } }, }), this.prisma.team.findUnique({ where: { id: targetTeamId }, include: { branches: { orderBy: { createdAt: 'asc' } } }, }), ]); this.realtime.emitStateUpdate(sessionId, { teamUpdated: actorTeam }); this.realtime.emitStateUpdate(sessionId, { teamUpdated: targetTeam }); } }