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
71 changes: 71 additions & 0 deletions packages/core/src/runtime/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/runtime/worldMirror.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
42 changes: 42 additions & 0 deletions packages/ws/src/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
23 changes: 23 additions & 0 deletions packages/ws/src/hostRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading