fix(saves): treat a wrapper's [[DYNAMIC]] save path as the same save
A SWF a wrapper loads from memory saves under [[DYNAMIC]]/<n>, and MochiAds does that in Ruffle but not in the standalone Flash Player, so Bloons kept its progress at different paths in each. Saves differing only by that step are now one save: the stream starts with Ruffle's copy (the dynamic one when both exist) at every path it has for it, and only saves the stream changed are copied back, to every Ruffle key for them.
This commit is contained in:
@@ -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]]/<n>`, 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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/<folder>/<host>/<path>/<name>.sol`.
|
||||
*
|
||||
* A SWF loaded from memory, as by a MochiAds wrapper, saves under its loader's
|
||||
* path plus `[[DYNAMIC]]/<n>`, 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, `<host>/<path>/<name>.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<string> | 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, `<host>/<path>/<name>.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]]/<n> 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
|
||||
* ("<site>//<name>") 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<string, string>,
|
||||
context: FlashSaveContext,
|
||||
rootKeys: ReadonlySet<string>,
|
||||
folder: string
|
||||
existing: SaveFiles = {}
|
||||
): SaveFiles {
|
||||
const files: SaveFiles = {};
|
||||
const folder = sharedObjectsFolder(existing);
|
||||
const chosen = new Map<string, { value: string; dynamic: boolean }>();
|
||||
const targets = new Map<string, Set<string>>();
|
||||
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<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
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<SaveFiles> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user