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: 1 addition & 1 deletion apps/web/bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[test]
preload = ["@zgeoff/bun-test-extended", "./test-setup.ts"]
preload = ["@zgeoff/bun-test-extended", "./register-zustand-reset-early.ts", "./test-setup.ts"]
8 changes: 8 additions & 0 deletions apps/web/register-zustand-reset-early.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { registerZustandReset } from '@vers/client-test-utils';
Comment thread
zgeoff marked this conversation as resolved.

// its own preload entry, ahead of `test-setup.ts`: that file's local `register-*-mock` imports
// (`registerWorldmapSceneMock` among them) transitively import zustand-backed stores, and
// `registerZustandReset` must wrap zustand's `create` before any of those imports run or the
// stores they create are never tracked for reset — a same-file call after those imports is too
// late, since ES module imports are hoisted and evaluate before the importing module's own body
registerZustandReset();
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from 'bun:test';
import { buildRevealedNodesQueryOptions } from './build-revealed-nodes-query-options';

test('it keys the query by avatar id and viewport, with a sensible staleTime', () => {
const viewport = { maxCX: 16, maxCY: 16, minCX: 0, minCY: 0 };
const options = buildRevealedNodesQueryOptions('avatar_1', viewport);

expect(options.queryKey).toMatchInlineSnapshot(`
[
[
"getRevealedNodes",
],
{
"input": {
"avatarID": "avatar_1",
"viewport": {
"maxCX": 16,
"maxCY": 16,
"minCX": 0,
"minCY": 0,
},
},
"type": "query",
},
]
`);

expect(options.staleTime).toBe(30_000);
});
23 changes: 23 additions & 0 deletions apps/web/src/lib/activity/build-revealed-nodes-query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Viewport } from '@vers/worldmap-core';
import { orpc } from '../rpc/orpc';

/**
* Reveal state changes only when the avatar earns a new first-clear grant, far less often than the
* player pans — long enough that a re-mount or window refocus doesn't re-fetch a viewport nothing
* has changed for.
*/
const REVEALED_NODES_STALE_TIME_MS = 30_000;

/**
* Query options for an avatar's revealed world-map cells inside a viewport. The query key carries
* both inputs: the avatar id, so no avatar ever reads another's cached reveal data, and the
* viewport. Callers pass an already chunk-aligned viewport, so for a given avatar the key changes
* only when the player pans across a chunk boundary rather than on every frame's cell-granular
* move.
*/
Comment thread
zgeoff marked this conversation as resolved.
export function buildRevealedNodesQueryOptions(avatarID: string, viewport: Viewport) {
return orpc.activity.getRevealedNodes.queryOptions({
input: { avatarID, viewport },
staleTime: REVEALED_NODES_STALE_TIME_MS,
});
}
2 changes: 2 additions & 0 deletions apps/web/src/routes/-game/game-world.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { GameCanvas } from '@vers/game-rendering';
import { SceneRoot } from './scene-root';
import { useAvatarRegionGraph } from './use-avatar-region-graph';
import { useRevealedNodesQuery } from './use-revealed-nodes-query';

