diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts new file mode 100644 index 0000000..d2c266b --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-store.ts @@ -0,0 +1,464 @@ +import { signal, type Reactive, type Unsubscribe } from '../../core/reactive.ts'; +import { + hasOwn, + requireArray, + requireInteger, + requireRecord, + requireString, + type ParsedLoginResponse, + type ParsedOnlinePayload, + type ParsedRoomResponse, + type ParsedSeatPayload, +} from '../../protocol/contracts/index.ts'; +import type { + InsideRoomState, + PlatformPlayer, + PlatformState, +} from './platform-types.ts'; + +interface RoomBuildResult { + readonly players: PlatformState['players']; + readonly room: Extract; +} + +function requirePositiveInteger(value: unknown, path: string): number { + const parsed = requireInteger(value, path); + if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`); + return parsed; +} + +function requireProtocolFlag(value: unknown, path: string): number { + const parsed = requireInteger(value, path); + if (parsed !== 0 && parsed !== 1) { + throw new RangeError(`${path}: expected protocol flag 0 or 1`); + } + return parsed; +} + +function requireOnlineState(value: unknown, path: string): number { + const parsed = requireInteger(value, path); + if (parsed < 0 || parsed > 2) { + throw new RangeError(`${path}: expected onstate 0, 1, or 2`); + } + return parsed; +} + +function parseRoomPlayer(value: unknown, path: string): { + readonly player: PlatformPlayer; + readonly isprepare: number; + readonly onstate: number; +} { + const raw = requireRecord(value, path); + const isprepare = requireProtocolFlag(raw.isprepare, `${path}.isprepare`); + const onstate = requireOnlineState(raw.onstate, `${path}.onstate`); + const player: PlatformPlayer = { + playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`), + nickname: requireString(raw.nickname, `${path}.nickname`), + avatar: requireString(raw.avatar, `${path}.avatar`), + ...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}), + isprepare, + onstate, + }; + 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 buildRoom( + roomValue: unknown, + rawValue: unknown, + selfPlayer: PlatformPlayer, +): RoomBuildResult { + const room = requireRecord(roomValue, '$.room'); + const raw = requireRecord(rawValue, '$.raw'); + const roomcode = requireString(room.roomcode, '$.room.roomcode'); + const selfSeat = requireInteger(room.seat, '$.room.seat'); + const roomtype = requireArray(room.roomtype, '$.room.roomtype'); + const sourcePlayers = requireArray(room.players, '$.room.players'); + if (requireString(raw.roomcode, '$.raw.roomcode') !== roomcode) { + throw new RangeError('$.room.roomcode: expected value parsed from raw roomcode'); + } + if (requireInteger(raw.seat, '$.raw.seat') !== selfSeat) { + throw new RangeError('$.room.seat: expected value parsed from raw seat'); + } + if (raw.roomtype !== roomtype) { + throw new RangeError('$.room.roomtype: expected canonical raw roomtype reference'); + } + if (raw.players !== sourcePlayers) { + throw new RangeError('$.room.players: expected canonical raw players reference'); + } + if (selfSeat < 0 || selfSeat >= sourcePlayers.length) { + throw new RangeError('$.room.seat: expected server seat index within players'); + } + + let stage: number; + if (hasOwn(raw, 'isbattle')) { + stage = requireProtocolFlag(raw.isbattle, '$.raw.isbattle'); + } else if (hasOwn(raw, 'deskwar')) { + stage = requireProtocolFlag(raw.deskwar, '$.raw.deskwar'); + } else { + throw new TypeError('$.raw: expected isbattle or deskwar'); + } + const needprepare = requireProtocolFlag(raw.needprepare, '$.raw.needprepare'); + const infinite = requireProtocolFlag(raw.infinite, '$.raw.infinite'); + + const seatPlayerIds: (number | null)[] = []; + const readySeats: number[] = []; + const offlineSeats: number[] = []; + const parsedPlayers = new Map(); + + for (let seat = 0; seat < sourcePlayers.length; seat += 1) { + const value = sourcePlayers[seat]; + if (value === null) { + seatPlayerIds.push(null); + continue; + } + const parsed = parseRoomPlayer(value, `$.room.players[${seat}]`); + if (parsedPlayers.has(parsed.player.playerid)) { + throw new RangeError(`$.room.players[${seat}].playerid: duplicate player seat`); + } + parsedPlayers.set(parsed.player.playerid, parsed.player); + seatPlayerIds.push(parsed.player.playerid); + if (parsed.isprepare === 1) readySeats.push(seat); + if (parsed.onstate === 1) offlineSeats.push(seat); + } + + if (seatPlayerIds[selfSeat] !== selfPlayer.playerid) { + throw new RangeError('$.room.players: self seat must contain the authenticated player'); + } + + recursivelyFreeze(roomtype); + const entities: Record = {}; + for (const [playerid, parsedPlayer] of parsedPlayers) { + const player = playerid === selfPlayer.playerid + ? { ...selfPlayer, ...parsedPlayer } + : parsedPlayer; + entities[playerid] = Object.freeze(player); + } + + return { + players: Object.freeze({ + selfPlayerId: selfPlayer.playerid, + entities: Object.freeze(entities), + }), + room: Object.freeze({ + kind: 'inside', + roomcode, + selfSeat, + seatPlayerIds: Object.freeze(seatPlayerIds), + readySeats: Object.freeze(readySeats), + offlineSeats: Object.freeze(offlineSeats), + roomtype, + stage, + needprepare, + infinite, + }), + }; +} + +function requireInsideRoom(state: PlatformState): InsideRoomState { + if (state.room.kind !== 'inside') { + throw new Error('room: expected player to be inside a room'); + } + return state.room; +} + +function parseSeatAction(value: ParsedSeatPayload): { + readonly seat: number; + readonly raw: Readonly>; +} { + const input = requireRecord(value, '$.event'); + const seat = requireInteger(input.seat, '$.event.seat'); + const raw = requireRecord(input.raw, '$.event.raw'); + if (requireInteger(raw.seat, '$.event.raw.seat') !== seat) { + throw new RangeError('$.event.raw.seat: expected the parsed seat'); + } + return { seat, raw }; +} + +function requireOccupiedSeat(room: InsideRoomState, seat: number): number { + if (seat < 0 || seat >= room.seatPlayerIds.length) { + throw new RangeError('$.event.seat: expected room seat range'); + } + const playerid = room.seatPlayerIds[seat]; + if (playerid === null) { + throw new RangeError('$.event.seat: expected an occupied seat'); + } + return playerid; +} + +const INITIAL_STATE: PlatformState = Object.freeze({ + app: Object.freeze({ phase: 'idle' }), + players: Object.freeze({ + selfPlayerId: null, + entities: Object.freeze({}), + }), + room: Object.freeze({ kind: 'outside' }), +}); + +export type PlatformStateListener = (state: PlatformState, previous: PlatformState) => void; + +export class PlatformStore { + private readonly stateCell: Reactive = signal(INITIAL_STATE); + + getState(): PlatformState { + return this.stateCell.value; + } + + subscribe(listener: PlatformStateListener): Unsubscribe { + return this.stateCell.subscribe(listener); + } + + applyLoginSuccess(parsed: ParsedLoginResponse): void { + const input = requireRecord(parsed, '$.login'); + const state = requireInteger(input.state, '$.login.state'); + if (state !== 0) { + throw new RangeError('$.login.state: expected successful state 0'); + } + const playerid = requireInteger(input.playerid, '$.login.playerid'); + 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'); + } + const player: PlatformPlayer = Object.freeze({ + playerid, + nickname: requireString(raw.nickname, '$.login.raw.nickname'), + avatar: requireString(raw.avatar, '$.login.raw.avatar'), + ip: requireString(raw.ip, '$.login.raw.ip'), + }); + const roomResult = input.room === null + ? { + players: Object.freeze({ + selfPlayerId: playerid, + entities: Object.freeze({ [playerid]: player }), + }), + room: Object.freeze({ kind: 'outside' as const }), + } + : buildRoom(input.room, raw, player); + this.stateCell.value = Object.freeze({ + app: Object.freeze({ phase: 'logged-in' }), + players: roomResult.players, + room: roomResult.room, + }); + } + + replaceRoom(parsed: ParsedRoomResponse): void { + const input = requireRecord(parsed, '$.response'); + if (requireInteger(input.state, '$.response.state') !== 0) { + throw new RangeError('$.response.state: expected successful state 0'); + } + if (input.room === null || input.room === undefined) { + throw new TypeError('$.response.room: expected parsed room snapshot'); + } + const previous = this.getState(); + const selfPlayerId = previous.players.selfPlayerId; + if (selfPlayerId === null) { + throw new Error('players.selfPlayerId: expected authenticated player'); + } + const selfPlayer = previous.players.entities[selfPlayerId]; + if (selfPlayer === undefined) { + throw new Error('players.entities: missing authenticated player'); + } + const roomResult = buildRoom(input.room, input.raw, selfPlayer); + this.stateCell.value = Object.freeze({ + app: previous.app, + players: roomResult.players, + room: roomResult.room, + }); + } + + clearRoom(): void { + const previous = this.getState(); + requireInsideRoom(previous); + const selfPlayerId = previous.players.selfPlayerId; + if (selfPlayerId === null) { + throw new Error('players.selfPlayerId: expected authenticated player'); + } + const selfPlayer = previous.players.entities[selfPlayerId]; + if (selfPlayer === undefined) { + throw new Error('players.entities: missing authenticated player'); + } + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId, + entities: Object.freeze({ [selfPlayerId]: selfPlayer }), + }), + room: Object.freeze({ kind: 'outside' }), + }); + } + + playerJoin(parsed: ParsedSeatPayload): void { + const event = parseSeatAction(parsed); + const previous = this.getState(); + const room = requireInsideRoom(previous); + if (event.seat < 0 || event.seat >= room.seatPlayerIds.length) { + throw new RangeError('$.event.seat: expected occupied room seat range'); + } + if (room.seatPlayerIds[event.seat] !== null) { + throw new RangeError('$.event.seat: expected an empty seat'); + } + const joined = parseRoomPlayer(event.raw, '$.event.raw'); + if (room.seatPlayerIds.includes(joined.player.playerid)) { + throw new RangeError('$.event.raw.playerid: duplicate player seat'); + } + + const player = Object.freeze(joined.player); + const entities = Object.freeze({ + ...previous.players.entities, + [player.playerid]: player, + }); + const seatPlayerIds = [...room.seatPlayerIds]; + seatPlayerIds[event.seat] = player.playerid; + const readySeats = joined.isprepare === 1 + ? [...room.readySeats, event.seat] + : [...room.readySeats]; + const offlineSeats = joined.onstate === 1 + ? [...room.offlineSeats, event.seat] + : [...room.offlineSeats]; + + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId: previous.players.selfPlayerId, + entities, + }), + room: Object.freeze({ + ...room, + seatPlayerIds: Object.freeze(seatPlayerIds), + readySeats: Object.freeze(readySeats), + offlineSeats: Object.freeze(offlineSeats), + }), + }); + } + + playerReady(parsed: ParsedSeatPayload): void { + const event = parseSeatAction(parsed); + const previous = this.getState(); + const room = requireInsideRoom(previous); + const playerid = requireOccupiedSeat(room, event.seat); + if (room.readySeats.includes(event.seat)) { + throw new RangeError('$.event.seat: player is already ready'); + } + const player = previous.players.entities[playerid]; + if (player === undefined) { + throw new RangeError('players.entities: occupied seat has no canonical player'); + } + const entities = Object.freeze({ + ...previous.players.entities, + [playerid]: Object.freeze({ ...player, isprepare: 1 }), + }); + const readySeats = Object.freeze([...room.readySeats, event.seat]); + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId: previous.players.selfPlayerId, + entities, + }), + room: Object.freeze({ ...room, readySeats }), + }); + } + + playerOffline(parsed: ParsedSeatPayload): void { + const event = parseSeatAction(parsed); + const previous = this.getState(); + const room = requireInsideRoom(previous); + const playerid = requireOccupiedSeat(room, event.seat); + if (room.offlineSeats.includes(event.seat)) { + throw new RangeError('$.event.seat: player is already offline'); + } + const player = previous.players.entities[playerid]; + if (player === undefined) { + throw new RangeError('players.entities: occupied seat has no canonical player'); + } + const entities = Object.freeze({ + ...previous.players.entities, + [playerid]: Object.freeze({ ...player, onstate: 1 }), + }); + const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]); + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId: previous.players.selfPlayerId, + entities, + }), + room: Object.freeze({ ...room, offlineSeats }), + }); + } + + playerOnline(parsed: ParsedOnlinePayload): void { + const event = parseSeatAction(parsed); + const input = requireRecord(parsed, '$.event'); + const ip = requireString(input.ip, '$.event.ip'); + if (requireString(event.raw.ip, '$.event.raw.ip') !== ip) { + throw new RangeError('$.event.raw.ip: expected the parsed ip'); + } + const previous = this.getState(); + const room = requireInsideRoom(previous); + const playerid = requireOccupiedSeat(room, event.seat); + if (!room.offlineSeats.includes(event.seat)) { + throw new RangeError('$.event.seat: player is already online'); + } + const player = previous.players.entities[playerid]; + if (player === undefined) { + throw new RangeError('players.entities: occupied seat has no canonical player'); + } + const entities = Object.freeze({ + ...previous.players.entities, + [playerid]: Object.freeze({ ...player, ip, onstate: 0 }), + }); + const offlineSeats = Object.freeze( + room.offlineSeats.filter((seat) => seat !== event.seat), + ); + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId: previous.players.selfPlayerId, + entities, + }), + room: Object.freeze({ ...room, offlineSeats }), + }); + } + + playerExit(parsed: ParsedSeatPayload): void { + const event = parseSeatAction(parsed); + const previous = this.getState(); + const room = requireInsideRoom(previous); + const playerid = requireOccupiedSeat(room, event.seat); + if (event.seat === room.selfSeat) { + throw new RangeError('$.event.seat: self exit must clear the room'); + } + + const entities: Record = { + ...previous.players.entities, + }; + delete entities[playerid]; + const seatPlayerIds = [...room.seatPlayerIds]; + seatPlayerIds[event.seat] = null; + const readySeats = room.readySeats.filter((seat) => seat !== event.seat); + const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat); + + this.stateCell.value = Object.freeze({ + app: previous.app, + players: Object.freeze({ + selfPlayerId: previous.players.selfPlayerId, + entities: Object.freeze(entities), + }), + room: Object.freeze({ + ...room, + seatPlayerIds: Object.freeze(seatPlayerIds), + readySeats: Object.freeze(readySeats), + offlineSeats: Object.freeze(offlineSeats), + }), + }); + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts new file mode 100644 index 0000000..e711af7 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/platform-types.ts @@ -0,0 +1,49 @@ +import type { PlatformGameConnectionPhase } from '../../sdk/contracts/snapshots.ts'; + +export type PlatformConnectionPhase = + | 'idle' + | 'connecting' + | PlatformGameConnectionPhase; + +export interface PlatformAppState { + readonly phase: PlatformConnectionPhase; +} + +export interface PlatformPlayer { + readonly playerid: number; + readonly nickname: string; + readonly avatar: string; + readonly ip?: string; + readonly isprepare?: number; + readonly onstate?: number; +} + +export interface PlayerDirectoryState { + readonly selfPlayerId: number | null; + readonly entities: Readonly>; +} + +export interface OutsideRoomState { + readonly kind: 'outside'; +} + +export interface InsideRoomState { + readonly kind: 'inside'; + readonly roomcode: string; + readonly selfSeat: number; + readonly seatPlayerIds: readonly (number | null)[]; + readonly readySeats: readonly number[]; + readonly offlineSeats: readonly number[]; + readonly roomtype: readonly unknown[]; + readonly stage: number; + readonly needprepare: number; + readonly infinite: number; +} + +export type RoomState = OutsideRoomState | InsideRoomState; + +export interface PlatformState { + readonly app: PlatformAppState; + readonly players: PlayerDirectoryState; + readonly room: RoomState; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/selectors.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/selectors.ts new file mode 100644 index 0000000..1d8c90c --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/selectors.ts @@ -0,0 +1,188 @@ +import type { + GameSeatSnapshot, + PlatformGameAppSnapshot, + PlatformGameConnectionPhase, + PlatformGameRoomSnapshot, + PlatformGameSelfSnapshot, + PlatformGameSnapshot, +} from '../../sdk/contracts/snapshots.ts'; +import type { + InsideRoomState, + PlatformAppState, + PlatformPlayer, + PlatformState, + PlayerDirectoryState, +} from './platform-types.ts'; + +const roomPlayerCache = new WeakMap< + InsideRoomState, + WeakMap +>(); +const connectionCache = new WeakMap(); +const roomSnapshotCache = new WeakMap(); +const selfSnapshotCache = new WeakMap< + InsideRoomState, + WeakMap +>(); +const seatsSnapshotCache = new WeakMap< + InsideRoomState, + WeakMap +>(); +const gameSnapshotCache = new WeakMap< + PlatformAppState, + WeakMap> +>(); + +export function selectSelfPlayer(state: PlatformState): PlatformPlayer | null { + const playerId = state.players.selfPlayerId; + if (playerId === null) return null; + const player = state.players.entities[playerId]; + if (player === undefined) { + throw new RangeError('players.entities: missing authenticated player'); + } + return player; +} + +export function selectRoomPlayers( + state: PlatformState, +): readonly (PlatformPlayer | null)[] { + if (state.room.kind !== 'inside') { + throw new Error('room: expected player to be inside a room'); + } + let byPlayers = roomPlayerCache.get(state.room); + if (byPlayers === undefined) { + byPlayers = new WeakMap(); + roomPlayerCache.set(state.room, byPlayers); + } + const cached = byPlayers.get(state.players); + if (cached !== undefined) return cached; + + const result = state.room.seatPlayerIds.map((playerid, seat) => { + if (playerid === null) return null; + const player = state.players.entities[playerid]; + if (player === undefined) { + throw new RangeError(`players.entities: missing player for occupied seat ${seat}`); + } + return player; + }); + const frozen = Object.freeze(result); + byPlayers.set(state.players, frozen); + return frozen; +} + +function selectConnectionPhase(state: PlatformState): PlatformGameConnectionPhase { + const phase = state.app.phase; + if (phase === 'idle' || phase === 'connecting') { + throw new Error(`app.phase: cannot expose game snapshot while ${phase}`); + } + return phase; +} + +function selectConnectionSnapshot(state: PlatformState): PlatformGameAppSnapshot { + const cached = connectionCache.get(state.app); + if (cached !== undefined) return cached; + const snapshot = Object.freeze({ phase: selectConnectionPhase(state) }); + connectionCache.set(state.app, snapshot); + return snapshot; +} + +function selectSelfSnapshot( + state: PlatformState, + room: InsideRoomState, +): PlatformGameSelfSnapshot { + let byPlayers = selfSnapshotCache.get(room); + if (byPlayers === undefined) { + byPlayers = new WeakMap(); + selfSnapshotCache.set(room, byPlayers); + } + const cached = byPlayers.get(state.players); + if (cached !== undefined) return cached; + const self = selectSelfPlayer(state); + if (self === null) { + throw new Error('players.selfPlayerId: expected authenticated player'); + } + if (room.seatPlayerIds[room.selfSeat] !== self.playerid) { + throw new RangeError('room.selfSeat: expected authenticated player seat'); + } + const snapshot = Object.freeze({ playerId: self.playerid, seat: room.selfSeat }); + byPlayers.set(state.players, snapshot); + return snapshot; +} + +function selectRoomSnapshot(room: InsideRoomState): PlatformGameRoomSnapshot { + const cached = roomSnapshotCache.get(room); + if (cached !== undefined) return cached; + const snapshot = Object.freeze({ + roomcode: room.roomcode, + roomtype: room.roomtype, + stage: room.stage, + needprepare: room.needprepare, + infinite: room.infinite, + }); + roomSnapshotCache.set(room, snapshot); + return snapshot; +} + +function selectSeatsSnapshot( + state: PlatformState, + room: InsideRoomState, +): readonly GameSeatSnapshot[] { + let byPlayers = seatsSnapshotCache.get(room); + if (byPlayers === undefined) { + byPlayers = new WeakMap(); + seatsSnapshotCache.set(room, byPlayers); + } + const cached = byPlayers.get(state.players); + if (cached !== undefined) return cached; + + const seats: GameSeatSnapshot[] = []; + const roomPlayers = selectRoomPlayers(state); + for (let seat = 0; seat < roomPlayers.length; seat += 1) { + const player = roomPlayers[seat]; + if (player === null) continue; + if (player.onstate === undefined) { + throw new TypeError(`players.entities[${player.playerid}].onstate: expected protocol number`); + } + if (player.isprepare === undefined) { + throw new TypeError(`players.entities[${player.playerid}].isprepare: expected protocol number`); + } + seats.push(Object.freeze({ + seat, + playerId: player.playerid, + nickname: player.nickname, + avatar: player.avatar, + online: player.onstate !== 1, + ready: player.isprepare === 1, + })); + } + const snapshot = Object.freeze(seats); + byPlayers.set(state.players, snapshot); + return snapshot; +} + +export function selectGameSnapshot(state: PlatformState): PlatformGameSnapshot { + if (state.room.kind !== 'inside') { + throw new Error('room: expected player to be inside a room'); + } + let byPlayers = gameSnapshotCache.get(state.app); + if (byPlayers === undefined) { + byPlayers = new WeakMap(); + gameSnapshotCache.set(state.app, byPlayers); + } + let byRoom = byPlayers.get(state.players); + if (byRoom === undefined) { + byRoom = new WeakMap(); + byPlayers.set(state.players, byRoom); + } + const cached = byRoom.get(state.room); + if (cached !== undefined) return cached; + + const snapshot = Object.freeze({ + connection: selectConnectionSnapshot(state), + self: selectSelfSnapshot(state, state.room), + room: selectRoomSnapshot(state.room), + seats: selectSeatsSnapshot(state, state.room), + }); + byRoom.set(state.room, snapshot); + return snapshot; +} diff --git a/cocoscreator_projects/framework-tests/platform/platform-store.test.ts b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts new file mode 100644 index 0000000..3f8f51e --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/platform-store.test.ts @@ -0,0 +1,306 @@ +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; +import { + parseLoginResponse, + parseOtherExitRoomPayload, + parseOtherOfflinePayload, + parseOtherJoinRoomPayload, + parseOtherOnlinePayload, + parsePlayerPreparePayload, + parseSelfJoinRoomResponse, +} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; + +function fixture(name: string): { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} + +test('PlatformStore starts unauthenticated and outside a room', () => { + const state = new PlatformStore().getState(); + assert.equal(state.players.selfPlayerId, null); + assert.deepEqual(state.room, { kind: 'outside' }); +}); + +test('applyLoginSuccess commits the complete login once and notifies once', () => { + const store = new PlatformStore(); + const parsedLogin = parseLoginResponse(fixture('player-login-success.json').data); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.applyLoginSuccess(parsedLogin); + + const state = store.getState(); + assert.equal(notifications, 1); + assert.equal(state.app.phase, 'logged-in'); + assert.equal(state.players.selfPlayerId, 430511); + assert.deepEqual(state.players.entities[430511], { + playerid: 430511, + nickname: '测试号', + avatar: 'http://a', + ip: '127.0.0.1', + }); + assert.deepEqual(state.room, { kind: 'outside' }); +}); + +test('room login stores one canonical player entity and recursively freezes roomtype', () => { + const data = fixture('player-login-room.json').data; + const parsedLogin = parseLoginResponse(data); + const store = new PlatformStore(); + + store.applyLoginSuccess(parsedLogin); + + const state = store.getState(); + assert.equal(state.players.selfPlayerId, 430511); + assert.deepEqual(Object.keys(state.players.entities), ['430511']); + assert.equal(state.players.entities[430511]?.nickname, '测试号'); + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + const room = state.room; + assert.deepEqual(room.seatPlayerIds, [null, 430511, null, null]); + assert.equal(state.players.entities[430511]?.isprepare, 1); + 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.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('deskinfo' in state, false); + assert.equal('deskinfo' in state.room, false); + assert.throws(() => { + (room.roomtype[5] as unknown[])[0] = 99; + }, TypeError); +}); + +test('playerJoin atomically adds one canonical entity and its seat id', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + const joined = parseOtherJoinRoomPayload({ + seat: 2, + playerid: 77, + nickname: '对家', + avatar: 'http://b', + sex: 1, + ip: '127.0.0.2', + onstate: 0, + bean: 90, + isprepare: 0, + }); + + store.playerJoin(joined); + + const state = store.getState(); + assert.equal(notifications, 1); + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + assert.deepEqual(state.room.seatPlayerIds, [null, 430511, 77, null]); + assert.deepEqual(state.players.entities[77], { + playerid: 77, + nickname: '对家', + avatar: 'http://b', + ip: '127.0.0.2', + isprepare: 0, + onstate: 0, + }); + assert.equal(Object.isFrozen(state.players.entities[77]), true); +}); + +test('playerReady replaces the affected player and room subtrees without prior mutation', () => { + const store = new PlatformStore(); + 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, + })); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.playerReady(parsePlayerPreparePayload({ seat: 2 })); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.notEqual(after.players, before.players); + assert.equal(after.players.entities[77]?.isprepare, 1); + assert.notEqual(after.room, before.room); + assert.equal(after.room.kind, 'inside'); + assert.equal(before.room.kind, 'inside'); + if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return; + assert.deepEqual(before.room.readySeats, [1]); + assert.deepEqual(after.room.readySeats, [1, 2]); + assert.equal(Object.isFrozen(after.room.readySeats), true); +}); + +test('playerOffline records protocol and room status with one atomic commit', () => { + const store = new PlatformStore(); + 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, + })); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.playerOffline(parseOtherOfflinePayload({ seat: 2 })); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.notEqual(after.players, before.players); + assert.equal(after.players.entities[77]?.onstate, 1); + assert.equal(after.room.kind, 'inside'); + assert.equal(before.room.kind, 'inside'); + if (after.room.kind !== 'inside' || before.room.kind !== 'inside') return; + assert.deepEqual(before.room.offlineSeats, [1]); + assert.deepEqual(after.room.offlineSeats, [1, 2]); +}); + +test('playerOnline updates the canonical player ip and removes the offline seat atomically', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.playerOnline(parseOtherOnlinePayload({ seat: 1, ip: '127.0.0.9' })); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.notEqual(after.players, before.players); + assert.notEqual(after.room, before.room); + assert.equal(after.players.entities[430511]?.ip, '127.0.0.9'); + assert.equal(before.players.entities[430511]?.ip, '127.0.0.1'); + assert.equal(after.room.kind, 'inside'); + if (after.room.kind !== 'inside') return; + assert.deepEqual(after.room.offlineSeats, []); +}); + +test('playerExit removes the seat entity and every seat-owned status in one commit', () => { + const store = new PlatformStore(); + 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, + })); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.playerExit(parseOtherExitRoomPayload({ seat: 2 })); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.equal(after.players.selfPlayerId, 430511); + assert.equal(after.players.entities[430511], before.players.entities[430511]); + assert.equal(after.players.entities[77], undefined); + assert.equal(after.room.kind, 'inside'); + if (after.room.kind !== 'inside') return; + assert.deepEqual(after.room.seatPlayerIds, [null, 430511, null, null]); + assert.deepEqual(after.room.readySeats, [1]); + assert.deepEqual(after.room.offlineSeats, [1]); +}); + +test('replaceRoom atomically replaces the complete room from a parsed DTO', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data)); + const parsedRoom = parseSelfJoinRoomResponse(fixture('self-join-room.json').data); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.replaceRoom(parsedRoom); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.equal(after.room.kind, 'inside'); + if (after.room.kind !== 'inside') return; + assert.equal(after.room.roomcode, '100001'); + assert.equal(after.room.selfSeat, 1); + assert.equal(after.room.stage, 1); + 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.equal(after.players.entities[430511]?.ip, '127.0.0.1'); +}); + +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)); + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', + ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + const before = store.getState(); + const self = before.players.entities[430511]; + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + store.clearRoom(); + + const after = store.getState(); + assert.equal(notifications, 1); + assert.equal(after.app, before.app); + assert.deepEqual(after.room, { kind: 'outside' }); + assert.deepEqual(Object.keys(after.room), ['kind']); + assert.deepEqual(Object.keys(after.players.entities), ['430511']); + assert.equal(after.players.entities[430511], self); +}); + +test('failed actions keep the exact root, notify nobody, and do not mutate inputs', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + const before = store.getState(); + let notifications = 0; + store.subscribe(() => { notifications += 1; }); + + const tampered = parseSelfJoinRoomResponse(fixture('self-join-room.json').data); + (tampered.room as { roomcode: string }).roomcode = 'tampered'; + assert.throws(() => store.replaceRoom(tampered), /roomcode/); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); + + assert.throws( + () => store.playerReady(parsePlayerPreparePayload({ seat: 99 })), + /seat/, + ); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); + + assert.throws( + () => store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate', + ip: '127.0.0.3', onstate: 0, isprepare: 0, + })), + /duplicate player seat/, + ); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); + + const duplicateData = fixture('self-join-room.json').data; + const duplicatePlayers = duplicateData.players as Array | null>; + duplicatePlayers[2] = { ...duplicatePlayers[1] as Record }; + const mutableRoomtype = duplicateData.roomtype as unknown[]; + const duplicateRoom = parseSelfJoinRoomResponse(duplicateData); + assert.throws(() => store.replaceRoom(duplicateRoom), /duplicate player seat/); + assert.equal(Object.isFrozen(mutableRoomtype), false); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); + + assert.throws(() => { + (before.players.entities[430511] as { nickname: string }).nickname = '篡改'; + }, TypeError); + assert.throws(() => { + (before.room as { kind: string }).kind = 'outside'; + }, TypeError); + assert.equal(store.getState(), before); + assert.equal(notifications, 0); +}); diff --git a/cocoscreator_projects/framework-tests/platform/selectors.test.ts b/cocoscreator_projects/framework-tests/platform/selectors.test.ts new file mode 100644 index 0000000..45ee659 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/selectors.test.ts @@ -0,0 +1,132 @@ +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + selectGameSnapshot, + selectRoomPlayers, + selectSelfPlayer, +} from '../../YouleNexus/assets/framework/platform/stores/selectors.ts'; +import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; +import type { PlatformState } from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts'; +import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; + +function fixture(name: string): { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} + +test('selectSelfPlayer returns null for an unauthenticated state', () => { + const state = { + app: { phase: 'idle' }, + players: { selfPlayerId: null, entities: {} }, + room: { kind: 'outside' }, + } as PlatformState; + assert.equal(selectSelfPlayer(state), null); +}); + +test('selectRoomPlayers memoizes a frozen positional projection of canonical entities', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + const state = store.getState(); + + const first = selectRoomPlayers(state); + const second = selectRoomPlayers(state); + + assert.equal(first, second); + assert.equal(Object.isFrozen(first), true); + assert.deepEqual( + first.map((player) => player === null ? null : player.playerid), + [null, 430511, null, null], + ); + assert.equal(first[1], selectSelfPlayer(state)); + assert.throws(() => { + (first as unknown[])[1] = null; + }, TypeError); +}); + +test('selectGameSnapshot returns only recursively frozen SDK-owned DTOs', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + const state = store.getState(); + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + + const snapshot = selectGameSnapshot(state); + + assert.deepEqual(snapshot, { + connection: { phase: 'logged-in' }, + self: { playerId: 430511, seat: 1 }, + room: { + roomcode: '100001', + roomtype: state.room.roomtype, + stage: 1, + needprepare: 1, + infinite: 0, + }, + seats: [{ + seat: 1, + playerId: 430511, + nickname: '测试号', + avatar: 'http://a', + online: false, + ready: true, + }], + }); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.connection), true); + assert.equal(Object.isFrozen(snapshot.self), true); + assert.equal(Object.isFrozen(snapshot.room), true); + assert.equal(Object.isFrozen(snapshot.seats), true); + assert.equal(Object.isFrozen(snapshot.seats[0]), true); + assert.equal(snapshot.room.roomtype, state.room.roomtype); + assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']); +}); + +test('selectGameSnapshot memoizes each SDK branch by its immutable source subtrees', () => { + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data)); + const base = store.getState(); + assert.equal(base.room.kind, 'inside'); + if (base.room.kind !== 'inside') return; + + const first = selectGameSnapshot(base); + const repeated = selectGameSnapshot(base); + assert.equal(repeated, first); + assert.equal(repeated.connection, first.connection); + assert.equal(repeated.self, first.self); + assert.equal(repeated.room, first.room); + assert.equal(repeated.seats, first.seats); + + const appChanged: PlatformState = Object.freeze({ + ...base, + app: Object.freeze({ phase: 'reconnecting' }), + }); + const afterApp = selectGameSnapshot(appChanged); + assert.notEqual(afterApp, first); + assert.notEqual(afterApp.connection, first.connection); + assert.equal(afterApp.self, first.self); + assert.equal(afterApp.room, first.room); + assert.equal(afterApp.seats, first.seats); + + const playersChanged: PlatformState = Object.freeze({ + ...base, + players: Object.freeze({ + selfPlayerId: base.players.selfPlayerId, + entities: Object.freeze({ ...base.players.entities }), + }), + }); + const afterPlayers = selectGameSnapshot(playersChanged); + assert.equal(afterPlayers.connection, first.connection); + assert.notEqual(afterPlayers.self, first.self); + assert.equal(afterPlayers.room, first.room); + assert.notEqual(afterPlayers.seats, first.seats); + + const roomChanged: PlatformState = Object.freeze({ + ...base, + room: Object.freeze({ ...base.room, stage: 0 }), + }); + const afterRoom = selectGameSnapshot(roomChanged); + assert.equal(afterRoom.connection, first.connection); + assert.notEqual(afterRoom.self, first.self); + assert.notEqual(afterRoom.room, first.room); + assert.notEqual(afterRoom.seats, first.seats); +});