fix(platform): separate room stage contracts

This commit is contained in:
2026-09-05 03:12:19 +08:00
parent 4ffa59bf8e
commit 462e2e9267
3 changed files with 311 additions and 46 deletions
@@ -47,6 +47,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
test('room login stores one canonical player entity and recursively freezes roomtype', () => {
const data = fixture('player-login-room.json').data;
const sourceRoomtype = data.roomtype as unknown[];
const expectedRoomtype = structuredClone(sourceRoomtype);
const parsedLogin = parseLoginResponse(data);
const store = new PlatformStore();
@@ -64,10 +66,15 @@ test('room login stores one canonical player entity and recursively freezes room
assert.equal(state.players.entities[430511]?.onstate, 1);
assert.equal(typeof state.players.entities[430511]?.isprepare, 'number');
assert.equal(typeof state.players.entities[430511]?.onstate, 'number');
assert.equal(room.roomtype, data.roomtype);
assert.notEqual(room.roomtype, sourceRoomtype);
assert.deepEqual(room.roomtype, expectedRoomtype);
assert.equal(Object.isFrozen(room.roomtype), true);
assert.equal(Object.isFrozen(room.roomtype[5]), true);
assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true);
assert.equal(Object.isFrozen(sourceRoomtype), false);
assert.equal(Object.isFrozen(sourceRoomtype[5]), false);
assert.equal(Object.isFrozen((sourceRoomtype[5] as unknown[])[2]), false);
assert.deepEqual(sourceRoomtype, expectedRoomtype);
assert.equal('deskinfo' in state, false);
assert.equal('deskinfo' in state.room, false);
assert.throws(() => {
@@ -232,10 +239,75 @@ test('replaceRoom atomically replaces the complete room from a parsed DTO', () =
assert.equal(after.room.needprepare, 1);
assert.equal(after.room.infinite, 0);
assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]);
assert.equal(after.room.roomtype, parsedRoom.room?.roomtype);
assert.notEqual(after.room.roomtype, parsedRoom.room?.roomtype);
assert.deepEqual(after.room.roomtype, parsedRoom.room?.roomtype);
assert.equal(after.players.entities[430511]?.ip, '127.0.0.1');
});
test('replaceRoom derives a reconnect stage only from deskinfo presence', () => {
for (const deskwar of [undefined, 0] as const) {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const data = fixture('self-join-room.json').data;
if (deskwar === undefined) delete data.deskwar;
else data.deskwar = deskwar;
const parsed = parseSelfJoinRoomResponse(data);
store.replaceRoom(parsed);
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') continue;
assert.equal(state.room.stage, 1);
}
});
test('applyLoginSuccess requires isbattle even when raw deskwar is fabricated', () => {
const store = new PlatformStore();
const data = fixture('player-login-room.json').data;
delete data.isbattle;
data.deskwar = 1;
const parsed = parseLoginResponse(data);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), /isbattle/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('replaceRoom without reconnect requires deskwar and never substitutes isbattle', () => {
const successfulStore = new PlatformStore();
successfulStore.applyLoginSuccess(
parseLoginResponse(fixture('player-login-success.json').data),
);
const successfulData = fixture('self-join-room.json').data;
delete successfulData.deskinfo;
successfulData.deskwar = 0;
successfulStore.replaceRoom(parseSelfJoinRoomResponse(successfulData));
const successfulState = successfulStore.getState();
assert.equal(successfulState.room.kind, 'inside');
if (successfulState.room.kind === 'inside') {
assert.equal(successfulState.room.stage, 0);
}
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const data = fixture('self-join-room.json').data;
delete data.deskinfo;
delete data.deskwar;
data.isbattle = 1;
const parsed = parseSelfJoinRoomResponse(data);
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /deskwar/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('clearRoom leaves no fabricated room fields and retains only the self entity', () => {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
@@ -378,12 +450,14 @@ test('room self profile conflicts are rejected instead of overwriting login auth
}
});
test('roomtype traversal failure freezes nothing and leaves store state untouched', () => {
test('roomtype accessors are rejected without invocation or input mutation', () => {
const child = { value: 1 };
const throwing = Object.create(null) as Record<string, unknown>;
let getterCalls = 0;
Object.defineProperty(throwing, 'boom', {
enumerable: true,
get(): never {
getterCalls += 1;
throw new Error('roomtype getter boom');
},
});
@@ -397,10 +471,104 @@ test('roomtype traversal failure freezes nothing and leaves store state untouche
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype getter boom/);
assert.throws(() => store.replaceRoom(parsed), /roomtype.*boom.*accessor/i);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(child), false);
assert.equal(Object.isFrozen(throwing), false);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('roomtype copying never runs preventExtensions on caller-owned proxies', () => {
const sibling = { value: 1 };
const nested = { value: 2 };
let preventExtensionsCalls = 0;
const proxy = new Proxy({ nested }, {
preventExtensions(): boolean {
preventExtensionsCalls += 1;
return false;
},
});
const roomtype = [sibling, proxy];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
store.replaceRoom(parsed);
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
assert.notEqual(state.room.roomtype, roomtype);
assert.deepEqual(state.room.roomtype, roomtype);
assert.equal(Object.isFrozen(state.room.roomtype), true);
assert.equal(Object.isFrozen(state.room.roomtype[0]), true);
assert.equal(Object.isFrozen(state.room.roomtype[1]), true);
assert.equal(
Object.isFrozen(
(state.room.roomtype[1] as { readonly nested: object }).nested,
),
true,
);
assert.equal(preventExtensionsCalls, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(sibling), false);
assert.equal(Object.isFrozen(proxy), false);
assert.equal(Object.isFrozen(nested), false);
});
test('dynamic roomtype accessors cannot leak mutable children into canonical state', () => {
const first = { value: 1 };
const second = { value: 2 };
let getterCalls = 0;
const dynamic = {} as Record<string, unknown>;
Object.defineProperty(dynamic, 'child', {
enumerable: true,
get(): object {
getterCalls += 1;
return getterCalls === 1 ? first : second;
},
});
const roomtype = [dynamic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype.*child.*accessor/i);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(dynamic), false);
assert.equal(Object.isFrozen(first), false);
assert.equal(Object.isFrozen(second), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('cyclic roomtype input is rejected atomically without mutating caller data', () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
const roomtype = [{ value: 1 }, cyclic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[1\]\[0\].*cyclic/i);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(roomtype[0]), false);
assert.equal(Object.isFrozen(cyclic), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
@@ -130,3 +130,23 @@ test('selectGameSnapshot memoizes each SDK branch by its immutable source subtre
assert.notEqual(afterRoom.room, first.room);
assert.notEqual(afterRoom.seats, first.seats);
});
test('selector memoization remains isolated across independent stores', () => {
const firstStore = new PlatformStore();
const secondStore = new PlatformStore();
firstStore.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
secondStore.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
const firstState = firstStore.getState();
const secondState = secondStore.getState();
const firstSnapshot = selectGameSnapshot(firstState);
const secondSnapshot = selectGameSnapshot(secondState);
assert.notEqual(firstSnapshot, secondSnapshot);
assert.notEqual(firstSnapshot.connection, secondSnapshot.connection);
assert.notEqual(firstSnapshot.self, secondSnapshot.self);
assert.notEqual(firstSnapshot.room, secondSnapshot.room);
assert.notEqual(firstSnapshot.seats, secondSnapshot.seats);
assert.equal(selectGameSnapshot(firstState), firstSnapshot);
assert.equal(selectGameSnapshot(secondState), secondSnapshot);
});