/
kochkatech
/
CorpGame
Обзор
Документация
Войти
/
kochkatech
/
CorpGame
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
dev
backend/src/interaction/theft.service.ts
186 строк
11 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 { buildTheftOptions } from './interaction.calc'; /** * Кража — не мгновенное действие, а 20-секундный процесс: жертве предлагается угадать * вора среди нескольких команд. Угадала — кража сорвана (CAUGHT). Не угадала или не успела — * кража удаётся (SUCCESS), watchdog (economy-tick.service.ts) добирает истёкшие попытки. */ @Injectable() export class TheftService { constructor( private readonly prisma: PrismaService, private readonly realtime: RealtimeGateway, private readonly events: EventsService, ) {} async start(sessionId: string, actorTeamId: string, targetTeamId: string, resource: 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('Действия доступны только во время активной игры'); if (!session.theftEnabled) throw new BadRequestException('Кража ресурсов отключена администратором'); const config = session.config as unknown as GameConfig; if (!config.resources.some((r) => r.key === resource)) throw new BadRequestException('Неизвестный ресурс'); const [actor, target, sessionTeams] = await Promise.all([ this.prisma.team.findUnique({ where: { id: actorTeamId } }), this.prisma.team.findUnique({ where: { id: targetTeamId } }), this.prisma.team.findMany({ where: { sessionId }, select: { id: true, name: true } }), ]); if (!actor || actor.sessionId !== sessionId) throw new ForbiddenException('Команда не принадлежит этой сессии'); if (!target || target.sessionId !== sessionId) throw new NotFoundException('Команда-цель не найдена'); // Известный компромисс: проверка лимита читает theftsUsed вне лока, а инкремент ниже — // атомарный на уровне БД ({ increment: 1 }), но сам по себе не сериализован с этой // проверкой. Два запроса на старт кражи от одной команды, поданные в один момент, // теоретически оба пройдут проверку и команда израсходует на одну попытку больше лимита. // Не трогает ресурсы (лишь счётчик использований) и симметрично для всех команд — не даёт // системного преимущества, поэтому сознательно не оборачиваем весь метод в withTeamLock // (здесь после проверки ещё идёт неатомарная работа: выбор вариантов угадывания, рассылка // по сокету — под локом это нарушило бы инвариант «никаких посторонних await» из team-lock.ts). if (actor.theftsUsed >= config.interactions.maxTheftsPerTeam) { throw new BadRequestException('Лимит краж для вашей команды исчерпан'); } const existing = await this.prisma.theftAttempt.findFirst({ where: { actorTeamId, targetTeamId, status: 'PENDING' }, }); if (existing) throw new BadRequestException('Вы уже грабите эту команду — дождитесь результата'); const targetResources = target.resources as Record<string, number>; if ((targetResources[resource] ?? 0) <= 0) { throw new BadRequestException('У цели нечего украсть из этого ресурса'); } const otherTeamIds = sessionTeams.filter((t) => t.id !== targetTeamId).map((t) => t.id); const optionTeamIds = buildTheftOptions(actorTeamId, otherTeamIds, config.interactions.theftCatchOptions); const now = new Date(); const expiresAt = new Date(now.getTime() + config.interactions.theftDurationSec * 1000); const [, attempt] = await this.prisma.$transaction([ this.prisma.team.update({ where: { id: actorTeamId }, data: { theftsUsed: { increment: 1 } } }), this.prisma.theftAttempt.create({ data: { sessionId, actorTeamId, targetTeamId, resource, expiresAt, optionTeamIds }, }), ]); const options = sessionTeams.filter((t) => optionTeamIds.includes(t.id)); this.realtime.emitToTeam(actorTeamId, 'theft:started', { attemptId: attempt.id, targetTeamId, targetName: target.name, expiresAt, }); this.realtime.emitToTeam(targetTeamId, 'theft:incoming', { attemptId: attempt.id, resource, options, expiresAt, }); return { attemptId: attempt.id, expiresAt }; } async guess(attemptId: string, victimTeamId: string, guessTeamId: string) { const attempt = await this.prisma.theftAttempt.findUnique({ where: { id: attemptId } }); if (!attempt) throw new NotFoundException('Попытка кражи не найдена'); if (attempt.targetTeamId !== victimTeamId) throw new ForbiddenException('Это не ваша кража для угадывания'); if (attempt.status !== 'PENDING') throw new BadRequestException('Эта кража уже завершена'); if (attempt.expiresAt <= new Date()) throw new BadRequestException('Время на угадывание истекло'); const outcome = guessTeamId === attempt.actorTeamId ? 'CAUGHT' : 'SUCCESS'; return this.resolveAttempt(attempt, outcome, guessTeamId); } /** Вызывается watchdog'ом: все просроченные PENDING-попытки автоматически становятся успешными */ async resolveExpired() { const now = new Date(); const expired = await this.prisma.theftAttempt.findMany({ where: { status: 'PENDING', expiresAt: { lte: now } }, }); for (const attempt of expired) { await this.resolveAttempt(attempt, 'SUCCESS', null).catch(() => undefined); } } private async resolveAttempt( attempt: { id: string; sessionId: string; actorTeamId: string; targetTeamId: string; resource: string }, outcome: 'SUCCESS' | 'CAUGHT', guessTeamId: string | null, ) { // Атомарно «застолбить» попытку: обновление затронет строку, только если она всё ещё // PENDING. Без этого guess() и watchdog (resolveExpired) могли бы обработать одну и ту же // попытку дважды — guess() успевает пройти проверку статуса до того, как срок истёк, а // watchdog в этот же момент уже считает её просроченной. Раньше статус писался последним // шагом, уже после списания ресурсов — здесь claim идёт первым, поэтому вторая сторона // видит count 0 и тихо останавливается, не начисляя кражу повторно и не дублируя события. const claimed = await this.prisma.theftAttempt.updateMany({ where: { id: attempt.id, status: 'PENDING' }, data: { status: outcome, guessTeamId: guessTeamId ?? undefined }, }); if (claimed.count === 0) return { outcome, stolen: 0 }; let stolen = 0; if (outcome === 'SUCCESS') { // Обе команды блокируются под row-lock (в отсортированном порядке id — против deadlock со // встречным трансфером), ресурсы перечитываются под локом, поэтому кража не перезаписывает // параллельный сбор/трансфер и наоборот. await withTeamLock(this.prisma, [attempt.targetTeamId, attempt.actorTeamId], async (tx, teams) => { const target = teams.get(attempt.targetTeamId); const actor = teams.get(attempt.actorTeamId); if (!target || !actor) return; const targetResources = target.resources as Record<string, number>; const available = targetResources[attempt.resource] ?? 0; stolen = Math.min(1, available); if (stolen > 0) { const actorResources = actor.resources as Record<string, number>; await tx.team.update({ where: { id: attempt.targetTeamId }, data: { resources: { ...targetResources, [attempt.resource]: available - stolen } }, }); await tx.team.update({ where: { id: attempt.actorTeamId }, data: { resources: { ...actorResources, [attempt.resource]: (actorResources[attempt.resource] ?? 0) + stolen } }, }); } }); } await this.events.logAndEmit({ sessionId: attempt.sessionId, type: outcome === 'SUCCESS' ? 'theft:success' : 'theft:caught', actorTeamId: attempt.actorTeamId, targetTeamId: attempt.targetTeamId, payload: { resource: attempt.resource, amount: stolen }, }); const [actorTeam, targetTeam] = await Promise.all([ this.prisma.team.findUnique({ where: { id: attempt.actorTeamId }, include: { branches: { orderBy: { createdAt: 'asc' } } }, }), this.prisma.team.findUnique({ where: { id: attempt.targetTeamId }, include: { branches: { orderBy: { createdAt: 'asc' } } }, }), ]); this.realtime.emitStateUpdate(attempt.sessionId, { teamUpdated: actorTeam }); this.realtime.emitStateUpdate(attempt.sessionId, { teamUpdated: targetTeam }); const payload = { attemptId: attempt.id, outcome, resource: attempt.resource, amount: stolen }; this.realtime.emitToTeam(attempt.actorTeamId, 'theft:resolved', payload); this.realtime.emitToTeam(attempt.targetTeamId, 'theft:resolved', payload); return { outcome, stolen }; } }