Skip to content

Commit

Permalink
feat(game-options): skip turn if no target option (#367)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Aug 2, 2023
1 parent 3d1d57d commit c3d7e5c
Show file tree
Hide file tree
Showing 16 changed files with 37,678 additions and 35,431 deletions.
2 changes: 1 addition & 1 deletion config/eslint/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const MAX_LENGTH_DEFAULT_CONFIG = {
ignoreTemplateLiterals: true,
ignorePattern: "^import\\s.+\\sfrom\\s.+;$",
};
const BOOLEAN_PREFIXES = ["is", "was", "are", "were", "should", "has", "can", "does", "did", "must"];
const BOOLEAN_PREFIXES = ["is", "was", "are", "were", "should", "has", "can", "does", "do", "did", "must"];
const NAMING_CONVENTION_DEFAULT_CONFIG = [
{
selector: ["enum", "enumMember"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const defaultGameOptions: GameOptions = Object.freeze({
composition: { isHidden: false },
roles: {
areRevealedOnDeath: true,
doSkipCallIfNoTarget: false,
sheriff: {
isEnabled: true,
electedAt: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import type { ApiPropertyOptions } from "@nestjs/swagger";
import type { RolesGameOptions } from "../../../schemas/game-options/roles-game-options/roles-game-options.schema";
import { defaultGameOptions } from "../game-options.constant";

const rolesGameOptionsFieldsSpecs = Object.freeze({ areRevealedOnDeath: { default: defaultGameOptions.roles.areRevealedOnDeath } });
const rolesGameOptionsFieldsSpecs = Object.freeze({
areRevealedOnDeath: { default: defaultGameOptions.roles.areRevealedOnDeath },
doSkipCallIfNoTarget: { default: defaultGameOptions.roles.doSkipCallIfNoTarget },
});

const rolesGameOptionsApiProperties: Record<keyof RolesGameOptions, ApiPropertyOptions> = Object.freeze({
areRevealedOnDeath: {
description: "If set to `true`, player's role is revealed when he's dead",
...rolesGameOptionsFieldsSpecs.areRevealedOnDeath,
},
doSkipCallIfNoTarget: {
description: "If set to `true`, player's role won't be called if there is no target available for him",
...rolesGameOptionsFieldsSpecs.doSkipCallIfNoTarget,
},
sheriff: { description: "Game `sheriff` role's options." },
bigBadWolf: { description: "Game `big bad wolf` role's options." },
whiteWerewolf: { description: "Game `white werewolf` role's options." },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ import { CreateWhiteWerewolfGameOptionsDto } from "./create-white-werewolf-game-
import { CreateWildChildGameOptionsDto } from "./create-wild-child-game-options.dto";

class CreateRolesGameOptionsDto {
@ApiProperty({
...rolesGameOptionsApiProperties.doSkipCallIfNoTarget,
required: false,
})
@IsOptional()
@IsBoolean()
public doSkipCallIfNoTarget: boolean = rolesGameOptionsFieldsSpecs.doSkipCallIfNoTarget.default;

@ApiProperty({
...rolesGameOptionsApiProperties.areRevealedOnDeath,
required: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ export class GamePhaseService {
return clonedGame;
}

public switchPhaseAndAppendGamePhaseUpcomingPlays(game: Game): Game {
public async switchPhaseAndAppendGamePhaseUpcomingPlays(game: Game): Promise<Game> {
const clonedGame = cloneDeep(game);
clonedGame.phase = clonedGame.phase === GAME_PHASES.NIGHT ? GAME_PHASES.DAY : GAME_PHASES.NIGHT;
const phaseUpcomingPlays = clonedGame.phase === GAME_PHASES.NIGHT ? this.gamePlayService.getUpcomingNightPlays(clonedGame) : this.gamePlayService.getUpcomingDayPlays();
const upcomingNightPlays = await this.gamePlayService.getUpcomingNightPlays(clonedGame);
const upcomingDayPlays = this.gamePlayService.getUpcomingDayPlays();
const phaseUpcomingPlays = clonedGame.phase === GAME_PHASES.NIGHT ? upcomingNightPlays : upcomingDayPlays;
clonedGame.upcomingPlays = [...clonedGame.upcomingPlays, ...phaseUpcomingPlays];
return clonedGame;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import type { MakeGamePlayVoteWithRelationsDto } from "../../../dto/make-game-pl
import type { MakeGamePlayWithRelationsDto } from "../../../dto/make-game-play/make-game-play-with-relations.dto";
import { GAME_PLAY_ACTIONS, GAME_PLAY_CAUSES, WITCH_POTIONS } from "../../../enums/game-play.enum";
import { PLAYER_ATTRIBUTE_NAMES, PLAYER_GROUPS } from "../../../enums/player.enum";
import { getLeftToCharmByPiedPiperPlayers, getLeftToEatByWerewolvesPlayers, getLeftToEatByWhiteWerewolfPlayers, getPlayerWithCurrentRole } from "../../../helpers/game.helper";
import { doesPlayerHaveAttribute, isPlayerAliveAndPowerful, isPlayerOnVillagersSide, isPlayerOnWerewolvesSide } from "../../../helpers/player/player.helper";
import { getLeftToCharmByPiedPiperPlayers, getLeftToEatByWerewolvesPlayers, getLeftToEatByWhiteWerewolfPlayers, getPlayerWithCurrentRole, getPlayerWithId } from "../../../helpers/game.helper";
import { doesPlayerHaveAttribute, isPlayerAliveAndPowerful } from "../../../helpers/player/player.helper";
import type { Game } from "../../../schemas/game.schema";
import type { GameWithCurrentPlay } from "../../../types/game-with-current-play";
import type { GameSource } from "../../../types/game.type";
Expand Down Expand Up @@ -112,16 +112,15 @@ export class GamePlayValidatorService {
return;
}
const targetedPlayer = playTargets[0].player;
if (game.currentPlay.source === PLAYER_GROUPS.WEREWOLVES && (!targetedPlayer.isAlive || !isPlayerOnVillagersSide(targetedPlayer))) {
const pureWolvesAvailableTargets = getLeftToEatByWerewolvesPlayers(game.players);
if (game.currentPlay.source === PLAYER_GROUPS.WEREWOLVES && !getPlayerWithId(pureWolvesAvailableTargets, targetedPlayer._id)) {
throw new BadGamePlayPayloadException(BAD_GAME_PLAY_PAYLOAD_REASONS.BAD_WEREWOLVES_TARGET);
}
if (game.currentPlay.source === ROLE_NAMES.BIG_BAD_WOLF &&
(!targetedPlayer.isAlive || !isPlayerOnVillagersSide(targetedPlayer) || doesPlayerHaveAttribute(targetedPlayer, PLAYER_ATTRIBUTE_NAMES.EATEN))) {
if (game.currentPlay.source === ROLE_NAMES.BIG_BAD_WOLF && !getPlayerWithId(pureWolvesAvailableTargets, targetedPlayer._id)) {
throw new BadGamePlayPayloadException(BAD_GAME_PLAY_PAYLOAD_REASONS.BAD_BIG_BAD_WOLF_TARGET);
}
const whiteWerewolfPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.WHITE_WEREWOLF);
if (game.currentPlay.source === ROLE_NAMES.WHITE_WEREWOLF && (!targetedPlayer.isAlive || !isPlayerOnWerewolvesSide(targetedPlayer) ||
whiteWerewolfPlayer?._id === targetedPlayer._id)) {
const whiteWerewolfAvailableTargets = getLeftToEatByWhiteWerewolfPlayers(game.players);
if (game.currentPlay.source === ROLE_NAMES.WHITE_WEREWOLF && !getPlayerWithId(whiteWerewolfAvailableTargets, targetedPlayer._id)) {
throw new BadGamePlayPayloadException(BAD_GAME_PLAY_PAYLOAD_REASONS.BAD_WHITE_WEREWOLF_TARGET);
}
await this.validateGamePlayInfectedTargets(playTargets, game);
Expand Down
68 changes: 47 additions & 21 deletions src/modules/game/providers/services/game-play/game-play.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@ import { ROLE_NAMES } from "../../../../role/enums/role.enum";
import { gamePlaysNightOrder } from "../../../constants/game.constant";
import { CreateGamePlayerDto } from "../../../dto/create-game/create-game-player/create-game-player.dto";
import { CreateGameDto } from "../../../dto/create-game/create-game.dto";
import { GAME_PLAY_CAUSES } from "../../../enums/game-play.enum";
import { GAME_PLAY_CAUSES, WITCH_POTIONS } from "../../../enums/game-play.enum";
import type { GAME_PHASES } from "../../../enums/game.enum";
import { PLAYER_ATTRIBUTE_NAMES, PLAYER_GROUPS } from "../../../enums/player.enum";
import { createGamePlay, createGamePlayAllElectSheriff, createGamePlayAllVote } from "../../../helpers/game-play/game-play.factory";
import { areAllWerewolvesAlive, getGroupOfPlayers, getPlayerDtoWithRole, getPlayersWithAttribute, getPlayersWithCurrentRole, getPlayerWithAttribute, getPlayerWithCurrentRole, isGameSourceGroup, isGameSourceRole } from "../../../helpers/game.helper";
import { areAllWerewolvesAlive, getGroupOfPlayers, getLeftToEatByWerewolvesPlayers, getLeftToEatByWhiteWerewolfPlayers, getPlayerDtoWithRole, getPlayersWithAttribute, getPlayersWithCurrentRole, getPlayerWithAttribute, getPlayerWithCurrentRole, isGameSourceGroup, isGameSourceRole } from "../../../helpers/game.helper";
import { canPiedPiperCharm, isPlayerAliveAndPowerful, isPlayerPowerful } from "../../../helpers/player/player.helper";
import type { SheriffGameOptions } from "../../../schemas/game-options/roles-game-options/sheriff-game-options/sheriff-game-options.schema";
import type { GamePlay } from "../../../schemas/game-play.schema";
import type { Game } from "../../../schemas/game.schema";
import { GameHistoryRecordService } from "../game-history/game-history-record.service";

@Injectable()
export class GamePlayService {
public removeObsoleteUpcomingPlays(game: Game): Game {
public constructor(private readonly gameHistoryRecordService: GameHistoryRecordService) {}

public async removeObsoleteUpcomingPlays(game: Game): Promise<Game> {
const clonedGame = cloneDeep(game);
clonedGame.upcomingPlays = clonedGame.upcomingPlays.filter(upcomingPlay => this.isGamePlaySuitableForCurrentPhase(clonedGame, upcomingPlay));
const validUpcomingPlays: GamePlay[] = [];
for (const upcomingPlay of clonedGame.upcomingPlays) {
if (await this.isGamePlaySuitableForCurrentPhase(clonedGame, upcomingPlay)) {
validUpcomingPlays.push(upcomingPlay);
}
}
clonedGame.upcomingPlays = validUpcomingPlays;
return clonedGame;
}

Expand All @@ -37,17 +46,17 @@ export class GamePlayService {
return [createGamePlayAllVote()];
}

public getUpcomingNightPlays(game: CreateGameDto | Game): GamePlay[] {
public async getUpcomingNightPlays(game: CreateGameDto | Game): Promise<GamePlay[]> {
const isFirstNight = game.turn === 1;
const eligibleNightPlays = gamePlaysNightOrder.filter(play => isFirstNight || play.isFirstNightOnly !== true);
const isSheriffElectionTime = this.isSheriffElectionTime(game.options.roles.sheriff, game.turn, game.phase);
const upcomingNightPlays: GamePlay[] = isSheriffElectionTime ? [createGamePlayAllElectSheriff()] : [];
return eligibleNightPlays.reduce((acc: GamePlay[], gamePlay) => {
if (this.isGamePlaySuitableForCurrentPhase(game, gamePlay)) {
return [...acc, createGamePlay(gamePlay)];
for (const eligibleNightPlay of eligibleNightPlays) {
if (await this.isGamePlaySuitableForCurrentPhase(game, eligibleNightPlay)) {
upcomingNightPlays.push(createGamePlay(eligibleNightPlay));
}
return acc;
}, upcomingNightPlays);
}
return upcomingNightPlays;
}

private isSheriffElectionTime(sheriffGameOptions: SheriffGameOptions, currentTurn: number, currentPhase: GAME_PHASES): boolean {
Expand Down Expand Up @@ -90,14 +99,27 @@ export class GamePlayService {
return specificGroupMethods[source](game, gamePlay);
}

private async isWitchGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): Promise<boolean> {
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.WITCH);
}
const hasWitchUsedLifePotion = (await this.gameHistoryRecordService.getGameHistoryWitchUsesSpecificPotionRecords(game._id, WITCH_POTIONS.LIFE)).length > 0;
const hasWitchUsedDeathPotion = (await this.gameHistoryRecordService.getGameHistoryWitchUsesSpecificPotionRecords(game._id, WITCH_POTIONS.DEATH)).length > 0;
const { doSkipCallIfNoTarget } = game.options.roles;
const witchPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.WITCH);
return !!witchPlayer && isPlayerAliveAndPowerful(witchPlayer) && (!doSkipCallIfNoTarget || !hasWitchUsedLifePotion || !hasWitchUsedDeathPotion);
}

private isWhiteWerewolfGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
const { wakingUpInterval } = game.options.roles.whiteWerewolf;
const shouldWhiteWerewolfBeCalled = wakingUpInterval > 0;
if (game instanceof CreateGameDto) {
return shouldWhiteWerewolfBeCalled && !!getPlayerDtoWithRole(game.players, ROLE_NAMES.WHITE_WEREWOLF);
}
const { doSkipCallIfNoTarget } = game.options.roles;
const availableTargets = getLeftToEatByWhiteWerewolfPlayers(game.players);
const whiteWerewolfPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.WHITE_WEREWOLF);
return shouldWhiteWerewolfBeCalled && !!whiteWerewolfPlayer && isPlayerAliveAndPowerful(whiteWerewolfPlayer);
return shouldWhiteWerewolfBeCalled && !!whiteWerewolfPlayer && isPlayerAliveAndPowerful(whiteWerewolfPlayer) && (!doSkipCallIfNoTarget || !!availableTargets.length);
}

private isPiedPiperGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
Expand All @@ -113,9 +135,12 @@ export class GamePlayService {
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.BIG_BAD_WOLF);
}
const { doSkipCallIfNoTarget } = game.options.roles;
const availableTargets = getLeftToEatByWerewolvesPlayers(game.players);
const { isPowerlessIfWerewolfDies } = game.options.roles.bigBadWolf;
const bigBadWolfPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.BIG_BAD_WOLF);
return !!bigBadWolfPlayer && isPlayerAliveAndPowerful(bigBadWolfPlayer) && (!isPowerlessIfWerewolfDies || areAllWerewolvesAlive(game.players));
return !!bigBadWolfPlayer && isPlayerAliveAndPowerful(bigBadWolfPlayer) &&
(!isPowerlessIfWerewolfDies || areAllWerewolvesAlive(game.players) && (!doSkipCallIfNoTarget || !!availableTargets.length));
}

private isThreeBrothersGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
Expand All @@ -139,23 +164,24 @@ export class GamePlayService {
return shouldTwoSistersBeCalled && twoSistersPlayers.length > 0 && twoSistersPlayers.every(sister => sister.isAlive);
}

private isRoleGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): boolean {
private async isRoleGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): Promise<boolean> {
const source = gamePlay.source as ROLE_NAMES;
const player = game instanceof CreateGameDto ? getPlayerDtoWithRole(game.players, source) : getPlayerWithCurrentRole(game.players, source);
if (!player) {
return false;
}
const specificRoleMethods: Partial<Record<ROLE_NAMES, (game: CreateGameDto | Game, gamePlay?: GamePlay) => boolean>> = {
[ROLE_NAMES.TWO_SISTERS]: this.isTwoSistersGamePlaySuitableForCurrentPhase,
[ROLE_NAMES.THREE_BROTHERS]: this.isThreeBrothersGamePlaySuitableForCurrentPhase,
[ROLE_NAMES.BIG_BAD_WOLF]: this.isBigBadWolfGamePlaySuitableForCurrentPhase,
[ROLE_NAMES.PIED_PIPER]: this.isPiedPiperGamePlaySuitableForCurrentPhase,
[ROLE_NAMES.WHITE_WEREWOLF]: this.isWhiteWerewolfGamePlaySuitableForCurrentPhase,
const specificRoleMethods: Partial<Record<ROLE_NAMES, () => Promise<boolean> | boolean>> = {
[ROLE_NAMES.TWO_SISTERS]: () => this.isTwoSistersGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.THREE_BROTHERS]: () => this.isThreeBrothersGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.BIG_BAD_WOLF]: () => this.isBigBadWolfGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.PIED_PIPER]: () => this.isPiedPiperGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.WHITE_WEREWOLF]: () => this.isWhiteWerewolfGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.WITCH]: async() => this.isWitchGamePlaySuitableForCurrentPhase(game),
[ROLE_NAMES.HUNTER]: () => player instanceof CreateGamePlayerDto || isPlayerPowerful(player),
[ROLE_NAMES.SCAPEGOAT]: () => player instanceof CreateGamePlayerDto || isPlayerPowerful(player),
};
if (specificRoleMethods[source] !== undefined) {
return specificRoleMethods[source]?.(game, gamePlay) === true;
return await specificRoleMethods[source]?.() === true;
}
return player instanceof CreateGamePlayerDto || isPlayerAliveAndPowerful(player);
}
Expand All @@ -171,7 +197,7 @@ export class GamePlayService {
return !!sheriffPlayer;
}

private isGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): boolean {
private async isGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): Promise<boolean> {
if (isGameSourceRole(gamePlay.source)) {
return this.isRoleGamePlaySuitableForCurrentPhase(game, gamePlay);
} else if (isGameSourceGroup(gamePlay.source)) {
Expand Down
6 changes: 3 additions & 3 deletions src/modules/game/providers/services/game.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class GameService {
}

public async createGame(game: CreateGameDto): Promise<Game> {
const upcomingPlays = this.gamePlayService.getUpcomingNightPlays(game);
const upcomingPlays = await this.gamePlayService.getUpcomingNightPlays(game);
if (!upcomingPlays.length) {
throw createCantGenerateGamePlaysUnexpectedException("createGame");
}
Expand Down Expand Up @@ -67,7 +67,7 @@ export class GameService {
const play = createMakeGamePlayDtoWithRelations(makeGamePlayDto, clonedGame);
await this.gamePlayValidatorService.validateGamePlayWithRelationsDto(play, clonedGame);
clonedGame = await this.gamePlayMakerService.makeGamePlay(play, clonedGame);
clonedGame = this.gamePlayService.removeObsoleteUpcomingPlays(clonedGame);
clonedGame = await this.gamePlayService.removeObsoleteUpcomingPlays(clonedGame);
clonedGame = this.gamePlayService.proceedToNextGamePlay(clonedGame);
clonedGame.tick++;
if (isGamePhaseOver(clonedGame)) {
Expand All @@ -85,7 +85,7 @@ export class GameService {
let clonedGame = cloneDeep(game);
clonedGame = await this.gamePhaseService.applyEndingGamePhasePlayerAttributesOutcomesToPlayers(clonedGame);
clonedGame = this.playerAttributeService.decreaseRemainingPhasesAndRemoveObsoletePlayerAttributes(clonedGame);
clonedGame = this.gamePhaseService.switchPhaseAndAppendGamePhaseUpcomingPlays(clonedGame);
clonedGame = await this.gamePhaseService.switchPhaseAndAppendGamePhaseUpcomingPlays(clonedGame);
return this.gamePlayService.proceedToNextGamePlay(clonedGame);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import { WildChildGameOptions, WildChildGameOptionsSchema } from "./wild-child-g
_id: false,
})
class RolesGameOptions {
@ApiProperty(rolesGameOptionsApiProperties.doSkipCallIfNoTarget)
@Prop({ default: rolesGameOptionsFieldsSpecs.doSkipCallIfNoTarget.default })
@Expose()
public doSkipCallIfNoTarget: boolean;

@ApiProperty(rolesGameOptionsApiProperties.areRevealedOnDeath)
@Prop({ default: rolesGameOptionsFieldsSpecs.areRevealedOnDeath.default })
@Expose()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ describe("Game Controller", () => {
const options: Partial<GameOptions> = {
roles: {
areRevealedOnDeath: false,
doSkipCallIfNoTarget: true,
sheriff: {
isEnabled: false,
electedAt: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function createFakeCreateSheriffGameOptionsDto(sheriffGameOptions: Partial<Creat

function createFakeRolesGameOptionsDto(rolesGameOptions: Partial<CreateRolesGameOptionsDto> = {}, override: object = {}): CreateRolesGameOptionsDto {
return plainToInstance(CreateRolesGameOptionsDto, {
doSkipCallIfNoTarget: rolesGameOptions.doSkipCallIfNoTarget ?? faker.datatype.boolean(),
areRevealedOnDeath: rolesGameOptions.areRevealedOnDeath ?? faker.datatype.boolean(),
sheriff: createFakeCreateSheriffGameOptionsDto(rolesGameOptions.sheriff),
bigBadWolf: createFakeCreateBigBadWolfGameOptionsDto(rolesGameOptions.bigBadWolf),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ function createFakeSheriffGameOptions(sheriffGameOptions: Partial<SheriffGameOpt

function createFakeRolesGameOptions(rolesGameOptions: Partial<RolesGameOptions> = {}, override: object = {}): RolesGameOptions {
return plainToInstance(RolesGameOptions, {
doSkipCallIfNoTarget: rolesGameOptions.doSkipCallIfNoTarget ?? faker.datatype.boolean(),
areRevealedOnDeath: rolesGameOptions.areRevealedOnDeath ?? faker.datatype.boolean(),
sheriff: createFakeSheriffGameOptions(rolesGameOptions.sheriff),
bigBadWolf: createFakeBigBadWolfGameOptions(rolesGameOptions.bigBadWolf),
Expand Down

0 comments on commit c3d7e5c

Please sign in to comment.