fix(saves): keep Ruffle's copy of a Flash save current in both directions
The stream starts with Ruffle's copy of each save, but Ruffle starts first, and a game with progress only in the stream would write a fresh save there that then replaced it. Before Ruffle starts a game it now gets the stream's saves it doesn't have yet. This replaces the first-switch rule, which kept a stale stream save over Ruffle progress. Moving the Flash content type above GamePlayer's early return also fixes a conditional hook.
This commit is contained in:
@@ -37,8 +37,18 @@ Starting the original Flash Player adds the game's Ruffle saves to the files the
|
||||
stream starts with. While it plays, its saves are copied back into Ruffle's
|
||||
storage as they change. Saves made with a `localPath` of `/` are shared by
|
||||
every game in Ruffle, so only those seen changing while this game ran are
|
||||
carried over. The first time a game switches, a save that exists in both places
|
||||
from before this keeps the streamed copy; after that the two stay in step.
|
||||
carried over.
|
||||
|
||||
Ruffle's copy of a save is kept up to date in both directions, so it's the one
|
||||
the stream starts with:
|
||||
|
||||
- Before Ruffle starts a game, it's given any of the stream's saves it doesn't
|
||||
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.
|
||||
|
||||
A stream save replaced this way is still in the account's backups.
|
||||
|
||||
## Sync
|
||||
|
||||
|
||||
@@ -17,14 +17,15 @@ const flashGame = {
|
||||
describe('GamePlayer player choice', () => {
|
||||
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
|
||||
render(<GamePlayer {...flashGame} streamable />);
|
||||
expect(screen.getByText('ruffle')).toBeInTheDocument();
|
||||
// Ruffle starts once any streamed saves have been handed to it
|
||||
expect(await screen.findByText('ruffle')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
|
||||
expect(screen.getByText('stream')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
|
||||
expect(screen.getByText('ruffle')).toBeInTheDocument();
|
||||
expect(await screen.findByText('ruffle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no choice when the game cannot be streamed', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
|
||||
import { StreamPlayer } from './StreamPlayer';
|
||||
import { SavesDialog } from './SavesDialog';
|
||||
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
||||
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||
@@ -137,6 +138,41 @@ function GamePlayerInternal({
|
||||
}
|
||||
}, [contentUrl, platform]);
|
||||
|
||||
// Determine Flash content type: direct SWF vs HTML wrapper
|
||||
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
|
||||
const flashContentType = useMemo(() => {
|
||||
if (platform !== 'Flash' || !contentUrl) return null;
|
||||
|
||||
const urlPath = contentUrl.toLowerCase();
|
||||
const isSwfUrl = urlPath.includes('.swf');
|
||||
const isHtmlWrapper =
|
||||
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
|
||||
|
||||
if (isSwfUrl && !isHtmlWrapper) {
|
||||
return 'swf' as const;
|
||||
}
|
||||
return 'html-wrapper' as const;
|
||||
}, [platform, contentUrl]);
|
||||
|
||||
// Before Ruffle starts, it gets any saves the streamed Flash Player made that
|
||||
// it doesn't have yet. Otherwise a game played only streamed would start from
|
||||
// scratch here, and that fresh save would replace its progress on the next switch.
|
||||
const ruffleSwf = gameId && flashContentType === 'swf' && contentUrl ? contentUrl : null;
|
||||
const [ruffleSavesEpoch, setRuffleSavesEpoch] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!saveSync.ready || !ruffleSwf || !gameId) return;
|
||||
let cancelled = false;
|
||||
addStreamSavesToRuffle(gameId, ruffleSwf)
|
||||
.catch((error) => logger.error('[GamePlayer] Failed to copy stream saves to Ruffle:', error))
|
||||
.finally(() => {
|
||||
if (!cancelled) setRuffleSavesEpoch(saveSync.epoch);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [saveSync.ready, saveSync.epoch, ruffleSwf, gameId]);
|
||||
const ruffleSavesReady = !ruffleSwf || ruffleSavesEpoch === saveSync.epoch;
|
||||
|
||||
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
|
||||
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
|
||||
|
||||
@@ -178,22 +214,6 @@ function GamePlayerInternal({
|
||||
|
||||
const isAspectVideo = height === 'aspect-video';
|
||||
|
||||
// Determine Flash content type: direct SWF vs HTML wrapper
|
||||
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
|
||||
const flashContentType = useMemo(() => {
|
||||
if (platform !== 'Flash' || !contentUrl) return null;
|
||||
|
||||
const urlPath = contentUrl.toLowerCase();
|
||||
const isSwfUrl = urlPath.includes('.swf');
|
||||
const isHtmlWrapper =
|
||||
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
|
||||
|
||||
if (isSwfUrl && !isHtmlWrapper) {
|
||||
return 'swf' as const;
|
||||
}
|
||||
return 'html-wrapper' as const;
|
||||
}, [platform, contentUrl]);
|
||||
|
||||
return (
|
||||
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
||||
{showControls && (isFullscreen || !renderControls) ? (
|
||||
@@ -327,7 +347,7 @@ function GamePlayerInternal({
|
||||
}
|
||||
}
|
||||
>
|
||||
{!saveSync.ready ? (
|
||||
{!saveSync.ready || (!isStreamed && !ruffleSavesReady) ? (
|
||||
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>Loading your save...</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
addStreamSavesToRuffle,
|
||||
flashSaveContext,
|
||||
flashToRuffle,
|
||||
mirrorToRuffle,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from './flashSaves';
|
||||
|
||||
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
|
||||
const streamSaves = vi.hoisted(() => ({ files: {} as Record<string, string> }));
|
||||
vi.mock('@/lib/streamSaves', () => ({ loadSaves: async () => streamSaves.files }));
|
||||
vi.mock('./gameSaves', () => ({
|
||||
loadSyncState: async (gameId: string) => ({
|
||||
gameId,
|
||||
@@ -36,6 +39,7 @@ if (!context) throw new Error('expected a context');
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
syncState.keys = [];
|
||||
streamSaves.files = {};
|
||||
});
|
||||
|
||||
describe('flash save names', () => {
|
||||
@@ -80,11 +84,13 @@ describe('carrying saves between players', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the stream's copy the first time, then prefers Ruffle's", async () => {
|
||||
it("replaces the stream's copy of a save with Ruffle's, in the stream's folder", async () => {
|
||||
localStorage.setItem(PATH_KEY, PATH_SOL);
|
||||
const streamCopy = { [PATH_FILE]: 'AL8streamcopy' };
|
||||
expect(await withRuffleSaves('game', streamCopy, context)).toEqual(streamCopy);
|
||||
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({ [PATH_FILE]: PATH_SOL });
|
||||
const streamCopy = { [PATH_FILE]: 'AL8stalecopy', [ROOT_FILE]: ROOT_SOL };
|
||||
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({
|
||||
[PATH_FILE]: PATH_SOL,
|
||||
[ROOT_FILE]: ROOT_SOL,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies the stream's saves into Ruffle's storage", () => {
|
||||
@@ -92,4 +98,18 @@ describe('carrying saves between players', () => {
|
||||
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
|
||||
expect(localStorage.getItem(ROOT_KEY)).toBe(ROOT_SOL);
|
||||
});
|
||||
|
||||
it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => {
|
||||
window.history.replaceState(null, '', '/browse/game');
|
||||
const host = window.location.hostname;
|
||||
streamSaves.files = { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL };
|
||||
localStorage.setItem(`${host}//rootsave`, 'AL8ruffleprogress');
|
||||
|
||||
await addStreamSavesToRuffle('game', '/game-proxy/http://savetest.example/dir/test.swf');
|
||||
|
||||
expect(
|
||||
localStorage.getItem(`${host}/game-proxy/http://savetest.example/dir/test.swf/pathsave`)
|
||||
).toBe(PATH_SOL);
|
||||
expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { loadSyncState } from './gameSaves';
|
||||
import type { SaveFiles } from '@/lib/streamSaves';
|
||||
import { loadSaves, type SaveFiles } from '@/lib/streamSaves';
|
||||
|
||||
/**
|
||||
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
|
||||
@@ -20,8 +20,6 @@ const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
|
||||
const DEFAULT_FOLDER = 'DOTCOMAR';
|
||||
/** Every .sol file starts with the bytes 00 BF 00, which base64-encode to "AL8" */
|
||||
const SOL_PREFIX = 'AL8';
|
||||
/** Games whose saves have been brought together once already */
|
||||
const LINKED_KEY = 'flashpoint-flash-saves-linked';
|
||||
|
||||
export interface FlashSaveContext {
|
||||
/** Host Ruffle keys saves by: the site's own */
|
||||
@@ -103,19 +101,11 @@ function readLocalStorage(): Record<string, string> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
function linkedGames(): Set<string> {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(LINKED_KEY) ?? '[]') as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The save files to start a streamed Flash game with: its stream saves plus its
|
||||
* Ruffle saves. Ruffle's copy wins, being kept up to date with the stream's;
|
||||
* the first time a game is linked, saves from before that are left as the
|
||||
* stream had them.
|
||||
* The save files to start a streamed Flash game with: its stream saves, with
|
||||
* its Ruffle saves on top. Ruffle is where a game is usually played, and the
|
||||
* stream's own changes are copied back into Ruffle as they happen, so Ruffle's
|
||||
* copy is the latest.
|
||||
*/
|
||||
export async function withRuffleSaves(
|
||||
gameId: string,
|
||||
@@ -123,19 +113,10 @@ export async function withRuffleSaves(
|
||||
context: FlashSaveContext
|
||||
): Promise<SaveFiles> {
|
||||
const { keys } = await loadSyncState(gameId);
|
||||
const fromRuffle = ruffleToFlash(
|
||||
readLocalStorage(),
|
||||
context,
|
||||
new Set(keys),
|
||||
sharedObjectsFolder(files)
|
||||
);
|
||||
const linked = linkedGames();
|
||||
if (linked.has(gameId)) {
|
||||
return { ...files, ...fromRuffle };
|
||||
}
|
||||
linked.add(gameId);
|
||||
localStorage.setItem(LINKED_KEY, JSON.stringify([...linked]));
|
||||
return { ...fromRuffle, ...files };
|
||||
return {
|
||||
...files,
|
||||
...ruffleToFlash(readLocalStorage(), context, new Set(keys), sharedObjectsFolder(files)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Copies a streamed game's Flash Player saves into Ruffle's storage. */
|
||||
@@ -146,3 +127,18 @@ export function mirrorToRuffle(files: SaveFiles, context: FlashSaveContext): voi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before Ruffle plays a game, gives it the streamed Flash Player's saves it
|
||||
* doesn't have yet, so progress made only in the stream isn't started over.
|
||||
* Saves Ruffle already has are left alone.
|
||||
*/
|
||||
export async function addStreamSavesToRuffle(gameId: string, swfUrl: string): Promise<void> {
|
||||
const context = flashSaveContext(swfUrl, window.location.hostname);
|
||||
if (!context) return;
|
||||
for (const [key, value] of Object.entries(flashToRuffle(await loadSaves(gameId), context))) {
|
||||
if (localStorage.getItem(key) === null) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user