Files
OpenFrontIO/src/core/GameRunner.ts
T
YoussfeCantCode 1c52d20e83 Added pause functionality for private multiplayer games (#2657)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #2491

## Description:
Adds pause/unpause functionality for private multiplayer games. Only the
lobby creator can pause the game, and all players see a pause overlay
when the game is paused.

  **Key features:**
- Lobby creator sees pause/play button in control panel (alongside
existing singleplayer/replay controls)
  - Server validates that only lobby creator can toggle pause
  - All players see "Game paused by Lobby Creator" overlay when paused
  - Game state freezes (no turn execution) while paused
  - Unpause resumes normal gameplay

  **Implementation details:**
- Server-side pause state (`isPaused`) prevents turn execution during
pause
- Each client receives `isLobbyCreator` flag in `GameStartInfo` to
show/hide pause button
- Added `TogglePauseIntent` that broadcasts to all clients via
`NoOpExecution`
  - New `PauseOverlay` component (shows in single player also)

## 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:

furo18

<img width="1459" height="861" alt="Screenshot 2025-12-20 at 15 16 33"
src="https://github.com/user-attachments/assets/f5a3222f-f54b-473c-b0f6-104ce4c1e7a8"
/>
2025-12-26 10:21:18 -08:00

269 lines
7.5 KiB
TypeScript

import { placeName } from "../client/graphics/NameBoxCalculator";
import { getConfig } from "./configuration/ConfigLoader";
import { Executor } from "./execution/ExecutionManager";
import { WinCheckExecution } from "./execution/WinCheckExecution";
import {
AllPlayers,
Attack,
Cell,
Game,
GameUpdates,
NameViewData,
Nation,
Player,
PlayerActions,
PlayerBorderTiles,
PlayerID,
PlayerInfo,
PlayerProfile,
PlayerType,
} from "./game/Game";
import { createGame } from "./game/GameImpl";
import { TileRef } from "./game/GameMap";
import { GameMapLoader } from "./game/GameMapLoader";
import {
ErrorUpdate,
GameUpdateType,
GameUpdateViewData,
} from "./game/GameUpdates";
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
import { simpleHash } from "./Util";
import { censorNameWithClanTag } from "./validations/username";
export async function createGameRunner(
gameStart: GameStartInfo,
clientID: ClientID,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
mapLoader,
);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
const humans = gameStart.players.map((p) => {
return new PlayerInfo(
p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
PlayerType.Human,
p.clientID,
random.nextID(),
p.isLobbyCreator ?? false,
);
});
const nations = gameStart.config.disableNations
? []
: gameMap.nations.map(
(n) =>
new Nation(
new Cell(n.coordinates[0], n.coordinates[1]),
new PlayerInfo(n.name, PlayerType.Nation, null, random.nextID()),
),
);
const game: Game = createGame(
humans,
nations,
gameMap.gameMap,
gameMap.miniGameMap,
config,
);
const gr = new GameRunner(
game,
new Executor(game, gameStart.gameID, clientID),
callBack,
);
gr.init();
return gr;
}
export class GameRunner {
private turns: Turn[] = [];
private currTurn = 0;
private isExecuting = false;
private playerViewData: Record<PlayerID, NameViewData> = {};
constructor(
public game: Game,
private execManager: Executor,
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
) {}
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()),
);
}
if (this.game.config().spawnNations()) {
this.game.addExecution(...this.execManager.nationExecutions());
}
this.game.addExecution(new WinCheckExecution());
}
public addTurn(turn: Turn): void {
this.turns.push(turn);
}
public executeNextTick() {
if (this.isExecuting) {
return;
}
if (this.currTurn >= this.turns.length) {
return;
}
this.isExecuting = true;
this.game.addExecution(
...this.execManager.createExecs(this.turns[this.currTurn]),
);
this.currTurn++;
let updates: GameUpdates;
let tickExecutionDuration: number = 0;
try {
const startTime = performance.now();
updates = this.game.executeNextTick();
const endTime = performance.now();
tickExecutionDuration = endTime - startTime;
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Game tick error:", error.message);
this.callBack({
errMsg: error.message,
stack: error.stack,
} as ErrorUpdate);
} else {
console.error("Game tick error:", error);
}
return;
}
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
this.game
.players()
.filter(
(p) =>
p.type() === PlayerType.Human || p.type() === PlayerType.Nation,
)
.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);
});
}
// Many tiles are updated to pack it into an array
const packedTileUpdates = updates[GameUpdateType.Tile].map((u) => u.update);
updates[GameUpdateType.Tile] = [];
this.callBack({
tick: this.game.ticks(),
packedTileUpdates: new BigUint64Array(packedTileUpdates),
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
}
public playerActions(
playerID: PlayerID,
x?: number,
y?: number,
): PlayerActions {
const player = this.game.player(playerID);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
canEmbargoAll: player.canEmbargoAll(),
} as PlayerActions;
if (tile !== null && this.game.hasOwner(tile)) {
const other = this.game.owner(tile) as Player;
actions.interaction = {
sharedBorder: player.sharesBorderWith(other),
canSendEmoji: player.canSendEmoji(other),
canTarget: player.canTarget(other),
canSendAllianceRequest: player.canSendAllianceRequest(other),
canBreakAlliance: player.isAlliedWith(other),
canDonateGold: player.canDonateGold(other),
canDonateTroops: player.canDonateTroops(other),
canEmbargo: !player.hasEmbargoAgainst(other),
};
const alliance = player.allianceWith(other as Player);
if (alliance) {
actions.interaction.allianceExpiresAt = alliance.expiresAt();
}
}
return actions;
}
public playerProfile(playerID: number): PlayerProfile {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.playerProfile();
}
public playerBorderTiles(playerID: PlayerID): PlayerBorderTiles {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return {
borderTiles: player.borderTiles(),
} as PlayerBorderTiles;
}
public attackAveragePosition(
playerID: number,
attackID: string,
): Cell | null {
const player = this.game.playerBySmallID(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
const condition = (a: Attack) => a.id() === attackID;
const attack =
player.outgoingAttacks().find(condition) ??
player.incomingAttacks().find(condition);
if (attack === undefined) {
return null;
}
return attack.averagePosition();
}
public bestTransportShipSpawn(
playerID: PlayerID,
targetTile: TileRef,
): TileRef | false {
const player = this.game.player(playerID);
if (!player.isPlayer()) {
throw new Error(`player with id ${playerID} not found`);
}
return player.bestTransportShipSpawn(targetTile);
}
}