Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 155 additions & 47 deletions common/game/game.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { InformationOf, RoleId, ROLES, RoleSet } from "@common/game/roles.js";
import {
Alignment,
InformationOf,
RoleId,
ROLES,
RoleSet,
} from "@common/game/roles.js";

/**
* The type of a unique identifier for a user in the lobby
Expand Down Expand Up @@ -60,6 +66,14 @@ export type GamePhase =
| "assassination"
| "game_over";

/**
* Describes the ways in which a game can finish
*/
export type GameResult =
| { reason: "vote_tracker"; winning_team: "evil" }
| { reason: "assassination_result"; winning_team: Alignment }
| { reason: "mission_result"; winning_team: Alignment };

/**
* Includes how many votes have failed in a row
*/
Expand All @@ -83,6 +97,13 @@ export type WithVotes<TPhase> = TPhase extends "round:mission_reveal"
? { votes: { success: number; fail: number } }
: unknown;

/**
* Includes the result of the game
*/
export type WithGameResult<TPhase> = TPhase extends "game_over"
? { result: GameResult }
: unknown;

/**
* Describes the current state of the game, along with any available
* information relevant to the current phase
Expand Down Expand Up @@ -113,20 +134,26 @@ export type GameState = {
/**
* The results of missions so far
*/
missionResults: boolean[];
missionResults: Alignment[];

/**
* The current round of the game
*/
currentMission: number;
missionIndex: number;

/**
* The index of the current leader in the player list
*/
leaderIndex: number;

/**
* The index of the current Lady of the Lake in the player list
*/
ladyIndex: number;
} & WithVotesFailed<TPhase> &
WithCurrentTeam<TPhase> &
WithVotes<TPhase>;
WithVotes<TPhase> &
WithGameResult<TPhase>;
}[GamePhase];

/**
Expand Down Expand Up @@ -193,14 +220,15 @@ export type UserInputByPhase = {
"round:team_select": UserId[];
"round:team_vote": boolean;
"round:mission": boolean;
"round:lady_choice": UserId;
assassination: UserId;
};

/**
* Describes the type of user input required for a given phase
*/
export type UserInput<TPhase extends GamePhase> =
TPhase extends keyof UserInputByPhase ? UserInputByPhase[TPhase] : unknown;
TPhase extends keyof UserInputByPhase ? UserInputByPhase[TPhase] : never;

/**
* Describes a transition from some game state (in a specific phase) to another,
Expand All @@ -215,10 +243,47 @@ export abstract class GameTransition<TFrom extends GamePhase> {
): GameState;
}

/**
* team select/vote failed, advance leader and vote tracker
*/
function restartTeamSelect(
state: GameStateInPhase<`round:${"team_select" | "team_vote"}`>,
): GameState {
if (state.votesFailed == 4) {
return {
...state,
phase: "game_over",
result: { reason: "vote_tracker", winning_team: "evil" },
};
}
return {
...state,
phase: "round:team_select",
leaderIndex: (state.leaderIndex + 1) % state.players.length,
votesFailed: state.votesFailed + 1,
};
}

/**
* Describes the flow of the game through transitions from all phases
*/
export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
/**
* Role reveal: all players receive their role assignment + information
*/
role_reveal: {
getTargetPlayers(_state): UserId[] {
return [];
},
processInteraction(state, _responses): GameState {
return {
...state,
phase: "round:team_select",
votesFailed: 0,
};
},
},

