51902b995f
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.
Stage 1 (vector-preserving, no rasterisation):
- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
single Ticker.shared flush gated on viewport.dirty (camera) plus an
internal requestRender() from every content mutation (fog / hide-set /
extras / wrap mode / resize / pick overlay). An idle map now does zero
GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
deterministic e2e assertions.
Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.
If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
// Verifies the Phase 6 storage layer end-to-end in real browser
|
|
// engines: a freshly generated device keypair persists across a page
|
|
// reload, signs deterministically with the same private key after the
|
|
// reload, and is wiped by `clearDeviceSession` so the next load
|
|
// generates a different keypair. The live-gateway round-trip is
|
|
// covered by Phase 7's e2e once the email-code login flow lands;
|
|
// this spec deliberately stops at the storage boundary.
|
|
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
interface DebugSnapshot {
|
|
publicKey: number[];
|
|
deviceSessionId: string | null;
|
|
}
|
|
|
|
import type {
|
|
MapCameraSnapshot,
|
|
MapFogSnapshot,
|
|
MapPickStateSnapshot,
|
|
MapPrimitiveSnapshot,
|
|
} from "../../src/lib/debug-surface.svelte";
|
|
import type { WrapMode } from "../../src/map/world";
|
|
|
|
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
|
|
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
|
|
// `cargo-routes.spec.ts`) reuse the global declaration below, so this
|
|
// interface lists every helper any spec calls — not only those
|
|
// exercised by this file.
|
|
interface DebugSurface {
|
|
ready: true;
|
|
loadSession(): Promise<DebugSnapshot>;
|
|
clearSession(): Promise<void>;
|
|
signWithDevice(message: number[]): Promise<number[]>;
|
|
setDeviceSessionId(id: string): Promise<void>;
|
|
verifyWithStoredPublicKey(
|
|
message: number[],
|
|
signature: number[],
|
|
): Promise<boolean>;
|
|
seedOrderDraft(
|
|
gameId: string,
|
|
commands: ReadonlyArray<{
|
|
kind: "placeholder";
|
|
id: string;
|
|
label: string;
|
|
}>,
|
|
): Promise<void>;
|
|
clearOrderDraft(gameId: string): Promise<void>;
|
|
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
|
|
getMapPickState(): MapPickStateSnapshot;
|
|
getMapCamera(): MapCameraSnapshot | null;
|
|
getMapFog(): MapFogSnapshot;
|
|
getMapMode(): WrapMode | null;
|
|
getMapRenderCount(): number;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__galaxyDebug?: DebugSurface;
|
|
}
|
|
}
|
|
|
|
const CANONICAL = Array.from(new TextEncoder().encode("phase-6-canonical"));
|
|
|
|
async function bootDebugPage(page: Page): Promise<void> {
|
|
await page.goto("/__debug/store");
|
|
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
|
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
|
}
|
|
|
|
test("device keypair survives reload and produces verifiable signatures", async ({
|
|
page,
|
|
}) => {
|
|
await bootDebugPage(page);
|
|
// Wipe any leftover state from a previous Playwright run that
|
|
// shared the same browser profile.
|
|
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
|
|
|
const first = await page.evaluate(async (canonical) => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
const signature = await window.__galaxyDebug!.signWithDevice(canonical);
|
|
return { publicKey: sess.publicKey, signature };
|
|
}, CANONICAL);
|
|
expect(first.publicKey.length).toBe(32);
|
|
expect(first.signature.length).toBe(64);
|
|
|
|
await page.reload();
|
|
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
|
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
|
|
|
const second = await page.evaluate(
|
|
async ({ canonical, firstSig }) => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
const fresh = await window.__galaxyDebug!.signWithDevice(canonical);
|
|
// Signatures produced before the reload must verify under the
|
|
// post-reload public key. A pre-reload signature only verifies
|
|
// when the persisted private key is identical to the original.
|
|
const prevVerifies =
|
|
await window.__galaxyDebug!.verifyWithStoredPublicKey(
|
|
canonical,
|
|
firstSig,
|
|
);
|
|
const freshVerifies =
|
|
await window.__galaxyDebug!.verifyWithStoredPublicKey(
|
|
canonical,
|
|
fresh,
|
|
);
|
|
return {
|
|
publicKey: sess.publicKey,
|
|
signature: fresh,
|
|
prevVerifies,
|
|
freshVerifies,
|
|
};
|
|
},
|
|
{ canonical: CANONICAL, firstSig: first.signature },
|
|
);
|
|
expect(second.publicKey).toEqual(first.publicKey);
|
|
expect(second.prevVerifies).toBe(true);
|
|
expect(second.freshVerifies).toBe(true);
|
|
});
|
|
|
|
test("clearDeviceSession forces a fresh keypair on next load", async ({
|
|
page,
|
|
}) => {
|
|
await bootDebugPage(page);
|
|
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
|
const before = await page.evaluate(async () => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
return sess.publicKey;
|
|
});
|
|
|
|
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
|
const after = await page.evaluate(async () => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
return sess.publicKey;
|
|
});
|
|
|
|
expect(after).not.toEqual(before);
|
|
});
|
|
|
|
test("setDeviceSessionId is observable through loadDeviceSession", async ({
|
|
page,
|
|
}) => {
|
|
await bootDebugPage(page);
|
|
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
|
const before = await page.evaluate(async () => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
return sess.deviceSessionId;
|
|
});
|
|
expect(before).toBeNull();
|
|
|
|
await page.evaluate(() => window.__galaxyDebug!.setDeviceSessionId("dev-1"));
|
|
const after = await page.evaluate(async () => {
|
|
const sess = await window.__galaxyDebug!.loadSession();
|
|
return sess.deviceSessionId;
|
|
});
|
|
expect(after).toBe("dev-1");
|
|
});
|