mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
35e6ee0d39
- Added support for rewriting game history through new TimelineRewriteHistoryEvent. - Enhanced ClientGameRunner to handle timeline jump and history rewrite events. - Introduced methods in LocalServer and Transport to manage rewrite state and truncate turns. - Updated TimelineController to facilitate history truncation and checkpoint management. - Improved TimelineArchive for efficient deletion of tick records and checkpoints after a specified tick. This commit enhances the timeline feature, allowing players to discard future events and rewrite history, improving gameplay flexibility.
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
import { z } from "zod";
|
|
import { EventBus } from "../core/EventBus";
|
|
import {
|
|
AllPlayersStats,
|
|
ClientID,
|
|
ClientMessage,
|
|
ClientSendWinnerMessage,
|
|
PartialGameRecordSchema,
|
|
PlayerRecord,
|
|
ServerMessage,
|
|
ServerStartGameMessage,
|
|
StampedIntent,
|
|
Turn,
|
|
} from "../core/Schemas";
|
|
import {
|
|
createPartialGameRecord,
|
|
decompressGameRecord,
|
|
getClanTag,
|
|
replacer,
|
|
} from "../core/Util";
|
|
import { getPersistentID } from "./Auth";
|
|
import { LobbyConfig } from "./ClientGameRunner";
|
|
import { ReplaySpeedChangeEvent } from "./InputHandler";
|
|
import {
|
|
defaultReplaySpeedMultiplier,
|
|
ReplaySpeedMultiplier,
|
|
} from "./utilities/ReplaySpeedMultiplier";
|
|
|
|
// build a small backlog so MAX can catch up.
|
|
const MAX_REPLAY_BACKLOG_TURNS = 60;
|
|
|
|
export class LocalServer {
|
|
// All turns from the game record on replay.
|
|
private replayTurns: Turn[] = [];
|
|
|
|
private turns: Turn[] = [];
|
|
|
|
private intents: StampedIntent[] = [];
|
|
private startedAt: number;
|
|
|
|
private paused = false;
|
|
private rewriteFrozen = false;
|
|
private replaySpeedMultiplier = defaultReplaySpeedMultiplier;
|
|
|
|
private clientID: ClientID | undefined;
|
|
private winner: ClientSendWinnerMessage | null = null;
|
|
private allPlayersStats: AllPlayersStats = {};
|
|
|
|
private turnsExecuted = 0;
|
|
private turnStartTime = 0;
|
|
|
|
private turnCheckInterval: NodeJS.Timeout;
|
|
private clientConnect: () => void;
|
|
private clientMessage: (message: ServerMessage) => void;
|
|
|
|
constructor(
|
|
private lobbyConfig: LobbyConfig,
|
|
private isReplay: boolean,
|
|
private eventBus: EventBus,
|
|
) {}
|
|
|
|
public updateCallback(
|
|
clientConnect: () => void,
|
|
clientMessage: (message: ServerMessage) => void,
|
|
) {
|
|
this.clientConnect = clientConnect;
|
|
this.clientMessage = clientMessage;
|
|
}
|
|
|
|
start() {
|
|
console.log("local server starting");
|
|
this.turnCheckInterval = setInterval(() => {
|
|
const turnIntervalMs =
|
|
this.lobbyConfig.serverConfig.turnIntervalMs() *
|
|
this.replaySpeedMultiplier;
|
|
const backlog = Math.max(0, this.turns.length - this.turnsExecuted);
|
|
const allowReplayBacklog =
|
|
this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest &&
|
|
this.lobbyConfig.gameRecord !== undefined;
|
|
const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0;
|
|
|
|
const canQueueNextTurn =
|
|
backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog);
|
|
if (
|
|
canQueueNextTurn &&
|
|
Date.now() > this.turnStartTime + turnIntervalMs
|
|
) {
|
|
this.turnStartTime = Date.now();
|
|
// End turn on the server means the client will start processing the turn.
|
|
this.endTurn();
|
|
}
|
|
}, 5);
|
|
|
|
this.eventBus.on(ReplaySpeedChangeEvent, (event) => {
|
|
this.replaySpeedMultiplier = event.replaySpeedMultiplier;
|
|
});
|
|
|
|
this.startedAt = Date.now();
|
|
this.clientConnect();
|
|
if (this.lobbyConfig.gameRecord) {
|
|
this.replayTurns = decompressGameRecord(
|
|
this.lobbyConfig.gameRecord,
|
|
).turns;
|
|
}
|
|
if (this.lobbyConfig.gameStartInfo === undefined) {
|
|
throw new Error("missing gameStartInfo");
|
|
}
|
|
this.clientID = this.lobbyConfig.gameStartInfo.players[0]?.clientID;
|
|
if (!this.clientID) {
|
|
throw new Error("missing clientID");
|
|
}
|
|
this.clientMessage({
|
|
type: "start",
|
|
gameStartInfo: this.lobbyConfig.gameStartInfo,
|
|
turns: [],
|
|
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
|
|
myClientID: this.clientID,
|
|
} satisfies ServerStartGameMessage);
|
|
}
|
|
|
|
onMessage(clientMsg: ClientMessage) {
|
|
if (clientMsg.type === "rejoin") {
|
|
if (!this.clientID) {
|
|
throw new Error("missing clientID");
|
|
}
|
|
this.clientMessage({
|
|
type: "start",
|
|
gameStartInfo: this.lobbyConfig.gameStartInfo!,
|
|
turns: this.turns,
|
|
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
|
|
myClientID: this.clientID,
|
|
} satisfies ServerStartGameMessage);
|
|
}
|
|
if (clientMsg.type === "intent") {
|
|
// Server stamps clientID - client doesn't send it
|
|
const stampedIntent = {
|
|
...clientMsg.intent,
|
|
clientID: this.clientID!,
|
|
};
|
|
if (stampedIntent.type === "toggle_pause") {
|
|
if (stampedIntent.paused) {
|
|
// Pausing: add intent and end turn before pause takes effect
|
|
this.intents.push(stampedIntent);
|
|
this.endTurn();
|
|
this.paused = true;
|
|
} else {
|
|
// Unpausing: clear pause flag before adding intent so next turn can execute
|
|
this.paused = false;
|
|
this.intents.push(stampedIntent);
|
|
this.endTurn();
|
|
}
|
|
return;
|
|
}
|
|
// Don't process non-pause intents during replays or while paused
|
|
if (this.lobbyConfig.gameRecord || this.paused) {
|
|
return;
|
|
}
|
|
|
|
this.intents.push(stampedIntent);
|
|
}
|
|
if (clientMsg.type === "hash") {
|
|
if (!this.lobbyConfig.gameRecord) {
|
|
if (clientMsg.turnNumber % 100 === 0) {
|
|
// In singleplayer, only store hash every 100 turns to reduce size of game record.
|
|
const turn = this.turns[clientMsg.turnNumber];
|
|
if (turn) {
|
|
turn.hash = clientMsg.hash;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// If we are replaying a game then verify hash.
|
|
const archivedHash = this.replayTurns[clientMsg.turnNumber].hash;
|
|
if (!archivedHash) {
|
|
console.warn(
|
|
`no archived hash found for turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}`,
|
|
);
|
|
return;
|
|
}
|
|
if (archivedHash !== clientMsg.hash) {
|
|
console.error(
|
|
`desync detected on turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}, server hash: ${archivedHash}`,
|
|
);
|
|
this.clientMessage({
|
|
type: "desync",
|
|
turn: clientMsg.turnNumber,
|
|
correctHash: archivedHash,
|
|
clientsWithCorrectHash: 0,
|
|
totalActiveClients: 1,
|
|
yourHash: clientMsg.hash,
|
|
});
|
|
} else {
|
|
console.log(
|
|
`hash verified on turn ${clientMsg.turnNumber}, client hash: ${clientMsg.hash}, server hash: ${archivedHash}`,
|
|
);
|
|
}
|
|
}
|
|
if (clientMsg.type === "winner") {
|
|
this.winner = clientMsg;
|
|
this.allPlayersStats = clientMsg.allPlayersStats;
|
|
}
|
|
}
|
|
|
|
// This is so the client can tell us when it finished processing the turn.
|
|
public turnComplete() {
|
|
this.turnsExecuted++;
|
|
}
|
|
|
|
// endTurn in this context means the server has collected all the intents
|
|
// and will send the turn to the client.
|
|
private endTurn() {
|
|
if (this.paused || this.rewriteFrozen) {
|
|
return;
|
|
}
|
|
if (this.replayTurns.length > 0) {
|
|
if (this.turns.length >= this.replayTurns.length) {
|
|
this.endGame();
|
|
return;
|
|
}
|
|
this.intents = this.replayTurns[this.turns.length].intents;
|
|
}
|
|
const pastTurn: Turn = {
|
|
turnNumber: this.turns.length,
|
|
intents: this.intents,
|
|
};
|
|
this.turns.push(pastTurn);
|
|
this.intents = [];
|
|
this.clientMessage({
|
|
type: "turn",
|
|
turn: pastTurn,
|
|
});
|
|
}
|
|
|
|
setRewriteFrozen(frozen: boolean): void {
|
|
this.rewriteFrozen = frozen;
|
|
}
|
|
|
|
truncateToTurnCount(turnCount: number): void {
|
|
const clamped = Math.max(0, Math.min(turnCount, this.turns.length));
|
|
this.turns = this.turns.slice(0, clamped);
|
|
this.intents = [];
|
|
|
|
// After rewriting history, the client will re-send turns to a fresh worker,
|
|
// so turnsExecuted must start from 0 to keep the backlog logic correct.
|
|
this.turnsExecuted = 0;
|
|
this.turnStartTime = Date.now();
|
|
}
|
|
|
|
public endGame() {
|
|
console.log("local server ending game");
|
|
clearInterval(this.turnCheckInterval);
|
|
if (this.isReplay) {
|
|
return;
|
|
}
|
|
const players: PlayerRecord[] = [
|
|
{
|
|
persistentID: getPersistentID(),
|
|
username: this.lobbyConfig.playerName,
|
|
clientID: this.clientID!,
|
|
stats: this.allPlayersStats[this.clientID!],
|
|
cosmetics: this.lobbyConfig.gameStartInfo?.players[0].cosmetics,
|
|
clanTag: getClanTag(this.lobbyConfig.playerName) ?? undefined,
|
|
},
|
|
];
|
|
if (this.lobbyConfig.gameStartInfo === undefined) {
|
|
throw new Error("missing gameStartInfo");
|
|
}
|
|
const record = createPartialGameRecord(
|
|
this.lobbyConfig.gameStartInfo.gameID,
|
|
this.lobbyConfig.gameStartInfo.config,
|
|
players,
|
|
this.turns,
|
|
this.startedAt,
|
|
Date.now(),
|
|
this.winner?.winner,
|
|
);
|
|
|
|
const result = PartialGameRecordSchema.safeParse(record);
|
|
if (!result.success) {
|
|
const error = z.prettifyError(result.error);
|
|
console.error("Error parsing game record", error);
|
|
return;
|
|
}
|
|
const workerPath = this.lobbyConfig.serverConfig.workerPath(
|
|
this.lobbyConfig.gameStartInfo.gameID,
|
|
);
|
|
|
|
const jsonString = JSON.stringify(result.data, replacer);
|
|
|
|
compress(jsonString)
|
|
.then((compressedData) => {
|
|
return fetch(`/${workerPath}/api/archive_singleplayer_game`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Encoding": "gzip",
|
|
},
|
|
body: compressedData,
|
|
keepalive: true, // Ensures request completes even if page unloads
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to archive singleplayer game:", error);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function compress(data: string): Promise<ArrayBuffer> {
|
|
const stream = new CompressionStream("gzip");
|
|
const writer = stream.writable.getWriter();
|
|
const reader = stream.readable.getReader();
|
|
|
|
// Write the data to the compression stream
|
|
writer.write(new TextEncoder().encode(data));
|
|
writer.close();
|
|
|
|
// Read the compressed data
|
|
const chunks: Uint8Array[] = [];
|
|
let done = false;
|
|
while (!done) {
|
|
const { value, done: readerDone } = await reader.read();
|
|
done = readerDone;
|
|
if (value) {
|
|
chunks.push(value);
|
|
}
|
|
}
|
|
|
|
// Combine all chunks into a single Uint8Array
|
|
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
|
const compressedData = new Uint8Array(totalLength);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
compressedData.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
|
|
return compressedData.buffer;
|
|
}
|