├── tsconfig.base.json ├── .eslintrc.json ├── tsconfig.eslint.json ├── src ├── lib │ ├── util │ │ └── utils.ts │ ├── mafia │ │ ├── roles │ │ │ ├── town │ │ │ │ ├── Vanilla.ts │ │ │ │ └── SuperSaint.ts │ │ │ ├── mafia │ │ │ │ └── VanillaMafia.ts │ │ │ └── mixins │ │ │ │ ├── Townie.ts │ │ │ │ └── MafiaRole.ts │ │ ├── structures │ │ │ ├── Faction.ts │ │ │ ├── Player.ts │ │ │ ├── Role.ts │ │ │ └── Game.ts │ │ ├── managers │ │ │ ├── PlayerManager.ts │ │ │ └── VoteManager.ts │ │ └── factions │ │ │ ├── Town.ts │ │ │ └── Mafia.ts │ ├── GodfatherClient.ts │ └── GodfatherCommand.ts ├── index.ts ├── tsconfig.json └── commands │ └── ping.ts ├── tests ├── tsconfig.json ├── utils.ts └── ss3.test.ts ├── jest.config.js ├── .gitignore ├── LICENSE └── package.json /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sapphire/ts-config" 3 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sapphire", 3 | "rules": { 4 | "max-len": ["error", { "code": 100 }] 5 | } 6 | } -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["src", "tests", "jest.config.ts"] 4 | } -------------------------------------------------------------------------------- /src/lib/util/utils.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null; 2 | 3 | export const shuffle = (array: T[]): T[] => { 4 | let m = array.length; 5 | while (m) { 6 | const i = Math.floor(Math.random() * m--); 7 | [array[m], array[i]] = [array[i], array[m]]; 8 | } 9 | return array; 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { container } from "@sapphire/framework"; 2 | import "@sapphire/plugin-logger/register"; 3 | import { GodfatherClient } from "#lib/GodfatherClient"; 4 | 5 | const client = new GodfatherClient({ 6 | intents: [] 7 | }); 8 | 9 | void client.login(process.env.DISCORD_TOKEN).then(() => container.logger.info("Logged in!")); 10 | -------------------------------------------------------------------------------- /src/lib/mafia/roles/town/Vanilla.ts: -------------------------------------------------------------------------------- 1 | import Townie from "#mafia/roles/mixins/Townie"; 2 | import Role from "#mafia/structures/Role"; 3 | 4 | class Vanilla extends Role { 5 | public name = "Vanilla"; 6 | 7 | public description = 8 | "You have no night actions. Your vote is your only power."; 9 | } 10 | 11 | export default Townie(Vanilla); 12 | -------------------------------------------------------------------------------- /src/lib/mafia/roles/mafia/VanillaMafia.ts: -------------------------------------------------------------------------------- 1 | import MafiaRole from "#mafia/roles/mixins/MafiaRole"; 2 | import Role from "#mafia/structures/Role"; 3 | 4 | class VanillaMafia extends Role { 5 | public name = "Vanilla"; 6 | 7 | public description = 8 | "You have no night actions. Your vote is your only power."; 9 | } 10 | 11 | export default MafiaRole(VanillaMafia); 12 | -------------------------------------------------------------------------------- /src/lib/mafia/roles/mixins/Townie.ts: -------------------------------------------------------------------------------- 1 | import TownFaction from "#mafia/factions/Town"; 2 | import type Role from "#mafia/structures/Role"; 3 | 4 | export default function Townie( 5 | BaseRole: TBaseRole 6 | ) { 7 | // @ts-ignore A constructor isn't necessary here 8 | class Townie extends BaseRole { 9 | public faction = new TownFaction(); 10 | } 11 | 12 | return Townie; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/GodfatherClient.ts: -------------------------------------------------------------------------------- 1 | import { SapphireClient } from "@sapphire/framework"; 2 | 3 | // const DEV = process.env.NODE_ENV === "development"; 4 | 5 | export class GodfatherClient extends SapphireClient { 6 | // public constructor() { 7 | // super({ 8 | // intents: ["GUILDS"], 9 | // logger: { 10 | // level: DEV ? LogLevel.Debug : LogLevel.Info, 11 | // }, 12 | // }); 13 | // } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/mafia/roles/mixins/MafiaRole.ts: -------------------------------------------------------------------------------- 1 | import MafiaFaction from "#mafia/factions/Mafia"; 2 | import type Role from "#mafia/structures/Role"; 3 | 4 | export default function MafiaRole( 5 | BaseRole: TBaseRole 6 | ) { 7 | // @ts-ignore A constructor isn't necessary here 8 | class MafiaRole extends BaseRole { 9 | public faction = new MafiaFaction(); 10 | } 11 | 12 | return MafiaRole; 13 | } 14 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "rootDir": "./", 6 | "outDir": "../dist", 7 | "baseUrl": ".", 8 | "composite": true, 9 | "preserveConstEnums": true, 10 | "paths": { 11 | "#lib/*": ["./lib/*"], 12 | "#mafia/*": ["./lib/mafia/*"], 13 | "#util/*": ["./lib/util/*"] 14 | } 15 | }, 16 | "include": ["."] 17 | } -------------------------------------------------------------------------------- /src/lib/mafia/structures/Faction.ts: -------------------------------------------------------------------------------- 1 | import type { Player } from "#lib/mafia/structures/Player"; 2 | 3 | class Faction { 4 | /** 5 | * Whether the faction wins individually, or as teammates 6 | */ 7 | public independent = false; 8 | public informed = false; 9 | public name = ""; 10 | public winCondition = ""; 11 | } 12 | 13 | interface Faction { 14 | hasWon(player: Player): boolean; 15 | } 16 | 17 | export default Faction; 18 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "incremental": false, 6 | "baseUrl": ".", 7 | "paths": { 8 | "#lib/*": ["../src/lib/*"], 9 | "#mafia/*": ["../src/lib/mafia/*"], 10 | "#util/*": ["../src/lib/util/*"] 11 | } 12 | }, 13 | "include": [".", "../src/**/*"], 14 | "exclude": ["../dist/**/*"], 15 | "references": [{ "path": "../src" }] 16 | } -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "../src/lib/mafia/structures/Game"; 2 | import { Player } from "../src/lib/mafia/structures/Player"; 3 | 4 | interface TestUser { 5 | id: string; 6 | } 7 | 8 | export class TestPlayer extends Player { 9 | public constructor(game: TestGame, name: TestUser) { 10 | super(game, { id: name.id }); 11 | } 12 | } 13 | 14 | export class TestGame extends Game { 15 | public makePlayer(user: TestUser): TestPlayer { 16 | return new TestPlayer(this, user); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | 3 | const { createDefaultPreset } = require('ts-jest'); 4 | 5 | module.exports = { 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | roots: [''], 9 | moduleNameMapper: { 10 | '^#lib/(.*)$': '/src/lib/$1', 11 | '^#mafia/(.*)$': '/src/lib/mafia/$1', 12 | '^#util/(.*)$': '/src/lib/util/$1', 13 | }, 14 | transform: { 15 | ...createDefaultPreset({ 16 | tsconfig: '/tests/tsconfig.json' 17 | }).transform 18 | } 19 | } -------------------------------------------------------------------------------- /src/lib/GodfatherCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandRegistry, 3 | Command, 4 | RegisterBehavior, 5 | } from "@sapphire/framework"; 6 | 7 | export class GodfatherCommand extends Command { 8 | public override registerApplicationCommands( 9 | registry: ApplicationCommandRegistry 10 | ) { 11 | registry.registerChatInputCommand( 12 | { 13 | name: this.name, 14 | description: this.description, 15 | }, 16 | { behaviorWhenNotIdentical: RegisterBehavior.Overwrite } 17 | ); 18 | } 19 | 20 | public get client() { 21 | return this.container.client; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore a blackhole and the folder for development 2 | node_modules/ 3 | .vs/ 4 | .idea/ 5 | *.iml 6 | 7 | # Yarn files 8 | .yarn/install-state.gz 9 | .yarn/build-state.yml 10 | 11 | # Ignore the dist output 12 | dist/ 13 | .swc/ 14 | 15 | # Ignore heapsnapshot and log files 16 | *.heapsnapshot 17 | *.log 18 | 19 | # Ignore package locks 20 | package-lock.json 21 | 22 | # Environment variables 23 | .env.local 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | 28 | # File that I run locally to configure Permissions v1 29 | scripts/configure-command-permissions.mjs 30 | scripts/prune-global-commands.mjs 31 | -------------------------------------------------------------------------------- /src/lib/mafia/managers/PlayerManager.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "#lib/mafia/structures/Game"; 2 | import type { Player } from "#lib/mafia/structures/Player"; 3 | 4 | export class PlayerManager< 5 | PlayerClass extends Player, 6 | UserType extends { id: string } 7 | > extends Array { 8 | public constructor(public game: Game) { 9 | super(); 10 | } 11 | 12 | public get(user: UserType): Player | undefined { 13 | return this.find((player) => player.user === user); 14 | } 15 | 16 | public add(user: UserType) { 17 | this.push(this.game.makePlayer(user)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/mafia/roles/town/SuperSaint.ts: -------------------------------------------------------------------------------- 1 | import Townie from "#mafia/roles/mixins/Townie"; 2 | import type { Player } from "#mafia/structures/Player"; 3 | import Role from "#mafia/structures/Role"; 4 | 5 | class SuperSaint extends Role { 6 | public name = "Super Saint"; 7 | public description = "If eliminated, you kill the last person voting you."; 8 | 9 | public onDeath(player: Player) { 10 | if (player.deathReason.startsWith("eliminated")) { 11 | const { game } = player; 12 | 13 | const votesOnSaint = game.votes.on(player); 14 | const { by: lastVoter } = votesOnSaint!.slice().pop()!; 15 | 16 | lastVoter.kill("blown up by Super Saint"); 17 | } 18 | 19 | return super.onDeath(player); 20 | } 21 | } 22 | 23 | export default Townie(SuperSaint); 24 | -------------------------------------------------------------------------------- /src/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandRegistry, Command } from "@sapphire/framework"; 2 | import { ChatInputCommandInteraction } from "discord.js"; 3 | 4 | export class PingCommand extends Command { 5 | constructor(context: Command.LoaderContext) { 6 | super(context, { 7 | description: "Ping pong command", 8 | }); 9 | } 10 | 11 | public registerApplicationCommands(registry: ApplicationCommandRegistry) { 12 | registry.registerChatInputCommand((builder) => 13 | builder 14 | .setName(this.name) 15 | .setDescription(this.description) 16 | ); 17 | } 18 | 19 | public async chatInputRun(interaction: ChatInputCommandInteraction) { 20 | await interaction.reply("Pong!"); 21 | } 22 | } -------------------------------------------------------------------------------- /src/lib/mafia/structures/Player.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "#mafia/structures/Game"; 2 | import type Role from "#mafia/structures/Role"; 3 | 4 | /** 5 | * Represents a base Player class with no Discord related contextual information, that will be added 6 | * by an extension class. In unit tests, UserType is a simple JS object, in Discord related code, 7 | * it'll be a discordjs.User instance 8 | */ 9 | export class Player { 10 | public role!: Role; 11 | public isAlive = true; 12 | public deathReason = ""; 13 | 14 | public constructor( 15 | public game: Game, UserType>, 16 | public user: UserType 17 | ) {} 18 | 19 | public kill(deathReason: string) { 20 | this.isAlive = false; 21 | this.deathReason = deathReason; 22 | 23 | this.role.onDeath(this); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/mafia/structures/Role.ts: -------------------------------------------------------------------------------- 1 | import type Faction from "#lib/mafia/structures/Faction"; 2 | import type { Player } from "#mafia/structures/Player"; 3 | 4 | const INNOCENT_FACTIONS = [ 5 | "Town", 6 | "Survivor", 7 | "Jester", 8 | "Amnesiac", 9 | "Guardian Angel", 10 | "Juggernaut", 11 | "Godfather", 12 | "Executioner", 13 | ]; 14 | 15 | class Role { 16 | public name = ""; 17 | public description = ""; 18 | 19 | public get innocence() { 20 | return INNOCENT_FACTIONS.includes(this.faction.name); 21 | } 22 | 23 | // eslint-disable-next-line @typescript-eslint/class-literal-property-style 24 | // ESLint complains that this shouldn't be a getter, but it won't be constant for roles such as 25 | // Mayor 26 | public readonly voteWeight = 1; 27 | 28 | public onDeath(_player: Player) { 29 | // noop 30 | } 31 | } 32 | 33 | interface Role { 34 | faction: Faction; 35 | } 36 | 37 | export default Role; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2022 Stitch07 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/lib/mafia/factions/Town.ts: -------------------------------------------------------------------------------- 1 | import Faction from "#mafia/structures/Faction"; 2 | import type { Player } from "#mafia/structures/Player"; 3 | 4 | const OPPOSING_FACTIONS = [ 5 | "Mafia", 6 | "Serial Killer", 7 | "Arsonist", 8 | "Werewolf", 9 | "Juggernaut", 10 | "Cult", 11 | ]; 12 | 13 | export default class TownFaction extends Faction { 14 | public name = "Town"; 15 | 16 | public winCondition = "Eliminate every evildoer."; 17 | 18 | public hasWon(player: Player): boolean { 19 | // source: https://town-of-salem.fandom.com/wiki/Victory 20 | // Town Victory will occur when the Town is the last standing faction alive when all 21 | // members of the Mafia and Neutral Killing are dead 22 | const { game } = player; 23 | const { players } = game; 24 | 25 | const aliveTownies = players.filter( 26 | (player) => player.isAlive && player.role.faction.name === this.name 27 | ); 28 | const aliveOpposing = players.filter( 29 | (player) => 30 | player.isAlive && OPPOSING_FACTIONS.includes(player.role.faction.name) 31 | ); 32 | 33 | return aliveTownies.length > 0 && aliveOpposing.length === 0; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/mafia/factions/Mafia.ts: -------------------------------------------------------------------------------- 1 | import Faction from "#mafia/structures/Faction"; 2 | import type { Player } from "#mafia/structures/Player"; 3 | 4 | const OPPOSING_FACTIONS = [ 5 | "Town", 6 | "Arsonist", 7 | "Werewolf", 8 | "Serial Killer", 9 | "Juggernaut", 10 | ]; 11 | // TODO: Add Mayor check here after adding night actions 12 | const filterOpposingPowerRoles = (player: Player) => 13 | player.isAlive && OPPOSING_FACTIONS.includes(player.role!.faction.name); 14 | 15 | export default class MafiaFaction extends Faction { 16 | public name = "Mafia"; 17 | 18 | public winCondition = "Kill all townies and competing evil factions."; 19 | 20 | public informed = true; 21 | 22 | public hasWon(player: Player) { 23 | // source: https://town-of-salem.fandom.com/wiki/Victory 24 | // The Mafia need at least one member alive, and all opposing factions dead 25 | const { players } = player.game; 26 | 27 | const aliveMafia = players.filter( 28 | (player) => player.isAlive && player.role!.faction.name === "Mafia" 29 | ); 30 | const aliveOpposingPrs = players.filter(filterOpposingPowerRoles); 31 | const aliveOpposing = players.filter( 32 | (player) => 33 | player.isAlive && OPPOSING_FACTIONS.includes(player.role!.faction.name) 34 | ); 35 | 36 | return ( 37 | aliveMafia.length > 0 && 38 | aliveMafia.length >= aliveOpposing.length && 39 | aliveOpposingPrs.length === 0 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "godfather", 3 | "version": "2.0.0-next", 4 | "description": "", 5 | "main": "dist/index", 6 | "imports": { 7 | "#root/*": "./dist/*.js", 8 | "#lib/*": "./dist/lib/*.js", 9 | "#mafia/*": "./dist/lib/mafia/*.js", 10 | "#util/*": "./dist/lib/util/*.js" 11 | }, 12 | "scripts": { 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "build": "tsc -b src", 15 | "lint": "eslint src --ext ts --fix", 16 | "start": "node --enable-source-maps dist/index.js", 17 | "watch:start": "tsc-watch -b src --onSuccess \"yarn start\"" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git@github.com-Stitch07:Stitch07/godfather.git" 22 | }, 23 | "author": "Soumil", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@babel/preset-typescript": "^7.24.7", 27 | "@sapphire/eslint-config": "^5.0.5", 28 | "@sapphire/ts-config": "^5.0.1", 29 | "@types/jest": "^29.5.12", 30 | "@types/node": "^20.14.10", 31 | "@typescript-eslint/eslint-plugin": "^7.16.0", 32 | "@typescript-eslint/parser": "^7.16.0", 33 | "eslint": "^9.6.0", 34 | "jest": "^29.7.0", 35 | "ts-jest": "^29.2.0", 36 | "tsc-watch": "^6.2.0", 37 | "typescript": "^5.4.5" 38 | }, 39 | "dependencies": { 40 | "@sapphire/decorators": "^6.1.0", 41 | "@sapphire/framework": "^5.2.1", 42 | "@sapphire/plugin-logger": "^4.0.2", 43 | "@sapphire/prettier-config": "^2.0.0", 44 | "@sapphire/snowflake": "^3.5.3", 45 | "discord.js": "^14.15.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/mafia/managers/VoteManager.ts: -------------------------------------------------------------------------------- 1 | import type { Game } from "#mafia/structures/Game"; 2 | import type { Player } from "#mafia/structures/Player"; 3 | 4 | export interface WeightedData { 5 | weight: number; 6 | } 7 | 8 | export interface Vote extends WeightedData { 9 | by: Player; 10 | } 11 | 12 | export class WeightedArrayProxy extends Array { 13 | // Flattens weighted votes and gives the number of actual votes on a player 14 | public count(): number { 15 | return this.reduce((acc, vote) => acc + vote.weight, 0); 16 | } 17 | } 18 | 19 | export class VoteProxy extends WeightedArrayProxy {} 20 | 21 | export const NotVoting = "notVoting"; 22 | export const NoEliminate = "noEliminate"; 23 | 24 | export class VoteManager extends Map { 25 | public constructor(public game: Game) { 26 | super(); 27 | this.reset(); 28 | } 29 | 30 | public vote(voter: Player, target: Player) { 31 | const votes = this.on(target); 32 | for (const votes of this.values()) { 33 | for (const vote of votes) 34 | if (vote.by === voter) votes.splice(votes.indexOf(vote), 1); 35 | } 36 | votes.push({ 37 | by: voter, 38 | weight: voter.role!.voteWeight, 39 | }); 40 | this.set(target.user.id, votes); 41 | return this.on(target).count() >= this.game.majorityVotes; 42 | } 43 | 44 | public on(player: Player) { 45 | return this.get(player.user.id) ?? new VoteProxy(); 46 | } 47 | 48 | public reset() { 49 | this.clear(); 50 | this.set(NotVoting, new VoteProxy()); 51 | this.set(NoEliminate, new VoteProxy()); 52 | 53 | // populate not-voting cache 54 | const alivePlayers = this.game.players.filter((player) => player.isAlive); 55 | for (const alivePlayer of alivePlayers) { 56 | this.get(NotVoting)!.push({ by: alivePlayer, weight: 1 }); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/ss3.test.ts: -------------------------------------------------------------------------------- 1 | import VanillaMafia from "#mafia/roles/mafia/VanillaMafia"; 2 | import SuperSaint from "#mafia/roles/town/SuperSaint"; 3 | import Vanilla from "#mafia/roles/town/Vanilla"; 4 | import { Phase } from "#mafia/structures/Game"; 5 | import type Role from "#mafia/structures/Role"; 6 | import { TestGame } from "./utils"; 7 | 8 | test("ss3 with town win", () => { 9 | const game = new TestGame({ id: "Host" }); 10 | const setup = [new Vanilla(), new VanillaMafia(), new SuperSaint()] as Role[]; 11 | game.players.add({ id: "Player 1" }); 12 | game.players.add({ id: "Player 2" }); 13 | 14 | expect(game.hasStarted).toBe(false); 15 | 16 | game.setup(setup); 17 | game.startDay(); 18 | 19 | expect(game.phase).toBe(Phase.Day); 20 | expect(game.hasStarted).toBe(true); 21 | 22 | // Voting 23 | expect(game.votes.vote(game.players[0], game.players[1])).toBe(false); 24 | // 2 votes on the VanillaMafia are a successful hammer 25 | expect(game.votes.vote(game.players[2], game.players[1])).toBe(true); 26 | game.players[1].kill("Hammered D1"); 27 | expect(game.players[1].isAlive).toBe(false); 28 | // Town wins! 29 | const { winningFaction } = game.checkEndgame(); 30 | expect(winningFaction?.name).toBe("Town"); 31 | }); 32 | 33 | test("ss3 with super saint hammered", () => { 34 | const game = new TestGame({ id: "Host" }); 35 | const setup = [new Vanilla(), new VanillaMafia(), new SuperSaint()] as Role[]; 36 | game.players.add({ id: "Player 1" }); 37 | game.players.add({ id: "Player 2" }); 38 | 39 | game.setup(setup); 40 | game.startDay(); 41 | 42 | // Voting 43 | expect(game.votes.vote(game.players[0], game.players[2])).toBe(false); 44 | expect(game.votes.vote(game.players[1], game.players[2])).toBe(true); 45 | game.players[2].kill("eliminated D1"); 46 | 47 | // The Super Saint should blow up Player 1 48 | expect(game.players[1].isAlive).toBe(false); 49 | // Town wins, as the VM got blown up 50 | expect(game.checkEndgame().hasEnded).toBe(true); 51 | expect(game.checkEndgame().winningFaction?.name).toBe("Town"); 52 | }); 53 | -------------------------------------------------------------------------------- /src/lib/mafia/structures/Game.ts: -------------------------------------------------------------------------------- 1 | import { Player } from "#lib/mafia/structures/Player"; 2 | import type { Nullable } from "#lib/util/utils"; 3 | import { PlayerManager } from "#mafia/managers/PlayerManager"; 4 | import { VoteManager } from "#mafia/managers/VoteManager"; 5 | import type Faction from "#mafia/structures/Faction"; 6 | import type Role from "#mafia/structures/Role"; 7 | 8 | export const enum Phase { 9 | Pregame, 10 | Standby, 11 | Day, 12 | Trial, 13 | TrialVoting, 14 | Night, 15 | Ended, 16 | } 17 | 18 | export class Game< 19 | PlayerClass extends Player, 20 | UserClass extends { id: string } 21 | > { 22 | /** 23 | * The current phase of the game 24 | */ 25 | public phase = Phase.Pregame; 26 | 27 | /** 28 | * The current cycle 29 | */ 30 | public cycle = 0; 31 | 32 | public players: PlayerManager = new PlayerManager< 33 | PlayerClass, 34 | UserClass 35 | >(this); 36 | 37 | public votes: VoteManager = new VoteManager(this); 38 | 39 | public get majorityVotes() { 40 | const alivePlayers = this.players.filter((player) => player.isAlive); 41 | return Math.floor(alivePlayers.length / 2) + 1; 42 | } 43 | 44 | public constructor(public host: UserClass) { 45 | this.players.push(this.makePlayer(host)); 46 | } 47 | 48 | public setup(roles: Role[]) { 49 | // roles = shuffle(roles); 50 | for (const player of this.players) { 51 | player.role = roles.shift()!; 52 | } 53 | } 54 | 55 | public startDay() { 56 | const winCheck = this.checkEndgame(); 57 | if (winCheck.hasEnded) { 58 | return this.end(winCheck); 59 | } 60 | 61 | this.phase = Phase.Day; 62 | this.cycle++; 63 | this.votes.reset(); 64 | } 65 | 66 | public get hasStarted() { 67 | return this.phase !== Phase.Pregame && this.phase !== Phase.Ended; 68 | } 69 | 70 | public checkEndgame(): EndgameCheckData { 71 | let winningFaction: Nullable = null; 72 | const independentWins: PlayerClass[] = []; 73 | 74 | const alivePlayers = this.players.filter((player) => player.isAlive); 75 | 76 | for (const player of alivePlayers) { 77 | const win = player.role.faction.hasWon(player); 78 | if (win) { 79 | if (player.role.faction.independent) { 80 | independentWins.push(player); 81 | } else { 82 | winningFaction = player.role.faction; 83 | } 84 | } 85 | } 86 | 87 | // if there are no major factions left end game immediately 88 | const majorFactions = this.players.filter( 89 | (player) => player.isAlive && !player.role.faction.independent 90 | ); 91 | if (majorFactions.length === 0) { 92 | return { 93 | hasEnded: true, 94 | winningFaction: null, 95 | independentWins, 96 | }; 97 | } 98 | 99 | // draw by wipe-out 100 | if (alivePlayers.length === 0) { 101 | return { 102 | hasEnded: true, 103 | winningFaction: null, 104 | independentWins, 105 | }; 106 | } 107 | 108 | return { 109 | hasEnded: winningFaction !== null, 110 | winningFaction, 111 | independentWins, 112 | }; 113 | } 114 | 115 | public end(_data: EndgameCheckData) { 116 | this.phase = Phase.Ended; 117 | } 118 | 119 | public makePlayer(user: UserClass): PlayerClass { 120 | return new Player(this, user) as PlayerClass; 121 | } 122 | } 123 | 124 | export interface EndgameCheckData { 125 | hasEnded: boolean; 126 | winningFaction: Nullable; 127 | independentWins: PlayerClass[]; 128 | } 129 | --------------------------------------------------------------------------------