From ef5f38deeb29df19b836ed955433359a9c9586ad Mon Sep 17 00:00:00 2001
From: Kipstz Avenger <140314732+Kipstz@users.noreply.github.com>
Date: Thu, 31 Jul 2025 14:43:20 +0200
Subject: [PATCH 01/21] Remove spawn timer on singleplayer
---
src/client/ClientGameRunner.ts | 2 +-
src/core/GameRunner.ts | 22 +++++++--
src/core/configuration/DefaultConfig.ts | 2 +-
.../execution/DelayedBotSpawnExecution.ts | 45 +++++++++++++++++++
src/core/execution/SpawnExecution.ts | 21 ++++++++-
5 files changed, 84 insertions(+), 8 deletions(-)
create mode 100644 src/core/execution/DelayedBotSpawnExecution.ts
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index c3c093426d..3a9b436d25 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -12,7 +12,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
-import { PlayerActions, UnitType } from "../core/game/Game";
+import { GameType, PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 6a8a4042f3..e87cb88fe4 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -1,5 +1,6 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
+import { DelayedBotSpawnExecution } from "./execution/DelayedBotSpawnExecution";
import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
@@ -7,6 +8,7 @@ import {
Attack,
Cell,
Game,
+ GameType,
GameUpdates,
NameViewData,
Nation,
@@ -85,6 +87,7 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
+ gameStart,
);
gr.init();
return gr;
@@ -101,6 +104,7 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
+ private gameStartInfo: GameStartInfo,
) {}
init() {
@@ -108,9 +112,15 @@ export class GameRunner {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (this.game.config().bots() > 0) {
- this.game.addExecution(
- ...this.execManager.spawnBots(this.game.config().numBots()),
- );
+ if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
+ this.game.addExecution(
+ new DelayedBotSpawnExecution(this.gameStartInfo.gameID),
+ );
+ } else {
+ this.game.addExecution(
+ ...this.execManager.spawnBots(this.game.config().numBots()),
+ );
+ }
}
if (this.game.config().spawnNPCs()) {
this.game.addExecution(...this.execManager.fakeHumanExecutions());
@@ -157,7 +167,11 @@ export class GameRunner {
return;
}
- if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
+ if (
+ (this.game.inSpawnPhase() ||
+ this.game.config().gameConfig().gameType === GameType.Singleplayer) &&
+ this.game.ticks() % 2 === 0
+ ) {
this.game
.players()
.filter(
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 4a215da49b..0870400135 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -626,7 +626,7 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
- return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
+ return this._gameConfig.gameType === GameType.Singleplayer ? 0 : 300;
}
numBots(): number {
return this.bots();
diff --git a/src/core/execution/DelayedBotSpawnExecution.ts b/src/core/execution/DelayedBotSpawnExecution.ts
new file mode 100644
index 0000000000..0c604219b9
--- /dev/null
+++ b/src/core/execution/DelayedBotSpawnExecution.ts
@@ -0,0 +1,45 @@
+import { Execution, Game, PlayerType } from "../game/Game";
+import { BotSpawner } from "./BotSpawner";
+
+export class DelayedBotSpawnExecution implements Execution {
+ private active = true;
+ private mg: Game;
+ private botsSpawned = false;
+
+ constructor(private gameID: string) {}
+
+ init(mg: Game, ticks: number) {
+ this.mg = mg;
+ }
+
+ tick(ticks: number) {
+ if (this.botsSpawned) {
+ this.active = false;
+ return;
+ }
+
+ const hasHumanSpawned = this.mg
+ .players()
+ .some(
+ (player) => player.type() === PlayerType.Human && player.hasSpawned(),
+ );
+
+ if (hasHumanSpawned) {
+ const botSpawner = new BotSpawner(this.mg, this.gameID);
+ const botSpawns = botSpawner.spawnBots(this.mg.config().numBots());
+
+ this.mg.addExecution(...botSpawns);
+
+ this.botsSpawned = true;
+ this.active = false;
+ }
+ }
+
+ isActive(): boolean {
+ return this.active;
+ }
+
+ activeDuringSpawnPhase(): boolean {
+ return true;
+ }
+}
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index 57baff6eeb..4e9b34f7ec 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -1,4 +1,11 @@
-import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game";
+import {
+ Execution,
+ Game,
+ GameType,
+ Player,
+ PlayerInfo,
+ PlayerType,
+} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
@@ -20,12 +27,22 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;
+ console.log(
+ `SpawnExecution tick ${ticks}: player=${this.playerInfo.name}, tile=${this.tile}, inSpawnPhase=${this.mg.inSpawnPhase()}, gameType=${this.mg.config().gameConfig().gameType}`,
+ );
+
if (!this.mg.isValidRef(this.tile)) {
console.warn(`SpawnExecution: tile ${this.tile} not valid`);
return;
}
- if (!this.mg.inSpawnPhase()) {
+ if (
+ !this.mg.inSpawnPhase() &&
+ this.mg.config().gameConfig().gameType !== GameType.Singleplayer
+ ) {
+ console.log(
+ `SpawnExecution: spawn phase ended, not singleplayer, aborting`,
+ );
this.active = false;
return;
}
From d287d6a406614218089a5041626fc5d7ac69f6a2 Mon Sep 17 00:00:00 2001
From: Kipstz Avenger <140314732+Kipstz@users.noreply.github.com>
Date: Thu, 31 Jul 2025 15:01:58 +0200
Subject: [PATCH 02/21] update: reviews
---
src/core/execution/DelayedBotSpawnExecution.ts | 14 ++++++++++++++
src/core/execution/SpawnExecution.ts | 7 -------
2 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/src/core/execution/DelayedBotSpawnExecution.ts b/src/core/execution/DelayedBotSpawnExecution.ts
index 0c604219b9..09b726f297 100644
--- a/src/core/execution/DelayedBotSpawnExecution.ts
+++ b/src/core/execution/DelayedBotSpawnExecution.ts
@@ -5,6 +5,8 @@ export class DelayedBotSpawnExecution implements Execution {
private active = true;
private mg: Game;
private botsSpawned = false;
+ private tickCount = 0;
+ private readonly MAX_WAIT_TICKS = 6000;
constructor(private gameID: string) {}
@@ -13,11 +15,23 @@ export class DelayedBotSpawnExecution implements Execution {
}
tick(ticks: number) {
+ this.tickCount++;
+
if (this.botsSpawned) {
this.active = false;
return;
}
+ if (this.tickCount >= this.MAX_WAIT_TICKS) {
+ console.warn("DelayedBotSpawnExecution: No human spawned after timeout, spawning bots anyway");
+ const botSpawner = new BotSpawner(this.mg, this.gameID);
+ const botSpawns = botSpawner.spawnBots(this.mg.config().numBots());
+ this.mg.addExecution(...botSpawns);
+ this.botsSpawned = true;
+ this.active = false;
+ return;
+ }
+
const hasHumanSpawned = this.mg
.players()
.some(
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index 4e9b34f7ec..26701a25fd 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -27,10 +27,6 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;
- console.log(
- `SpawnExecution tick ${ticks}: player=${this.playerInfo.name}, tile=${this.tile}, inSpawnPhase=${this.mg.inSpawnPhase()}, gameType=${this.mg.config().gameConfig().gameType}`,
- );
-
if (!this.mg.isValidRef(this.tile)) {
console.warn(`SpawnExecution: tile ${this.tile} not valid`);
return;
@@ -40,9 +36,6 @@ export class SpawnExecution implements Execution {
!this.mg.inSpawnPhase() &&
this.mg.config().gameConfig().gameType !== GameType.Singleplayer
) {
- console.log(
- `SpawnExecution: spawn phase ended, not singleplayer, aborting`,
- );
this.active = false;
return;
}
From 454bdf88a1d8d7f1ed62e03e84a0492a4b5997bb Mon Sep 17 00:00:00 2001
From: Kipstz Avenger <140314732+Kipstz@users.noreply.github.com>
Date: Thu, 31 Jul 2025 15:04:27 +0200
Subject: [PATCH 03/21] format / lint
---
src/core/execution/DelayedBotSpawnExecution.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/core/execution/DelayedBotSpawnExecution.ts b/src/core/execution/DelayedBotSpawnExecution.ts
index 09b726f297..a524fd87d3 100644
--- a/src/core/execution/DelayedBotSpawnExecution.ts
+++ b/src/core/execution/DelayedBotSpawnExecution.ts
@@ -16,14 +16,16 @@ export class DelayedBotSpawnExecution implements Execution {
tick(ticks: number) {
this.tickCount++;
-
+
if (this.botsSpawned) {
this.active = false;
return;
}
if (this.tickCount >= this.MAX_WAIT_TICKS) {
- console.warn("DelayedBotSpawnExecution: No human spawned after timeout, spawning bots anyway");
+ console.warn(
+ "DelayedBotSpawnExecution: No human spawned after timeout, spawning bots anyway",
+ );
const botSpawner = new BotSpawner(this.mg, this.gameID);
const botSpawns = botSpawner.spawnBots(this.mg.config().numBots());
this.mg.addExecution(...botSpawns);
From 5f892415b33d9c330e18d602cf6b3a8b3056a2f0 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 21:59:39 +0100
Subject: [PATCH 04/21] Merge
---
src/client/graphics/layers/HeadsUpMessage.ts | 9 +-
src/client/graphics/layers/SpawnAd.ts | 142 +++++++++++++++++++
2 files changed, 150 insertions(+), 1 deletion(-)
create mode 100644 src/client/graphics/layers/SpawnAd.ts
diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts
index 9ab20dae35..b9147c854c 100644
--- a/src/client/graphics/layers/HeadsUpMessage.ts
+++ b/src/client/graphics/layers/HeadsUpMessage.ts
@@ -1,6 +1,7 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
+import { GameType } from "../../../core/game/Game";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@@ -21,7 +22,13 @@ export class HeadsUpMessage extends LitElement implements Layer {
}
tick() {
- if (!this.game.inSpawnPhase()) {
+ // Check if we should show the spawn message
+ const shouldShowSpawnMessage =
+ this.game.inSpawnPhase() ||
+ (this.game.config().gameConfig().gameType === GameType.Singleplayer &&
+ !this.game.myPlayer()?.hasSpawned());
+
+ if (!shouldShowSpawnMessage) {
this.isVisible = false;
this.requestUpdate();
}
diff --git a/src/client/graphics/layers/SpawnAd.ts b/src/client/graphics/layers/SpawnAd.ts
new file mode 100644
index 0000000000..404c48886f
--- /dev/null
+++ b/src/client/graphics/layers/SpawnAd.ts
@@ -0,0 +1,142 @@
+import { LitElement, css, html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { translateText } from "../../../client/Utils";
+import { GameView } from "../../../core/game/GameView";
+import { GameType } from "../../../core/game/Game";
+import { getGamesPlayed } from "../../Utils";
+import { Layer } from "./Layer";
+
+const AD_TYPE = "bottom_rail";
+const AD_CONTAINER_ID = "bottom-rail-ad-container";
+
+@customElement("spawn-ad")
+export class SpawnAd extends LitElement implements Layer {
+ public g: GameView;
+
+ @state()
+ private isVisible: boolean = false;
+
+ @state()
+ private adLoaded: boolean = false;
+
+ private gamesPlayed: number = 0;
+
+ // Override createRenderRoot to disable shadow DOM
+ createRenderRoot() {
+ return this;
+ }
+
+ static styles = css``;
+
+ constructor() {
+ super();
+ }
+
+ init() {
+ this.gamesPlayed = getGamesPlayed();
+ }
+
+ public show(): void {
+ this.isVisible = true;
+ this.loadAd();
+ this.requestUpdate();
+ }
+
+ public hide(): void {
+ // Destroy the ad when hiding
+ this.destroyAd();
+ this.isVisible = false;
+ this.adLoaded = false;
+ this.requestUpdate();
+ }
+
+ public async tick() {
+ // Check if we should show the spawn ad
+ const shouldShowSpawnAd =
+ this.g.inSpawnPhase() ||
+ (this.g.config().gameConfig().gameType === GameType.Singleplayer &&
+ !this.g.myPlayer()?.hasSpawned());
+
+ if (
+ !this.isVisible &&
+ shouldShowSpawnAd &&
+ this.g.ticks() > 10 &&
+ this.gamesPlayed > 5
+ ) {
+ console.log("not showing spawn ad");
+ // this.show();
+ }
+ if (this.isVisible && !shouldShowSpawnAd) {
+ console.log("hiding bottom left ad");
+ this.hide();
+ }
+ }
+
+ private loadAd(): void {
+ if (!window.ramp) {
+ console.warn("Playwire RAMP not available");
+ return;
+ }
+ if (this.adLoaded) {
+ console.log("Ad already loaded, skipping");
+ return;
+ }
+ try {
+ window.ramp.que.push(() => {
+ window.ramp.spaAddAds([
+ {
+ type: AD_TYPE,
+ selectorId: AD_CONTAINER_ID,
+ },
+ ]);
+ this.adLoaded = true;
+ console.log("Playwire ad loaded:", AD_TYPE);
+ });
+ } catch (error) {
+ console.error("Failed to load Playwire ad:", error);
+ }
+ }
+
+ private destroyAd(): void {
+ if (!window.ramp || !this.adLoaded) {
+ return;
+ }
+ try {
+ window.ramp.que.push(() => {
+ window.ramp.destroyUnits("all");
+ console.log("Playwire spawn ad destroyed");
+ });
+ } catch (error) {
+ console.error("Failed to destroy Playwire ad:", error);
+ }
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ // Clean up ad when component is removed
+ this.destroyAd();
+ }
+
+ render() {
+ if (!this.isVisible) {
+ return html``;
+ }
+
+ return html`
+
+
+ ${!this.adLoaded
+ ? html`${translateText("spawn_ad.loading")}`
+ : ""}
+
+
+ `;
+ }
+}
From bf84bd3538b058eba87f5c9ac371c17772e92dff Mon Sep 17 00:00:00 2001
From: Kipstz Avenger <140314732+Kipstz@users.noreply.github.com>
Date: Thu, 31 Jul 2025 23:59:34 +0200
Subject: [PATCH 05/21] Big refactor after previously reviews
---
src/client/graphics/layers/HeadsUpMessage.ts | 9 +--
src/client/graphics/layers/SpawnAd.ts | 20 +++---
src/client/graphics/layers/SpawnTimer.ts | 5 ++
src/core/GameRunner.ts | 53 ++++++++++------
src/core/configuration/DefaultConfig.ts | 9 ++-
src/core/execution/BotExecution.ts | 10 ++-
.../execution/DelayedBotSpawnExecution.ts | 61 -------------------
src/core/execution/FakeHumanExecution.ts | 1 -
src/core/execution/SpawnExecution.ts | 32 +++++-----
src/core/game/GameImpl.ts | 9 +++
src/core/game/GameView.ts | 9 +++
src/server/GameManager.ts | 6 +-
src/server/GameServer.ts | 9 +--
13 files changed, 103 insertions(+), 130 deletions(-)
delete mode 100644 src/core/execution/DelayedBotSpawnExecution.ts
diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts
index b9147c854c..9ab20dae35 100644
--- a/src/client/graphics/layers/HeadsUpMessage.ts
+++ b/src/client/graphics/layers/HeadsUpMessage.ts
@@ -1,7 +1,6 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GameView } from "../../../core/game/GameView";
-import { GameType } from "../../../core/game/Game";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
@@ -22,13 +21,7 @@ export class HeadsUpMessage extends LitElement implements Layer {
}
tick() {
- // Check if we should show the spawn message
- const shouldShowSpawnMessage =
- this.game.inSpawnPhase() ||
- (this.game.config().gameConfig().gameType === GameType.Singleplayer &&
- !this.game.myPlayer()?.hasSpawned());
-
- if (!shouldShowSpawnMessage) {
+ if (!this.game.inSpawnPhase()) {
this.isVisible = false;
this.requestUpdate();
}
diff --git a/src/client/graphics/layers/SpawnAd.ts b/src/client/graphics/layers/SpawnAd.ts
index 404c48886f..b2868d470c 100644
--- a/src/client/graphics/layers/SpawnAd.ts
+++ b/src/client/graphics/layers/SpawnAd.ts
@@ -1,8 +1,8 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
-import { GameView } from "../../../core/game/GameView";
import { GameType } from "../../../core/game/Game";
+import { GameView } from "../../../core/game/GameView";
import { getGamesPlayed } from "../../Utils";
import { Layer } from "./Layer";
@@ -51,23 +51,19 @@ export class SpawnAd extends LitElement implements Layer {
}
public async tick() {
- // Check if we should show the spawn ad
- const shouldShowSpawnAd =
- this.g.inSpawnPhase() ||
- (this.g.config().gameConfig().gameType === GameType.Singleplayer &&
- !this.g.myPlayer()?.hasSpawned());
-
+ if (this.g.config().gameConfig().gameType === GameType.Singleplayer) {
+ return;
+ }
+
if (
!this.isVisible &&
- shouldShowSpawnAd &&
+ this.g.inSpawnPhase() &&
this.g.ticks() > 10 &&
this.gamesPlayed > 5
) {
- console.log("not showing spawn ad");
- // this.show();
+ this.show();
}
- if (this.isVisible && !shouldShowSpawnAd) {
- console.log("hiding bottom left ad");
+ if (this.isVisible && !this.g.inSpawnPhase()) {
this.hide();
}
}
diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts
index 393cf96d4d..13fbcf741b 100644
--- a/src/client/graphics/layers/SpawnTimer.ts
+++ b/src/client/graphics/layers/SpawnTimer.ts
@@ -32,6 +32,11 @@ export class SpawnTimer extends LitElement implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
+ if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
+ this.ratios = [];
+ this.colors = [];
+ return;
+ }
// During spawn phase, only one segment filling full width
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index e87cb88fe4..3b6c8dca40 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -1,14 +1,13 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
-import { DelayedBotSpawnExecution } from "./execution/DelayedBotSpawnExecution";
import { Executor } from "./execution/ExecutionManager";
+import { SpawnExecution } from "./execution/SpawnExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
Cell,
Game,
- GameType,
GameUpdates,
NameViewData,
Nation,
@@ -87,7 +86,6 @@ export async function createGameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
- gameStart,
);
gr.init();
return gr;
@@ -97,6 +95,7 @@ export class GameRunner {
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
+ private nationsSpawned = false;
private playerViewData: Record = {};
@@ -104,7 +103,6 @@ export class GameRunner {
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
- private gameStartInfo: GameStartInfo,
) {}
init() {
@@ -112,20 +110,25 @@ export class GameRunner {
this.game.addExecution(...this.execManager.spawnPlayers());
}
if (this.game.config().bots() > 0) {
- if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
- this.game.addExecution(
- new DelayedBotSpawnExecution(this.gameStartInfo.gameID),
- );
- } else {
- this.game.addExecution(
- ...this.execManager.spawnBots(this.game.config().numBots()),
- );
- }
+ this.game.addExecution(
+ ...this.execManager.spawnBots(this.game.config().numBots()),
+ );
}
+
+ this.game.addExecution(new WinCheckExecution());
+ }
+
+ private spawnNationsWithDelay() {
if (this.game.config().spawnNPCs()) {
- this.game.addExecution(...this.execManager.fakeHumanExecutions());
+ for (const nation of this.game.nations()) {
+ const tile = this.game.ref(nation.spawnCell.x, nation.spawnCell.y);
+ const spawnExecution = new SpawnExecution(nation.playerInfo, tile);
+ this.game.addExecution(spawnExecution);
+ }
+
+ const nationExecutions = this.execManager.fakeHumanExecutions();
+ this.game.addExecution(...nationExecutions);
}
- this.game.addExecution(new WinCheckExecution());
}
public addTurn(turn: Turn): void {
@@ -167,11 +170,12 @@ export class GameRunner {
return;
}
- if (
- (this.game.inSpawnPhase() ||
- this.game.config().gameConfig().gameType === GameType.Singleplayer) &&
- this.game.ticks() % 2 === 0
- ) {
+ if (!this.nationsSpawned && !this.game.inSpawnPhase()) {
+ this.spawnNationsWithDelay();
+ this.nationsSpawned = true;
+ }
+
+ if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
this.game
.players()
.filter(
@@ -183,6 +187,15 @@ export class GameRunner {
);
}
+ if (this.nationsSpawned && this.game.ticks() % 2 === 0) {
+ this.game
+ .players()
+ .filter((p) => p.type() === PlayerType.FakeHuman)
+ .forEach(
+ (p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
+ );
+ }
+
if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 0870400135..91d12daa08 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -273,6 +273,13 @@ export class DefaultConfig implements Config {
return this._serverConfig;
}
+ turnIntervalMs(): number {
+ if (this._gameConfig.gameType === GameType.Singleplayer) {
+ return 10;
+ }
+ return this._serverConfig.turnIntervalMs();
+ }
+
userSettings(): UserSettings {
if (this._userSettings === null) {
throw new Error("userSettings is null");
@@ -626,7 +633,7 @@ export class DefaultConfig implements Config {
return 3;
}
numSpawnPhaseTurns(): number {
- return this._gameConfig.gameType === GameType.Singleplayer ? 0 : 300;
+ return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
numBots(): number {
return this.bots();
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index 69f1f4f296..b106a5426f 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -8,6 +8,7 @@ export class BotExecution implements Execution {
private random: PseudoRandom;
private mg: Game;
private neighborsTerraNullius = true;
+ private initializationDelay = 50;
private behavior: BotBehavior | null = null;
private attackRate: number;
@@ -34,6 +35,8 @@ export class BotExecution implements Execution {
}
tick(ticks: number) {
+ if (ticks < this.initializationDelay) return;
+
if (ticks % this.attackRate !== this.attackTick) return;
if (!this.bot.isAlive()) {
@@ -51,8 +54,6 @@ export class BotExecution implements Execution {
this.expandRatio,
);
- // Send an attack on the first tick
- this.behavior.sendAttack(this.mg.terraNullius());
return;
}
@@ -81,7 +82,10 @@ export class BotExecution implements Execution {
}
}
- if (this.neighborsTerraNullius) {
+ if (
+ this.neighborsTerraNullius &&
+ this.mg.ticks() > this.initializationDelay + 100
+ ) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
this.behavior.sendAttack(this.mg.terraNullius());
return;
diff --git a/src/core/execution/DelayedBotSpawnExecution.ts b/src/core/execution/DelayedBotSpawnExecution.ts
deleted file mode 100644
index a524fd87d3..0000000000
--- a/src/core/execution/DelayedBotSpawnExecution.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { Execution, Game, PlayerType } from "../game/Game";
-import { BotSpawner } from "./BotSpawner";
-
-export class DelayedBotSpawnExecution implements Execution {
- private active = true;
- private mg: Game;
- private botsSpawned = false;
- private tickCount = 0;
- private readonly MAX_WAIT_TICKS = 6000;
-
- constructor(private gameID: string) {}
-
- init(mg: Game, ticks: number) {
- this.mg = mg;
- }
-
- tick(ticks: number) {
- this.tickCount++;
-
- if (this.botsSpawned) {
- this.active = false;
- return;
- }
-
- if (this.tickCount >= this.MAX_WAIT_TICKS) {
- console.warn(
- "DelayedBotSpawnExecution: No human spawned after timeout, spawning bots anyway",
- );
- const botSpawner = new BotSpawner(this.mg, this.gameID);
- const botSpawns = botSpawner.spawnBots(this.mg.config().numBots());
- this.mg.addExecution(...botSpawns);
- this.botsSpawned = true;
- this.active = false;
- return;
- }
-
- const hasHumanSpawned = this.mg
- .players()
- .some(
- (player) => player.type() === PlayerType.Human && player.hasSpawned(),
- );
-
- if (hasHumanSpawned) {
- const botSpawner = new BotSpawner(this.mg, this.gameID);
- const botSpawns = botSpawner.spawnBots(this.mg.config().numBots());
-
- this.mg.addExecution(...botSpawns);
-
- this.botsSpawned = true;
- this.active = false;
- }
- }
-
- isActive(): boolean {
- return this.active;
- }
-
- activeDuringSpawnPhase(): boolean {
- return true;
- }
-}
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index 10805d4b17..03bdf20db4 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -24,7 +24,6 @@ import { EmojiExecution } from "./EmojiExecution";
import { MirvExecution } from "./MIRVExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { NukeExecution } from "./NukeExecution";
-import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior";
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index 26701a25fd..0282415f36 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -1,11 +1,4 @@
-import {
- Execution,
- Game,
- GameType,
- Player,
- PlayerInfo,
- PlayerType,
-} from "../game/Game";
+import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { BotExecution } from "./BotExecution";
import { PlayerExecution } from "./PlayerExecution";
@@ -34,7 +27,7 @@ export class SpawnExecution implements Execution {
if (
!this.mg.inSpawnPhase() &&
- this.mg.config().gameConfig().gameType !== GameType.Singleplayer
+ this.playerInfo.playerType !== PlayerType.FakeHuman
) {
this.active = false;
return;
@@ -47,18 +40,21 @@ export class SpawnExecution implements Execution {
player = this.mg.addPlayer(this.playerInfo);
}
- player.tiles().forEach((t) => player.relinquish(t));
- getSpawnTiles(this.mg, this.tile).forEach((t) => {
- player.conquer(t);
- });
+ if (player) {
+ player.tiles().forEach((t) => player.relinquish(t));
+ getSpawnTiles(this.mg, this.tile).forEach((t) => {
+ player.conquer(t);
+ });
- if (!player.hasSpawned()) {
- this.mg.addExecution(new PlayerExecution(player));
- if (player.type() === PlayerType.Bot) {
- this.mg.addExecution(new BotExecution(player));
+ if (player && !player.hasSpawned()) {
+ this.mg.addExecution(new PlayerExecution(player));
+ if (player.type() === PlayerType.Bot) {
+ this.mg.addExecution(new BotExecution(player));
+ }
+
+ player.setHasSpawned(true);
}
}
- player.setHasSpawned(true);
}
isActive(): boolean {
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 2c23ad8641..414292a517 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -14,6 +14,7 @@ import {
Execution,
Game,
GameMode,
+ GameType,
GameUpdates,
HumansVsNations,
MessageType,
@@ -338,6 +339,14 @@ export class GameImpl implements Game {
}
inSpawnPhase(): boolean {
+ if (this.config().gameConfig().gameType === GameType.Singleplayer) {
+ const hasHumanSpawned = Array.from(this._players.values()).some(
+ (player) => player.type() === PlayerType.Human && player.hasSpawned(),
+ );
+ if (hasHumanSpawned) {
+ return false;
+ }
+ }
return this._ticks <= this.config().numSpawnPhaseTurns();
}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index c525129d10..bb877c5459 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -9,6 +9,7 @@ import { WorkerClient } from "../worker/WorkerClient";
import {
Cell,
EmojiMessage,
+ GameType,
GameUpdates,
Gold,
NameViewData,
@@ -655,6 +656,14 @@ export class GameView implements GameMap {
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
+ if (this._config.gameConfig().gameType === GameType.Singleplayer) {
+ const hasHumanSpawned = Array.from(this._players.values()).some(
+ (player) => player.type() === PlayerType.Human && player.hasSpawned(),
+ );
+ if (hasHumanSpawned) {
+ return false;
+ }
+ }
return this.ticks() <= this._config.numSpawnPhaseTurns();
}
config(): Config {
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 0cd4420da7..5794246df0 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -88,14 +88,16 @@ export class GameManager {
if (!game.hasStarted()) {
// Prestart tells clients to start loading the game.
game.prestart();
- // Start game on delay to allow time for clients to connect.
+ // Start game immediately for single player games, with delay for multiplayer games
+ const startDelay =
+ game.gameConfig.gameType === GameType.Singleplayer ? 0 : 2000;
setTimeout(() => {
try {
game.start();
} catch (error) {
this.log.error(`error starting game ${id}: ${error}`);
}
- }, 2000);
+ }, startDelay);
}
}
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 04c87d5d75..8f3c3abe1d 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -430,10 +430,11 @@ export class GameServer {
}
this.gameStartInfo = result.data satisfies GameStartInfo;
- this.endTurnIntervalID = setInterval(
- () => this.endTurn(),
- this.config.turnIntervalMs(),
- );
+ const turnInterval =
+ this.gameConfig.gameType === GameType.Singleplayer
+ ? 10
+ : this.config.turnIntervalMs();
+ this.endTurnIntervalID = setInterval(() => this.endTurn(), turnInterval);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
clientID: c.clientID,
From 8aca6f3c3a96426a7aa3ade814281eaed2d4b4bd Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 10:38:09 +0100
Subject: [PATCH 06/21] Merge branch 'main' of
https://github.com/openfrontio/OpenFrontIO into feat-sp-timer
fix merge errors
---
src/client/Main.ts | 1 +
src/client/graphics/layers/SpawnAd.ts | 138 -----------------------
src/core/execution/FakeHumanExecution.ts | 1 +
src/server/GameServer.ts | 5 +-
4 files changed, 3 insertions(+), 142 deletions(-)
delete mode 100644 src/client/graphics/layers/SpawnAd.ts
diff --git a/src/client/Main.ts b/src/client/Main.ts
index ade4758385..83b5d844e7 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -12,6 +12,7 @@ import { DarkModeButton } from "./DarkModeButton";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
import { FlagInputModal } from "./FlagInputModal";
+import { GameType } from "../core/game/Game";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
diff --git a/src/client/graphics/layers/SpawnAd.ts b/src/client/graphics/layers/SpawnAd.ts
deleted file mode 100644
index b2868d470c..0000000000
--- a/src/client/graphics/layers/SpawnAd.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { LitElement, css, html } from "lit";
-import { customElement, state } from "lit/decorators.js";
-import { translateText } from "../../../client/Utils";
-import { GameType } from "../../../core/game/Game";
-import { GameView } from "../../../core/game/GameView";
-import { getGamesPlayed } from "../../Utils";
-import { Layer } from "./Layer";
-
-const AD_TYPE = "bottom_rail";
-const AD_CONTAINER_ID = "bottom-rail-ad-container";
-
-@customElement("spawn-ad")
-export class SpawnAd extends LitElement implements Layer {
- public g: GameView;
-
- @state()
- private isVisible: boolean = false;
-
- @state()
- private adLoaded: boolean = false;
-
- private gamesPlayed: number = 0;
-
- // Override createRenderRoot to disable shadow DOM
- createRenderRoot() {
- return this;
- }
-
- static styles = css``;
-
- constructor() {
- super();
- }
-
- init() {
- this.gamesPlayed = getGamesPlayed();
- }
-
- public show(): void {
- this.isVisible = true;
- this.loadAd();
- this.requestUpdate();
- }
-
- public hide(): void {
- // Destroy the ad when hiding
- this.destroyAd();
- this.isVisible = false;
- this.adLoaded = false;
- this.requestUpdate();
- }
-
- public async tick() {
- if (this.g.config().gameConfig().gameType === GameType.Singleplayer) {
- return;
- }
-
- if (
- !this.isVisible &&
- this.g.inSpawnPhase() &&
- this.g.ticks() > 10 &&
- this.gamesPlayed > 5
- ) {
- this.show();
- }
- if (this.isVisible && !this.g.inSpawnPhase()) {
- this.hide();
- }
- }
-
- private loadAd(): void {
- if (!window.ramp) {
- console.warn("Playwire RAMP not available");
- return;
- }
- if (this.adLoaded) {
- console.log("Ad already loaded, skipping");
- return;
- }
- try {
- window.ramp.que.push(() => {
- window.ramp.spaAddAds([
- {
- type: AD_TYPE,
- selectorId: AD_CONTAINER_ID,
- },
- ]);
- this.adLoaded = true;
- console.log("Playwire ad loaded:", AD_TYPE);
- });
- } catch (error) {
- console.error("Failed to load Playwire ad:", error);
- }
- }
-
- private destroyAd(): void {
- if (!window.ramp || !this.adLoaded) {
- return;
- }
- try {
- window.ramp.que.push(() => {
- window.ramp.destroyUnits("all");
- console.log("Playwire spawn ad destroyed");
- });
- } catch (error) {
- console.error("Failed to destroy Playwire ad:", error);
- }
- }
-
- disconnectedCallback() {
- super.disconnectedCallback();
- // Clean up ad when component is removed
- this.destroyAd();
- }
-
- render() {
- if (!this.isVisible) {
- return html``;
- }
-
- return html`
-
-
- ${!this.adLoaded
- ? html`${translateText("spawn_ad.loading")}`
- : ""}
-
-
- `;
- }
-}
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index 03bdf20db4..10805d4b17 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -24,6 +24,7 @@ import { EmojiExecution } from "./EmojiExecution";
import { MirvExecution } from "./MIRVExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { NukeExecution } from "./NukeExecution";
+import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { calculateTerritoryCenter, closestTwoTiles } from "./Util";
import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior";
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 8f3c3abe1d..64e6b0f2bb 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -430,10 +430,7 @@ export class GameServer {
}
this.gameStartInfo = result.data satisfies GameStartInfo;
- const turnInterval =
- this.gameConfig.gameType === GameType.Singleplayer
- ? 10
- : this.config.turnIntervalMs();
+ const turnInterval = this.config.turnIntervalMs();
this.endTurnIntervalID = setInterval(() => this.endTurn(), turnInterval);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
From a5920f1b2dbfff708fcd6fb4852bc586e87f53e9 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 11:44:24 +0100
Subject: [PATCH 07/21] turnInterval unification feedback
---
src/core/configuration/Config.ts | 4 +++-
src/core/configuration/DefaultConfig.ts | 12 ++++++------
src/server/GameManager.ts | 3 +--
src/server/GameServer.ts | 2 +-
4 files changed, 11 insertions(+), 10 deletions(-)
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index abe0f0018c..01c53a0fa8 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -5,6 +5,7 @@ import {
Game,
GameMapType,
GameMode,
+ GameType,
Gold,
Player,
PlayerInfo,
@@ -27,7 +28,7 @@ export enum GameEnv {
}
export interface ServerConfig {
- turnIntervalMs(): number;
+ turnIntervalMs(gameType?: GameType): number;
gameCreationRate(): number;
lobbyMaxPlayers(
map: GameMapType,
@@ -72,6 +73,7 @@ export interface NukeMagnitude {
}
export interface Config {
+ turnIntervalMs(gameType?: GameType): number;
samHittingChance(): number;
samWarheadHittingChance(): number;
spawnImmunityDuration(): Tick;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 91d12daa08..4ecdf47ac5 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -172,7 +172,10 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
abstract numWorkers(): number;
abstract env(): GameEnv;
- turnIntervalMs(): number {
+ turnIntervalMs(gameType?: GameType): number {
+ if (gameType && gameType === GameType.Singleplayer) {
+ return 10;
+ }
return 100;
}
gameCreationRate(): number {
@@ -273,11 +276,8 @@ export class DefaultConfig implements Config {
return this._serverConfig;
}
- turnIntervalMs(): number {
- if (this._gameConfig.gameType === GameType.Singleplayer) {
- return 10;
- }
- return this._serverConfig.turnIntervalMs();
+ turnIntervalMs(gameType?: GameType): number {
+ return this._serverConfig.turnIntervalMs(gameType);
}
userSettings(): UserSettings {
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index 5794246df0..d9b8dd6c1d 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -89,8 +89,7 @@ export class GameManager {
// Prestart tells clients to start loading the game.
game.prestart();
// Start game immediately for single player games, with delay for multiplayer games
- const startDelay =
- game.gameConfig.gameType === GameType.Singleplayer ? 0 : 2000;
+ const startDelay = this.config.turnIntervalMs(game.gameConfig.gameType);
setTimeout(() => {
try {
game.start();
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 64e6b0f2bb..b5d22c79df 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -430,7 +430,7 @@ export class GameServer {
}
this.gameStartInfo = result.data satisfies GameStartInfo;
- const turnInterval = this.config.turnIntervalMs();
+ const turnInterval = this.config.turnIntervalMs(this.gameConfig.gameType);
this.endTurnIntervalID = setInterval(() => this.endTurn(), turnInterval);
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
From 1ae7884cc7bb8d15a7dba969756fa8dfa97087eb Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 11:51:15 +0100
Subject: [PATCH 08/21] startDelay encapsulation feedback
---
src/client/Main.ts | 2 +-
src/core/configuration/Config.ts | 1 +
src/core/configuration/DefaultConfig.ts | 6 +++++-
src/server/GameManager.ts | 2 +-
tests/util/TestServerConfig.ts | 5 ++++-
5 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 83b5d844e7..3700a34fe0 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -3,6 +3,7 @@ import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
+import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { joinLobby } from "./ClientGameRunner";
@@ -12,7 +13,6 @@ import { DarkModeButton } from "./DarkModeButton";
import "./FlagInput";
import { FlagInput } from "./FlagInput";
import { FlagInputModal } from "./FlagInputModal";
-import { GameType } from "../core/game/Game";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 01c53a0fa8..255ff631ba 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -29,6 +29,7 @@ export enum GameEnv {
export interface ServerConfig {
turnIntervalMs(gameType?: GameType): number;
+ startDelay(gameType: GameType): number;
gameCreationRate(): number;
lobbyMaxPlayers(
map: GameMapType,
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 4ecdf47ac5..02aa1dca8a 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -178,6 +178,10 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
return 100;
}
+ // Start game immediately for single player games, with delay for multiplayer games
+ startDelay(gameType: GameType): number {
+ return gameType === GameType.Singleplayer ? 0 : 2000;
+ }
gameCreationRate(): number {
return 60 * 1000;
}
@@ -277,7 +281,7 @@ export class DefaultConfig implements Config {
}
turnIntervalMs(gameType?: GameType): number {
- return this._serverConfig.turnIntervalMs(gameType);
+ return this._serverConfig.turnIntervalMs(gameType);
}
userSettings(): UserSettings {
diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts
index d9b8dd6c1d..2096fa3fa5 100644
--- a/src/server/GameManager.ts
+++ b/src/server/GameManager.ts
@@ -89,7 +89,7 @@ export class GameManager {
// Prestart tells clients to start loading the game.
game.prestart();
// Start game immediately for single player games, with delay for multiplayer games
- const startDelay = this.config.turnIntervalMs(game.gameConfig.gameType);
+ const startDelay = this.config.startDelay(game.gameConfig.gameType);
setTimeout(() => {
try {
game.start();
diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts
index 6f20fa1cd6..d1a5f58748 100644
--- a/tests/util/TestServerConfig.ts
+++ b/tests/util/TestServerConfig.ts
@@ -1,6 +1,6 @@
import { JWK } from "jose";
import { GameEnv, ServerConfig } from "../../src/core/configuration/Config";
-import { GameMapType } from "../../src/core/game/Game";
+import { GameMapType, GameType } from "../../src/core/game/Game";
import { GameID } from "../../src/core/Schemas";
export class TestServerConfig implements ServerConfig {
@@ -64,6 +64,9 @@ export class TestServerConfig implements ServerConfig {
numWorkers(): number {
throw new Error("Method not implemented.");
}
+ startDelay(gameType: GameType): number {
+ throw new Error("Method not implemented.");
+ }
workerIndex(gameID: GameID): number {
throw new Error("Method not implemented.");
}
From 0b12d89a45b9bc82f7b816b1d60db751564a645a Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 12:51:58 +0100
Subject: [PATCH 09/21] Remove out of scope changes
---
src/core/GameRunner.ts | 9 ---------
1 file changed, 9 deletions(-)
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 3b6c8dca40..9302b0c9a5 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -187,15 +187,6 @@ export class GameRunner {
);
}
- if (this.nationsSpawned && this.game.ticks() % 2 === 0) {
- this.game
- .players()
- .filter((p) => p.type() === PlayerType.FakeHuman)
- .forEach(
- (p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
- );
- }
-
if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
From 75a4f17b4f85438322fbbca7a6784f29a48b6210 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 14:04:04 +0100
Subject: [PATCH 10/21] Spawn nations on the first tick for all games
---
src/core/GameRunner.ts | 23 +++--------------------
src/core/execution/FakeHumanExecution.ts | 3 ++-
2 files changed, 5 insertions(+), 21 deletions(-)
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 9302b0c9a5..9c49222b12 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -1,7 +1,6 @@
import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
-import { SpawnExecution } from "./execution/SpawnExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
@@ -95,7 +94,6 @@ export class GameRunner {
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
- private nationsSpawned = false;
private playerViewData: Record = {};
@@ -114,21 +112,11 @@ export class GameRunner {
...this.execManager.spawnBots(this.game.config().numBots()),
);
}
-
- this.game.addExecution(new WinCheckExecution());
- }
-
- private spawnNationsWithDelay() {
if (this.game.config().spawnNPCs()) {
- for (const nation of this.game.nations()) {
- const tile = this.game.ref(nation.spawnCell.x, nation.spawnCell.y);
- const spawnExecution = new SpawnExecution(nation.playerInfo, tile);
- this.game.addExecution(spawnExecution);
- }
-
- const nationExecutions = this.execManager.fakeHumanExecutions();
- this.game.addExecution(...nationExecutions);
+ this.game.addExecution(...this.execManager.fakeHumanExecutions());
}
+
+ this.game.addExecution(new WinCheckExecution());
}
public addTurn(turn: Turn): void {
@@ -170,11 +158,6 @@ export class GameRunner {
return;
}
- if (!this.nationsSpawned && !this.game.inSpawnPhase()) {
- this.spawnNationsWithDelay();
- this.nationsSpawned = true;
- }
-
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
this.game
.players()
diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts
index 10805d4b17..67787f7b7b 100644
--- a/src/core/execution/FakeHumanExecution.ts
+++ b/src/core/execution/FakeHumanExecution.ts
@@ -149,7 +149,8 @@ export class FakeHumanExecution implements Execution {
this.trackTradeShipsAndRetaliate();
}
- if (ticks % this.attackRate !== this.attackTick) {
+ // Tries to spawn nations on the first tick of the match
+ if (ticks > 1 && ticks % this.attackRate !== this.attackTick) {
return;
}
From 16460955ff32ec629e3dfa543ee4d119952986fe Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 14:16:41 +0100
Subject: [PATCH 11/21] Add back spawntimer to singleplayer
---
src/client/graphics/layers/SpawnTimer.ts | 5 -----
1 file changed, 5 deletions(-)
diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts
index 13fbcf741b..393cf96d4d 100644
--- a/src/client/graphics/layers/SpawnTimer.ts
+++ b/src/client/graphics/layers/SpawnTimer.ts
@@ -32,11 +32,6 @@ export class SpawnTimer extends LitElement implements Layer {
tick() {
if (this.game.inSpawnPhase()) {
- if (this.game.config().gameConfig().gameType === GameType.Singleplayer) {
- this.ratios = [];
- this.colors = [];
- return;
- }
// During spawn phase, only one segment filling full width
this.ratios = [
this.game.ticks() / this.game.config().numSpawnPhaseTurns(),
From 1c264f855dca0dbe99d2570a3576ff03869c43c1 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 14:49:08 +0100
Subject: [PATCH 12/21] rever out of scope changed to bot execution
---
src/core/execution/BotExecution.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index b106a5426f..f559535d3a 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -8,7 +8,7 @@ export class BotExecution implements Execution {
private random: PseudoRandom;
private mg: Game;
private neighborsTerraNullius = true;
- private initializationDelay = 50;
+ private initializationDelay = 0;
private behavior: BotBehavior | null = null;
private attackRate: number;
@@ -54,6 +54,9 @@ export class BotExecution implements Execution {
this.expandRatio,
);
+ // Send an attack on the first tick
+ this.behavior.sendAttack(this.mg.terraNullius());
+
return;
}
@@ -84,7 +87,7 @@ export class BotExecution implements Execution {
if (
this.neighborsTerraNullius &&
- this.mg.ticks() > this.initializationDelay + 100
+ this.mg.ticks() > this.initializationDelay
) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
this.behavior.sendAttack(this.mg.terraNullius());
From 6a592c473162ecd03020a63d2d1bacb5b18194fa Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 14:57:37 +0100
Subject: [PATCH 13/21] Other out of scope changes: bot behaviour, preloading
games
---
src/client/Main.ts | 1 -
src/core/execution/SpawnExecution.ts | 5 +----
2 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 3700a34fe0..ade4758385 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -3,7 +3,6 @@ import { UserMeResponse } from "../core/ApiSchemas";
import { EventBus } from "../core/EventBus";
import { GameRecord, GameStartInfo, ID } from "../core/Schemas";
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
-import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { joinLobby } from "./ClientGameRunner";
diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts
index 0282415f36..03c699bc38 100644
--- a/src/core/execution/SpawnExecution.ts
+++ b/src/core/execution/SpawnExecution.ts
@@ -25,10 +25,7 @@ export class SpawnExecution implements Execution {
return;
}
- if (
- !this.mg.inSpawnPhase() &&
- this.playerInfo.playerType !== PlayerType.FakeHuman
- ) {
+ if (!this.mg.inSpawnPhase()) {
this.active = false;
return;
}
From dc73692cd9e72b1ad2d79e2937f311ada94944af Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 19:17:21 +0100
Subject: [PATCH 14/21] add grace period to config (1.5s = 15 turns) for player
to change placement
---
src/client/ClientGameRunner.ts | 2 +-
src/core/configuration/Config.ts | 1 +
src/core/configuration/DefaultConfig.ts | 4 ++++
src/core/game/GameImpl.ts | 24 ++++++++++++++++++------
src/core/game/GameView.ts | 24 ++++++++++++++++++------
5 files changed, 42 insertions(+), 13 deletions(-)
diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts
index 3a9b436d25..c3c093426d 100644
--- a/src/client/ClientGameRunner.ts
+++ b/src/client/ClientGameRunner.ts
@@ -12,7 +12,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
-import { GameType, PlayerActions, UnitType } from "../core/game/Game";
+import { PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 255ff631ba..64905e2f76 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -93,6 +93,7 @@ export interface Config {
instantBuild(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
+ numSingleplayerGracePeriodTurns(): number;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 02aa1dca8a..6fbf6f9ed0 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -639,6 +639,10 @@ export class DefaultConfig implements Config {
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
+ // Amount of ticks for player to change spawn placement in singleplayer
+ numSingleplayerGracePeriodTurns(): number {
+ return 15;
+ }
numBots(): number {
return this.bots();
}
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 414292a517..3a4765bc88 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -70,6 +70,8 @@ export class GameImpl implements Game {
private _height: number;
_terraNullius: TerraNulliusImpl;
+ private firstHumanSpawnTurn: number;
+
allianceRequests: AllianceRequestImpl[] = [];
alliances_: AllianceImpl[] = [];
@@ -339,15 +341,25 @@ export class GameImpl implements Game {
}
inSpawnPhase(): boolean {
+ if (this._ticks > this.config().numSpawnPhaseTurns()) {
+ return false;
+ }
if (this.config().gameConfig().gameType === GameType.Singleplayer) {
- const hasHumanSpawned = Array.from(this._players.values()).some(
- (player) => player.type() === PlayerType.Human && player.hasSpawned(),
- );
- if (hasHumanSpawned) {
- return false;
+ if (!this.firstHumanSpawnTurn) {
+ this.firstHumanSpawnTurn = Array.from(this._players.values()).some(
+ (player) => player.type() === PlayerType.Human && player.hasSpawned(),
+ )
+ ? this._ticks
+ : 0;
+ } else {
+ return (
+ this._ticks <=
+ this.firstHumanSpawnTurn +
+ this.config().numSingleplayerGracePeriodTurns()
+ );
}
}
- return this._ticks <= this.config().numSpawnPhaseTurns();
+ return true;
}
ticks(): number {
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index bb877c5459..08c94293f2 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -476,6 +476,8 @@ export class GameView implements GameMap {
private _map: GameMap;
+ private firstHumanSpawnTurn: number;
+
constructor(
public worker: WorkerClient,
private _config: Config,
@@ -656,15 +658,25 @@ export class GameView implements GameMap {
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
+ if (this.ticks() > this._config.numSpawnPhaseTurns()) {
+ return false;
+ }
if (this._config.gameConfig().gameType === GameType.Singleplayer) {
- const hasHumanSpawned = Array.from(this._players.values()).some(
- (player) => player.type() === PlayerType.Human && player.hasSpawned(),
- );
- if (hasHumanSpawned) {
- return false;
+ if (!this.firstHumanSpawnTurn) {
+ this.firstHumanSpawnTurn = Array.from(this._players.values()).some(
+ (player) => player.type() === PlayerType.Human && player.hasSpawned(),
+ )
+ ? this.ticks()
+ : 0;
+ } else {
+ return (
+ this.ticks() <=
+ this.firstHumanSpawnTurn +
+ this._config.numSingleplayerGracePeriodTurns()
+ );
}
}
- return this.ticks() <= this._config.numSpawnPhaseTurns();
+ return true;
}
config(): Config {
return this._config;
From f4aaca22c86edfcab3ee8094f7effd637dc9e0ea Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 19:21:20 +0100
Subject: [PATCH 15/21] Remove redundant delay
---
src/core/execution/BotExecution.ts | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index f559535d3a..953e09ff97 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -8,7 +8,6 @@ export class BotExecution implements Execution {
private random: PseudoRandom;
private mg: Game;
private neighborsTerraNullius = true;
- private initializationDelay = 0;
private behavior: BotBehavior | null = null;
private attackRate: number;
@@ -35,8 +34,6 @@ export class BotExecution implements Execution {
}
tick(ticks: number) {
- if (ticks < this.initializationDelay) return;
-
if (ticks % this.attackRate !== this.attackTick) return;
if (!this.bot.isAlive()) {
@@ -85,10 +82,7 @@ export class BotExecution implements Execution {
}
}
- if (
- this.neighborsTerraNullius &&
- this.mg.ticks() > this.initializationDelay
- ) {
+ if (this.neighborsTerraNullius) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
this.behavior.sendAttack(this.mg.terraNullius());
return;
From 023eac925556a5b46b420629db4228cdbd9520a0 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Tue, 2 Dec 2025 22:22:01 +0100
Subject: [PATCH 16/21] Style
---
src/core/GameRunner.ts | 1 -
src/core/execution/BotExecution.ts | 1 -
2 files changed, 2 deletions(-)
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 9c49222b12..6a8a4042f3 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -115,7 +115,6 @@ export class GameRunner {
if (this.game.config().spawnNPCs()) {
this.game.addExecution(...this.execManager.fakeHumanExecutions());
}
-
this.game.addExecution(new WinCheckExecution());
}
diff --git a/src/core/execution/BotExecution.ts b/src/core/execution/BotExecution.ts
index 953e09ff97..69f1f4f296 100644
--- a/src/core/execution/BotExecution.ts
+++ b/src/core/execution/BotExecution.ts
@@ -53,7 +53,6 @@ export class BotExecution implements Execution {
// Send an attack on the first tick
this.behavior.sendAttack(this.mg.terraNullius());
-
return;
}
From 4d3359d5504c1bb66a90db4c1d1c817cb87a9fba Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Wed, 3 Dec 2025 07:47:34 +0100
Subject: [PATCH 17/21] Remove variable tickrate
---
src/core/configuration/Config.ts | 4 ++--
src/core/configuration/DefaultConfig.ts | 9 +++------
src/server/GameServer.ts | 6 ++++--
3 files changed, 9 insertions(+), 10 deletions(-)
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 64905e2f76..9ed86f77ab 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -28,7 +28,7 @@ export enum GameEnv {
}
export interface ServerConfig {
- turnIntervalMs(gameType?: GameType): number;
+ turnIntervalMs(): number;
startDelay(gameType: GameType): number;
gameCreationRate(): number;
lobbyMaxPlayers(
@@ -74,7 +74,7 @@ export interface NukeMagnitude {
}
export interface Config {
- turnIntervalMs(gameType?: GameType): number;
+ turnIntervalMs(): number;
samHittingChance(): number;
samWarheadHittingChance(): number;
spawnImmunityDuration(): Tick;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 6fbf6f9ed0..414de6d0a1 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -172,10 +172,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
}
abstract numWorkers(): number;
abstract env(): GameEnv;
- turnIntervalMs(gameType?: GameType): number {
- if (gameType && gameType === GameType.Singleplayer) {
- return 10;
- }
+ turnIntervalMs(): number {
return 100;
}
// Start game immediately for single player games, with delay for multiplayer games
@@ -280,8 +277,8 @@ export class DefaultConfig implements Config {
return this._serverConfig;
}
- turnIntervalMs(gameType?: GameType): number {
- return this._serverConfig.turnIntervalMs(gameType);
+ turnIntervalMs(): number {
+ return this._serverConfig.turnIntervalMs();
}
userSettings(): UserSettings {
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index b5d22c79df..04c87d5d75 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -430,8 +430,10 @@ export class GameServer {
}
this.gameStartInfo = result.data satisfies GameStartInfo;
- const turnInterval = this.config.turnIntervalMs(this.gameConfig.gameType);
- this.endTurnIntervalID = setInterval(() => this.endTurn(), turnInterval);
+ this.endTurnIntervalID = setInterval(
+ () => this.endTurn(),
+ this.config.turnIntervalMs(),
+ );
this.activeClients.forEach((c) => {
this.log.info("sending start message", {
clientID: c.clientID,
From 6b9dd7feedff53b0510c819be47ca6864b3aa4be Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Wed, 3 Dec 2025 07:51:47 +0100
Subject: [PATCH 18/21] Better variable name
---
src/core/game/GameImpl.ts | 8 ++++----
src/core/game/GameView.ts | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 3a4765bc88..06999c88a7 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -70,7 +70,7 @@ export class GameImpl implements Game {
private _height: number;
_terraNullius: TerraNulliusImpl;
- private firstHumanSpawnTurn: number;
+ private firstHumanSpawnTick: number;
allianceRequests: AllianceRequestImpl[] = [];
alliances_: AllianceImpl[] = [];
@@ -345,8 +345,8 @@ export class GameImpl implements Game {
return false;
}
if (this.config().gameConfig().gameType === GameType.Singleplayer) {
- if (!this.firstHumanSpawnTurn) {
- this.firstHumanSpawnTurn = Array.from(this._players.values()).some(
+ if (!this.firstHumanSpawnTick) {
+ this.firstHumanSpawnTick = Array.from(this._players.values()).some(
(player) => player.type() === PlayerType.Human && player.hasSpawned(),
)
? this._ticks
@@ -354,7 +354,7 @@ export class GameImpl implements Game {
} else {
return (
this._ticks <=
- this.firstHumanSpawnTurn +
+ this.firstHumanSpawnTick +
this.config().numSingleplayerGracePeriodTurns()
);
}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 08c94293f2..27186948e3 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -476,7 +476,7 @@ export class GameView implements GameMap {
private _map: GameMap;
- private firstHumanSpawnTurn: number;
+ private firstHumanSpawnTick: number;
constructor(
public worker: WorkerClient,
@@ -662,8 +662,8 @@ export class GameView implements GameMap {
return false;
}
if (this._config.gameConfig().gameType === GameType.Singleplayer) {
- if (!this.firstHumanSpawnTurn) {
- this.firstHumanSpawnTurn = Array.from(this._players.values()).some(
+ if (!this.firstHumanSpawnTick) {
+ this.firstHumanSpawnTick = Array.from(this._players.values()).some(
(player) => player.type() === PlayerType.Human && player.hasSpawned(),
)
? this.ticks()
@@ -671,7 +671,7 @@ export class GameView implements GameMap {
} else {
return (
this.ticks() <=
- this.firstHumanSpawnTurn +
+ this.firstHumanSpawnTick +
this._config.numSingleplayerGracePeriodTurns()
);
}
From a0a7fa74f4a82edb64c633f66a195757e5d1b6e9 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Wed, 3 Dec 2025 08:42:23 +0100
Subject: [PATCH 19/21] encapsulate isSpawnPhase calculation into default
config for reusability
---
src/core/configuration/Config.ts | 7 +++++-
src/core/configuration/DefaultConfig.ts | 20 ++++++++++++++++-
src/core/game/GameImpl.ts | 28 +++++++++---------------
src/core/game/GameView.ts | 29 +++++++++----------------
4 files changed, 45 insertions(+), 39 deletions(-)
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 9ed86f77ab..fd329e6587 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -93,7 +93,12 @@ export interface Config {
instantBuild(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
- numSingleplayerGracePeriodTurns(): number;
+ numGracePeriodTurns(): number;
+ isSpawnPhase(
+ ticks: number,
+ gameType: GameType,
+ firstHumanSpawnTick?: number,
+ ): boolean;
userSettings(): UserSettings;
playerTeams(): TeamCountConfig;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 414de6d0a1..c6c830f30f 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -633,13 +633,31 @@ export class DefaultConfig implements Config {
boatMaxNumber(): number {
return 3;
}
+
numSpawnPhaseTurns(): number {
return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300;
}
// Amount of ticks for player to change spawn placement in singleplayer
- numSingleplayerGracePeriodTurns(): number {
+ numGracePeriodTurns(): number {
return 15;
}
+ isSpawnPhase(
+ ticks: number,
+ gameType: GameType,
+ firstHumanSpawnTick?: number,
+ ): boolean {
+ if (ticks > this.numSpawnPhaseTurns()) {
+ return false;
+ }
+ if (gameType !== GameType.Singleplayer) {
+ return true;
+ }
+ if (!firstHumanSpawnTick) {
+ return true;
+ }
+ return ticks <= firstHumanSpawnTick + this.numGracePeriodTurns();
+ }
+
numBots(): number {
return this.bots();
}
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 06999c88a7..9605da8058 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -14,7 +14,6 @@ import {
Execution,
Game,
GameMode,
- GameType,
GameUpdates,
HumansVsNations,
MessageType,
@@ -341,24 +340,17 @@ export class GameImpl implements Game {
}
inSpawnPhase(): boolean {
- if (this._ticks > this.config().numSpawnPhaseTurns()) {
- return false;
- }
- if (this.config().gameConfig().gameType === GameType.Singleplayer) {
- if (!this.firstHumanSpawnTick) {
- this.firstHumanSpawnTick = Array.from(this._players.values()).some(
- (player) => player.type() === PlayerType.Human && player.hasSpawned(),
- )
- ? this._ticks
- : 0;
- } else {
- return (
- this._ticks <=
- this.firstHumanSpawnTick +
- this.config().numSingleplayerGracePeriodTurns()
- );
- }
+ if (!this.firstHumanSpawnTick) {
+ const humanSpawned = Array.from(this._players.values()).some(
+ (p) => p.type() === PlayerType.Human && p.hasSpawned(),
+ );
+ if (humanSpawned) this.firstHumanSpawnTick = this._ticks;
}
+ return this._config.isSpawnPhase(
+ this.ticks(),
+ this.config().gameConfig().gameType,
+ this.firstHumanSpawnTick,
+ );
return true;
}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index 27186948e3..d3f77d8674 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -9,7 +9,6 @@ import { WorkerClient } from "../worker/WorkerClient";
import {
Cell,
EmojiMessage,
- GameType,
GameUpdates,
Gold,
NameViewData,
@@ -658,25 +657,17 @@ export class GameView implements GameMap {
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
- if (this.ticks() > this._config.numSpawnPhaseTurns()) {
- return false;
- }
- if (this._config.gameConfig().gameType === GameType.Singleplayer) {
- if (!this.firstHumanSpawnTick) {
- this.firstHumanSpawnTick = Array.from(this._players.values()).some(
- (player) => player.type() === PlayerType.Human && player.hasSpawned(),
- )
- ? this.ticks()
- : 0;
- } else {
- return (
- this.ticks() <=
- this.firstHumanSpawnTick +
- this._config.numSingleplayerGracePeriodTurns()
- );
- }
+ if (!this.firstHumanSpawnTick) {
+ const humanSpawned = this.playerViews().some(
+ (p) => p.type() === PlayerType.Human && p.hasSpawned(),
+ );
+ if (humanSpawned) this.firstHumanSpawnTick = this.ticks();
}
- return true;
+ return this.config().isSpawnPhase(
+ this.ticks(),
+ this.config().gameConfig().gameType,
+ this.firstHumanSpawnTick,
+ );
}
config(): Config {
return this._config;
From d6d35b9826379af0f50177fe3ad8102063db7902 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Wed, 3 Dec 2025 09:04:08 +0100
Subject: [PATCH 20/21] redundant return statement
---
src/core/game/GameImpl.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index 9605da8058..f3dc53e142 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -351,7 +351,6 @@ export class GameImpl implements Game {
this.config().gameConfig().gameType,
this.firstHumanSpawnTick,
);
- return true;
}
ticks(): number {
From 2246a13479379269c76665b895c4a24d99837e55 Mon Sep 17 00:00:00 2001
From: Lavodan <21205085+Lavodan@users.noreply.github.com>
Date: Wed, 3 Dec 2025 09:24:40 +0100
Subject: [PATCH 21/21] Change default gameType from singleplayer to fix
failing tests
---
tests/util/Setup.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts
index 963b134c13..1622df5412 100644
--- a/tests/util/Setup.ts
+++ b/tests/util/Setup.ts
@@ -59,7 +59,7 @@ export async function setup(
gameMap: GameMapType.Asia,
gameMapSize: GameMapSize.Normal,
gameMode: GameMode.FFA,
- gameType: GameType.Singleplayer,
+ gameType: GameType.Public,
difficulty: Difficulty.Medium,
disableNPCs: false,
donateGold: false,