Skip to content

Commit

Permalink
feat(game-plays): remove obsolete upcoming plays (#277)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinezanardi committed Jun 24, 2023
1 parent 4d7bfbb commit e18fbd6
Show file tree
Hide file tree
Showing 21 changed files with 36,843 additions and 33,804 deletions.
3 changes: 2 additions & 1 deletion src/modules/game/constants/game.constant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ApiPropertyOptions } from "@nestjs/swagger";
import { ROLE_NAMES } from "../../role/enums/role.enum";
import { GAME_PLAY_ACTIONS } from "../enums/game-play.enum";
import { GAME_PLAY_ACTIONS, GAME_PLAY_CAUSES } from "../enums/game-play.enum";
import { GAME_PHASES, GAME_STATUSES } from "../enums/game.enum";
import { PLAYER_ATTRIBUTE_NAMES, PLAYER_GROUPS } from "../enums/player.enum";
import type { GamePlay } from "../schemas/game-play.schema";
Expand Down Expand Up @@ -58,6 +58,7 @@ const gamePlaysNightOrder: Readonly<(GamePlay & { isFirstNightOnly?: boolean })[
{
source: PLAYER_GROUPS.ALL,
action: GAME_PLAY_ACTIONS.VOTE,
cause: GAME_PLAY_CAUSES.ANGEL_PRESENCE,
isFirstNightOnly: true,
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/modules/game/enums/game-play.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ enum GAME_PLAY_ACTIONS {

enum GAME_PLAY_CAUSES {
STUTTERING_JUDGE_REQUEST = "stuttering-judge-request",
PREVIOUS_VOTES_WERE_IN_TIES = "previous-votes-were-in-ties",
ANGEL_PRESENCE = "angel-presence",
}

enum WITCH_POTIONS {
Expand Down
2 changes: 1 addition & 1 deletion src/modules/game/helpers/game-play/game-play.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ function createGamePlayWerewolvesEat(gamePlay: Partial<GamePlay> = {}): GamePlay
}

function createGamePlay(gamePlay: GamePlay): GamePlay {
return plainToInstance(GamePlay, gamePlay, plainToInstanceDefaultOptions);
return plainToInstance(GamePlay, gamePlay, { ...plainToInstanceDefaultOptions, excludeExtraneousValues: true });
}

export {
Expand Down
7 changes: 4 additions & 3 deletions src/modules/game/helpers/game-victory/game-victory.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ function doesAngelWin(game: Game): boolean {

function isGameOver(game: Game): boolean {
const { players, upcomingPlays } = game;
const isShootPlayActionIncoming = !!upcomingPlays.find(({ action, source }) => action === GAME_PLAY_ACTIONS.SHOOT && source === ROLE_NAMES.HUNTER);
return !isShootPlayActionIncoming && (areAllPlayersDead(players) || doWerewolvesWin(players) || doVillagersWin(players) ||
doLoversWin(players) || doesWhiteWerewolfWin(players) || doesPiedPiperWin(game) || doesAngelWin(game));
const isShootPlayIncoming = !!upcomingPlays.find(({ action, source }) => action === GAME_PLAY_ACTIONS.SHOOT && source === ROLE_NAMES.HUNTER);
return areAllPlayersDead(players) || game.currentPlay.action !== GAME_PLAY_ACTIONS.SHOOT && !isShootPlayIncoming &&
(doWerewolvesWin(players) || doVillagersWin(players) ||
doLoversWin(players) || doesWhiteWerewolfWin(players) || doesPiedPiperWin(game) || doesAngelWin(game));
}

function generateGameVictoryData(game: Game): GameVictory | undefined {
Expand Down
7 changes: 6 additions & 1 deletion src/modules/game/helpers/player/player.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ function canPiedPiperCharm(piedPiperPlayer: Player, isPowerlessIfInfected: boole
return isPlayerAliveAndPowerful(piedPiperPlayer) && (!isPowerlessIfInfected || piedPiperPlayer.side.current === ROLE_SIDES.VILLAGERS);
}

function isPlayerPowerful(player: Player): boolean {
return !doesPlayerHaveAttribute(player, PLAYER_ATTRIBUTE_NAMES.POWERLESS);
}

function isPlayerAliveAndPowerful(player: Player): boolean {
return player.isAlive && !doesPlayerHaveAttribute(player, PLAYER_ATTRIBUTE_NAMES.POWERLESS);
return player.isAlive && isPlayerPowerful(player);
}

function isPlayerOnWerewolvesSide(player: Player): boolean {
Expand All @@ -25,6 +29,7 @@ function isPlayerOnVillagersSide(player: Player): boolean {
export {
doesPlayerHaveAttribute,
canPiedPiperCharm,
isPlayerPowerful,
isPlayerAliveAndPowerful,
isPlayerOnWerewolvesSide,
isPlayerOnVillagersSide,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export class GamePlaysMakerService {
}
const previousGameHistoryRecord = await this.gameHistoryRecordService.getPreviousGameHistoryRecord(clonedGame._id);
if (previousGameHistoryRecord?.play.votingResult !== GAME_HISTORY_RECORD_VOTING_RESULTS.TIE) {
const gamePlayAllVote = createGamePlayAllVote();
const gamePlayAllVote = createGamePlayAllVote({ cause: GAME_PLAY_CAUSES.PREVIOUS_VOTES_WERE_IN_TIES });
return prependUpcomingPlayInGame(gamePlayAllVote, clonedGame);
}
return clonedGame;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import { Injectable } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { cloneDeep } from "lodash";
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 type { GAME_PHASES } from "../../../enums/game.enum";
import { PLAYER_GROUPS } from "../../../enums/player.enum";
import { createGamePlayAllElectSheriff } from "../../../helpers/game-play/game-play.factory";
import { areAllWerewolvesAlive, getGroupOfPlayers, getPlayerDtoWithRole, getPlayersWithCurrentRole, getPlayerWithCurrentRole, isGameSourceGroup, isGameSourceRole } from "../../../helpers/game.helper";
import { canPiedPiperCharm, isPlayerAliveAndPowerful } from "../../../helpers/player/player.helper";
import { PLAYER_ATTRIBUTE_NAMES, PLAYER_GROUPS } from "../../../enums/player.enum";
import { createGamePlay, createGamePlayAllElectSheriff } from "../../../helpers/game-play/game-play.factory";
import { areAllWerewolvesAlive, getGroupOfPlayers, 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 { GamePlay } from "../../../schemas/game-play.schema";
import type { GamePlay } from "../../../schemas/game-play.schema";
import type { Game } from "../../../schemas/game.schema";
import type { GameSource } from "../../../types/game.type";

@Injectable()
export class GamePlaysManagerService {
public removeObsoleteUpcomingPlays(game: Game): Game {
const clonedGame = cloneDeep(game);
clonedGame.upcomingPlays = clonedGame.upcomingPlays.filter(upcomingPlay => this.isGamePlaySuitableForCurrentPhase(clonedGame, upcomingPlay));
return clonedGame;
}

public proceedToNextGamePlay(game: Game): Game {
const clonedGame = cloneDeep(game);
if (!clonedGame.upcomingPlays.length) {
Expand All @@ -32,49 +37,55 @@ export class GamePlaysManagerService {
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 plainToInstance(GamePlay, eligibleNightPlays.reduce((acc: GamePlay[], gamePlay) => {
const { source, action } = gamePlay;
return this.isSourcePlayableForNight(game, source) ? [...acc, { source, action }] : acc;
}, upcomingNightPlays));
return eligibleNightPlays.reduce((acc: GamePlay[], gamePlay) => {
if (this.isGamePlaySuitableForCurrentPhase(game, gamePlay)) {
return [...acc, createGamePlay(gamePlay)];
}
return acc;
}, upcomingNightPlays);
}

private isSheriffElectionTime(sheriffGameOptions: SheriffGameOptions, currentTurn: number, currentPhase: GAME_PHASES): boolean {
const { electedAt, isEnabled } = sheriffGameOptions;
return isEnabled && electedAt.turn === currentTurn && electedAt.phase === currentPhase;
}

private areLoversPlayableForNight(game: CreateGameDto | Game): boolean {
private isLoversGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.CUPID);
}
const cupidPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.CUPID);
return !!cupidPlayer && isPlayerAliveAndPowerful(cupidPlayer);
if (!cupidPlayer) {
return false;
}
const inLovePlayers = getPlayersWithAttribute(game.players, PLAYER_ATTRIBUTE_NAMES.IN_LOVE);
return !inLovePlayers.length && isPlayerAliveAndPowerful(cupidPlayer) || inLovePlayers.length > 0 && inLovePlayers.every(player => player.isAlive);
}

private areAllPlayableForNight(game: CreateGameDto | Game): boolean {
private isAllGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): boolean {
if (gamePlay.cause !== GAME_PLAY_CAUSES.ANGEL_PRESENCE) {
return true;
}
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.ANGEL);
}
const angelPlayer = getPlayerWithCurrentRole(game.players, ROLE_NAMES.ANGEL);
return !!angelPlayer && isPlayerAliveAndPowerful(angelPlayer);
}

private isGroupPlayableForNight(game: CreateGameDto | Game, source: PLAYER_GROUPS): boolean {
const specificGroupMethods: Partial<Record<PLAYER_GROUPS, (game: CreateGameDto | Game) => boolean>> = {
[PLAYER_GROUPS.ALL]: this.areAllPlayableForNight,
[PLAYER_GROUPS.LOVERS]: this.areLoversPlayableForNight,
[PLAYER_GROUPS.CHARMED]: this.isPiedPiperPlayableForNight,
private isGroupGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): boolean {
const source = gamePlay.source as PLAYER_GROUPS;
const specificGroupMethods: Record<PLAYER_GROUPS, (game: CreateGameDto | Game, gamePlay: GamePlay) => boolean> = {
[PLAYER_GROUPS.ALL]: this.isAllGamePlaySuitableForCurrentPhase,
[PLAYER_GROUPS.LOVERS]: this.isLoversGamePlaySuitableForCurrentPhase,
[PLAYER_GROUPS.CHARMED]: this.isPiedPiperGamePlaySuitableForCurrentPhase,
[PLAYER_GROUPS.WEREWOLVES]: () => game instanceof CreateGameDto || getGroupOfPlayers(game.players, source).some(werewolf => isPlayerAliveAndPowerful(werewolf)),
[PLAYER_GROUPS.VILLAGERS]: () => false,
};
if (specificGroupMethods[source] !== undefined) {
return specificGroupMethods[source]?.(game) === true;
} else if (game instanceof CreateGameDto) {
return true;
}
const players = getGroupOfPlayers(game.players, source);
return players.some(player => isPlayerAliveAndPowerful(player));
return specificGroupMethods[source](game, gamePlay);
}

private isWhiteWerewolfPlayableForNight(game: CreateGameDto | Game): boolean {
private isWhiteWerewolfGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
const { wakingUpInterval } = game.options.roles.whiteWerewolf;
const shouldWhiteWerewolfBeCalled = wakingUpInterval > 0;
if (game instanceof CreateGameDto) {
Expand All @@ -84,7 +95,7 @@ export class GamePlaysManagerService {
return shouldWhiteWerewolfBeCalled && !!whiteWerewolfPlayer && isPlayerAliveAndPowerful(whiteWerewolfPlayer);
}

private isPiedPiperPlayableForNight(game: CreateGameDto | Game): boolean {
private isPiedPiperGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.PIED_PIPER);
}
Expand All @@ -93,7 +104,7 @@ export class GamePlaysManagerService {
return !!piedPiperPlayer && canPiedPiperCharm(piedPiperPlayer, isPowerlessIfInfected);
}

private isBigBadWolfPlayableForNight(game: CreateGameDto | Game): boolean {
private isBigBadWolfGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
if (game instanceof CreateGameDto) {
return !!getPlayerDtoWithRole(game.players, ROLE_NAMES.BIG_BAD_WOLF);
}
Expand All @@ -102,7 +113,7 @@ export class GamePlaysManagerService {
return !!bigBadWolfPlayer && isPlayerAliveAndPowerful(bigBadWolfPlayer) && (!isPowerlessIfWerewolfDies || areAllWerewolvesAlive(game.players));
}

private areThreeBrothersPlayableForNight(game: CreateGameDto | Game): boolean {
private isThreeBrothersGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
const { wakingUpInterval } = game.options.roles.threeBrothers;
const shouldThreeBrothersBeCalled = wakingUpInterval > 0;
if (game instanceof CreateGameDto) {
Expand All @@ -113,7 +124,7 @@ export class GamePlaysManagerService {
return shouldThreeBrothersBeCalled && threeBrothersPlayers.filter(brother => brother.isAlive).length >= minimumBrotherCountToCall;
}

private areTwoSistersPlayableForNight(game: CreateGameDto | Game): boolean {
private isTwoSistersGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
const { wakingUpInterval } = game.options.roles.twoSisters;
const shouldTwoSistersBeCalled = wakingUpInterval > 0;
if (game instanceof CreateGameDto) {
Expand All @@ -123,30 +134,44 @@ export class GamePlaysManagerService {
return shouldTwoSistersBeCalled && twoSistersPlayers.length > 0 && twoSistersPlayers.every(sister => sister.isAlive);
}

private isRolePlayableForNight(game: CreateGameDto | Game, source: ROLE_NAMES): boolean {
private isRoleGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): 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) => boolean>> = {
[ROLE_NAMES.TWO_SISTERS]: this.areTwoSistersPlayableForNight,
[ROLE_NAMES.THREE_BROTHERS]: this.areThreeBrothersPlayableForNight,
[ROLE_NAMES.BIG_BAD_WOLF]: this.isBigBadWolfPlayableForNight,
[ROLE_NAMES.PIED_PIPER]: this.isPiedPiperPlayableForNight,
[ROLE_NAMES.WHITE_WEREWOLF]: this.isWhiteWerewolfPlayableForNight,
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,
[ROLE_NAMES.HUNTER]: () => player instanceof CreateGamePlayerDto || isPlayerPowerful(player),
[ROLE_NAMES.SCAPEGOAT]: () => player instanceof CreateGamePlayerDto || isPlayerPowerful(player),
};
if (specificRoleMethods[source] !== undefined) {
return specificRoleMethods[source]?.(game) === true;
return specificRoleMethods[source]?.(game, gamePlay) === true;
}
return player instanceof CreateGamePlayerDto || isPlayerAliveAndPowerful(player);
}

private isSourcePlayableForNight(game: CreateGameDto | Game, source: GameSource): boolean {
if (isGameSourceRole(source)) {
return this.isRolePlayableForNight(game, source);
} else if (isGameSourceGroup(source)) {
return this.isGroupPlayableForNight(game, source);
private isSheriffGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game): boolean {
if (!game.options.roles.sheriff.isEnabled) {
return false;
}
if (game instanceof CreateGameDto) {
return true;
}
const sheriffPlayer = getPlayerWithAttribute(game.players, PLAYER_ATTRIBUTE_NAMES.SHERIFF);
return !!sheriffPlayer;
}

private isGamePlaySuitableForCurrentPhase(game: CreateGameDto | Game, gamePlay: GamePlay): boolean {
if (isGameSourceRole(gamePlay.source)) {
return this.isRoleGamePlaySuitableForCurrentPhase(game, gamePlay);
} else if (isGameSourceGroup(gamePlay.source)) {
return this.isGroupGamePlaySuitableForCurrentPhase(game, gamePlay);
}
return false;
return this.isSheriffGamePlaySuitableForCurrentPhase(game);
}
}
1 change: 1 addition & 0 deletions src/modules/game/providers/services/game.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export class GameService {
const play = createMakeGamePlayDtoWithRelations(makeGamePlayDto, clonedGame);
await this.gamePlaysValidatorService.validateGamePlayWithRelationsDtoData(play, clonedGame);
clonedGame = await this.gamePlaysMakerService.makeGamePlay(play, clonedGame);
clonedGame = this.gamePlaysManagerService.removeObsoleteUpcomingPlays(clonedGame);
clonedGame = this.gamePlaysManagerService.proceedToNextGamePlay(clonedGame);
if (isGameOver(clonedGame)) {
clonedGame = this.setGameAsOver(clonedGame);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { CreateGamePlayerDto } from "../../../../../../src/modules/game/dto
import type { CreateGameDto } from "../../../../../../src/modules/game/dto/create-game/create-game.dto";
import type { GetGameRandomCompositionDto } from "../../../../../../src/modules/game/dto/get-game-random-composition/get-game-random-composition.dto";
import type { MakeGamePlayDto } from "../../../../../../src/modules/game/dto/make-game-play/make-game-play.dto";
import { GAME_PLAY_ACTIONS } from "../../../../../../src/modules/game/enums/game-play.enum";
import { GAME_PLAY_ACTIONS, GAME_PLAY_CAUSES } from "../../../../../../src/modules/game/enums/game-play.enum";
import { GAME_PHASES, GAME_STATUSES } from "../../../../../../src/modules/game/enums/game.enum";
import { PLAYER_GROUPS } from "../../../../../../src/modules/game/enums/player.enum";
import { GameModule } from "../../../../../../src/modules/game/game.module";
Expand All @@ -27,7 +27,7 @@ import { createFakeGameOptionsDto } from "../../../../../factories/game/dto/crea
import { bulkCreateFakeCreateGamePlayerDto } from "../../../../../factories/game/dto/create-game/create-game-player/create-game-player.dto.factory";
import { createFakeCreateGameDto, createFakeCreateGameWithPlayersDto } from "../../../../../factories/game/dto/create-game/create-game.dto.factory";
import { createFakeMakeGamePlayDto } from "../../../../../factories/game/dto/make-game-play/make-game-play.dto.factory";
import { createFakeGamePlayAllVote, createFakeGamePlayCupidCharms, createFakeGamePlaySeerLooks, createFakeGamePlayWerewolvesEat } from "../../../../../factories/game/schemas/game-play/game-play.schema.factory";
import { createFakeGamePlayAllVote, createFakeGamePlaySeerLooks, createFakeGamePlayWerewolvesEat } from "../../../../../factories/game/schemas/game-play/game-play.schema.factory";
import { bulkCreateFakeGames, createFakeGame } from "../../../../../factories/game/schemas/game.schema.factory";
import { createFakeSeenBySeerPlayerAttribute } from "../../../../../factories/game/schemas/player/player-attribute/player-attribute.schema.factory";
import { createFakeSeerAlivePlayer, createFakeVillagerAlivePlayer, createFakeWerewolfAlivePlayer } from "../../../../../factories/game/schemas/player/player-with-role.schema.factory";
Expand Down Expand Up @@ -600,7 +600,7 @@ describe("Game Controller", () => {
]);
const game = createFakeGame({
status: GAME_STATUSES.PLAYING,
upcomingPlays: [{ source: PLAYER_GROUPS.ALL, action: GAME_PLAY_ACTIONS.VOTE }],
upcomingPlays: [createFakeGamePlayAllVote()],
players,
});
await models.game.create(game);
Expand Down Expand Up @@ -657,8 +657,8 @@ describe("Game Controller", () => {
]);
const game = createFakeGame({
status: GAME_STATUSES.PLAYING,
currentPlay: { source: PLAYER_GROUPS.ALL, action: GAME_PLAY_ACTIONS.VOTE },
upcomingPlays: [createFakeGamePlayCupidCharms()],
currentPlay: createFakeGamePlayAllVote(),
upcomingPlays: [createFakeGamePlaySeerLooks()],
players,
});
await models.game.create(game);
Expand All @@ -668,7 +668,10 @@ describe("Game Controller", () => {
{ sourceId: players[1]._id, targetId: players[0]._id },
],
});
const expectedGame = createFakeGame(game);
const expectedGame = createFakeGame({
...game,
currentPlay: createFakeGamePlayAllVote({ cause: GAME_PLAY_CAUSES.PREVIOUS_VOTES_WERE_IN_TIES }),
});
const response = await app.inject({
method: "POST",
url: `/games/${game._id.toString()}/play`,
Expand Down

0 comments on commit e18fbd6

Please sign in to comment.