Skip to content

Commit

Permalink
feat(game-play): can be skipped field for current game play (#583)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Oct 17, 2023
1 parent 830ac85 commit a5908ef
Show file tree
Hide file tree
Showing 35 changed files with 18,794 additions and 15,774 deletions.
12 changes: 4 additions & 8 deletions src/modules/game/constants/game-play/game-play.constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,22 @@ const GAME_PLAY_SOURCE_NAMES = [
RoleNames.WITCH,
] as const satisfies Readonly<(PlayerAttributeNames | PlayerGroups | RoleNames)[]>;

const REQUIRED_TARGET_ACTIONS = [
const TARGET_ACTIONS = [
GamePlayActions.LOOK,
GamePlayActions.CHARM,
GamePlayActions.SHOOT,
GamePlayActions.PROTECT,
GamePlayActions.CHOOSE_MODEL,
GamePlayActions.DELEGATE,
GamePlayActions.SETTLE_VOTES,
] as const satisfies Readonly<GamePlayActions[]>;

const OPTIONAL_TARGET_ACTIONS = [
GamePlayActions.EAT,
GamePlayActions.USE_POTIONS,
GamePlayActions.MARK,
GamePlayActions.SNIFF,
GamePlayActions.BAN_VOTING,
] as const satisfies Readonly<GamePlayActions[]>;

const REQUIRED_VOTE_ACTIONS = [
const VOTE_ACTIONS = [
GamePlayActions.VOTE,
GamePlayActions.ELECT_SHERIFF,
] as const satisfies Readonly<GamePlayActions[]>;
Expand All @@ -57,8 +54,7 @@ const STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS = [

export {
GAME_PLAY_SOURCE_NAMES,
REQUIRED_TARGET_ACTIONS,
OPTIONAL_TARGET_ACTIONS,
REQUIRED_VOTE_ACTIONS,
TARGET_ACTIONS,
VOTE_ACTIONS,
STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS,
};
6 changes: 3 additions & 3 deletions src/modules/game/dto/make-game-play/make-game-play.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { Expose, Type } from "class-transformer";
import { ArrayUnique, IsArray, IsBoolean, IsEnum, IsMongoId, IsOptional, ValidateNested } from "class-validator";
import { Types } from "mongoose";

import { REQUIRED_TARGET_ACTIONS, REQUIRED_VOTE_ACTIONS, STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS } from "@/modules/game/constants/game-play/game-play.constant";
import { VOTE_ACTIONS, STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS, TARGET_ACTIONS } from "@/modules/game/constants/game-play/game-play.constant";
import { MakeGamePlayTargetDto } from "@/modules/game/dto/make-game-play/make-game-play-target/make-game-play-target.dto";
import { MakeGamePlayVoteDto } from "@/modules/game/dto/make-game-play/make-game-play-vote/make-game-play-vote.dto";
import { GamePlayActions } from "@/modules/game/enums/game-play.enum";
import { RoleNames, RoleSides } from "@/modules/role/enums/role.enum";

class MakeGamePlayDto {
@ApiProperty({ description: `Players affected by the play. Must be set when game's upcoming play action is one of the following : ${REQUIRED_TARGET_ACTIONS.toString()}` })
@ApiProperty({ description: `Players affected by the play. Must be set when game's upcoming play action is one of the following : ${TARGET_ACTIONS.toString()}` })
@IsOptional()
@Type(() => MakeGamePlayTargetDto)
@ValidateNested()
Expand All @@ -19,7 +19,7 @@ class MakeGamePlayDto {
@Expose()
public targets?: MakeGamePlayTargetDto[];

@ApiProperty({ description: `Players votes. Must be set when game's upcoming play action is one of the following : ${REQUIRED_VOTE_ACTIONS.toString()}` })
@ApiProperty({ description: `Players votes. Must be set when game's upcoming play action is one of the following : ${VOTE_ACTIONS.toString()}` })
@IsOptional()
@Type(() => MakeGamePlayVoteDto)
@ValidateNested()
Expand Down
2 changes: 2 additions & 0 deletions src/modules/game/game.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { MongooseModule } from "@nestjs/mongoose";

import { GamePlayAugmenterService } from "@/modules/game/providers/services/game-play/game-play-augmenter.service";
import { GameVictoryService } from "@/modules/game/providers/services/game-victory/game-victory.service";
import { DatabaseModule } from "@/modules/config/database/database.module";
import { GameController } from "@/modules/game/controllers/game.controller";
Expand Down Expand Up @@ -35,6 +36,7 @@ import { Game, GAME_SCHEMA } from "@/modules/game/schemas/game.schema";
GamePlayValidatorService,
GamePlayMakerService,
GamePlayVoteService,
GamePlayAugmenterService,
GamePhaseService,
GameVictoryService,
GameRepository,
Expand Down
10 changes: 9 additions & 1 deletion src/modules/game/helpers/game.factory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { plainToInstance } from "class-transformer";

import { GameWithCurrentPlay } from "@/modules/game/types/game-with-current-play";
import { Game } from "@/modules/game/schemas/game.schema";

import { toJSON } from "@/shared/misc/helpers/object.helper";
import { PLAIN_TO_INSTANCE_DEFAULT_OPTIONS } from "@/shared/validation/constants/validation.constant";

function createGameWithCurrentGamePlay(gameWithCurrentPlay: GameWithCurrentPlay): GameWithCurrentPlay {
return plainToInstance(GameWithCurrentPlay, toJSON(gameWithCurrentPlay), PLAIN_TO_INSTANCE_DEFAULT_OPTIONS);
}

function createGame(game: Game): Game {
return plainToInstance(Game, toJSON(game), PLAIN_TO_INSTANCE_DEFAULT_OPTIONS);
}

export { createGame };
export {
createGameWithCurrentGamePlay,
createGame,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Injectable } from "@nestjs/common";

import { WEREWOLF_ROLES } from "@/modules/role/constants/role.constant";
import { GamePlayActions, GamePlayCauses } from "@/modules/game/enums/game-play.enum";
import { PlayerGroups } from "@/modules/game/enums/player.enum";
import { createGamePlay } from "@/modules/game/helpers/game-play/game-play.factory";
import { getLeftToEatByWerewolvesPlayers } from "@/modules/game/helpers/game.helper";
import type { GamePlay } from "@/modules/game/schemas/game-play/game-play.schema";
import type { Game } from "@/modules/game/schemas/game.schema";
import type { GamePlaySourceName } from "@/modules/game/types/game-play.type";
import { RoleNames } from "@/modules/role/enums/role.enum";

@Injectable()
export class GamePlayAugmenterService {
private readonly canBeSkippedPlayMethods: Partial<Record<GamePlaySourceName, (gamePlay: GamePlay, game: Game) => boolean>> = {
[PlayerGroups.CHARMED]: () => true,
[PlayerGroups.LOVERS]: () => true,
[PlayerGroups.SURVIVORS]: (gamePlay: GamePlay, game: Game) => this.canSurvivorsSkipGamePlay(gamePlay, game),
[RoleNames.BIG_BAD_WOLF]: (gamePlay: GamePlay, game: Game) => this.canBigBadWolfSkipGamePlay(game),
[RoleNames.FOX]: () => true,
[RoleNames.RAVEN]: () => true,
[RoleNames.SCAPEGOAT]: () => true,
[RoleNames.THIEF]: (gamePlay: GamePlay, game: Game) => this.canThiefSkipGamePlay(game),
[RoleNames.TWO_SISTERS]: () => true,
[RoleNames.THREE_BROTHERS]: () => true,
[RoleNames.WHITE_WEREWOLF]: () => true,
[RoleNames.WITCH]: () => true,
};

public setGamePlayCanBeSkipped(gamePlay: GamePlay, game: Game): GamePlay {
const clonedGamePlay = createGamePlay(gamePlay);
clonedGamePlay.canBeSkipped = this.canGamePlayBeSkipped(gamePlay, game);
return clonedGamePlay;
}

private canSurvivorsSkipGamePlay(gamePlay: GamePlay, game: Game): boolean {
const { canBeSkipped } = game.options.votes;
const isGamePlayVoteCauseAngelPresence = gamePlay.action === GamePlayActions.VOTE && gamePlay.cause === GamePlayCauses.ANGEL_PRESENCE;
if (gamePlay.action === GamePlayActions.ELECT_SHERIFF || isGamePlayVoteCauseAngelPresence) {
return false;
}
return canBeSkipped;
}

private canBigBadWolfSkipGamePlay(game: Game): boolean {
const leftToEatByWerewolvesPlayers = getLeftToEatByWerewolvesPlayers(game);
return leftToEatByWerewolvesPlayers.length === 0;
}

private canThiefSkipGamePlay(game: Game): boolean {
const { mustChooseBetweenWerewolves } = game.options.roles.thief;
if (game.additionalCards === undefined || game.additionalCards.length === 0) {
return true;
}
const areAllAdditionalCardsWerewolves = game.additionalCards.every(({ roleName }) => WEREWOLF_ROLES.find(role => role.name === roleName));
return !areAllAdditionalCardsWerewolves || !mustChooseBetweenWerewolves;
}

private canGamePlayBeSkipped(gamePlay: GamePlay, game: Game): boolean {
const canBeSkippedGamePlayMethod = this.canBeSkippedPlayMethods[gamePlay.source.name];
if (!canBeSkippedGamePlayMethod) {
return false;
}
return canBeSkippedGamePlayMethod(gamePlay, game);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common";

import type { GamePlaySourceName } from "@/modules/game/types/game-play.type";
import { OPTIONAL_TARGET_ACTIONS, REQUIRED_TARGET_ACTIONS, REQUIRED_VOTE_ACTIONS, STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS } from "@/modules/game/constants/game-play/game-play.constant";
import { VOTE_ACTIONS, STUTTERING_JUDGE_REQUEST_OPPORTUNITY_ACTIONS, TARGET_ACTIONS } from "@/modules/game/constants/game-play/game-play.constant";
import type { MakeGamePlayTargetWithRelationsDto } from "@/modules/game/dto/make-game-play/make-game-play-target/make-game-play-target-with-relations.dto";
import type { MakeGamePlayVoteWithRelationsDto } from "@/modules/game/dto/make-game-play/make-game-play-vote/make-game-play-vote-with-relations.dto";
import type { MakeGamePlayWithRelationsDto } from "@/modules/game/dto/make-game-play/make-game-play-with-relations.dto";
Expand Down Expand Up @@ -279,17 +279,19 @@ export class GamePlayValidatorService {
}

private async validateGamePlayTargetsWithRelationsDto(playTargets: MakeGamePlayTargetWithRelationsDto[] | undefined, game: GameWithCurrentPlay): Promise<void> {
const targetActions: GamePlayActions[] = [...TARGET_ACTIONS];
if (!targetActions.includes(game.currentPlay.action)) {
if (playTargets !== undefined && playTargets.length > 0) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.UNEXPECTED_TARGETS);
}
return;
}
if (playTargets === undefined || playTargets.length === 0) {
const requiredTargetActions: GamePlayActions[] = [...REQUIRED_TARGET_ACTIONS];
if (requiredTargetActions.includes(game.currentPlay.action)) {
if (game.currentPlay.canBeSkipped === false) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.REQUIRED_TARGETS);
}
return;
}
const expectedTargetActions: GamePlayActions[] = [...REQUIRED_TARGET_ACTIONS, ...OPTIONAL_TARGET_ACTIONS];
if (!expectedTargetActions.includes(game.currentPlay.action)) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.UNEXPECTED_TARGETS);
}
this.validateInfectedTargetsAndPotionUsage(playTargets, game);
await this.validateGamePlaySourceTargets(playTargets, game);
}
Expand All @@ -313,27 +315,21 @@ export class GamePlayValidatorService {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.SAME_SOURCE_AND_TARGET_VOTE);
}
}

private validateUnsetGamePlayVotesWithRelationsDto(game: GameWithCurrentPlay): void {
const { action: currentPlayAction, cause: currentPlayCause } = game.currentPlay;
const { canBeSkipped: canVotesBeSkipped } = game.options.votes;
const isCurrentPlayVoteCauseOfAngelPresence = currentPlayAction === GamePlayActions.VOTE && currentPlayCause === GamePlayCauses.ANGEL_PRESENCE;
const isCurrentPlayVoteInevitable = currentPlayAction === GamePlayActions.ELECT_SHERIFF || isCurrentPlayVoteCauseOfAngelPresence;
const canSomePlayerVote = game.players.some(player => player.isAlive && !doesPlayerHaveActiveAttributeWithName(player, PlayerAttributeNames.CANT_VOTE, game));
const requiredVoteActions: GamePlayActions[] = [...REQUIRED_VOTE_ACTIONS];
if (canSomePlayerVote && (!canVotesBeSkipped && requiredVoteActions.includes(currentPlayAction) || canVotesBeSkipped && isCurrentPlayVoteInevitable)) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.REQUIRED_VOTES);
}
}

