From dc9b00284bcc62074640a83d9c0d14687cf1adef Mon Sep 17 00:00:00 2001 From: legonzaur Date: Wed, 3 Jul 2024 00:04:31 +0200 Subject: [PATCH] feat: card lock --- .eslintrc.js | 8 +++ src/games/controllers/games.controller.ts | 43 ++++------- src/games/controllers/turn.controller.ts | 52 ++++++++++++++ src/games/dto/read-games.dto.ts | 33 ++++++++- src/games/games.module.ts | 6 +- src/games/pipe/game.pipe.ts | 19 +++++ src/games/schemas/game.schema.ts | 26 ++++++- src/games/services/effect.functions.ts | 32 +++++++++ src/games/services/games.lobby.service.ts | 41 ++++++++--- src/games/services/games.service.ts | 74 ++++--------------- src/games/services/turn.service.ts | 88 +++++++++++++++++++++++ src/games/utils.ts | 79 ++++++++++++++++++++ 12 files changed, 393 insertions(+), 108 deletions(-) create mode 100644 src/games/controllers/turn.controller.ts create mode 100644 src/games/services/effect.functions.ts create mode 100644 src/games/services/turn.service.ts create mode 100644 src/games/utils.ts diff --git a/.eslintrc.js b/.eslintrc.js index 259de13..e336782 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,5 +21,13 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', // or "error" + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }; diff --git a/src/games/controllers/games.controller.ts b/src/games/controllers/games.controller.ts index dedcfe3..455fd59 100644 --- a/src/games/controllers/games.controller.ts +++ b/src/games/controllers/games.controller.ts @@ -13,32 +13,14 @@ import { LobbyService } from '../services/games.lobby.service'; import { AuthGuard } from '../guards/auth.guard'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { StartingDeckDto } from '../dto/update-game.dto'; -import { Game, Player } from '../schemas/game.schema'; +import { Game } from '../schemas/game.schema'; import { Document } from 'mongoose'; -import { GamePipe } from '../pipe/game.pipe'; +import { GamePipe, GameStartedPipe } from '../pipe/game.pipe'; -import { ReadUserDto } from '../dto/read-games.dto'; import { Observable, fromEvent, map } from 'rxjs'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { GameService } from '../services/games.service'; - -function getPlayer(session: Record, game: Game) { - const user = session.user as ReadUserDto; - if (!user) { - throw new HttpException('Please log in', HttpStatus.UNAUTHORIZED); - } - for (const team of game.teams) { - for (const player of team.players) { - if (player.user._id.toHexString() == user._id) { - return player as Document & Player; - } - } - } - throw new HttpException( - 'You are not part of this game', - HttpStatus.FORBIDDEN, - ); -} +import { getCurrentTeam, getPlayer, isInTeam } from '../utils'; @ApiTags('game') @Controller('games/:id') @@ -72,18 +54,19 @@ export class GamesController { @UseGuards(AuthGuard) async endTurn( @Param('id') id: string, - @Param('id', GamePipe) game: Document & Game, + @Param('id', GameStartedPipe) game: Document & Game, @Session() session: Record, ) { - if (!game.started || !game.currentTurn) { - throw new HttpException('Game not started', HttpStatus.BAD_REQUEST); - } - const player = getPlayer(session, game); - const currentTurn = game.teams.find( - (t) => t._id.toHexString() == game.currentTurn, - ); + const currentTeam = getCurrentTeam(game); + const currentPlayer = getPlayer(session, game); - await this.gameService.endTurn(game, player._id.toHexString()); + if (!isInTeam(currentTeam, currentPlayer)) { + throw new HttpException( + 'It is not your turn to play', + HttpStatus.FORBIDDEN, + ); + } + await this.gameService.endTurn(game); } @Sse('/subscribe') diff --git a/src/games/controllers/turn.controller.ts b/src/games/controllers/turn.controller.ts new file mode 100644 index 0000000..f70a5f9 --- /dev/null +++ b/src/games/controllers/turn.controller.ts @@ -0,0 +1,52 @@ +import { + Controller, + HttpException, + HttpStatus, + Param, + Post, + Session, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../guards/auth.guard'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Game } from '../schemas/game.schema'; +import { Document, Model } from 'mongoose'; +import { GameStartedPipe } from '../pipe/game.pipe'; + +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TurnService } from '../services/turn.service'; +import { getPlayer, getCurrentTeam, isInTeam } from '../utils'; + +@ApiTags('turn') +@Controller('games/:gameId/turn') +export class TurnController { + constructor( + private readonly turnService: TurnService, + private eventEmitter: EventEmitter2, + ) {} + + @Post('lockCard/:cardId') + @ApiOperation({ summary: 'Lock a card' }) + @UseGuards(AuthGuard) + async lockCard( + @Param('gameId') _gameId: string, + @Param('cardId') cardId: string, + @Param('gameId', GameStartedPipe) game: Document & Game, + @Session() session: Record, + ) { + const currentPlayer = getPlayer(session, game); + const currentTeam = getCurrentTeam(game); + if (!isInTeam(currentTeam, currentPlayer)) { + throw new HttpException( + 'It is not your turn to play', + HttpStatus.FORBIDDEN, + ); + } + if ( + game.currentTurn.cardsLocked.some((c) => c._id.toHexString() == cardId) + ) { + throw new HttpException('Card already locked', HttpStatus.BAD_REQUEST); + } + this.turnService.lockCard(game, currentPlayer, cardId); + } +} diff --git a/src/games/dto/read-games.dto.ts b/src/games/dto/read-games.dto.ts index 9e0d5d5..3bd2069 100644 --- a/src/games/dto/read-games.dto.ts +++ b/src/games/dto/read-games.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { DatabaseObjectDto } from './database-object.dto'; -import { Game, Player, Team } from '../schemas/game.schema'; +import { Game, Player, PlayingTurn, Team } from '../schemas/game.schema'; import { Card } from 'src/cards/schemas/cards.schema'; export class ReadPlayerDto extends DatabaseObjectDto { @@ -54,9 +54,11 @@ export class ReadContainerDto extends DatabaseObjectDto { // if (hidden) { // this.cards = data.cards.map(() => null); // } else { + this.id = data._id.toHexString(); this.cards = data.cards; // } } + id: string; cards: Card[]; } @@ -85,6 +87,31 @@ export class ReadUserDto extends DatabaseObjectDto { avatar: string; } +class ReadPlayingTeamDto { + constructor(currentTurn: PlayingTurn) { + this.currentTeam = currentTurn.currentTeam; + this.cardsLocked = currentTurn.cardsLocked.map((c) => c._id.toHexString()); + this.effectsUsed = currentTurn.effectsUsed; + this.damage = currentTurn.damage; + this.gold = currentTurn.gold; + } + + @ApiProperty() + currentTeam: string; + + @ApiProperty() + cardsLocked: string[]; + + @ApiProperty() + effectsUsed: string[]; + + @ApiProperty() + damage: number; + + @ApiProperty() + gold: number; +} + export class ReadGameDto extends DatabaseObjectDto { constructor(data: Game & ConstructorParameters[0]) { super(data); @@ -97,7 +124,7 @@ export class ReadGameDto extends DatabaseObjectDto { this.ended = obj.ended; this.owner = new ReadUserDto(obj.owner); this.packs = obj.packs; - this.currentTurn = obj.currentTurn; + this.currentTurn = new ReadPlayingTeamDto(obj.currentTurn); } @ApiProperty() @@ -113,7 +140,7 @@ export class ReadGameDto extends DatabaseObjectDto { fireGems: ReadContainerDto; @ApiProperty() - currentTurn?: string; + currentTurn: ReadPlayingTeamDto; @ApiProperty() started: boolean; diff --git a/src/games/games.module.ts b/src/games/games.module.ts index 4278ab5..5de0071 100644 --- a/src/games/games.module.ts +++ b/src/games/games.module.ts @@ -7,6 +7,8 @@ import { UsersModule } from 'src/users/users.module'; import { CardsModule } from 'src/cards/cards.module'; import { GamesController } from './controllers/games.controller'; import { GameService } from './services/games.service'; +import { TurnService } from './services/turn.service'; +import { TurnController } from './controllers/turn.controller'; @Module({ imports: [ @@ -14,7 +16,7 @@ import { GameService } from './services/games.service'; UsersModule, MongooseModule.forFeature([{ name: Game.name, schema: GameSchema }]), ], - controllers: [LobbyController, GamesController], - providers: [LobbyService, GameService], + controllers: [LobbyController, GamesController, TurnController], + providers: [LobbyService, GameService, TurnService], }) export class GamesModule {} diff --git a/src/games/pipe/game.pipe.ts b/src/games/pipe/game.pipe.ts index e136827..dec6f13 100644 --- a/src/games/pipe/game.pipe.ts +++ b/src/games/pipe/game.pipe.ts @@ -20,3 +20,22 @@ export class GamePipe implements PipeTransform { return game; } } + +@Injectable() +export class GameStartedPipe implements PipeTransform { + constructor(@InjectModel(Game.name) private gameModel: Model) {} + + async transform(value: any) { + const game = await this.gameModel.findById(value); + if (!game) { + throw new HttpException('Game not found', HttpStatus.NOT_FOUND); + } + if (!game.started) { + throw new HttpException( + 'The game must be started', + HttpStatus.BAD_REQUEST, + ); + } + return game; + } +} diff --git a/src/games/schemas/game.schema.ts b/src/games/schemas/game.schema.ts index 4e3e21f..c619c29 100644 --- a/src/games/schemas/game.schema.ts +++ b/src/games/schemas/game.schema.ts @@ -17,6 +17,28 @@ export class Container extends Types.ObjectId { export const ContainerSchema = SchemaFactory.createForClass(Container); +@Schema() +export class PlayingTurn extends Types.ObjectId { + @Prop() + currentTeam?: string; + + @Prop({ + type: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Card' }], + autopopulate: true, + default: [], + }) + cardsLocked: Card[]; + + @Prop({ type: [String], default: [] }) + effectsUsed: string[]; + + @Prop({ default: 0 }) + damage: number; + + @Prop({ default: 0 }) + gold: number; +} + @Schema() export class Player extends Types.ObjectId { @Prop({ @@ -72,8 +94,8 @@ export class Game extends Types.ObjectId { @Prop({ type: ContainerSchema, default: {}, autopopulate: true }) fireGems: Container; - @Prop() - currentTurn?: string; + @Prop({ type: PlayingTurn, default: {}, autopopulate: true }) + currentTurn: PlayingTurn; @Prop({ default: false }) started: boolean; diff --git a/src/games/services/effect.functions.ts b/src/games/services/effect.functions.ts new file mode 100644 index 0000000..299c799 --- /dev/null +++ b/src/games/services/effect.functions.ts @@ -0,0 +1,32 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Game, Player } from '../schemas/game.schema'; +import { Document } from 'mongoose'; +import { Card } from 'src/cards/schemas/cards.schema'; +import { CardEffects } from 'src/cards/schemas/cards.types'; +import { Effect } from 'src/cards/schemas/effect.schema'; + +export const effectMappings = { + [CardEffects.GOLD]: ( + game: Document & Game, + effect: Effect, + _player: Player, + _card: Card, + eventEmitter: EventEmitter2, + ) => { + game.currentTurn.gold += effect.amount; + eventEmitter.emit('sse.game.' + game._id.toHexString(), { + type: 'currentTurn.updateGold', + gold: game.currentTurn.gold, + operation: effect.amount, + }); + }, +} as Record< + CardEffects, + ( + game: Game, + effect: Effect, + player: Player, + card: Card, + eventEmitter: EventEmitter2, + ) => void +>; diff --git a/src/games/services/games.lobby.service.ts b/src/games/services/games.lobby.service.ts index fe44fcd..c46703d 100644 --- a/src/games/services/games.lobby.service.ts +++ b/src/games/services/games.lobby.service.ts @@ -2,7 +2,7 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { DatabaseObjectDto } from '../dto/database-object.dto'; import { InjectModel } from '@nestjs/mongoose'; import { Game, Player, Team } from '../schemas/game.schema'; -import { Document, Model } from 'mongoose'; +import mongoose, { Document, Model } from 'mongoose'; import { ReadContainerDto, ReadGameDto } from '../dto/read-games.dto'; import { User } from 'src/users/schemas/user.entity'; import { Card } from 'src/cards/schemas/cards.schema'; @@ -10,6 +10,7 @@ import { CardRole } from 'src/cards/schemas/cards.types'; import { StartingDeckDto, UpdateGameDto } from '../dto/update-game.dto'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { GameService } from './games.service'; +import { WithTransaction } from '../utils'; @Injectable() export class LobbyService { @@ -88,10 +89,14 @@ export class LobbyService { return await game.deleteOne(); } - async start(game: Document & Game) { + @WithTransaction + async start( + game: Document & Game, + session?: mongoose.mongo.ClientSession, + ) { const random = Math.floor(Math.random() * game.teams.length); - game.currentTurn = game.teams[random]._id.toHexString(); - await game.save(); + game.currentTurn.currentTeam = game.teams[random]._id.toHexString(); + await game.save({ session }); game.started = true; this.eventEmitter.emit('sse.lobby', { type: 'start', @@ -116,7 +121,7 @@ export class LobbyService { type: 'populateContainer', container: new ReadContainerDto(player.stack, true), }); - await this.gamesService.shuffleContainer(player.stack, game); + await this.gamesService.shuffleContainer(player.stack, game, session); playerIndex++; } } @@ -140,19 +145,33 @@ export class LobbyService { container: new ReadContainerDto(game.marketStack, true), }); - await this.gamesService.shuffleContainer(game.marketStack, game); - await this.gamesService.fillMarket(game); + await this.gamesService.shuffleContainer(game.marketStack, game, session); + await this.gamesService.fillMarket(game, session); for (const team of game.teams) { for (const player of team.players) { - if (team._id.toHexString() == game.currentTurn) { - await this.gamesService.drawToHand(3, player, game); + if (team._id.toHexString() == game.currentTurn.currentTeam) { + await this.gamesService.drawToHand(3, player, game, session); } else { - await this.gamesService.drawToHand(5, player, game); + await this.gamesService.drawToHand(5, player, game, session); } } } - await game.save(); + const currentTeam = game.teams.find( + (t) => t._id.toHexString() == game.currentTurn.currentTeam, + ); + + for (const player of currentTeam.players) { + await this.gamesService.distributeCards( + player.hand.cards, + player.hand, + player.board, + game, + session, + ); + } + + await game.save({ session }); } async changeStartingDeck( diff --git a/src/games/services/games.service.ts b/src/games/services/games.service.ts index 55770c6..280cf44 100644 --- a/src/games/services/games.service.ts +++ b/src/games/services/games.service.ts @@ -4,47 +4,7 @@ import { Container, Game, Player } from '../schemas/game.schema'; import mongoose, { Document, Model } from 'mongoose'; import { Card } from 'src/cards/schemas/cards.schema'; import { EventEmitter2 } from '@nestjs/event-emitter'; - -function shuffle(a) { - let j, x, i; - for (i = a.length - 1; i > 0; i--) { - j = Math.floor(Math.random() * (i + 1)); - x = a[i]; - a[i] = a[j]; - a[j] = x; - } - return a; -} - -function WithTransaction( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -) { - const originalFunc = descriptor.value; - - descriptor.value = async function (...args: any[]) { - if (args.at(-1) instanceof mongoose.mongo.ClientSession) { - return originalFunc.apply(this, args); - } else { - await this.connection - .transaction(async (session) => { - return originalFunc.apply(this, [...args, session]); - }) - .catch((e) => { - if (e instanceof HttpException) { - throw e; - } - throw new HttpException( - 'Another request is processing', - HttpStatus.TOO_MANY_REQUESTS, - ); - }); - } - }; - - return descriptor; -} +import { WithTransaction, getCurrentTeam, shuffle } from '../utils'; @Injectable() export class GameService { @@ -59,21 +19,11 @@ export class GameService { @WithTransaction async endTurn( game: Document & Game, - currentPlayerId: string, session?: mongoose.mongo.ClientSession, ) { - let currentTurn = game.teams.find( - (t) => t._id.toHexString() == game.currentTurn, - ); - if ( - !currentTurn.players.some((p) => p._id.toHexString() == currentPlayerId) - ) { - throw new HttpException( - 'It is not your turn to play', - HttpStatus.FORBIDDEN, - ); - } - for (const player of currentTurn.players) { + let currentTeam = getCurrentTeam(game); + + for (const player of currentTeam.players) { await this.distributeCards( player.board.cards.filter((c) => !c.defense), player.board, @@ -84,20 +34,23 @@ export class GameService { await this.drawToHand(5, player, game, session); } - const index = game.teams.indexOf(currentTurn); - game.currentTurn = + const index = game.teams.indexOf(currentTeam); + game.currentTurn.currentTeam = game.teams[(index + 1) % game.teams.length]._id.toHexString(); + game.currentTurn.cardsLocked = []; + game.currentTurn.effectsUsed = []; + game.currentTurn.damage = 0; + game.currentTurn.gold = 0; + this.eventEmitter.emit('sse.game.' + game._id.toHexString(), { type: 'endTurn', currentTurn: game.currentTurn, }); - currentTurn = game.teams.find( - (t) => t._id.toHexString() == game.currentTurn, - ); + currentTeam = getCurrentTeam(game); - for (const player of currentTurn.players) { + for (const player of currentTeam.players) { await this.distributeCards( player.hand.cards, player.hand, @@ -108,6 +61,7 @@ export class GameService { } await game.save({ session }); } + @WithTransaction async drawToBoard( amount, diff --git a/src/games/services/turn.service.ts b/src/games/services/turn.service.ts new file mode 100644 index 0000000..40b21ac --- /dev/null +++ b/src/games/services/turn.service.ts @@ -0,0 +1,88 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Game, Player } from '../schemas/game.schema'; +import mongoose, { Document, Model } from 'mongoose'; +import { Card } from 'src/cards/schemas/cards.schema'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Effect } from 'src/cards/schemas/effect.schema'; +import { CardEffects, EffectType } from 'src/cards/schemas/cards.types'; +import { WithTransaction } from '../utils'; +import { effectMappings } from './effect.functions'; +import { InjectConnection, InjectModel } from '@nestjs/mongoose'; + +@Injectable() +export class TurnService { + constructor( + private eventEmitter: EventEmitter2, + @InjectModel(Game.name) private gameModel: Model, + @InjectConnection() private readonly connection: mongoose.Connection, + ) {} + + @WithTransaction + async lockCard( + game: Document & Game, + player: Player, + cardId: string, + session?: mongoose.mongo.ClientSession, + ) { + const card = player.board.cards.find((c) => c._id.toHexString() == cardId); + if (!card) { + throw new HttpException( + 'Card not found in player board', + HttpStatus.NOT_FOUND, + ); + } + game.currentTurn.cardsLocked.push(card); + this.eventEmitter.emit('sse.game.' + game._id.toHexString(), { + type: 'lockCard', + card: card._id.toHexString(), + }); + for (const effect of card.effects) { + if ( + [, CardEffects.HEAL, CardEffects.GOLD, CardEffects.DAMAGE].includes( + effect.effect, + ) && + !effect.mutex && + !effect.per && + (!effect.condition || + effect.condition.effectId == EffectType.CHAMPION_ACTION) + ) { + await this.useEffect(game, player, effect, card, session); + } + } + await game.save({ session }); + } + + @WithTransaction + async useEffect( + game: Document & Game, + player: Player, + effect: Effect, + card: Card, + session?: mongoose.mongo.ClientSession, + ) { + const effectRunner = effectMappings[effect.effect]; + if (!effectMappings[effect.effect]) { + console.warn(`Effect ${effect.effect} not implemented`); + return; + throw new HttpException( + `Effect ${effect.effect} not implemented`, + HttpStatus.NOT_IMPLEMENTED, + ); + } + const effectUUID = effect._id.toHexString(); + if (game.currentTurn.effectsUsed.includes(effectUUID)) { + console.error(`Effect ${effectUUID} already used this turn`); + throw new HttpException( + `Effect ${effectUUID} already used this turn`, + HttpStatus.CONFLICT, + ); + } + this.eventEmitter.emit('sse.game.' + game._id.toHexString(), { + type: 'useEffect', + effect: effectUUID, + }); + game.currentTurn.effectsUsed.push(effectUUID); + await effectRunner(game, effect, player, card, this.eventEmitter); + await game.save({ session }); + } +} diff --git a/src/games/utils.ts b/src/games/utils.ts new file mode 100644 index 0000000..4d0211f --- /dev/null +++ b/src/games/utils.ts @@ -0,0 +1,79 @@ +import { HttpStatus } from '@nestjs/common/enums'; +import { HttpException } from '@nestjs/common/exceptions'; +import mongoose, { Document } from 'mongoose'; +import { Game, Player, Team } from './schemas/game.schema'; +import { ReadUserDto } from './dto/read-games.dto'; + +export function shuffle(a) { + let j, x, i; + for (i = a.length - 1; i > 0; i--) { + j = Math.floor(Math.random() * (i + 1)); + x = a[i]; + a[i] = a[j]; + a[j] = x; + } + return a; +} + +export function getCurrentTeam(game: Document & Game) { + return game.teams.find( + (t) => t._id.toHexString() == game.currentTurn.currentTeam, + ); +} + +export function getPlayer(session: Record, game: Game) { + const user = session.user as ReadUserDto; + if (!user) { + throw new HttpException('Please log in', HttpStatus.UNAUTHORIZED); + } + for (const team of game.teams) { + for (const player of team.players) { + if (player.user._id.toHexString() == user._id) { + return player as Document & Player; + } + } + } + throw new HttpException( + 'You are not part of this game', + HttpStatus.FORBIDDEN, + ); +} + +export function isInTeam(team: Team, player: Player) { + if ( + team.players.some((p) => p._id.toHexString() == player._id.toHexString()) + ) { + return true; + } + return false; +} + +export function WithTransaction( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, +) { + const originalFunc = descriptor.value; + + descriptor.value = async function (...args: any[]) { + if (args.at(-1) instanceof mongoose.mongo.ClientSession) { + return originalFunc.apply(this, args); + } else { + await this.connection + .transaction(async (session) => { + return originalFunc.apply(this, [...args, session]); + }) + .catch((e) => { + if (e instanceof HttpException) { + throw e; + } + throw new HttpException( + 'Another request is processing', + HttpStatus.TOO_MANY_REQUESTS, + ); + }); + } + }; + + return descriptor; +}