Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/runtime-and-play-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Redis只保存两小时临时run:窄header和被允许的file/index hash字段

保存multipart metadata+payload+可选PNG/JPEG截图。commitId为浏览器持久草稿ID:创建save ID取commitId;覆盖保存last_commit_id。相同commit/payload hash/extinfo重试返回同一版本,其他提交按CAS竞争。IO完成后短事务锁Game确认published,再保存CAS;游戏删除后不能提交新存档。列表按distinct game/core一次batch-identity和一次batch-restorable,不逐存档调用Prepare/Node。筛选分页与count采用同条件。

正式游玩新生成的存档截图在写入浏览器草稿前统一编码为JPEG,质量参数0.95,高度最多540像素、宽度等比取整,仅缩小、不放大;透明区域以黑色填充。状态存档、原生存档后台同步、核心自带保存点图片和退出最终截图共用此处理。重试复用草稿中的已编码字节;无新增保存点时继续保留已有截图。图片解码或编码失败仅省略本次截图,不阻止进度保存。服务端仍校验并原样保存收到的PNG/JPEG,不转换已有截图。

退出立即撤销PG会话、清cookie和释放运行资源,不整站Clear-Site-Data storage。未同步IndexedDB/OPFS草稿按User隔离,只有服务端明确成功或用户丢弃才删除。网络失败、Redis失效或响应丢失不能假成功、删除草稿或提前ack。

保存草稿只有在 IndexedDB 事务完成后才算持久化,不能以单次 put 请求成功代替。实际 `QuotaExceededError` 可触发一次已校验 Provider Module 的 `reclaimContentCache`,然后重试同一份草稿;存储估算不能代替真实写入结果。Provider 只回收未被活动运行租约占用、可重新下载的 Content I/O 缓存,不访问存档、草稿、挂载来源或其他缓存。该入口不依赖仍存活的 PlayerRuntime,退出后的最终存档也适用。回收和重试失败保留原错误原因、同一 commitId 与可导出的内存副本;其他写入错误不触发回收,用户重试仍先确认本地持久化。本地草稿清理失败也保留内存副本,服务端提交重试继续使用原 commitId。
Expand Down
19 changes: 19 additions & 0 deletions web/e2e/instant-save-choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ import {
} from "./clean-refactor-support";
import { ensurePlayerLandscape, nesFrame, sendNesInput } from "./nes-playback";

async function verifySaveScreenshot(page: Page, save: Schema<"Save">) {
if (!save.screenshotUrl) { throw new Error("Saved progress is missing its screenshot."); }
const image = await page.evaluate(async (url) => {
const response = await fetch(url);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
try {
return { status: response.status, type: blob.type, width: bitmap.width, height: bitmap.height };
} finally { bitmap.close(); }
}, save.screenshotUrl);
expect(image.status).toBe(200);
expect(image.type).toBe("image/jpeg");
expect(image.width).toBeGreaterThan(0);
expect(image.height).toBeGreaterThan(0);
expect(image.height).toBeLessThanOrEqual(540);
}

export async function newInstantSave(page: Page, existing: Schema<"Save">) {
await revealPlayerControls(page);
const persisted = page.waitForResponse(
Expand All @@ -26,6 +43,7 @@ export async function newInstantSave(page: Page, existing: Schema<"Save">) {
const save = (await response.json()) as Schema<"Save">;
expect(save.id).not.toBe(existing.id);
expect(save.version).toBe(1);
await verifySaveScreenshot(page, save);
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
page.getByRole("status").filter({ hasText: "存档已同步。" }),
Expand Down Expand Up @@ -55,6 +73,7 @@ export async function overwriteInstantSave(page: Page, save: Schema<"Save">) {
expect(updated.id).toBe(save.id);
expect(updated.name).toBe(save.name);
expect(updated.version).toBe(save.version + 1);
await verifySaveScreenshot(page, updated);
await expect(
page.getByRole("status").filter({ hasText: "存档已同步。" }),
).toBeVisible();
Expand Down
85 changes: 85 additions & 0 deletions web/features/player/save-screenshot-session.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type * as SaveDraftModule from "./save-drafts";
import { act, cleanup } from "@testing-library/react";
import { renderHook } from "@/components/toast-test-utils";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useSaveSession } from "./use-save-session";
import { checkpoint, runFixture, runtimeFixture } from "./player-test-fixture";
import { putDraft, uploadDraft } from "./save-drafts";
import { prepareSaveScreenshot } from "./save-screenshot";

vi.mock("./runtime/provider-dispatcher", () => ({ reclaimProviderContentCache: vi.fn() }));
vi.mock("./save-screenshot", () => ({ prepareSaveScreenshot: vi.fn() }));
vi.mock("./save-drafts", async () => {
const original = await vi.importActual<typeof SaveDraftModule>("./save-drafts");
return { ...original, putDraft: vi.fn(), uploadDraft: vi.fn() };
});

const source = new Blob(["source"], { type: "image/png" });
const jpeg = new Blob(["compressed"], { type: "image/jpeg" });
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prepareSaveScreenshot).mockResolvedValue(jpeg);
vi.mocked(uploadDraft).mockResolvedValue({ id: "save-id", version: 1 } as Awaited<ReturnType<typeof uploadDraft>>);
});
afterEach(cleanup);