private async validateGamePlayVotesWithRelationsDto(playVotes: MakeGamePlayVoteWithRelationsDto[] | undefined, game: GameWithCurrentPlay): Promise<void> {
if (!playVotes || playVotes.length === 0) {
this.validateUnsetGamePlayVotesWithRelationsDto(game);
const { currentPlay } = game;
const voteActions: GamePlayActions[] = [...VOTE_ACTIONS];
if (!voteActions.includes(currentPlay.action)) {
if (playVotes !== undefined && playVotes.length > 0) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.UNEXPECTED_VOTES);
}
return;
}
const requiredVoteActions: GamePlayActions[] = [...REQUIRED_VOTE_ACTIONS];
if (!requiredVoteActions.includes(game.currentPlay.action)) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.UNEXPECTED_VOTES);
if (playVotes === undefined || playVotes.length === 0) {
if (game.currentPlay.canBeSkipped === false) {
throw new BadGamePlayPayloadException(BadGamePlayPayloadReasons.REQUIRED_VOTES);
}
return;
}
this.validateGamePlayVotesWithRelationsDtoSourceAndTarget(playVotes, game);
if (game.currentPlay.cause === GamePlayCauses.PREVIOUS_VOTES_WERE_IN_TIES) {
Expand Down
20 changes: 16 additions & 4 deletions src/modules/game/providers/services/game-play/game-play.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Injectable } from "@nestjs/common";