/**
* The persistent canvas's world content: dynamically imported through `GameCanvasMount`'s
* code-split boundary so three.js and the generated region never land in the initial bundle.
*/
export function GameWorld() {
useAvatarRegionGraph();
useRevealedNodesQuery();

return (
<GameCanvas>
Expand Down
195 changes: 187 additions & 8 deletions apps/web/src/routes/-game/use-avatar-region-graph.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { expect, test } from 'bun:test';
import { waitFor } from '@testing-library/react';
import * as db from '@vers/mock-services/db';
import { buildRegionGraph, useSelectedNode, useWorldGraph } from '@vers/worldmap-client';
import {
buildChunkAlignedViewport,
buildViewportGraph,
setViewport,
useSelectedNode,
useViewport,
useWorldGraph,
} from '@vers/worldmap-client';
import { toNodeID } from '@vers/worldmap-core';
import invariant from 'tiny-invariant';
import { createActiveAvatar } from '../../test-utils/create-active-avatar';
Expand All @@ -14,10 +21,16 @@ test("it builds the active avatar's region graph and selects its origin node", a
const signedIn = await createSignedInUser();
const avatar = await createActiveAvatar({ seed: 111, userID: signedIn.userID });

const expected = buildRegionGraph(avatar.seed, 24);
const expected = buildViewportGraph(avatar.seed, {
maxCX: 24,
maxCY: 24,
minCX: -24,
minCY: -24,
});

const expectedOrigin = expected.nodes[toNodeID(0, 0)];

invariant(expectedOrigin, 'the generated region always contains its origin cell');
invariant(expectedOrigin, 'the initial viewport always contains its origin cell');

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const hook = renderHook(() => {
Expand All @@ -33,7 +46,7 @@ test("it builds the active avatar's region graph and selects its origin node", a
});

expect(hook.result.current.worldGraph).toStrictEqual(expected);
expect(hook.result.current.selection.node?.id).toBe(toNodeID(0, 0));
expect(hook.result.current.selection.node).toMatchObject({ id: toNodeID(0, 0) });
});
});

Expand All @@ -42,13 +55,25 @@ test('it rebuilds the graph and resets the selection when the active avatar chan
const first = await createActiveAvatar({ seed: 333, userID: signedIn.userID });
const second = await db.avatarCollection.create({ seed: 444, userID: signedIn.userID });

const firstExpected = buildRegionGraph(first.seed, 24);
const secondExpected = buildRegionGraph(second.seed, 24);
const firstExpected = buildViewportGraph(first.seed, {
maxCX: 24,
maxCY: 24,
minCX: -24,
minCY: -24,
});

const secondExpected = buildViewportGraph(second.seed, {
maxCX: 24,
maxCY: 24,
minCX: -24,
minCY: -24,
});

const firstOrigin = firstExpected.nodes[toNodeID(0, 0)];
const secondOrigin = secondExpected.nodes[toNodeID(0, 0)];

invariant(firstOrigin, 'the generated region always contains its origin cell');
invariant(secondOrigin, 'the generated region always contains its origin cell');
invariant(firstOrigin, 'the initial viewport always contains its origin cell');
invariant(secondOrigin, 'the initial viewport always contains its origin cell');

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const hook = renderHook(() => {
Expand Down Expand Up @@ -88,3 +113,157 @@ test('it rebuilds the graph and resets the selection when the active avatar chan
expect(hook.result.current.selection.node).toStrictEqual(secondOrigin);
});
});

test('it refreshes the graph without resetting the selection when the viewport moves for the same avatar', async () => {
const signedIn = await createSignedInUser();
const avatar = await createActiveAvatar({ seed: 555, userID: signedIn.userID });

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const hook = renderHook(() => {
useAvatarRegionGraph();

return { selection: useSelectedNode(), worldGraph: useWorldGraph() };
});

await waitFor(() => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(hook.result.current.selection.node).toMatchObject({ id: toNodeID(0, 0) });
});

const movedViewport = { maxCX: 40, maxCY: 10, minCX: 20, minCY: -10 };

const expectedMovedGraph = buildViewportGraph(
avatar.seed,
buildChunkAlignedViewport(movedViewport),
);

const probeID = toNodeID(40, 10);
const expectedProbeNode = expectedMovedGraph.nodes[probeID];

invariant(expectedProbeNode, 'the aligned moved viewport contains its own upper corner cell');
setViewport(movedViewport);

// poll one moved-viewport node alone: a full-graph mismatch diff per retry starves the event
// loop of the query fetch this wait depends on
await waitFor(() => {
expect(hook.result.current.worldGraph.nodes[probeID]).toStrictEqual(expectedProbeNode);
});

expect(hook.result.current.worldGraph).toStrictEqual(expectedMovedGraph);
expect(hook.result.current.selection.node).toMatchObject({ id: toNodeID(0, 0) });
});
});