it.each([false, true])("retains and uploads the encoded screenshot for native=%s", async native => {
const runtime = runtimeFixture();
vi.spyOn(runtime, "screenshot").mockResolvedValue(source);
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", native));
await act(() => result.current.capture(runtime, native ? "EXPORT" : "CAPTURE"));
expect(prepareSaveScreenshot).toHaveBeenCalledExactlyOnceWith(source);
expect(vi.mocked(putDraft).mock.calls[0][0].screenshot).toBe(jpeg);
expect(vi.mocked(uploadDraft).mock.calls[0][0].screenshot).toBe(jpeg);
});

it("encodes a checkpoint's own save-point image instead of taking another frame", async () => {
const runtime = runtimeFixture();
vi.spyOn(runtime, "checkpoint").mockResolvedValue({ ...checkpoint, screenshot: source });
const capture = vi.spyOn(runtime, "screenshot");
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", true));
await act(() => result.current.capture(runtime, "EXPORT"));
expect(capture).not.toHaveBeenCalled();
expect(prepareSaveScreenshot).toHaveBeenCalledExactlyOnceWith(source);
expect(vi.mocked(putDraft).mock.calls[0][0].screenshot).toBe(jpeg);
});

it("encodes final native snapshots after the runtime has exited", async () => {
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", true));
await act(() => result.current.commit({ checkpoint, screenshot: source }, null));
expect(prepareSaveScreenshot).toHaveBeenCalledExactlyOnceWith(source);
expect(vi.mocked(putDraft).mock.calls[0][0].screenshot).toBe(jpeg);
});

it("encodes automatic native saves through the same draft boundary", async () => {
const runtime = runtimeFixture();
vi.spyOn(runtime, "screenshot").mockResolvedValue(source);
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", true));
await act(async () => { result.current.nativeChanged(runtime); await result.current.settle(); });
expect(prepareSaveScreenshot).toHaveBeenCalledExactlyOnceWith(source);
expect(vi.mocked(uploadDraft).mock.calls[0][0].screenshot).toBe(jpeg);
});

it("reuses the encoded draft when upload is retried", async () => {
const runtime = runtimeFixture();
vi.spyOn(runtime, "screenshot").mockResolvedValue(source);
vi.spyOn(runtime, "getCheckpointAvailability").mockReturnValue({ available: false, reason: "UNCHANGED" });
vi.mocked(uploadDraft).mockRejectedValueOnce(new Error("offline"));
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", true));
await act(() => result.current.capture(runtime, "EXPORT"));
const original = result.current.draft;
await act(() => result.current.retry(runtime));
expect(prepareSaveScreenshot).toHaveBeenCalledOnce();
expect(vi.mocked(uploadDraft).mock.calls.map(([draft]) => draft)).toEqual([original, original]);
expect(original?.screenshot).toBe(jpeg);
});

it("preserves checkpoint bytes and existing-image semantics if encoding is unavailable", async () => {
vi.mocked(prepareSaveScreenshot).mockResolvedValue(null);
const { result } = renderHook(() => useSaveSession(runFixture(), "user-a", true));
await act(() => result.current.commit({ checkpoint, screenshot: source }, null));
const draft = vi.mocked(putDraft).mock.calls[0][0];
expect(draft.screenshot).toBeNull();
expect(new Uint8Array(await draft.payload.arrayBuffer())).toEqual(checkpoint.bytes);
expect(uploadDraft).toHaveBeenCalledOnce();
});
72 changes: 72 additions & 0 deletions web/features/player/save-screenshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prepareSaveScreenshot } from "./save-screenshot";

const jpeg = new Blob(["encoded"], { type: "image/jpeg" });
const close = vi.fn();
const context = {
fillStyle: "",
fillRect: vi.fn(),
drawImage: vi.fn(),
imageSmoothingEnabled: false,
imageSmoothingQuality: "low",
};

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("createImageBitmap", vi.fn(async () => ({ width: 1920, height: 1080, close })));
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(context as unknown as CanvasRenderingContext2D);
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });

