diff --git a/docs/10-features/15-game-saves.md b/docs/10-features/15-game-saves.md index 3b8e328..89abd45 100644 --- a/docs/10-features/15-game-saves.md +++ b/docs/10-features/15-game-saves.md @@ -46,7 +46,18 @@ the stream starts with: have yet, such as progress made only in the stream. The game then loads that progress instead of starting a fresh save, which would otherwise replace the stream's progress on the next switch. Saves Ruffle already has are left alone. -- While the stream plays, its saves are copied into Ruffle as they change. +- While the stream plays, the saves it changes are copied into Ruffle. + +Some games save under a different path in each player. A SWF that a wrapper +(such as MochiAds) loads from memory saves under its loader's path plus +`[[DYNAMIC]]/`, and a wrapper may do that in Ruffle but not in the +standalone Flash Player. Bloons, for example, keeps its progress under +`bloons.swf/[[DYNAMIC]]/1/` in Ruffle and under `bloons.swf/` in Flash Player. +Saves that differ only by that step are treated as one save: + +- When the stream starts, a save is written to every path the stream has for + it. If Ruffle has both a dynamic and a plain copy, the dynamic one is used. +- A save the stream changes is copied to every key Ruffle has for it. A stream save replaced this way is still in the account's backups. diff --git a/frontend/src/components/player/StreamPlayer.tsx b/frontend/src/components/player/StreamPlayer.tsx index 72a1238..414cd9e 100644 --- a/frontend/src/components/player/StreamPlayer.tsx +++ b/frontend/src/components/player/StreamPlayer.tsx @@ -45,12 +45,14 @@ export function StreamPlayer({ gameId, title, swfUrl }: StreamPlayerProps) { if (cancelled) return; events = new EventSource(`${session.path}_fp/saves`); + let previous = files; events.onmessage = (event) => { const { files: latest } = JSON.parse(event.data); storeSaves(gameId, latest).catch((error) => logger.error('[StreamPlayer] Failed to store saves:', error) ); - if (flash) mirrorToRuffle(latest, flash); + if (flash) mirrorToRuffle(latest, previous, flash); + previous = latest; }; events.addEventListener('end', () => events?.close()); setState({ status: 'running', path: session.path }); diff --git a/frontend/src/lib/saves/flashSaves.test.ts b/frontend/src/lib/saves/flashSaves.test.ts index 1234178..23ef30c 100644 --- a/frontend/src/lib/saves/flashSaves.test.ts +++ b/frontend/src/lib/saves/flashSaves.test.ts @@ -54,10 +54,11 @@ describe('flash save names', () => { it("maps Ruffle's saves to Flash Player files, root saves only when the game made them", () => { const entries = { [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL, 'other-game': 'not a save' }; - expect(ruffleToFlash(entries, context, new Set(), 'ABCD1234')).toEqual({ + const folder = { 'Flash_Player/#SharedObjects/ABCD1234/other.example/x.sol': 'AL8x' }; + expect(ruffleToFlash(entries, context, new Set(), folder)).toEqual({ [PATH_FILE]: PATH_SOL, }); - expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), 'ABCD1234')).toEqual({ + expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), folder)).toEqual({ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL, }); @@ -93,10 +94,15 @@ describe('carrying saves between players', () => { }); }); - it("copies the stream's saves into Ruffle's storage", () => { - mirrorToRuffle({ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL }, context); + it("copies the saves the stream changed into Ruffle's storage, and only those", () => { + localStorage.setItem(ROOT_KEY, 'AL8ruffleprogress'); + mirrorToRuffle( + { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL }, + { [ROOT_FILE]: ROOT_SOL }, + context + ); expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL); - expect(localStorage.getItem(ROOT_KEY)).toBe(ROOT_SOL); + expect(localStorage.getItem(ROOT_KEY)).toBe('AL8ruffleprogress'); }); it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => { @@ -113,3 +119,45 @@ describe('carrying saves between players', () => { expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress'); }); }); + +// Bloons runs inside a MochiAds wrapper: in Ruffle the game saves under +// [[DYNAMIC]]/1/, in the standalone Flash Player under the plain path +describe('saves a wrapper keeps under [[DYNAMIC]] in one player only', () => { + const bloons = flashSaveContext( + '/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf', + 'arcade.example.com' + ); + if (!bloons) throw new Error('expected a context'); + const base = + 'arcade.example.com/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf'; + const files = + 'Flash_Player/#SharedObjects/AQUKE2KN/games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf'; + // A fresh profile, and one with levels cleared (shortened) + const FRESH = + 'AL8AAABEVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQAAAAAABmxldmVsMQIABWNsZWFyAAAKbGV2ZWxiZXN0MQAAAAAAAAAAAAA='; + const PROGRESS = 'AL8AAAJJVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQ'; + + it("gives Flash Player's plain path Ruffle's dynamic progress", () => { + const ruffle = { + [`${base}/bloonsProfile`]: FRESH, + [`${base}/[[DYNAMIC]]/1/bloonsProfile`]: PROGRESS, + }; + const stream = { [`${files}/bloonsProfile.sol`]: FRESH }; + expect(ruffleToFlash(ruffle, bloons, new Set(), stream)).toEqual({ + [`${files}/bloonsProfile.sol`]: PROGRESS, + [`${files}/[[DYNAMIC]]/1/bloonsProfile.sol`]: PROGRESS, + }); + }); + + it("brings progress made in Flash Player back to Ruffle's dynamic path", () => { + localStorage.setItem(`${base}/bloonsProfile`, FRESH); + localStorage.setItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`, FRESH); + mirrorToRuffle( + { [`${files}/bloonsProfile.sol`]: PROGRESS }, + { [`${files}/bloonsProfile.sol`]: FRESH }, + bloons + ); + expect(localStorage.getItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`)).toBe(PROGRESS); + expect(localStorage.getItem(`${base}/bloonsProfile`)).toBe(PROGRESS); + }); +}); diff --git a/frontend/src/lib/saves/flashSaves.ts b/frontend/src/lib/saves/flashSaves.ts index 08a99e5..5c94448 100644 --- a/frontend/src/lib/saves/flashSaves.ts +++ b/frontend/src/lib/saves/flashSaves.ts @@ -11,6 +11,12 @@ import { loadSaves, type SaveFiles } from '@/lib/streamSaves'; * - Flash Player, as files in the streamed game's saves directory: * `Flash_Player/#SharedObjects////.sol`. * + * A SWF loaded from memory, as by a MochiAds wrapper, saves under its loader's + * path plus `[[DYNAMIC]]/`, and a wrapper may do that in one player and not + * the other: Bloons saves at `bloons.swf/[[DYNAMIC]]/1/` in Ruffle and at + * `bloons.swf/` in Flash Player. Saves differing only by that step are + * treated as one save. + * * These helpers translate between the two so a game's saves follow it from one * player to the other. */ @@ -36,57 +42,114 @@ export function flashSaveContext(swfUrl: string, siteHost: string): FlashSaveCon return match ? { siteHost, proxyRoot: match[1], swfHost: match[2] } : null; } +/** + * A Ruffle key's save as a Flash Player path, `//.sol`, or + * null if it isn't this game's. Root-path saves are shared by every game, so + * only those in rootKeys count, unless rootKeys is null. + */ +function ruffleKeyToPath( + key: string, + context: FlashSaveContext, + rootKeys: ReadonlySet | null +): string | null { + if (!key.startsWith(`${context.siteHost}/`)) return null; + const rest = key.slice(context.siteHost.length); + const slash = rest.lastIndexOf('/'); + const localPath = rest.slice(0, slash) || '/'; + const name = rest.slice(slash + 1); + if (localPath === '/') { + return rootKeys === null || rootKeys.has(key) ? `${context.swfHost}/${name}.sol` : null; + } + if (localPath.startsWith(`${context.proxyRoot}/`)) { + return `${context.swfHost}${localPath.slice(context.proxyRoot.length)}/${name}.sol`; + } + return null; +} + +/** The Ruffle key for a Flash Player path, `//.sol`, or null for another host's. */ +function pathToRuffleKey(path: string, context: FlashSaveContext): string | null { + const parts = path.split('/'); + if (parts.length < 2 || parts[0] !== context.swfHost || !path.endsWith('.sol')) return null; + const name = parts[parts.length - 1].slice(0, -'.sol'.length); + const dirs = parts.slice(1, -1); + return dirs.length + ? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}` + : `${context.siteHost}//${name}`; +} + +/** A save file's folder and path below it, or null for files that aren't SharedObjects. */ +function splitSaveFile(file: string): { folder: string; path: string } | null { + if (!file.startsWith(SHARED_OBJECTS) || !file.endsWith('.sol')) return null; + const rest = file.slice(SHARED_OBJECTS.length); + const slash = rest.indexOf('/'); + return slash > 0 ? { folder: rest.slice(0, slash), path: rest.slice(slash + 1) } : null; +} + +/** A save's path without its [[DYNAMIC]]/ steps, which one player may add and the other not. */ +function saveIdentity(path: string): string { + return path.replace(/\/\[\[DYNAMIC\]\]\/\d+(?=\/)/g, ''); +} + +function isDynamic(path: string): boolean { + return saveIdentity(path) !== path; +} + function sharedObjectsFolder(files: SaveFiles): string { - const existing = Object.keys(files).find((path) => path.startsWith(SHARED_OBJECTS)); - return existing?.slice(SHARED_OBJECTS.length).split('/')[0] ?? DEFAULT_FOLDER; + for (const file of Object.keys(files)) { + const split = splitSaveFile(file); + if (split) return split.folder; + } + return DEFAULT_FOLDER; } /** - * Flash Player save files for a game's Ruffle SharedObjects. Root-path saves - * ("//") are shared by every game, so only those in rootKeys, the - * keys seen changing while this game ran, are included. + * Flash Player save files for a game's Ruffle SharedObjects. Each save is + * written to its own path and to every path the stream already has for it. + * Where Ruffle has a save under both a [[DYNAMIC]] path and the plain one, the + * dynamic copy is used: that's the game itself, the plain one its wrapper's. */ export function ruffleToFlash( entries: Record, context: FlashSaveContext, rootKeys: ReadonlySet, - folder: string + existing: SaveFiles = {} ): SaveFiles { - const files: SaveFiles = {}; + const folder = sharedObjectsFolder(existing); + const chosen = new Map(); + const targets = new Map>(); + const addTarget = (identity: string, file: string) => + targets.set(identity, (targets.get(identity) ?? new Set()).add(file)); + for (const [key, value] of Object.entries(entries)) { - if (!value.startsWith(SOL_PREFIX) || !key.startsWith(`${context.siteHost}/`)) continue; - const rest = key.slice(context.siteHost.length); - const slash = rest.lastIndexOf('/'); - const localPath = rest.slice(0, slash) || '/'; - const name = rest.slice(slash + 1); - let dir: string; - if (localPath === '/') { - if (!rootKeys.has(key)) continue; - dir = context.swfHost; - } else if (localPath.startsWith(`${context.proxyRoot}/`)) { - dir = context.swfHost + localPath.slice(context.proxyRoot.length); - } else { - continue; + if (!value.startsWith(SOL_PREFIX)) continue; + const path = ruffleKeyToPath(key, context, rootKeys); + if (path === null) continue; + const identity = saveIdentity(path); + const current = chosen.get(identity); + if (!current || (isDynamic(path) && !current.dynamic)) { + chosen.set(identity, { value, dynamic: isDynamic(path) }); } - files[`${SHARED_OBJECTS}${folder}/${dir}/${name}.sol`] = value; + addTarget(identity, `${SHARED_OBJECTS}${folder}/${path}`); + } + for (const file of Object.keys(existing)) { + const split = splitSaveFile(file); + if (split && chosen.has(saveIdentity(split.path))) addTarget(saveIdentity(split.path), file); + } + + const files: SaveFiles = {}; + for (const [identity, { value }] of chosen) { + for (const file of targets.get(identity) ?? []) files[file] = value; } return files; } -/** Ruffle localStorage entries for a game's Flash Player save files. */ +/** Ruffle localStorage entries for a game's Flash Player save files, path for path. */ export function flashToRuffle(files: SaveFiles, context: FlashSaveContext): Record { const entries: Record = {}; - for (const [path, value] of Object.entries(files)) { - if (!path.startsWith(SHARED_OBJECTS) || !path.endsWith('.sol')) continue; - // folder, host, any directories, name.sol - const parts = path.slice(SHARED_OBJECTS.length).split('/'); - if (parts.length < 3 || parts[1] !== context.swfHost) continue; - const name = parts[parts.length - 1].slice(0, -'.sol'.length); - const dirs = parts.slice(2, -1); - const key = dirs.length - ? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}` - : `${context.siteHost}//${name}`; - entries[key] = value; + for (const [file, value] of Object.entries(files)) { + const split = splitSaveFile(file); + const key = split && pathToRuffleKey(split.path, context); + if (key) entries[key] = value; } return entries; } @@ -113,17 +176,31 @@ export async function withRuffleSaves( context: FlashSaveContext ): Promise { const { keys } = await loadSyncState(gameId); - return { - ...files, - ...ruffleToFlash(readLocalStorage(), context, new Set(keys), sharedObjectsFolder(files)), - }; + return { ...files, ...ruffleToFlash(readLocalStorage(), context, new Set(keys), files) }; } -/** Copies a streamed game's Flash Player saves into Ruffle's storage. */ -export function mirrorToRuffle(files: SaveFiles, context: FlashSaveContext): void { - for (const [key, value] of Object.entries(flashToRuffle(files, context))) { - if (localStorage.getItem(key) !== value) { - localStorage.setItem(key, value); +/** + * Copies the saves a streamed game changed since `previous` into Ruffle's + * storage: to the save's own key and to every key Ruffle already has for it. + */ +export function mirrorToRuffle( + files: SaveFiles, + previous: SaveFiles, + context: FlashSaveContext +): void { + const ruffleKeys = Object.keys(readLocalStorage()); + for (const [file, value] of Object.entries(files)) { + if (previous[file] === value) continue; + const split = splitSaveFile(file); + const key = split && pathToRuffleKey(split.path, context); + if (!split || !key) continue; + const identity = saveIdentity(split.path); + const aliases = ruffleKeys.filter((k) => { + const path = ruffleKeyToPath(k, context, null); + return path !== null && saveIdentity(path) === identity; + }); + for (const target of new Set([key, ...aliases])) { + if (localStorage.getItem(target) !== value) localStorage.setItem(target, value); } } }