diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 5b7c27515b..924720ce31 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -94,6 +94,7 @@ "disable_nations": "single_modal.disable_nations", "instant_build": "single_modal.instant_build", "infinite_gold": "single_modal.infinite_gold", + "random_spawn": "single_modal.random_spawn", "infinite_troops": "single_modal.infinite_troops", "disable_nukes": "single_modal.disable_nukes", "start": "single_modal.start" @@ -146,6 +147,7 @@ "bots_disabled": "host_modal.bots_disabled", "disable_nations": "host_modal.disable_nations", "instant_build": "host_modal.instant_build", + "random_spawn": "host_modal.random_spawn", "infinite_gold": "host_modal.infinite_gold", "infinite_troops": "host_modal.infinite_troops", "disable_nukes": "host_modal.disable_nukes", @@ -166,6 +168,7 @@ "Impossible": "difficulty.Impossible" }, "heads_up_message": { - "choose_spawn": "heads_up_message.choose_spawn" + "choose_spawn": "heads_up_message.choose_spawn", + "random_spawn": "heads_up_message.random_spawn" } } diff --git a/resources/lang/en.json b/resources/lang/en.json index 12cc0f05f8..052086266a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -135,6 +135,7 @@ }, "single_modal": { "title": "Single Player", + "random_spawn": "Random spawn", "allow_alliances": "Allow alliances", "options_title": "Options", "bots": "Bots: ", @@ -262,6 +263,7 @@ "player": "Player", "players": "Players", "waiting": "Waiting for players...", + "random_spawn": "Random spawn", "start": "Start Game", "host_badge": "Host" }, @@ -661,10 +663,15 @@ "copy_clipboard": "Copy to clipboard", "copied": "Copied!", "failed_copy": "Failed to copy", + "spawn_failed": { + "title": "Spawn failed", + "description": "Automatic spawn selection failed. You can't play this game." + }, "desync_notice": "You are desynced from other players. What you see might differ from other players." }, "heads_up_message": { - "choose_spawn": "Choose a starting location" + "choose_spawn": "Choose a starting location", + "random_spawn": "Random spawn is enabled. Selecting starting location for you..." }, "territory_patterns": { "title": "Skins", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 320b8e3d89..d4b8f8a5a1 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -48,6 +48,7 @@ import { } from "./Transport"; import { createCanvas } from "./Utils"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; +import { GoToPlayerEvent } from "./graphics/layers/Leaderboard"; import SoundManager from "./sound/SoundManager"; export interface LobbyConfig { @@ -202,6 +203,8 @@ export class ClientGameRunner { private lastMessageTime: number = 0; private connectionCheckInterval: NodeJS.Timeout | null = null; + private goToPlayerTimeout: NodeJS.Timeout | null = null; + private lastTickReceiveTime: number = 0; private currentTickDelay: number | undefined = undefined; @@ -325,6 +328,39 @@ export class ClientGameRunner { if (message.type === "start") { this.hasJoined = true; console.log("starting game!"); + + if (this.gameView.config().isRandomSpawn()) { + const goToPlayer = () => { + const myPlayer = this.gameView.myPlayer(); + + if (this.gameView.inSpawnPhase() && !myPlayer?.hasSpawned()) { + this.goToPlayerTimeout = setTimeout(goToPlayer, 1000); + return; + } + + if (!myPlayer) { + return; + } + + if (!this.gameView.inSpawnPhase() && !myPlayer.hasSpawned()) { + showErrorModal( + "spawn_failed", + translateText("error_modal.spawn_failed.description"), + this.lobby.gameID, + this.lobby.clientID, + true, + false, + translateText("error_modal.spawn_failed.title"), + ); + return; + } + + this.eventBus.emit(new GoToPlayerEvent(myPlayer)); + }; + + goToPlayer(); + } + for (const turn of message.turns) { if (turn.turnNumber < this.turnsSeen) { continue; @@ -402,6 +438,10 @@ export class ClientGameRunner { clearInterval(this.connectionCheckInterval); this.connectionCheckInterval = null; } + if (this.goToPlayerTimeout) { + clearTimeout(this.goToPlayerTimeout); + this.goToPlayerTimeout = null; + } } private inputEvent(event: MouseUpEvent) { @@ -420,7 +460,8 @@ export class ClientGameRunner { if ( this.gameView.isLand(tile) && !this.gameView.hasOwner(tile) && - this.gameView.inSpawnPhase() + this.gameView.inSpawnPhase() && + !this.gameView.config().isRandomSpawn() ) { this.eventBus.emit(new SendSpawnIntentEvent(tile)); return; diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 4da0867918..ccb01843c8 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -48,6 +48,7 @@ export class HostLobbyModal extends LitElement { @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; @state() private instantBuild: boolean = false; + @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @@ -390,6 +391,22 @@ export class HostLobbyModal extends LitElement { + + + + + ${translateText("host_modal.random_spawn")} + + + + + + + + ${translateText("single_modal.random_spawn")} + + + Object.values(UnitType).find((ut) => ut === u)) .filter((ut): ut is UnitType => ut !== undefined), diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 435116223e..9ab20dae35 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -40,7 +40,9 @@ export class HeadsUpMessage extends LitElement implements Layer { backdrop-blur-md text-white text-md lg:text-xl p-1 lg:p-2" @contextmenu=${(e: MouseEvent) => e.preventDefault()} > - ${translateText("heads_up_message.choose_spawn")} + ${this.game.config().isRandomSpawn() + ? translateText("heads_up_message.random_spawn") + : translateText("heads_up_message.choose_spawn")} `; } diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 9a77218578..a78e396991 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -100,6 +100,9 @@ export class GameRunner { ) {} init() { + if (this.game.config().isRandomSpawn()) { + this.game.addExecution(...this.execManager.spawnPlayers()); + } if (this.game.config().bots() > 0) { this.game.addExecution( ...this.execManager.spawnBots(this.game.config().numBots()), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index a390e9912a..caac254d86 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -167,6 +167,7 @@ export const GameConfigSchema = z.object({ infiniteGold: z.boolean(), infiniteTroops: z.boolean(), instantBuild: z.boolean(), + randomSpawn: z.boolean(), maxPlayers: z.number().optional(), maxTimerValue: z.number().int().min(1).max(120).optional(), disabledUnits: z.enum(UnitType).array().optional(), diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 273dfce089..696bc8d26b 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -88,6 +88,7 @@ export interface Config { infiniteTroops(): boolean; donateTroops(): boolean; instantBuild(): boolean; + isRandomSpawn(): boolean; numSpawnPhaseTurns(): number; userSettings(): UserSettings; playerTeams(): TeamCountConfig; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 2a4f1329e4..bed100c27a 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -338,6 +338,9 @@ export class DefaultConfig implements Config { instantBuild(): boolean { return this._gameConfig.instantBuild; } + isRandomSpawn(): boolean { + return this._gameConfig.randomSpawn; + } infiniteGold(): boolean { return this._gameConfig.infiniteGold; } diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 6fadf0d6e4..4a8e5df916 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -26,6 +26,7 @@ import { SpawnExecution } from "./SpawnExecution"; import { TargetPlayerExecution } from "./TargetPlayerExecution"; import { TransportShipExecution } from "./TransportShipExecution"; import { UpgradeStructureExecution } from "./UpgradeStructureExecution"; +import { PlayerSpawner } from "./utils/PlayerSpawner"; export class Executor { // private random = new PseudoRandom(999) @@ -131,6 +132,10 @@ export class Executor { return new BotSpawner(this.mg, this.gameID).spawnBots(numBots); } + spawnPlayers(): Execution[] { + return new PlayerSpawner(this.mg, this.gameID).spawnPlayers(); + } + fakeHumanExecutions(): Execution[] { const execs: Execution[] = []; for (const nation of this.mg.nations()) { diff --git a/src/core/execution/utils/PlayerSpawner.ts b/src/core/execution/utils/PlayerSpawner.ts new file mode 100644 index 0000000000..29c0fe1a6e --- /dev/null +++ b/src/core/execution/utils/PlayerSpawner.ts @@ -0,0 +1,83 @@ +import { Game, PlayerType } from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PseudoRandom } from "../../PseudoRandom"; +import { GameID } from "../../Schemas"; +import { simpleHash } from "../../Util"; +import { SpawnExecution } from "../SpawnExecution"; + +export class PlayerSpawner { + private random: PseudoRandom; + private players: SpawnExecution[] = []; + private static readonly MAX_SPAWN_TRIES = 10_000; + private static readonly MIN_SPAWN_DISTANCE = 30; + + constructor( + private gm: Game, + gameID: GameID, + ) { + this.random = new PseudoRandom(simpleHash(gameID)); + } + + private randTile(): TileRef { + const x = this.random.nextInt(0, this.gm.width()); + const y = this.random.nextInt(0, this.gm.height()); + + return this.gm.ref(x, y); + } + + private randomSpawnLand(): TileRef | null { + let tries = 0; + + while (tries < PlayerSpawner.MAX_SPAWN_TRIES) { + tries++; + + const tile = this.randTile(); + + if ( + !this.gm.isLand(tile) || + this.gm.hasOwner(tile) || + this.gm.isBorder(tile) + ) { + continue; + } + + let tooCloseToOtherPlayer = false; + for (const spawn of this.players) { + if ( + this.gm.manhattanDist(spawn.tile, tile) < + PlayerSpawner.MIN_SPAWN_DISTANCE + ) { + tooCloseToOtherPlayer = true; + break; + } + } + + if (tooCloseToOtherPlayer) { + continue; + } + + return tile; + } + + return null; + } + + spawnPlayers(): SpawnExecution[] { + for (const player of this.gm.allPlayers()) { + if (player.type() !== PlayerType.Human) { + continue; + } + + const spawnLand = this.randomSpawnLand(); + + if (spawnLand === null) { + // TODO: this should normally not happen, additional logic may be needed, if this occurs + continue; + } + + this.players.push(new SpawnExecution(player.info(), spawnLand)); + } + + return this.players; + } +} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index f366a63ec7..0cd4420da7 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -56,6 +56,7 @@ export class GameManager { infiniteTroops: false, maxTimerValue: undefined, instantBuild: false, + randomSpawn: false, gameMode: GameMode.FFA, bots: 400, disabledUnits: [], diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 9a9e97333d..078c482b44 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -115,6 +115,9 @@ export class GameServer { if (gameConfig.instantBuild !== undefined) { this.gameConfig.instantBuild = gameConfig.instantBuild; } + if (gameConfig.randomSpawn !== undefined) { + this.gameConfig.randomSpawn = gameConfig.randomSpawn; + } if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index b84938b97b..564af609e1 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -95,6 +95,7 @@ export class MapPlaylist { infiniteTroops: false, maxTimerValue: undefined, instantBuild: false, + randomSpawn: false, disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations, gameMode: mode, playerTeams, diff --git a/tests/core/execution/utils/PlayerSpawner.test.ts b/tests/core/execution/utils/PlayerSpawner.test.ts new file mode 100644 index 0000000000..6e719c2db8 --- /dev/null +++ b/tests/core/execution/utils/PlayerSpawner.test.ts @@ -0,0 +1,72 @@ +import { PlayerSpawner } from "../../../../src/core/execution/utils/PlayerSpawner"; +import { PlayerInfo, PlayerType } from "../../../../src/core/game/Game"; +import { setup } from "../../../util/Setup"; + +describe("PlayerSpawner", () => { + // Manually calculated based on number of tiles in manifest of each map + // and minimum distance between players in PlayerSpawner + test.each([ + ["big_plains", 49], + ["half_land_half_ocean", 1], + ["ocean_and_land", 1], + ["plains", 9], + ])( + "Spawn location is found for all players in %s map with %i players", + async (mapName, maxPlayers) => { + const players: PlayerInfo[] = []; + + for (let i = 0; i < maxPlayers; i++) { + players.push( + new PlayerInfo( + `player${i}`, + PlayerType.Human, + `client_id${i}`, + `player_id${i}`, + ), + ); + } + + const game = await setup(mapName, undefined, players); + + const executors = new PlayerSpawner(game, "game_id").spawnPlayers(); + expect(executors.length).toBe(maxPlayers); + + for (const executor of executors) { + expect(game.isLand(executor.tile)).toBe(true); + expect(game.isBorder(executor.tile)).toBe(false); + } + + for (let i = 0; i < executors.length; i++) { + for (let j = i + 1; j < executors.length; j++) { + const distance = game.manhattanDist( + executors[i].tile, + executors[j].tile, + ); + expect(distance).toBeGreaterThanOrEqual(30); + } + } + }, + ); + + test("Handles spawn failure when map is too crowded", async () => { + const players: PlayerInfo[] = []; + + // Try to spawn more players than possible on a small map + for (let i = 0; i < 5; i++) { + players.push( + new PlayerInfo( + `player${i}`, + PlayerType.Human, + `client_id${i}`, + `player_id${i}`, + ), + ); + } + + const game = await setup("half_land_half_ocean", undefined, players); + const executors = new PlayerSpawner(game, "game_id").spawnPlayers(); + + // Should spawn fewer than requested when map is too small + expect(executors.length).toBe(1); + }); +}); diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index 9ef2a00bef..963b134c13 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -68,6 +68,7 @@ export async function setup( infiniteGold: false, infiniteTroops: false, instantBuild: false, + randomSpawn: false, ..._gameConfig, }; const config = new ConfigClass(