diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts index d2c266b..8087257 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -3,6 +3,7 @@ import { hasOwn, requireArray, requireInteger, + requireNumber, requireRecord, requireString, type ParsedLoginResponse, @@ -21,6 +22,8 @@ interface RoomBuildResult { readonly room: Extract; } +const LOGIN_ROOM_FIELDS = ['roomcode', 'seat', 'roomtype', 'players'] as const; + function requirePositiveInteger(value: unknown, path: string): number { const parsed = requireInteger(value, path); if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`); @@ -55,6 +58,8 @@ function parseRoomPlayer(value: unknown, path: string): { playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`), nickname: requireString(raw.nickname, `${path}.nickname`), avatar: requireString(raw.avatar, `${path}.avatar`), + sex: requireInteger(raw.sex, `${path}.sex`), + bean: requireNumber(raw.bean, `${path}.bean`), ...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}), isprepare, onstate, @@ -62,12 +67,67 @@ function parseRoomPlayer(value: unknown, path: string): { return { player, isprepare, onstate }; } -function recursivelyFreeze(value: T, seen = new WeakSet()): T { - if (typeof value !== 'object' || value === null) return value; - if (seen.has(value)) return value; - seen.add(value); - for (const child of Object.values(value)) recursivelyFreeze(child, seen); - return Object.freeze(value); +function collectFreezableGraph(value: unknown): object[] { + const graph: object[] = []; + const seen = new WeakSet(); + + const visit = (current: unknown): void => { + if (typeof current !== 'object' || current === null || seen.has(current)) return; + seen.add(current); + graph.push(current); + + const prototype = Object.getPrototypeOf(current); + if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) { + throw new TypeError('roomtype: expected JSON array or plain object container'); + } + Object.isExtensible(current); + for (const key of Reflect.ownKeys(current)) { + const descriptor = Reflect.getOwnPropertyDescriptor(current, key); + if (descriptor === undefined) { + throw new TypeError('roomtype: unstable property structure'); + } + visit(Reflect.get(current, key)); + } + }; + + visit(value); + return graph; +} + +function recursivelyFreeze(value: T): T { + const graph = collectFreezableGraph(value); + for (let index = graph.length - 1; index >= 0; index -= 1) { + Object.freeze(graph[index]); + } + return value; +} + +function assertSelfProfileMatches( + loginPlayer: PlatformPlayer, + roomPlayer: PlatformPlayer, + path: string, +): void { + for (const field of ['playerid', 'nickname', 'avatar', 'sex', 'bean'] as const) { + if (roomPlayer[field] !== loginPlayer[field]) { + throw new RangeError(`${path}.${field}: conflicts with authoritative login profile`); + } + } + if (roomPlayer.ip !== undefined && roomPlayer.ip !== loginPlayer.ip) { + throw new RangeError(`${path}.ip: conflicts with authoritative login profile`); + } +} + +function requireMatchingState( + parsedState: unknown, + raw: Readonly>, + path: string, +): number { + const state = requireInteger(parsedState, `${path}.state`); + const rawState = requireInteger(raw.state, `${path}.raw.state`); + if (state !== rawState) { + throw new RangeError(`${path}.state: expected parsed state to equal raw.state`); + } + return state; } function buildRoom( @@ -129,15 +189,21 @@ function buildRoom( if (parsed.onstate === 1) offlineSeats.push(seat); } - if (seatPlayerIds[selfSeat] !== selfPlayer.playerid) { - throw new RangeError('$.room.players: self seat must contain the authenticated player'); + const selfRoomPlayer = parsedPlayers.get(selfPlayer.playerid); + if (seatPlayerIds[selfSeat] !== selfPlayer.playerid || selfRoomPlayer === undefined) { + throw new RangeError(`$.room.players[${selfSeat}].playerid: conflicts with authenticated player`); } + assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`); recursivelyFreeze(roomtype); const entities: Record = {}; for (const [playerid, parsedPlayer] of parsedPlayers) { const player = playerid === selfPlayer.playerid - ? { ...selfPlayer, ...parsedPlayer } + ? { + ...selfPlayer, + isprepare: parsedPlayer.isprepare, + onstate: parsedPlayer.onstate, + } : parsedPlayer; entities[playerid] = Object.freeze(player); } @@ -217,7 +283,12 @@ export class PlatformStore { applyLoginSuccess(parsed: ParsedLoginResponse): void { const input = requireRecord(parsed, '$.login'); - const state = requireInteger(input.state, '$.login.state'); + const raw = requireRecord(input.raw, '$.login.raw'); + const state = requireMatchingState(input.state, raw, '$.login'); + const rawHasRoom = LOGIN_ROOM_FIELDS.some((field) => hasOwn(raw, field)); + if (input.room === undefined || (input.room !== null) !== rawHasRoom) { + throw new TypeError('$.login.room: parsed room presence conflicts with raw room fields'); + } if (state !== 0) { throw new RangeError('$.login.state: expected successful state 0'); } @@ -225,7 +296,6 @@ export class PlatformStore { if (playerid <= 0) { throw new RangeError('$.login.playerid: expected positive integer'); } - const raw = requireRecord(input.raw, '$.login.raw'); if (requireInteger(raw.playerid, '$.login.raw.playerid') !== playerid) { throw new RangeError('$.login.raw.playerid: expected the parsed playerid'); } @@ -233,6 +303,8 @@ export class PlatformStore { playerid, nickname: requireString(raw.nickname, '$.login.raw.nickname'), avatar: requireString(raw.avatar, '$.login.raw.avatar'), + sex: requireInteger(raw.sex, '$.login.raw.sex'), + bean: requireNumber(raw.bean, '$.login.raw.bean'), ip: requireString(raw.ip, '$.login.raw.ip'), }); const roomResult = input.room === null @@ -253,10 +325,15 @@ export class PlatformStore { replaceRoom(parsed: ParsedRoomResponse): void { const input = requireRecord(parsed, '$.response'); - if (requireInteger(input.state, '$.response.state') !== 0) { + const raw = requireRecord(input.raw, '$.response.raw'); + const state = requireMatchingState(input.state, raw, '$.response'); + if (input.room === undefined || (input.room !== null) !== (state === 0)) { + throw new TypeError('$.response.room: parsed room presence conflicts with raw state'); + } + if (state !== 0) { throw new RangeError('$.response.state: expected successful state 0'); } - if (input.room === null || input.room === undefined) { + if (input.room === null) { throw new TypeError('$.response.room: expected parsed room snapshot'); } const previous = this.getState(); @@ -268,7 +345,7 @@ export class PlatformStore { if (selfPlayer === undefined) { throw new Error('players.entities: missing authenticated player'); } - const roomResult = buildRoom(input.room, input.raw, selfPlayer); + const roomResult = buildRoom(input.room, raw, selfPlayer); this.stateCell.value = Object.freeze({ app: previous.app, players: roomResult.players, diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts index e711af7..e726ecf 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts @@ -13,6 +13,8 @@ export interface PlatformPlayer { readonly playerid: number; readonly nickname: string; readonly avatar: string; + readonly sex: number; + readonly bean: number; readonly ip?: string; readonly isprepare?: number; readonly onstate?: number; diff --git a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts index 3f8f51e..5ee6982 100644 --- a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts +++ b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts @@ -38,6 +38,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () = playerid: 430511, nickname: '测试号', avatar: 'http://a', + sex: 0, + bean: 0, ip: '127.0.0.1', }); assert.deepEqual(state.room, { kind: 'outside' }); @@ -101,6 +103,8 @@ test('playerJoin atomically adds one canonical entity and its seat id', () => { playerid: 77, nickname: '对家', avatar: 'http://b', + sex: 1, + bean: 90, ip: '127.0.0.2', isprepare: 0, onstate: 0, @@ -113,7 +117,7 @@ test('playerReady replaces the affected player and room subtrees without prior m store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', - ip: '127.0.0.2', onstate: 0, isprepare: 0, + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); const before = store.getState(); let notifications = 0; @@ -140,7 +144,7 @@ test('playerOffline records protocol and room status with one atomic commit', () store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', - ip: '127.0.0.2', onstate: 0, isprepare: 0, + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); const before = store.getState(); let notifications = 0; @@ -186,7 +190,7 @@ test('playerExit removes the seat entity and every seat-owned status in one comm store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', - ip: '127.0.0.2', onstate: 1, isprepare: 1, + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 1, isprepare: 1, })); const before = store.getState(); let notifications = 0; @@ -237,7 +241,7 @@ test('clearRoom leaves no fabricated room fields and retains only the self entit store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', - ip: '127.0.0.2', onstate: 0, isprepare: 0, + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, })); const before = store.getState(); const self = before.players.entities[430511]; @@ -278,7 +282,7 @@ test('failed actions keep the exact root, notify nobody, and do not mutate input assert.throws( () => store.playerJoin(parseOtherJoinRoomPayload({ seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate', - ip: '127.0.0.3', onstate: 0, isprepare: 0, + sex: 0, bean: 0, ip: '127.0.0.3', onstate: 0, isprepare: 0, })), /duplicate player seat/, ); @@ -304,3 +308,99 @@ test('failed actions keep the exact root, notify nobody, and do not mutate input assert.equal(store.getState(), before); assert.equal(notifications, 0); }); + +test('applyLoginSuccess rejects a room login whose parsed room was changed to null', () => { + const store = new PlatformStore(); + const parsed = parseLoginResponse(fixture('player-login-room.json').data); + (parsed as { room: null }).room = null; + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + assert.throws(() => store.applyLoginSuccess(parsed), /room.*raw/i); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); + +test('applyLoginSuccess rejects a failed login whose parsed state was changed to success', () => { + const store = new PlatformStore(); + const parsed = parseLoginResponse({ state: 1 }); + (parsed as { state: number }).state = 0; + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + assert.throws(() => store.applyLoginSuccess(parsed), /state.*raw/i); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); + +test('replaceRoom rejects a successful parsed join whose raw state was changed to failure', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data)); + const parsed = parseSelfJoinRoomResponse(fixture('self-join-room.json').data); + (parsed.raw as Record).state = 9; + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + assert.throws(() => store.replaceRoom(parsed), /state.*raw/i); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); + +test('room self profile conflicts are rejected instead of overwriting login authority', () => { + const conflicts: ReadonlyArray = [ + ['playerid', 77], + ['nickname', '冲突昵称'], + ['avatar', 'http://conflict'], + ['sex', 2], + ['bean', 999], + ['ip', '127.0.0.99'], + ]; + + for (const [field, conflict] of conflicts) { + const store = new PlatformStore(); + const parsed = parseLoginResponse(fixture('player-login-room.json').data); + const selfRoomPlayer = parsed.room?.players[1] as Record; + selfRoomPlayer[field] = conflict; + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + assert.throws( + () => store.applyLoginSuccess(parsed), + new RegExp(field, 'i'), + `expected conflicting ${field} to fail`, + ); + assert.equal(store.getState(), before, `${field} changed the root`); + assert.equal(notifications, 0, `${field} notified subscribers`); + } +}); + +test('roomtype traversal failure freezes nothing and leaves store state untouched', () => { + const child = { value: 1 }; + const throwing = Object.create(null) as Record; + Object.defineProperty(throwing, 'boom', { + enumerable: true, + get(): never { + throw new Error('roomtype getter boom'); + }, + }); + const roomtype = [child, throwing]; + 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 getter boom/); + 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); +});