Random spawn (#2375)

## Description:

https://github.com/openfrontio/OpenFrontIO/issues/2352

This is my first PR in this project, and I’ll continue refining it based
on feedback.

<img width="1088" height="859" alt="image"
src="https://github.com/user-attachments/assets/07f4f8b1-52fa-4136-add4-19b00aefd963"
/>

<img width="1157" height="783" alt="image"
src="https://github.com/user-attachments/assets/1c5be80d-72f8-4ead-8d4b-706a3a04fd73"
/>

<img width="1488" height="777" alt="image"
src="https://github.com/user-attachments/assets/4d743548-f0c3-4579-963b-43676f68fab1"
/>

<img width="1499" height="778" alt="image"
src="https://github.com/user-attachments/assets/f808e44f-ef97-467f-9e41-812e2857c36e"
/>


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

nikolaj_mykola

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Mykola
2025-11-07 01:49:37 +02:00
committed by GitHub
parent 020486686f
commit 25ea11114e
17 changed files with 276 additions and 4 deletions
+4 -1
View File
@@ -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"
}
}
+8 -1
View File
@@ -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",
+42 -1
View File
@@ -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;
+23
View File
@@ -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 {
</div>
</label>
<label
for="random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("host_modal.random_spawn")}
</div>
</label>
<label
for="donate-gold"
class="option-card ${this.donateGold ? "selected" : ""}"
@@ -668,6 +685,11 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -749,6 +771,7 @@ export class HostLobbyModal extends LitElement {
infiniteTroops: this.infiniteTroops,
donateTroops: this.donateTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
playerTeams: this.teamCount,
+22
View File
@@ -44,6 +44,7 @@ export class SinglePlayerModal 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 useRandomMap: boolean = false;
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@@ -293,6 +294,22 @@ export class SinglePlayerModal extends LitElement {
</div>
</label>
<label
for="singleplayer-modal-random-spawn"
class="option-card ${this.randomSpawn ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="singleplayer-modal-random-spawn"
@change=${this.handleRandomSpawnChange}
.checked=${this.randomSpawn}
/>
<div class="option-card-title">
${translateText("single_modal.random_spawn")}
</div>
</label>
<label
for="singleplayer-modal-infinite-gold"
class="option-card ${this.infiniteGold ? "selected" : ""}"
@@ -440,6 +457,10 @@ export class SinglePlayerModal extends LitElement {
this.instantBuild = Boolean((e.target as HTMLInputElement).checked);
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
}
private handleInfiniteGoldChange(e: Event) {
this.infiniteGold = Boolean((e.target as HTMLInputElement).checked);
}
@@ -563,6 +584,7 @@ export class SinglePlayerModal extends LitElement {
donateTroops: true,
infiniteTroops: this.infiniteTroops,
instantBuild: this.instantBuild,
randomSpawn: this.randomSpawn,
disabledUnits: this.disabledUnits
.map((u) => Object.values(UnitType).find((ut) => ut === u))
.filter((ut): ut is UnitType => ut !== undefined),
+3 -1
View File
@@ -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")}
</div>
`;
}
+3
View File
@@ -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()),
+1
View File
@@ -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(),
+1
View File
@@ -88,6 +88,7 @@ export interface Config {
infiniteTroops(): boolean;
donateTroops(): boolean;
instantBuild(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
+3
View File
@@ -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;
}
+5
View File
@@ -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()) {
+83
View File
@@ -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;
}
}
+1
View File
@@ -56,6 +56,7 @@ export class GameManager {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
+3
View File
@@ -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;
}
+1
View File
@@ -95,6 +95,7 @@ export class MapPlaylist {
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
randomSpawn: false,
disableNPCs: mode === GameMode.Team && playerTeams !== HumansVsNations,
gameMode: mode,
playerTeams,
@@ -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);
});
});
+1
View File
@@ -68,6 +68,7 @@ export async function setup(
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
randomSpawn: false,
..._gameConfig,
};
const config = new ConfigClass(