describe("save screenshot encoding", () => {
it.each([
[1920, 1080, 960, 540],
[1080, 1920, 304, 540],
[320, 240, 320, 240],
[960, 540, 960, 540],
[1, 1080, 1, 540],
])("encodes %i × %i as JPEG at %i × %i without upscaling", async (width, height, expectedWidth, expectedHeight) => {
vi.mocked(createImageBitmap).mockResolvedValue({ width, height, close } as unknown as ImageBitmap);
const encode = vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(function (this: HTMLCanvasElement, callback) {
expect([this.width, this.height]).toEqual([expectedWidth, expectedHeight]);
callback(jpeg);
});
expect(await prepareSaveScreenshot(new Blob(["source"], { type: "image/png" }))).toBe(jpeg);
expect(context.fillStyle).toBe("#000");
expect(context.fillRect).toHaveBeenCalledWith(0, 0, expectedWidth, expectedHeight);
expect(context.drawImage).toHaveBeenCalledWith(expect.objectContaining({ width, height }), 0, 0, expectedWidth, expectedHeight);
expect(context.imageSmoothingEnabled).toBe(true);
expect(context.imageSmoothingQuality).toBe("high");
expect(encode).toHaveBeenCalledWith(expect.any(Function), "image/jpeg", 0.95);
expect(close).toHaveBeenCalledOnce();
});

it("leaves an omitted screenshot absent", async () => {
expect(await prepareSaveScreenshot(null)).toBeNull();
expect(createImageBitmap).not.toHaveBeenCalled();
});

it("omits an undecodable image without blocking the checkpoint", async () => {
vi.mocked(createImageBitmap).mockRejectedValue(new Error("decode failed"));
expect(await prepareSaveScreenshot(new Blob(["invalid"]))).toBeNull();
expect(close).not.toHaveBeenCalled();
});

it("releases decoded pixels when a drawing context is unavailable", async () => {
vi.mocked(HTMLCanvasElement.prototype.getContext).mockReturnValue(null);
expect(await prepareSaveScreenshot(new Blob(["source"]))).toBeNull();
expect(close).toHaveBeenCalledOnce();
});

it.each([null, new Blob(), new Blob(["png"], { type: "image/png" })])("omits a failed or unsupported JPEG encoding", async (encoded) => {
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(callback => callback(encoded));
expect(await prepareSaveScreenshot(new Blob(["source"]))).toBeNull();
expect(close).toHaveBeenCalledOnce();
});

it("releases decoded pixels when the encoder throws", async () => {
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation(() => { throw new Error("encode failed"); });
expect(await prepareSaveScreenshot(new Blob(["source"]))).toBeNull();
expect(close).toHaveBeenCalledOnce();
});
});
26 changes: 26 additions & 0 deletions web/features/player/save-screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Encode new save images once, before retaining the draft for upload or retry. */
export async function prepareSaveScreenshot(image: Blob | null): Promise<Blob | null> {
if (!image) { return null; }
let bitmap: ImageBitmap | undefined;
try {
bitmap = await createImageBitmap(image);
const canvas = document.createElement("canvas");
canvas.height = Math.min(bitmap.height, 540);
canvas.width = Math.max(1, Math.round(bitmap.width * canvas.height / bitmap.height));
const context = canvas.getContext("2d", { alpha: false });
if (!context) { return null; }
context.fillStyle = "#000";
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => resolve(blob?.size && blob.type === "image/jpeg" ? blob : null), "image/jpeg", 0.95);
});
} catch {
// Screenshot failure must not discard the player's checkpoint.
return null;
} finally {
bitmap?.close();
}
}
4 changes: 3 additions & 1 deletion web/features/player/use-save-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from "./runtime/contract";
import { makeDraft, putDraft, uploadDraft } from "./save-drafts";
import type { SaveDraft } from "./save-drafts";
import { prepareSaveScreenshot } from "./save-screenshot";
import { checkpointScreenshot } from "./runtime/dispose-runtime";
import { reclaimProviderContentCache } from "./runtime/provider-dispatcher";
type SaveFeedback = "FOREGROUND" | "BACKGROUND";
Expand Down Expand Up @@ -84,7 +85,8 @@ export function useSaveSession(
}
if (native && pending.current && await sameDraftCheckpoint(pending.current, snapshot.checkpoint)) { return false; }
const writable = writableTarget(target);
const record = makeDraft(userId, run, snapshot.checkpoint, snapshot.screenshot, writable, native);
const screenshot = await prepareSaveScreenshot(snapshot.screenshot);
const record = makeDraft(userId, run, snapshot.checkpoint, screenshot, writable, native);
setDraft(record);
pending.current = record;
await putDraft(record, reclaimCache);
Expand Down
Loading