feat: card lock

This commit is contained in:
2024-07-03 00:04:31 +02:00
parent ebbb07829f
commit dc9b00284b
12 changed files with 393 additions and 108 deletions
+8
View File
@@ -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: '^_',
},
],
},
};
+13 -30
View File
@@ -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<string, any>, 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> & 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> & Game,
@Param('id', GameStartedPipe) game: Document<Game> & Game,
@Session() session: Record<string, string>,
) {
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')
+52
View File
@@ -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> & Game,
@Session() session: Record<string, string>,
) {
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);
}
}
+30 -3
View File
@@ -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<typeof DatabaseObjectDto>[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;
+4 -2
View File
@@ -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 {}
+19
View File
@@ -20,3 +20,22 @@ export class GamePipe implements PipeTransform {
return game;
}
}
@Injectable()
export class GameStartedPipe implements PipeTransform {
constructor(@InjectModel(Game.name) private gameModel: Model<Game>) {}
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;
}
}
+24 -2
View File
@@ -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;
+32
View File
@@ -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> & 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
>;
+30 -11
View File
@@ -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> & Game) {
@WithTransaction
async start(
game: Document<Game> & 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(
+14 -60
View File
@@ -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> & 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,
+88
View File
@@ -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<Game>,
@InjectConnection() private readonly connection: mongoose.Connection,
) {}
@WithTransaction
async lockCard(
game: Document<Game> & 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> & 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 });
}
}
+79
View File
@@ -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> & Game) {
return game.teams.find(
(t) => t._id.toHexString() == game.currentTurn.currentTeam,
);
}
export function getPlayer(session: Record<string, any>, 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> & 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;
}