import type { GameWithCurrentPlay } from "@/modules/game/types/game-with-current-play";
import { GamePlayAugmenterService } from "@/modules/game/providers/services/game-play/game-play-augmenter.service";
import { ON_FIRST_AND_LATER_NIGHTS_GAME_PLAYS_PRIORITY_LIST, ON_NIGHTS_GAME_PLAYS_PRIORITY_LIST } from "@/modules/game/constants/game.constant";
import { CreateGamePlayerDto } from "@/modules/game/dto/create-game/create-game-player/create-game-player.dto";
import { CreateGameDto } from "@/modules/game/dto/create-game/create-game.dto";
Expand All @@ -8,7 +10,7 @@ import { GamePhases } from "@/modules/game/enums/game.enum";
import { PlayerAttributeNames, PlayerGroups } from "@/modules/game/enums/player.enum";
import { createGamePlay, createGamePlaySurvivorsElectSheriff, createGamePlaySurvivorsVote } from "@/modules/game/helpers/game-play/game-play.factory";
import { areGamePlaysEqual, canSurvivorsVote, findPlayPriorityIndex } from "@/modules/game/helpers/game-play/game-play.helper";
import { createGame } from "@/modules/game/helpers/game.factory";
import { createGame, createGameWithCurrentGamePlay } from "@/modules/game/helpers/game.factory";
import { areAllWerewolvesAlive, getExpectedPlayersToPlay, getGroupOfPlayers, getLeftToEatByWerewolvesPlayers, getLeftToEatByWhiteWerewolfPlayers, getPlayerDtoWithRole, getPlayersWithActiveAttributeName, getPlayersWithCurrentRole, getPlayerWithActiveAttributeName, getPlayerWithCurrentRole, isGameSourceGroup, isGameSourceRole } from "@/modules/game/helpers/game.helper";
import { canPiedPiperCharm, isPlayerAliveAndPowerful, isPlayerPowerful } from "@/modules/game/helpers/player/player.helper";
import { GameHistoryRecordService } from "@/modules/game/providers/services/game-history/game-history-record.service";
Expand All @@ -22,7 +24,10 @@ import { createNoGamePlayPriorityUnexpectedException } from "@/shared/exception/

