feat: remove spawn timer on singleplayer (#3199)

Resolves #1041 

## Description:

Remove the singleplayer spawn countdown so the game starts when the
player spawns, spawn nations immediately after player spawn, and align
game timer/max-timer timing with the new start point.

Added a singleplayer regression test for spawn-immunity timing
(GameImpl.test.ts) and updated spawn-phase loop tests to use gameType:
GameType.Public where singleplayer behavior is not under test (e.g.
MIRV/AI/Spawn/WinCheck-related suites), eliminating inSpawnPhase()
timeout hangs after the new singleplayer start logic.


https://github.com/user-attachments/assets/c07a585f-1153-490e-88ca-a91fc7ae5756

## 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:
aotumuri
This commit is contained in:
Aotumuri
2026-05-11 12:44:44 -07:00
committed by GitHub
parent 2b04c568fc
commit f1d162825e
46 changed files with 333 additions and 429 deletions
+2
View File
@@ -869,10 +869,12 @@ export interface Game extends GameMap {
// Immunity timer
isSpawnImmunityActive(): boolean;
isNationSpawnImmunityActive(): boolean;
elapsedGameSeconds(): number;
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
endSpawnPhase(): void;
executeNextTick(): GameUpdates;
drainPackedTileUpdates(): Uint32Array;
recordMotionPlan(record: MotionPlanRecord): void;
+29 -7
View File
@@ -76,6 +76,7 @@ export type CellString = string;
export class GameImpl implements Game {
private _ticks = 0;
private startTick: number | null = null;
private unInitExecs: Execution[] = [];
@@ -409,7 +410,18 @@ export class GameImpl implements Game {
}
inSpawnPhase(): boolean {
return this._ticks <= this.config().numSpawnPhaseTurns();
return this.startTick === null;
}
endSpawnPhase(): void {
if (this.startTick !== null) {
return;
}
this.startTick = this._ticks;
this.addUpdate({
type: GameUpdateType.SpawnPhaseEnd,
startTick: this.startTick,
});
}
ticks(): number {
@@ -819,20 +831,30 @@ export class GameImpl implements Game {
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().spawnImmunityDuration() >
this.ticks()
this.inSpawnPhase() ||
this.ticksSinceStart() < this.config().spawnImmunityDuration()
);
}
public elapsedGameSeconds(): number {
return this.ticksSinceStart() / 10;
}
public isNationSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().nationSpawnImmunityDuration() >
this.ticks()
this.inSpawnPhase() ||
this.ticksSinceStart() < this.config().nationSpawnImmunityDuration()
);
}
private ticksSinceStart(): number {
if (this.inSpawnPhase()) {
return 0;
}
return Math.max(0, this.ticks() - this.startTick!);
}
sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.Emoji,
+7
View File
@@ -66,6 +66,7 @@ export enum GameUpdateType {
RailroadSnapEvent,
ConquestEvent,
EmbargoEvent,
SpawnPhaseEnd,
GamePaused,
}
@@ -90,6 +91,7 @@ export type GameUpdate =
| RailroadSnapUpdate
| ConquestUpdate
| EmbargoUpdate
| SpawnPhaseEndUpdate
| GamePausedUpdate;
export interface BonusEventUpdate {
@@ -290,6 +292,11 @@ export interface EmbargoUpdate {
embargoedID: number;
}
export interface SpawnPhaseEndUpdate {
type: GameUpdateType.SpawnPhaseEnd;
startTick: Tick;
}
export interface GamePausedUpdate {
type: GameUpdateType.GamePaused;
paused: boolean;
+28 -6
View File
@@ -37,6 +37,7 @@ import {
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
SpawnPhaseEndUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
@@ -664,6 +665,7 @@ type TrainPlanState = {
export class GameView implements GameMap {
private lastUpdate: GameUpdateViewData | null;
private startTick: Tick | null = null;
private smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
@@ -799,6 +801,14 @@ export class GameView implements GameMap {
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
}
const spawnPhaseEndUpdate = gu.updates[GameUpdateType.SpawnPhaseEnd][0] as
| SpawnPhaseEndUpdate
| undefined;
if (spawnPhaseEndUpdate) {
this.startTick = spawnPhaseEndUpdate.startTick;
}
const myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
@@ -1215,21 +1225,33 @@ export class GameView implements GameMap {
return this.lastUpdate.tick;
}
inSpawnPhase(): boolean {
return this.ticks() <= this._config.numSpawnPhaseTurns();
return this.startTick === null;
}
isSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() >
this.ticks()
this.inSpawnPhase() ||
this.ticksSinceStart() < this._config.spawnImmunityDuration()
);
}
isNationSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() +
this._config.nationSpawnImmunityDuration() >
this.ticks()
this.inSpawnPhase() ||
this.ticksSinceStart() < this._config.nationSpawnImmunityDuration()
);
}
elapsedGameSeconds(): number {
return this.ticksSinceStart() / 10;
}
ticksSinceStart(): Tick {
if (this.inSpawnPhase()) {
return 0;
}
return Math.max(0, this.ticks() - this.startTick!);
}
config(): Config {
return this._config;
}