diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 4f081a002..10a1a84b2 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -16,6 +16,7 @@ import { GameType, HumansVsNations, Quads, + RankedType, Trios, UnitType, } from "./game/Game"; @@ -183,6 +184,7 @@ export const GameConfigSchema = z.object({ donateTroops: z.boolean(), // Configures donations to humans only gameType: z.enum(GameType), gameMode: z.enum(GameMode), + rankedType: z.enum(RankedType).optional(), // Only set for ranked games. gameMapSize: z.enum(GameMapSize), publicGameModifiers: z .object({ @@ -198,11 +200,10 @@ export const GameConfigSchema = z.object({ disableNavMesh: z.boolean().optional(), randomSpawn: z.boolean(), maxPlayers: z.number().optional(), - maxTimerValue: z.number().int().min(1).max(120).optional(), + maxTimerValue: z.number().int().min(1).max(120).optional(), // In minutes spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), - isOneVOne: z.boolean().optional(), }); export const TeamSchema = z.string(); diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index be9793cb8..700d14fad 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -5,6 +5,8 @@ import { Game, GameMode, Player, + PlayerType, + RankedType, Team, } from "../game/Game"; @@ -44,6 +46,19 @@ export class WinCheckExecution implements Execution { if (sorted.length === 0) { return; } + + if (this.mg.config().gameConfig().rankedType === RankedType.OneVOne) { + const humans = sorted.filter( + (p) => p.type() === PlayerType.Human && !p.isDisconnected(), + ); + if (humans.length === 1) { + this.mg.setWinner(humans[0], this.mg.stats().stats()); + console.log(`${humans[0].name()} has won the game`); + this.active = false; + return; + } + } + const max = sorted[0]; const timeElapsed = (this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 8b8d3dd70..4ead6efe5 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -195,6 +195,11 @@ export enum GameMode { FFA = "Free For All", Team = "Team", } + +export enum RankedType { + OneVOne = "1v1", +} + export const isGameMode = (value: unknown): value is GameMode => isEnumValue(GameMode, value); diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index d220c8fe6..a9ba0e78d 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -9,6 +9,7 @@ import { HumansVsNations, PublicGameModifiers, Quads, + RankedType, Trios, } from "../core/game/Game"; import { PseudoRandom } from "../core/PseudoRandom"; @@ -140,6 +141,40 @@ export class MapPlaylist { } satisfies GameConfig; } + public get1v1Config(): GameConfig { + const ffaMaps = [ + GameMapType.Iceland, + GameMapType.World, + GameMapType.EuropeClassic, + GameMapType.Australia, + GameMapType.FaroeIslands, + GameMapType.Pangaea, + GameMapType.Italia, + GameMapType.FalklandIslands, + GameMapType.Sierpinski, + ]; + return { + donateGold: false, + donateTroops: false, + gameMap: ffaMaps[Math.floor(Math.random() * ffaMaps.length)], + maxPlayers: 2, + gameType: GameType.Public, + gameMapSize: GameMapSize.Compact, + difficulty: Difficulty.Easy, + rankedType: RankedType.OneVOne, + infiniteGold: false, + infiniteTroops: false, + maxTimerValue: 10, // 10 minutes + instantBuild: false, + randomSpawn: false, + disableNations: false, + gameMode: GameMode.FFA, + bots: 100, + spawnImmunityDuration: 5 * 10, + disabledUnits: [], + } satisfies GameConfig; + } + private getNextMap(): MapWithMode { if (this.mapsPlaylist.length === 0) { const numAttempts = 10000; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 58801c090..97a9706c7 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { GameMapSize, GameType } from "../core/game/Game"; +import { GameType } from "../core/game/Game"; import { ClientMessageSchema, GameID, @@ -522,8 +522,7 @@ async function pollLobby(gm: GameManager) { log.info(`Lobby poll successful:`, data); if (data.assignment) { - const gameConfig = await playlist.gameConfig(); - gameConfig.gameMapSize = GameMapSize.Compact; + const gameConfig = playlist.get1v1Config(); const game = gm.createGame(gameId, gameConfig); setTimeout(() => { // Wait a few seconds to allow clients to connect. diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 17dbdb4e7..6b5b07d09 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -4,8 +4,9 @@ import { GameMode, PlayerInfo, PlayerType, + RankedType, } from "../../../src/core/game/Game"; -import { setup } from "../../util/Setup"; +import { playerInfo, setup } from "../../util/Setup"; describe("WinCheckExecution", () => { let mg: any; @@ -369,3 +370,216 @@ describe("WinCheckExecution - Nation Winners", () => { expect(winCheck.isActive()).toBe(true); }); }); + +describe("WinCheckExecution - 1v1 Ranked Mode", () => { + test("should set winner when only one human remains connected", async () => { + // Setup game with 1v1 ranked mode and two human players + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign some territory to both players + let human1Count = 0; + let human2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (human1Count < 10) { + human1.conquer(tile); + human1Count++; + } else if (human2Count < 10) { + human2.conquer(tile); + human2Count++; + } + }); + + // Mark player 2 as disconnected + human2.markDisconnected(true); + + // Mock setWinner to capture calls + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + // Initialize and run win check + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + winCheck.checkWinnerFFA(); + + // Verify the remaining connected human is declared winner + expect(setWinnerSpy).toHaveBeenCalledWith(human1, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); + + test("should not set winner when multiple humans are still connected", async () => { + // Setup game with 1v1 ranked mode and two human players + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign territory to both players + let human1Count = 0; + let human2Count = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (human1Count < 10) { + human1.conquer(tile); + human1Count++; + } else if (human2Count < 10) { + human2.conquer(tile); + human2Count++; + } + }); + + // Both players remain connected + expect(human1.isDisconnected()).toBe(false); + expect(human2.isDisconnected()).toBe(false); + + // Mock setWinner to capture calls + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + // Initialize and run win check + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + winCheck.checkWinnerFFA(); + + // Verify no winner declared yet (both players still connected) + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); + + test("should not set winner when no humans remain connected", async () => { + // Setup game with 1v1 ranked mode and two human players + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("Player1", PlayerType.Human), + playerInfo("Player2", PlayerType.Human), + ], + ); + + const human1 = game.player("Player1"); + const human2 = game.player("Player2"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Both players disconnect + human1.markDisconnected(true); + human2.markDisconnected(true); + + // Mock setWinner to capture calls + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + // Initialize and run win check + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + winCheck.checkWinnerFFA(); + + // Verify no winner declared (no connected humans) + expect(setWinnerSpy).not.toHaveBeenCalled(); + expect(winCheck.isActive()).toBe(true); + }); + + test("should ignore bots and nations in 1v1 ranked mode", async () => { + // Setup game with 1v1 ranked mode, one human, one bot, and one nation + const game = await setup( + "big_plains", + { + infiniteGold: true, + gameMode: GameMode.FFA, + instantBuild: true, + rankedType: RankedType.OneVOne, + }, + [ + playerInfo("HumanPlayer", PlayerType.Human), + playerInfo("BotPlayer", PlayerType.Bot), + playerInfo("NationPlayer", PlayerType.Nation), + ], + ); + + const human = game.player("HumanPlayer"); + const bot = game.player("BotPlayer"); + const nation = game.player("NationPlayer"); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + // Assign territory to all players + let humanCount = 0; + let botCount = 0; + let nationCount = 0; + game.map().forEachTile((tile) => { + if (!game.map().isLand(tile)) return; + if (humanCount < 10) { + human.conquer(tile); + humanCount++; + } else if (botCount < 10) { + bot.conquer(tile); + botCount++; + } else if (nationCount < 10) { + nation.conquer(tile); + nationCount++; + } + }); + + // Mock setWinner to capture calls + const setWinnerSpy = vi.fn(); + game.setWinner = setWinnerSpy; + + // Initialize and run win check + const winCheck = new WinCheckExecution(); + winCheck.init(game, 0); + winCheck.checkWinnerFFA(); + + // Verify human is declared winner (only one human player) + expect(setWinnerSpy).toHaveBeenCalledWith(human, expect.anything()); + expect(winCheck.isActive()).toBe(false); + }); +});