Skip to content
Draft

spawn #3810

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ export class ClientGameRunner {
if (
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
this.gameView.myPlayer()
this.gameView.myPlayer()?.nameLocation()
) {
hasGoneToPlayer = true;
this.eventBus.emit(new GoToPlayerEvent(this.gameView.myPlayer()!, 8));
Expand Down
7 changes: 2 additions & 5 deletions src/client/graphics/layers/GameRightSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export class GameRightSidebar extends LitElement implements Layer {
this.game?.config()?.gameConfig()?.gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this._isVisible = true;
this.game.inSpawnPhase();

this.eventBus.on(SpawnBarVisibleEvent, (e) => {
this.spawnBarVisible = e.visible;
Expand Down Expand Up @@ -113,10 +112,6 @@ export class GameRightSidebar extends LitElement implements Layer {
}

const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
const ticks = this.game.ticks();
const gameTicks = Math.max(0, ticks - spawnPhaseTurns);
const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second

if (this.game.inSpawnPhase()) {
this.timer =
Expand All @@ -126,6 +121,8 @@ export class GameRightSidebar extends LitElement implements Layer {
return;
}

const elapsedSeconds = Math.floor(this.game.elapsedGameSeconds());

if (this.hasWinner) {
return;
}
Expand Down
13 changes: 12 additions & 1 deletion src/client/graphics/layers/SpawnTimer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { GameMode, Team } from "../../../core/game/Game";
import { GameMode, GameType, Team } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
Expand Down Expand Up @@ -41,6 +41,17 @@ export class SpawnTimer extends LitElement implements Layer {
}

tick() {
if (
this.game.config().gameConfig().gameType === GameType.Singleplayer &&
this.game.inSpawnPhase()
) {
// Singleplayer has no spawn countdown.
this.ratios = [];
this.colors = [];
this.requestUpdate();
return;
}

if (this.game.inSpawnPhase()) {
// During spawn phase, only one segment filling full width
this.ratios = [
Expand Down
13 changes: 12 additions & 1 deletion src/core/GameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { placeName } from "../client/graphics/NameBoxCalculator";
import { getGameLogicConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
BuildableUnit,
Game,
GameType,
GameUpdates,
NameViewData,
Player,
Expand Down Expand Up @@ -104,6 +106,9 @@ export class GameRunner {
if (this.game.config().spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) {
this.game.addExecution(new SpawnTimerExecution());
}
this.game.addExecution(new WinCheckExecution());
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
this.game.addExecution(
Expand All @@ -130,6 +135,7 @@ export class GameRunner {
);
this.currTurn++;

const wasInSpawnPhase = this.game.inSpawnPhase();
let updates: GameUpdates;
let tickExecutionDuration: number = 0;

Expand Down Expand Up @@ -164,7 +170,12 @@ export class GameRunner {
);
}

if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
const spawnJustEnded = wasInSpawnPhase && !this.game.inSpawnPhase();
if (
spawnJustEnded ||
this.game.ticks() < 3 ||
this.game.ticks() % 30 === 0
) {
this.game.players().forEach((p) => {
this.playerViewData[p.id()] = placeName(this.game, p);
});
Expand Down
15 changes: 10 additions & 5 deletions src/core/execution/SpawnExecution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Execution,
Game,
GameType,
Player,
PlayerInfo,
PlayerType,
Expand Down Expand Up @@ -39,11 +40,6 @@ export class SpawnExecution implements Execution {
tick(ticks: number) {
this.active = false;

if (!this.mg.inSpawnPhase()) {
this.active = false;
return;
}

let player: Player | null = null;
if (this.mg.hasPlayer(this.playerInfo.id)) {
player = this.mg.player(this.playerInfo.id);
Expand Down Expand Up @@ -76,6 +72,15 @@ export class SpawnExecution implements Execution {
}

player.setSpawnTile(spawn.center);

if (
this.mg.config().gameConfig().gameType === GameType.Singleplayer &&
this.playerInfo.playerType === PlayerType.Human
) {
// In singleplayer, spawn ends when player selects
// a spawn location.
this.mg.endSpawnPhase();
}
}

isActive(): boolean {
Expand Down
23 changes: 23 additions & 0 deletions src/core/execution/SpawnTimerExecution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Execution, Game } from "../game/Game";

export class SpawnTimerExecution implements Execution {
private mg: Game;

init(mg: Game, ticks: number): void {
this.mg = mg;
}

tick(ticks: number): void {
if (this.mg.ticks() > this.mg.config().numSpawnPhaseTurns()) {
this.mg.endSpawnPhase();
}
Comment on lines +10 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

End spawn phase on the configured tick, not one tick later.

executeNextTick() runs executions before _ticks is incremented, so > delays the transition to numSpawnPhaseTurns() + 1. That shifts the countdown, startTick, and spawn-immunity window by one tick.

Suggested change
   tick(ticks: number): void {
-    if (this.mg.ticks() > this.mg.config().numSpawnPhaseTurns()) {
+    if (this.mg.ticks() >= this.mg.config().numSpawnPhaseTurns()) {
       this.mg.endSpawnPhase();
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tick(ticks: number): void {
if (this.mg.ticks() > this.mg.config().numSpawnPhaseTurns()) {
this.mg.endSpawnPhase();
}
tick(ticks: number): void {
if (this.mg.ticks() >= this.mg.config().numSpawnPhaseTurns()) {
this.mg.endSpawnPhase();
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/execution/SpawnTimerExecution.ts` around lines 10 - 13, In
SpawnTimerExecution.tick, the spawn phase is being ended one tick late because
executeNextTick runs before _ticks increments; change the comparison in
tick(ticks: number) to end the spawn phase when this.mg.ticks() is greater than
or equal to the configured limit (use >= with
this.mg.config().numSpawnPhaseTurns()) so endSpawnPhase() is called exactly on
the configured tick.

}

isActive(): boolean {
return this.mg.inSpawnPhase();
}

activeDuringSpawnPhase(): boolean {
return true;
}
}
6 changes: 2 additions & 4 deletions src/core/execution/WinCheckExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ export class WinCheckExecution implements Execution {
}

const max = sorted[0];
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const timeElapsed = this.mg.elapsedGameSeconds();
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
if (
Expand Down Expand Up @@ -100,8 +99,7 @@ export class WinCheckExecution implements Execution {
return;
}
const max = sorted[0];
const timeElapsed =
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
const timeElapsed = this.mg.elapsedGameSeconds();
const numTilesWithoutFallout =
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
const percentage = (max[1] / numTilesWithoutFallout) * 100;
Expand Down
2 changes: 2 additions & 0 deletions src/core/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,10 +865,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;
Expand Down
45 changes: 38 additions & 7 deletions src/core/game/GameImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Execution,
Game,
GameMode,
GameType,
GameUpdates,
HumansVsNations,
MessageType,
Expand Down Expand Up @@ -76,6 +77,7 @@ export type CellString = string;

export class GameImpl implements Game {
private _ticks = 0;
private startTick: number | null = null;

private unInitExecs: Execution[] = [];

Expand Down Expand Up @@ -409,7 +411,15 @@ export class GameImpl implements Game {
}

inSpawnPhase(): boolean {
return this._ticks <= this.config().numSpawnPhaseTurns();
return this.startTick === null;
}

endSpawnPhase(): void {
this.startTick = this._ticks;
this.addUpdate({
type: GameUpdateType.SpawnPhaseEnd,
startTick: this.startTick,
});
}

ticks(): number {
Expand Down Expand Up @@ -458,6 +468,17 @@ export class GameImpl implements Game {
for (const tile of waterChangedTiles) {
this.recordTileUpdate(tile);
}

if (
this.config().gameConfig().gameType !== GameType.Singleplayer &&
this._ticks === this.startTick
) {
this.addUpdate({
type: GameUpdateType.SpawnPhaseEnd,
startTick: this.startTick,
});
}
Comment on lines +472 to +480
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid emitting SpawnPhaseEnd twice in multiplayer.

On the tick where SpawnTimerExecution.tick() calls endSpawnPhase(), startTick has just been set to _ticks, so this block appends a second SpawnPhaseEnd update for the same transition. Clients and tests that expect one end event per transition will see duplicates here.

Suggested change
-    if (
-      this.config().gameConfig().gameType !== GameType.Singleplayer &&
-      this._ticks === this.startTick
-    ) {
-      this.addUpdate({
-        type: GameUpdateType.SpawnPhaseEnd,
-        startTick: this.startTick,
-      });
-    }
-
     this._ticks++;
     return this.updates;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
this.config().gameConfig().gameType !== GameType.Singleplayer &&
this._ticks === this.startTick
) {
this.addUpdate({
type: GameUpdateType.SpawnPhaseEnd,
startTick: this.startTick,
});
}
this._ticks++;
return this.updates;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/GameImpl.ts` around lines 472 - 480, This block can emit
SpawnPhaseEnd twice because endSpawnPhase() (called from
SpawnTimerExecution.tick()) can set startTick to _ticks just before this check;
change the guard in GameImpl to prevent duplicate emissions by tracking the last
tick when a SpawnPhaseEnd was emitted: add a private field like
_lastSpawnPhaseEndTick, only call addUpdate({ type:
GameUpdateType.SpawnPhaseEnd, startTick: this.startTick }) when this._ticks ===
this.startTick && this._lastSpawnPhaseEndTick !== this.startTick, and update
_lastSpawnPhaseEndTick = this.startTick when emitting; reference symbols:
GameImpl, this._ticks, this.startTick, addUpdate, GameUpdateType.SpawnPhaseEnd,
SpawnTimerExecution.tick(), endSpawnPhase().


this._ticks++;
return this.updates;
}
Expand Down Expand Up @@ -819,20 +840,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!);
}
Comment on lines +848 to +865
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep time as integers inside src/core.

elapsedGameSeconds() now depends on ticksSinceStart() / 10, which adds floating-point math to the core API. That is a poor fit for deterministic simulation code, especially now that core-side consumers use this helper too. Prefer keeping ticks or deciseconds as integers in src/core, and only convert to display seconds in UI code.

As per coding guidelines src/core/**/*.ts: Ensure deterministic behavior in src/core/ by using seeded PRNG and avoiding floating-point math.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/game/GameImpl.ts` around lines 848 - 865, elapsedGameSeconds()
introduces floating-point seconds into src/core; change core to expose integer
time (deciseconds) instead: replace or rename elapsedGameSeconds() with
elapsedGameDeciseconds() that returns this.ticksSinceStart() (an integer), keep
ticksSinceStart() and isNationSpawnImmunityActive() as-is, and update all
core-side callers to use elapsedGameDeciseconds() (convert to seconds only in
UI/adapter code by dividing by 10 there).


sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.Emoji,
Expand Down
7 changes: 7 additions & 0 deletions src/core/game/GameUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export enum GameUpdateType {
RailroadSnapEvent,
ConquestEvent,
EmbargoEvent,
SpawnPhaseEnd,
GamePaused,
}

Expand All @@ -90,6 +91,7 @@ export type GameUpdate =
| RailroadSnapUpdate
| ConquestUpdate
| EmbargoUpdate
| SpawnPhaseEndUpdate
| GamePausedUpdate;

export interface BonusEventUpdate {
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 28 additions & 6 deletions src/core/game/GameView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
SpawnPhaseEndUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
Expand Down Expand Up @@ -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>();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading