fix(platform): separate room stage contracts
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user