test('it keeps the same graph across a viewport move inside the same chunks', async () => {
const signedIn = await createSignedInUser();
const avatar = await createActiveAvatar({ seed: 666, userID: signedIn.userID });

const expectedInitialOrigin = buildViewportGraph(avatar.seed, {
maxCX: 24,
maxCY: 24,
minCX: -24,
minCY: -24,
}).nodes[toNodeID(0, 0)];

invariant(expectedInitialOrigin, 'the initial viewport always contains its origin cell');

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const hook = renderHook(() => {
useAvatarRegionGraph();

return { viewport: useViewport(), worldGraph: useWorldGraph() };
});

// poll this avatar's seed-specific origin: its region build resets the stored viewport, so a
// camera viewport set before that build lands would be wiped by it
await waitFor(() => {
expect(hook.result.current.worldGraph.nodes[toNodeID(0, 0)]).toStrictEqual(
expectedInitialOrigin,
);
});

const firstViewport = { maxCX: 5, maxCY: 5, minCX: 1, minCY: 1 };

const expectedAlignedGraph = buildViewportGraph(
avatar.seed,
buildChunkAlignedViewport(firstViewport),
);

setViewport(firstViewport);

// poll for a cell the aligned box excludes to observe the rebuild: the aligned graph's own
// nodes also all sit inside the wider initial region, so only an absence distinguishes it
await waitFor(() => {
expect(hook.result.current.worldGraph.nodes[toNodeID(-1, 0)]).toBeUndefined();
});

expect(hook.result.current.worldGraph).toStrictEqual(expectedAlignedGraph);

const stableGraph = hook.result.current.worldGraph;
const secondViewport = { maxCX: 9, maxCY: 9, minCX: 3, minCY: 3 };

setViewport(secondViewport);

await waitFor(() => {
expect(hook.result.current.viewport).toStrictEqual(secondViewport);
});

expect(hook.result.current.worldGraph).toBe(stableGraph);
});
});

test('it selects the new origin on an avatar switch even when the viewport had panned far from it', async () => {
const signedIn = await createSignedInUser();

await createActiveAvatar({ seed: 333, userID: signedIn.userID });

const second = await db.avatarCollection.create({ seed: 444, userID: signedIn.userID });

const secondExpected = buildViewportGraph(second.seed, {
maxCX: 24,
maxCY: 24,
minCX: -24,
minCY: -24,
});

const secondOrigin = secondExpected.nodes[toNodeID(0, 0)];

invariant(secondOrigin, 'the initial viewport always contains its origin cell');

await withRequestContext({ cookies: signedIn.cookies }, async () => {
const hook = renderHook(() => {
useAvatarRegionGraph();

return { selection: useSelectedNode(), viewport: useViewport(), worldGraph: useWorldGraph() };
});

await waitFor(() => {
expect(hook.result.current.selection.node).toMatchObject({ id: toNodeID(0, 0) });
});

const farViewport = { maxCX: 800, maxCY: 800, minCX: 760, minCY: 760 };

setViewport(farViewport);

const active = db.activeAvatarCollection.findFirst((q) => q.where({ userID: signedIn.userID }));

invariant(active, 'createActiveAvatar seeds an active-avatar row for this user');

await db.activeAvatarCollection.update(active, {
data(record) {
record.avatarID = second.id;
},
});

await hook.queryClient.invalidateQueries();

await waitFor(() => {
expect(hook.result.current.worldGraph.nodes[toNodeID(0, 0)]).toStrictEqual(secondOrigin);
});

expect(hook.result.current.selection.node).toStrictEqual(secondOrigin);

// the switch also clears the outgoing avatar's camera footprint, so nothing can query the new
// avatar with the far-panned coordinates before the camera reports a fresh viewport
expect(hook.result.current.viewport).toBeNull();
});
});
Loading