From a22b98a672ef7e9d3bc657ffd487f0a487b54667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Tue, 28 Jul 2026 21:11:37 +0200 Subject: [PATCH] Download available turns to save folders --- app/src/preload.js | 13 +- ui/app.component.html | 12 ++ ui/home/home.component.ts | 14 +++ ui/shared/autoDownloadSave.test.ts | 36 ++++++ ui/shared/autoDownloadSave.ts | 31 +++++ ui/shared/pydtSettings.ts | 1 + ui/shared/turnCacheService.ts | 184 ++++++++++++++++++++++++++++- 7 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 ui/shared/autoDownloadSave.test.ts create mode 100644 ui/shared/autoDownloadSave.ts diff --git a/app/src/preload.js b/app/src/preload.js index 9100ce9..f8d11e0 100644 --- a/app/src/preload.js +++ b/app/src/preload.js @@ -10,26 +10,25 @@ import * as chokidar from "chokidar"; import * as AutoLaunch from "auto-launch"; import { RPC_INVOKE, RPC_TO_MAIN, RPC_TO_RENDERER } from "./rpcChannels.js"; -let watcher; +const watchers = new Set(); electron.contextBridge.exposeInMainWorld("pydtApi", { startChokidar: arg => new Promise(resolve => { - if (watcher) { - watcher.close(); - } - - watcher = chokidar.watch(arg.path, { + const watcher = chokidar.watch(arg.path, { depth: 0, ignoreInitial: true, awaitWriteFinish: arg.awaitWriteFinish, usePolling: arg.awaitWriteFinish, }); + watchers.add(watcher); + electron.ipcRenderer.send(RPC_TO_MAIN.LOG_INFO, `Watching save directory: ${arg.path}`); const changeDetected = p => { + electron.ipcRenderer.send(RPC_TO_MAIN.LOG_INFO, `Save change detected: ${p}`); electron.ipcRenderer.send(RPC_TO_MAIN.SHOW_WINDOW); watcher.close(); - watcher = null; + watchers.delete(watcher); resolve(p); }; diff --git a/ui/app.component.html b/ui/app.component.html index 66fa83d..9ea331c 100644 --- a/ui/app.component.html +++ b/ui/app.component.html @@ -89,6 +89,18 @@ /> +
+ + +
Archive { + this.games = this.games?.filter(game => game.gameId !== gameId) || []; + this.setSortedTurns(); + this.notifyMain(); + this.pollUrl = ""; + void this.safeLoadGames(); + }); + this.navigationSubscription = this.router.events.subscribe((e: unknown) => { // If reloading page reload user and games (could be changing user) if (e instanceof NavigationEnd) { @@ -122,6 +131,11 @@ export class HomeComponent implements OnInit, OnDestroy { this.updateSub = null; } + if (this.completedGameSubscription) { + this.completedGameSubscription.unsubscribe(); + this.completedGameSubscription = null; + } + this.destroyed = true; window.pydtApi.ipc.removeAllListeners(RPC_TO_RENDERER.IOT_CONNECT); diff --git a/ui/shared/autoDownloadSave.test.ts b/ui/shared/autoDownloadSave.test.ts new file mode 100644 index 0000000..8a4f79b --- /dev/null +++ b/ui/shared/autoDownloadSave.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { autoDownloadFileName, saveDownloadedTurn } from "./autoDownloadSave.ts"; + +void test("uses the filename expected by the existing play-turn workflow", () => { + assert.equal(autoDownloadFileName("Civ7Save"), "(PYDT) Play This One!.Civ7Save"); +}); + +void test("creates the configured directory and writes the downloaded data", () => { + const calls: Array<{ operation: string; value: unknown }> = []; + const data = new Uint8Array([1, 2, 3]); + + const result = saveDownloadedTurn({ + saveDir: "/games/saves", + saveExtension: "Civ6Save", + data, + fs: { + existsSync: path => { + calls.push({ operation: "exists", value: path }); + return false; + }, + mkdirp: path => calls.push({ operation: "mkdirp", value: path }), + writeFileSync: (path, contents) => calls.push({ operation: "write", value: [path, contents] }), + }, + path: { + join: (...paths) => paths.join("/"), + }, + }); + + assert.equal(result, "/games/saves/(PYDT) Play This One!.Civ6Save"); + assert.deepEqual(calls, [ + { operation: "exists", value: "/games/saves" }, + { operation: "mkdirp", value: "/games/saves" }, + { operation: "write", value: ["/games/saves/(PYDT) Play This One!.Civ6Save", data] }, + ]); +}); diff --git a/ui/shared/autoDownloadSave.ts b/ui/shared/autoDownloadSave.ts new file mode 100644 index 0000000..4370bef --- /dev/null +++ b/ui/shared/autoDownloadSave.ts @@ -0,0 +1,31 @@ +export interface AutoDownloadFileSystem { + existsSync(path: string): boolean; + mkdirp(path: string): void; + writeFileSync(path: string, data: Uint8Array): void; +} + +export interface AutoDownloadPath { + join(...paths: string[]): string; +} + +export interface AutoDownloadSaveOptions { + saveDir: string; + saveExtension: string; + data: Uint8Array; + fs: AutoDownloadFileSystem; + path: AutoDownloadPath; +} + +export const autoDownloadFileName = (saveExtension: string): string => `(PYDT) Play This One!.${saveExtension}`; + +export const saveDownloadedTurn = (options: AutoDownloadSaveOptions): string => { + if (!options.fs.existsSync(options.saveDir)) { + options.fs.mkdirp(options.saveDir); + } + + const saveFile = options.path.join(options.saveDir, autoDownloadFileName(options.saveExtension)); + + options.fs.writeFileSync(saveFile, options.data); + + return saveFile; +}; diff --git a/ui/shared/pydtSettings.ts b/ui/shared/pydtSettings.ts index 1a5c206..b6b171f 100644 --- a/ui/shared/pydtSettings.ts +++ b/ui/shared/pydtSettings.ts @@ -17,6 +17,7 @@ export class PydtSettingsData { gameStores: { [index: string]: GameStore } = {}; savePaths: { [index: string]: string } = {}; autoDownload = false; + saveDownloadedTurns = false; constructor( civGames: CivGame[], diff --git a/ui/shared/turnCacheService.ts b/ui/shared/turnCacheService.ts index 0a60072..c7a4a9b 100644 --- a/ui/shared/turnCacheService.ts +++ b/ui/shared/turnCacheService.ts @@ -1,8 +1,11 @@ import { Injectable, inject } from "@angular/core"; import { BusyService, Game, GameService, GameTurnResponse } from "pydt-shared"; -import { BehaviorSubject, firstValueFrom, merge, of } from "rxjs"; -import { catchError, filter, map } from "rxjs/operators"; -import { PydtSettingsFactory } from "./pydtSettings"; +import { BehaviorSubject, firstValueFrom, merge, of, Subject } from "rxjs"; +import { catchError, filter, map, timeout } from "rxjs/operators"; +import { PydtSettingsData, PydtSettingsFactory } from "./pydtSettings"; +import { SafeMetadataLoader } from "./safeMetadataLoader"; +import { saveDownloadedTurn } from "./autoDownloadSave"; +import { RPC_TO_MAIN } from "../rpcChannels"; const BASE_RETRY_BACKOFF_MS = 5000; const MAX_RETRY_BACKOFF_MS = 5 * 60 * 1000; @@ -148,11 +151,15 @@ export class TurnCacheService { private readonly gameService = inject(GameService); private readonly busyService = inject(BusyService); private readonly pydtSettingsFactory = inject(PydtSettingsFactory); + private readonly metadataLoader = inject(SafeMetadataLoader); private readonly cache: TurnDownloader[] = []; + private readonly savedVersions = new Set(); + readonly completedGameIds$ = new Subject(); constructor() { void this.backgroundDownloader().then(); + void this.automaticTurnQueue().then(); } async backgroundDownloader(): Promise { @@ -173,6 +180,176 @@ export class TurnCacheService { } } + private async automaticTurnQueue(): Promise { + // eslint-disable-next-line no-constant-condition + while (true) { + await new Promise(resolve => setTimeout(resolve, 5000)); + + const settings = await this.pydtSettingsFactory.getSettings(); + const td = this.cache.find(candidate => { + const versionKey = `${candidate.game.gameId}:${candidate.game.version}`; + return !this.savedVersions.has(versionKey); + }); + + if (!settings.saveDownloadedTurns || !td) { + continue; + } + + try { + if (!td.data$.value) { + await td.waitForCompletion(); + } + + await this.processAutomaticTurn(td, settings); + } catch (err) { + // Keep the queue alive after temporary filesystem or network failures. + // eslint-disable-next-line no-console + console.error(`Unable to process automatic turn for ${td.game.displayName}`, err); + } + } + } + + private async processAutomaticTurn(td: TurnDownloader, settings: PydtSettingsData): Promise { + const data = td.data$.value; + const versionKey = `${td.game.gameId}:${td.game.version}`; + + if (!data || this.savedVersions.has(versionKey)) { + return; + } + + const metadata = await this.metadataLoader.loadMetadata(); + const civGame = metadata?.civGames.find(x => x.id === td.game.gameType); + + if (!civGame) { + return; + } + + const saveDir = settings.getSavePath(civGame); + const saveFile = saveDownloadedTurn({ + saveDir, + saveExtension: civGame.saveExtension, + data: data.data, + fs: window.pydtApi.fs, + path: window.pydtApi.path, + }); + + // Let the handoff write settle before watching for the save created by Civ. + await new Promise(resolve => setTimeout(resolve, 5000)); + const completedSave = await window.pydtApi.startChokidar({ + path: saveDir, + awaitWriteFinish: civGame.awaitWriteFinish, + }); + + // Do not advance or rewrite the handoff after a temporary upload failure. + // Keep retrying the completed save so the player's work remains intact. + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await this.uploadCompletedTurn(td.game.gameId, completedSave); + break; + } catch (err) { + // eslint-disable-next-line no-console + console.error(`Unable to upload automatic turn for ${td.game.displayName}; retrying`, err); + window.pydtApi.ipc.send( + RPC_TO_MAIN.LOG_ERROR, + `Unable to upload automatic turn for ${td.game.displayName}: ${String(err)}`, + ); + await new Promise(resolve => setTimeout(resolve, 30000)); + } + } + + const archiveDir = window.pydtApi.path.join(saveDir, "pydt-archive"); + if (!window.pydtApi.fs.existsSync(archiveDir)) { + window.pydtApi.fs.mkdirp(archiveDir); + } + + const archivedSave = window.pydtApi.path.join( + archiveDir, + `${td.game.gameId.slice(0, 8)}_${window.pydtApi.path.basename(completedSave)}`, + ); + window.pydtApi.fs.renameSync(completedSave, archivedSave); + + if (window.pydtApi.fs.existsSync(saveFile)) { + window.pydtApi.fs.unlinkSync(saveFile); + } + + this.trimArchive(archiveDir, settings.numSaves); + this.savedVersions.add(versionKey); + this.completedGameIds$.next(td.game.gameId); + } + + private async uploadCompletedTurn(gameId: string, saveFile: string): Promise { + window.pydtApi.ipc.send(RPC_TO_MAIN.LOG_INFO, `Compressing completed turn: ${saveFile}`); + const fileData = await window.pydtApi.readFileGzipped(saveFile); + window.pydtApi.ipc.send(RPC_TO_MAIN.LOG_INFO, `Starting turn submission: ${gameId}`); + const startResp = await firstValueFrom(this.gameService.startSubmit(gameId)); + window.pydtApi.ipc.send(RPC_TO_MAIN.LOG_INFO, `Uploading completed turn: ${gameId}`); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", startResp.putUrl, true); + xhr.timeout = 60000; + xhr.onload = () => { + if (xhr.status === 200) { + resolve(); + } else { + reject(new Error(`Turn upload returned HTTP ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error(`Turn upload returned HTTP ${xhr.status}`)); + xhr.ontimeout = () => reject(new Error("Turn upload timed out")); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.send(fileData as unknown as ArrayBuffer); + }); + + // The uploaded file is already durable at this point. Retry only the final + // confirmation if the API stalls so the save is not uploaded repeatedly. + // eslint-disable-next-line no-constant-condition + while (true) { + try { + window.pydtApi.ipc.send(RPC_TO_MAIN.LOG_INFO, `Finishing turn submission: ${gameId}`); + await firstValueFrom(this.gameService.finishSubmit(gameId).pipe(timeout(60000))); + break; + } catch (err) { + const httpError = err as { status?: number; message?: string; error?: unknown }; + const errorDetails = JSON.stringify({ + status: httpError.status, + message: httpError.message, + error: httpError.error, + }); + // eslint-disable-next-line no-console + console.error(`Unable to finish turn submission for ${gameId}; retrying`, err); + window.pydtApi.ipc.send( + RPC_TO_MAIN.LOG_ERROR, + `Unable to finish turn submission for ${gameId}: ${errorDetails}`, + ); + + if (httpError.status >= 400 && httpError.status < 500) { + throw err; + } + + await new Promise(resolve => setTimeout(resolve, 30000)); + } + } + + window.pydtApi.ipc.send(RPC_TO_MAIN.LOG_INFO, `Turn submission completed: ${gameId}`); + } + + private trimArchive(archiveDir: string, numSaves: number): void { + const files = window.pydtApi.fs + .readdirSync(archiveDir) + .flatMap(fileName => { + const file = window.pydtApi.path.join(archiveDir, fileName); + const stat = window.pydtApi.fs.statSync(file); + return stat.isDirectory ? [] : [{ file, time: stat.ctime.getTime() }]; + }) + .sort((a, b) => a.time - b.time); + + while (files.length > numSaves) { + window.pydtApi.fs.unlinkSync(files.shift().file); + } + } + updateGames(games: Game[]): void { const newGames = games.filter( x => !this.cache.some(y => x.gameId === y.game.gameId && x.version === y.game.version), @@ -190,6 +367,7 @@ export class TurnCacheService { this.cache[i].abort(); this.cache.splice(i, 1); + this.savedVersions.delete(`${dl.game.gameId}:${dl.game.version}`); } }