diff --git a/packages/core/src/runtime/runtime.test.ts b/packages/core/src/runtime/runtime.test.ts index 84c007a8a..89f3a323d 100644 --- a/packages/core/src/runtime/runtime.test.ts +++ b/packages/core/src/runtime/runtime.test.ts @@ -147,6 +147,77 @@ test("hydrate invokes onInit once per boot before any onNewPlayer", () => { expect(calls).toEqual(["init", "newPlayer"]); }); +test("joinPlayer marks server and player dirty, and a rejoin does not duplicate the dirty entry", () => { + const runtime = createGameRuntime({ gameId: "demo", save: "none", commands: {} }); + const hydrated = runtime.hydrate({ + gameId: "demo", + serverId: "srv_1", + serverRow: { entities: [], objects: [], session: {} }, + playersByUserId: {}, + chunksByKey: {}, + }); + + const joined = runtime.joinPlayer(hydrated, "alice", true); + expect(joined.revision).toBe(1); + expect(joined.dirty.server).toBe(true); + expect(joined.dirty.players).toEqual(["alice"]); + + const rejoined = runtime.joinPlayer(joined, "alice", false); + expect(rejoined.revision).toBe(2); + expect(rejoined.dirty.players).toEqual(["alice"]); +}); + +test("joinPlayer preserves an already-seeded player row on rejoin instead of resetting it", () => { + const runtime = createGameRuntime({ gameId: "demo", save: "none", commands: {} }); + const hydrated = runtime.hydrate({ + gameId: "demo", + serverId: "srv_1", + serverRow: { entities: [], objects: [], session: {} }, + playersByUserId: { + alice: { userId: "alice", inventories: {}, economy: { gold: 42 }, unlocks: ["sword"], session: {} }, + }, + chunksByKey: {}, + }); + + const rejoined = runtime.joinPlayer(hydrated, "alice", false); + expect(rejoined.players.alice?.economy.gold).toBe(42); + expect(rejoined.players.alice?.unlocks).toEqual(["sword"]); +}); + +test("toProfileRow returns null for an unknown player and a row for a known one", () => { + const runtime = createGameRuntime({ gameId: "demo", save: "none", commands: {} }); + const hydrated = runtime.hydrate({ + gameId: "demo", + serverId: "srv_1", + serverRow: { entities: [], objects: [], session: {} }, + playersByUserId: {}, + chunksByKey: {}, + }); + + expect(runtime.toProfileRow(hydrated, "ghost")).toBeNull(); + + const joined = runtime.joinPlayer(hydrated, "alice", true); + const row = runtime.toProfileRow(joined, "alice"); + expect(row?.userId).toBe("alice"); + expect(row?.gameId).toBe("demo"); + expect(row?.player.userId).toBe("alice"); +}); + +test("tick with no onTick hook returns the same snapshot unchanged", () => { + const runtime = createGameRuntime({ gameId: "demo", save: "none", commands: {} }); + const hydrated = runtime.hydrate({ + gameId: "demo", + serverId: "srv_1", + serverRow: { entities: [], objects: [], session: {} }, + playersByUserId: {}, + chunksByKey: {}, + }); + + const ticked = runtime.tick(hydrated, 0.1); + expect(ticked).toBe(hydrated); + expect(ticked.revision).toBe(0); +}); + test("tick runs onTick once per world step with player id fan-in", () => { const ticks: Array<{ playerIds: string[]; dt: number }> = []; const runtime = createGameRuntime({ diff --git a/packages/core/src/runtime/worldMirror.test.ts b/packages/core/src/runtime/worldMirror.test.ts index 4a7f2a206..2d2b384d1 100644 --- a/packages/core/src/runtime/worldMirror.test.ts +++ b/packages/core/src/runtime/worldMirror.test.ts @@ -174,6 +174,32 @@ describe("world mirror (revision continuity)", () => { expect(hydrated.at(-1)?.["store"]).toEqual([["phase", "combat"]]); }); + test("a diff that arrives out of order is dropped for good; only a diff matching the current revision recovers the mirror", () => { + const hydrated: WorldSnapshot[] = []; + const mirror = createWorldMirror({ hydrate: (s) => hydrated.push(s) }); + mirror.applyBaseline(1, { store: [["phase", "lobby"]] }); + + // Network reorders: revision 3 (built on 2) arrives before revision 2. + mirror.applyDiff(stubDiff({ revision: 3, baseRevision: 2, store: [["phase", "endgame"]] })); + expect(mirror.needsResync()).toBe(true); + expect(mirror.revision()).toBe(1); + expect(hydrated.at(-1)?.["store"]).toEqual([["phase", "lobby"]]); + + // The missing revision 2 finally arrives, matching the mirror's current revision — it recovers. + mirror.applyDiff(stubDiff({ revision: 2, baseRevision: 1, store: [["phase", "combat"]] })); + expect(mirror.needsResync()).toBe(false); + expect(mirror.revision()).toBe(2); + expect(hydrated.at(-1)?.["store"]).toEqual([["phase", "combat"]]); + + // The reordered revision-3 diff is gone for good: its "endgame" payload never lands. + expect(hydrated.some((snapshot) => JSON.stringify(snapshot["store"]).includes("endgame"))).toBe(false); + + // A diff built on the dropped revision (baseRevision 3) re-triggers resync until a fresh baseline. + mirror.applyDiff(stubDiff({ revision: 4, baseRevision: 3, store: [["phase", "overtime"]] })); + expect(mirror.needsResync()).toBe(true); + expect(mirror.revision()).toBe(2); + }); + test("a removed module is dropped client-side on apply", () => { const hydrated: WorldSnapshot[] = []; const mirror = createWorldMirror({ hydrate: (s) => hydrated.push(s) }); diff --git a/packages/ws/src/host.test.ts b/packages/ws/src/host.test.ts index cc44d71ec..5e759dd1c 100644 --- a/packages/ws/src/host.test.ts +++ b/packages/ws/src/host.test.ts @@ -172,6 +172,48 @@ test("the op ledger evicts the oldest ID once past its bound", async () => { expect(coinsOf(await host.getServerView({ userId: "alice", serverId }))).toBe(OP_LEDGER_LIMIT + 2); }); +test("two concurrent writers on the same room both apply, no update lost to a stale snapshot read", async () => { + const host = createGameHost({ persistence: memoryPersistence(), runtimes: [coinsRuntime()] }); + const { serverId } = await host.joinServer({ userId: "alice", gameId: "shop" }); + await host.joinServer({ userId: "bob", gameId: "shop", serverId }); + + const [first, second] = await Promise.all([ + host.runCommand({ userId: "alice", serverId, command: "buy", input: {} }), + host.runCommand({ userId: "bob", serverId, command: "buy", input: {} }), + ]); + + expect(first).toEqual({ ok: true }); + expect(second).toEqual({ ok: true }); + expect(coinsOf(await host.getServerView({ userId: "alice", serverId }))).toBe(2); +}); + +test("a burst of concurrent runCommand calls from one player serializes through the host queue", async () => { + const host = createGameHost({ persistence: memoryPersistence(), runtimes: [coinsRuntime()] }); + const { serverId } = await host.joinServer({ userId: "alice", gameId: "shop" }); + + const results = await Promise.all( + Array.from({ length: 10 }, () => host.runCommand({ userId: "alice", serverId, command: "buy", input: {} })), + ); + + expect(results.every((result) => result.ok === true)).toBe(true); + expect(coinsOf(await host.getServerView({ userId: "alice", serverId }))).toBe(10); +}); + +test("a concurrent join and runCommand on a fresh server never race past server creation", async () => { + const host = createGameHost({ persistence: memoryPersistence(), runtimes: [coinsRuntime()] }); + + const { serverId } = await host.joinServer({ userId: "alice", gameId: "shop" }); + const [joinResult, commandResult] = await Promise.all([ + host.joinServer({ userId: "bob", gameId: "shop", serverId }), + host.runCommand({ userId: "alice", serverId, command: "buy", input: {} }), + ]); + + expect(joinResult.serverId).toBe(serverId); + expect(commandResult).toEqual({ ok: true }); + expect(await host.isMember({ userId: "bob", serverId })).toBe(true); + expect(coinsOf(await host.getServerView({ userId: "alice", serverId }))).toBe(1); +}); + test("a stalled mutation in one room does not block another room's mutation", async () => { const base = memoryPersistence(); let slowServerId = ""; diff --git a/packages/ws/src/hostRouter.test.ts b/packages/ws/src/hostRouter.test.ts index ba145a363..f896a9d80 100644 --- a/packages/ws/src/hostRouter.test.ts +++ b/packages/ws/src/hostRouter.test.ts @@ -470,6 +470,29 @@ test("security: single-session lock evicts the older connection for the same use } }); +test("security: a mid-session reconnect evicts the stale connection but keeps room membership live", async () => { + const stack = startStack({ singleSession: true }); + try { + const alice1 = stack.connect("alice"); + const { serverId } = await alice1.transport.joinServer({ gameId: "test-game" }); + await alice1.transport.runCommand({ serverId, command: "engine.ping", input: null }); + + // A second connection for the same userId (e.g. a browser tab refresh) evicts the first + // without the server ever seeing the player leave. + const alice2 = stack.connect("alice"); + const pingAfterReconnect = await alice2.transport.runCommand({ + serverId, + command: "engine.ping", + input: null, + }); + expect(pingAfterReconnect).toEqual({ ok: true }); + expect(await stack.host.isMember({ userId: "alice", serverId })).toBe(true); + expect((await stack.host.getServerView({ userId: "alice", serverId }))?.memberUserIds).toEqual(["alice"]); + } finally { + await stack.shutdown(); + } +}); + test("security: pose chat and voice reject cross-room non-members", async () => { const stack = startStack(); try {