diff --git a/src/lib/playback/engine.ts b/src/lib/playback/engine.ts index aa89617..b8de662 100644 --- a/src/lib/playback/engine.ts +++ b/src/lib/playback/engine.ts @@ -26,6 +26,45 @@ export function fetchGame( ) { console.debug(`[playback] loading game ${gameID}`); + /** + * Helper function to add a single frame, whether via live Websocket + * or replay JSON. + */ + // TODO(schoon): Real types for these arguments. + function ingestFrame(gameInfo: any, engineEvent: any) { + if (engineEvent.Type == "frame" && !loadedFrames.has(engineEvent.Data.Turn)) { + loadedFrames.add(engineEvent.Data.Turn); + + const frame = engineEventToFrame(gameInfo, engineEvent.Data); + frames.push(frame); + frames.sort((a: Frame, b: Frame) => a.turn - b.turn); + + // Fire frame callback + if (engineEvent.Data.Turn == 0) { + console.debug("[playback] received first frame"); + } + onFrameLoad(frame); + } else if (engineEvent.Type == "game_end") { + console.debug("[playback] received final frame"); + if (ws) ws.close(); + + // Flag last frame as the last one and fire callback + frames[frames.length - 1].isFinalFrame = true; + onFinalFrame(frames[frames.length - 1]); + } + } + + // Special "url" for local files. + if (engineURL === "local") { + console.log("Using local playback engine..."); + fetchFunc(`/replays/${gameID}`) + .then((response) => response.json()) + .then(({ info, frames }) => { + frames.forEach((frame: unknown) => ingestFrame(info, frame)); + }); + return; + } + // Reset if (ws) ws.close(); loadedFrames = new Set(); @@ -51,26 +90,7 @@ export function fetchGame( ws.onmessage = (message) => { const engineEvent = JSON.parse(message.data); - if (engineEvent.Type == "frame" && !loadedFrames.has(engineEvent.Data.Turn)) { - loadedFrames.add(engineEvent.Data.Turn); - - const frame = engineEventToFrame(gameInfo, engineEvent.Data); - frames.push(frame); - frames.sort((a: Frame, b: Frame) => a.turn - b.turn); - - // Fire frame callback - if (engineEvent.Data.Turn == 0) { - console.debug("[playback] received first frame"); - } - onFrameLoad(frame); - } else if (engineEvent.Type == "game_end") { - console.debug("[playback] received final frame"); - if (ws) ws.close(); - - // Flag last frame as the last one and fire callback - frames[frames.length - 1].isFinalFrame = true; - onFinalFrame(frames[frames.length - 1]); - } + ingestFrame(gameInfo, engineEvent); }; ws.onclose = () => { diff --git a/src/routes/replays/[id]/+server.ts b/src/routes/replays/[id]/+server.ts new file mode 100644 index 0000000..61aca0b --- /dev/null +++ b/src/routes/replays/[id]/+server.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { RequestHandler } from "./$types"; + +const ROOT_DIR = process.env.REPLAYS_DIR ?? process.cwd(); + +export const GET: RequestHandler = async ({ params }) => { + // Load the local NDJSON 'replay' file. + // TODO(schoon): Once we decide on a real extension, use that here. + // Check that the file exists, and return a 404 otherwise. + const replayFile = await readFile(join(ROOT_DIR, `${params.id}.ndjson`), "utf-8"); + const replayLines = replayFile.split("\n").filter((line) => line.trim().length); + + // TODO(schoon): This would be much better with a clear delimiter, + // not just a gentlemen's agreement that the first line is special. + const info = JSON.parse(replayLines[0]); + const frames = replayLines.slice(1).map((line) => JSON.parse(line)); + + return new Response( + JSON.stringify({ + frames, + info + }), + { + headers: { + "Content-Type": "application/json" + } + } + ); +};