mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 02:25:40 +00:00
c911bfb2d8
## Description: Reduce per-step `Unit` update traffic by shipping packed motion plans and letting the client advance plan-driven units locally. Changes: - Add packed motion plan records (`packedMotionPlans?: Uint32Array`) to game updates and transfer the buffer worker -> main. - Introduce `src/core/game/MotionPlans.ts` (schema + pack/unpack) for grid + train motion plans. - Extend `Game` with `recordMotionPlan(...)` and `drainPackedMotionPlans()`, and implement buffering/packing in `GameImpl`. - Treat units with motion plans as “plan-driven”: suppress per-tile `Unit` updates on `move()` and advance positions client-side. - Emit motion plans from executions: - `TradeShipExecution`: record/update grid motion plans and `touch()` when changing target after capture. - `TransportShipExecution`: record initial plan and update it when destination changes. - `TrainExecution`: record a train plan on init (engine + cars). - Client: apply motion plans in `GameView` and ensure `UnitLayer` updates sprites for motion-planned units even when no `Unit` updates arrived. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] 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: DISCORD_USERNAME
274 lines
7.7 KiB
TypeScript
274 lines
7.7 KiB
TypeScript
import { placeName } from "../client/graphics/NameBoxCalculator";
|
|
import { getConfig } from "./configuration/ConfigLoader";
|
|
import { Executor } from "./execution/ExecutionManager";
|
|
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
|
|
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
|
import {
|
|
AllPlayers,
|
|
Attack,
|
|
Cell,
|
|
Game,
|
|
GameUpdates,
|
|
NameViewData,
|
|
Player,
|
|
PlayerActions,
|
|
PlayerBorderTiles,
|
|
PlayerID,
|
|
PlayerInfo,
|
|
PlayerProfile,
|
|
PlayerType,
|
|
UnitType,
|
|
} from "./game/Game";
|
|
import { createGame } from "./game/GameImpl";
|
|
import { TileRef } from "./game/GameMap";
|
|
import { GameMapLoader } from "./game/GameMapLoader";
|
|
import { ErrorUpdate, GameUpdateViewData } from "./game/GameUpdates";
|
|
import { createNationsForGame } from "./game/NationCreation";
|
|
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
|
import { PseudoRandom } from "./PseudoRandom";
|
|
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
|
import { simpleHash } from "./Util";
|
|
|
|
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.username,
|
|
PlayerType.Human,
|
|
p.clientID,
|
|
random.nextID(),
|
|
p.isLobbyCreator ?? false,
|
|
);
|
|
});
|
|
|
|
const nations = createNationsForGame(
|
|
gameStart,
|
|
gameMap.nations,
|
|
humans.length,
|
|
random,
|
|
);
|
|
|
|
const game: Game = createGame(
|
|
humans,
|
|
nations,
|
|
gameMap.gameMap,
|
|
gameMap.miniGameMap,
|
|
config,
|
|
gameMap.teamGameSpawnAreas,
|
|
);
|
|
|
|
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());
|
|
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
|
|
this.game.addExecution(
|
|
new RecomputeRailClusterExecution(this.game.railNetwork()),
|
|
);
|
|
}
|
|
}
|
|
|
|
public addTurn(turn: Turn): void {
|
|
this.turns.push(turn);
|
|
}
|
|
|
|
public executeNextTick(pendingTurns?: number): boolean {
|
|
if (this.isExecuting) {
|
|
return false;
|
|
}
|
|
if (this.currTurn >= this.turns.length) {
|
|
return false;
|
|
}
|
|
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);
|
|
}
|
|
this.isExecuting = false;
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
const packedTileUpdates = this.game.drainPackedTileUpdates();
|
|
const packedMotionPlans = this.game.drainPackedMotionPlans();
|
|
|
|
this.callBack({
|
|
tick: this.game.ticks(),
|
|
packedTileUpdates,
|
|
...(packedMotionPlans ? { packedMotionPlans } : {}),
|
|
updates: updates,
|
|
playerNameViewData: this.playerViewData,
|
|
tickExecutionDuration: tickExecutionDuration,
|
|
pendingTurns: pendingTurns ?? 0,
|
|
});
|
|
this.isExecuting = false;
|
|
return true;
|
|
}
|
|
|
|
public pendingTurns(): number {
|
|
return Math.max(0, this.turns.length - this.currTurn);
|
|
}
|
|
|
|
public playerActions(
|
|
playerID: PlayerID,
|
|
x?: number,
|
|
y?: number,
|
|
units?: UnitType[],
|
|
): PlayerActions {
|
|
const player = this.game.player(playerID);
|
|
const tile =
|
|
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
|
|
const actions = {
|
|
canAttack: tile !== null && units === undefined && player.canAttack(tile),
|
|
buildableUnits: player.buildableUnits(tile, units),
|
|
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),
|
|
allianceInfo: player.allianceInfo(other) ?? undefined,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|