@Injectable()
export class GamePlayService {
public constructor(private readonly gameHistoryRecordService: GameHistoryRecordService) {}
public constructor(
private readonly gamePlayAugmenterService: GamePlayAugmenterService,
private readonly gameHistoryRecordService: GameHistoryRecordService,
) {}

public async refreshUpcomingPlays(game: Game): Promise<Game> {
let clonedGame = createGame(game);
Expand All @@ -34,14 +39,21 @@ export class GamePlayService {
}

public proceedToNextGamePlay(game: Game): Game {
const clonedGame = createGame(game);
let clonedGame = createGame(game);
if (!clonedGame.upcomingPlays.length) {
clonedGame.currentPlay = null;
return clonedGame;
}
clonedGame.currentPlay = clonedGame.upcomingPlays[0];
clonedGame.currentPlay.source.players = getExpectedPlayersToPlay(clonedGame);
clonedGame.upcomingPlays.shift();
clonedGame = this.augmentCurrentGamePlay(clonedGame as GameWithCurrentPlay);
return clonedGame;
}

public augmentCurrentGamePlay(game: GameWithCurrentPlay): GameWithCurrentPlay {
const clonedGame = createGameWithCurrentGamePlay(game);
clonedGame.currentPlay = this.gamePlayAugmenterService.setGamePlayCanBeSkipped(clonedGame.currentPlay, clonedGame);
clonedGame.currentPlay.source.players = getExpectedPlayersToPlay(clonedGame);
return clonedGame;
}

Expand Down
5 changes: 2 additions & 3 deletions src/modules/game/providers/services/game.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { isGamePhaseOver } from "@/modules/game/helpers/game-phase/game-phase.he
import { createMakeGamePlayDtoWithRelations } from "@/modules/game/helpers/game-play/game-play.helper";
import { GameVictoryService } from "@/modules/game/providers/services/game-victory/game-victory.service";
import { createGame as createGameFromFactory } from "@/modules/game/helpers/game.factory";
import { getExpectedPlayersToPlay } from "@/modules/game/helpers/game.helper";
import { GameRepository } from "@/modules/game/providers/repositories/game.repository";
import { GameHistoryRecordService } from "@/modules/game/providers/services/game-history/game-history-record.service";
import { GamePhaseService } from "@/modules/game/providers/services/game-phase/game-phase.service";
Expand Down Expand Up @@ -55,8 +54,8 @@ export class GameService {
currentPlay,
upcomingPlays,
});
const createdGame = await this.gameRepository.create(gameToCreate) as GameWithCurrentPlay;
createdGame.currentPlay.source.players = getExpectedPlayersToPlay(createdGame);
let createdGame = await this.gameRepository.create(gameToCreate) as GameWithCurrentPlay;
createdGame = this.gamePlayService.augmentCurrentGamePlay(createdGame);
return this.updateGame(createdGame._id, createdGame);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Feature: 🗳️ Vote Game Play
| JB |
| Thomas |
And the game's current play occurrence should be on-days
And the game's current play can be skipped

When the survivors vote with the following votes
| voter | target |
Expand All @@ -47,6 +48,7 @@ Feature: 🗳️ Vote Game Play
| Antoine |
| JB |
| Thomas |
And the game's current play can be skipped

When the survivors vote with the following votes
| voter | target |
Expand All @@ -59,6 +61,7 @@ Feature: 🗳️ Vote Game Play
| JB |
| Thomas |
And the game's current play occurrence should be consequential
And the game's current play can be skipped

When the survivors vote with the following votes
| voter | target |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Feature: 🎖️ Sheriff player attribute
| JB |
| Babou |
And the game's current play occurrence should be anytime
And the game's current play can not be skipped

When the survivors elect sheriff with the following votes
| voter | target |
Expand All @@ -43,6 +44,7 @@ Feature: 🎖️ Sheriff player attribute
And the game's current play should be played by the following players
| name |
| Olivia |
And the game's current play can not be skipped

When the sheriff breaks the tie in votes by choosing the player named Thomas
Then the player named JB should be alive
Expand Down Expand Up @@ -143,6 +145,7 @@ Feature: 🎖️ Sheriff player attribute
When the werewolves eat the player named Babou
Then the player named Babou should be murdered by werewolves from eaten
And the game's current play should be survivors to elect-sheriff
And the game's current play can not be skipped

Scenario: 🎖️ Sheriff can be elected on second night instead of first night with right option

Expand Down Expand Up @@ -188,6 +191,7 @@ Feature: 🎖️ Sheriff player attribute
| name |
| Thomas |
And the game's current play occurrence should be consequential
And the game's current play can not be skipped

When the sheriff delegates his role to the player named Olivia
Then the player named Olivia should have the active sheriff from sheriff attribute
Expand Down
1 change: 1 addition & 0 deletions tests/acceptance/features/game/features/role/angel.feature
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Feature: 👼 Angel role
| JB |
| Thomas |
And the game's current play occurrence should be first-night-only
And the game's current play can not be skipped

When the survivors vote with the following votes
| source | vote |
Expand Down

0 comments on commit a5908ef

Please sign in to comment.