/**
* Team selection: the current leader selects a team to go on this mission
*/
Expand All @@ -230,10 +295,16 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
state: GameStateInPhase<"round:team_select">,
responses: Map<UserId, UserId[]>,
): GameState {
const team = [
...new Set(responses.get(state.players[state.leaderIndex]) ?? []),
].filter((player) => player in state.players);
if (team.length != state.config.rounds[state.missionIndex].teamSize) {
return restartTeamSelect(state);
}
return {
...state,
phase: "round:team_vote",
currentTeam: responses.get(this.getTargetPlayers(state)[0]) ?? [],
currentTeam: team,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store past teams/team proposals?

};
},
},
Expand All @@ -248,21 +319,16 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
processInteraction(state, responses): GameState {
Copy link
Collaborator Author

@Xylenox Xylenox Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def need to store player votes for non-anonymous voting

let votes = 0;
for (const player in state.players) {
if (responses.get(player)) {
if (responses.get(player) === true) {
votes++;
}
}
if (2 * votes > state.players.length) {
return {
...state,
phase: "round:mission",
};
if (2 * votes <= state.players.length) {
return restartTeamSelect(state);
}
return {
...state,
phase: "round:team_select",
leaderIndex: (state.leaderIndex + 1) % state.players.length,
votesFailed: state.votesFailed + 1,
phase: "round:mission",
};
},
},
Expand All @@ -286,12 +352,15 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
}
}
// Retrieve the number of fails required from round config
const reqFails = state.config.rounds[state.currentMission].reqFail;
const reqFails = state.config.rounds[state.missionIndex].reqFail;
return {
...state,
phase: "round:mission_reveal",
votes: { success, fail },
missionResults: [...state.missionResults, fail >= reqFails],
missionResults: [
...state.missionResults,
fail < reqFails ? "good" : "evil",
],
};
},
},
Expand All @@ -301,29 +370,50 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
*/
"round:mission_reveal": {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
return [];
},
processInteraction(_state, _responses): GameState {
throw Error("not implemented");
},
},

/**
* Role reveal: all players receive their role assignment + information
*/
role_reveal: {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
},
processInteraction(_state, _responses): GameState {
throw Error("not implemented");
processInteraction(state, _responses): GameState {
if (state.missionResults.filter((x) => x == "good").length >= 3) {
// good team win
if (state.config.roles.has("assassin")) {
return {
...state,
phase: "assassination",
};
}
return {
...state,
phase: "game_over",
result: { reason: "mission_result", winning_team: "good" },
};
}
if (state.missionResults.filter((x) => x == "evil").length >= 3) {
// evil team win
return {
...state,
phase: "game_over",
result: { reason: "mission_result", winning_team: "evil" },
};
}
if (state.config.ladyOfTheLake) {
return {
...state,
phase: "round:lady_choice",
};
}
return {
...state,
phase: "round:team_select",
votesFailed: 0,
leaderIndex: (state.leaderIndex + 1) % state.players.length,
};
},
},

/**
* Assassination: the assassin (or all evil players) guess(es) the identity of Merlin
* Lady of the Lake: the current player chooses a player whose role to see
*/
assassination: {
"round:lady_choice": {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
},
Expand All @@ -333,9 +423,9 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
},

/**
* Game over: the game ends and results are shown to all players
* Lady of the Lake reveal: the chosen player's role is revealed to the player with the Lady
*/
game_over: {
"round:lady_reveal": {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
},
Expand All @@ -345,26 +435,44 @@ export const GAME_FLOW: { [TFrom in GamePhase]: GameTransition<TFrom> } = {
},

/**
* Lady of the Lake: the current player chooses a player whose role to see
* Assassination: the assassin (or all evil players) guess(es) the identity of Merlin
*/
"round:lady_choice": {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
assassination: {
getTargetPlayers(state): UserId[] {
return [
state.players.filter((player) => state.roles[player] == "assassin")[0],
];
},
processInteraction(_state, _responses): GameState {
throw Error("not implemented");
processInteraction(state, responses): GameState {
const assassin = state.players.filter(
(player) => state.roles[player] == "assassin",
)[0];
const target = responses.get(assassin);
Copy link
Collaborator Author

@Xylenox Xylenox Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to store assassination and target information for game over

if (typeof target == "string" && state.roles[target] == "merlin") {
return {
...state,
phase: "game_over",
result: { reason: "assassination_result", winning_team: "evil" },
};
} else {
return {
...state,
phase: "game_over",
result: { reason: "assassination_result", winning_team: "good" },
};
}
},
},

/**
* Lady of the Lake reveal: the chosen player's role is revealed to the player with the Lady
* Game over: the game ends and results are shown to all players
*/
"round:lady_reveal": {
game_over: {
getTargetPlayers(_state): UserId[] {
throw Error("not implemented");
return [];
},
processInteraction(_state, _responses): GameState {
throw Error("not implemented");
processInteraction(state, _responses): GameState {
return state;
},
},
};
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineConfig([
{
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
"error",
{ ignoreRestSiblings: true, argsIgnorePattern: "^_" },
],
},
Expand Down