diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts new file mode 100644 index 0000000..fafcdcd --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts @@ -0,0 +1,96 @@ +import type { RuntimeConfig } from '../config/runtime-config.ts'; +import type { OutboundEnvelope } from '../core/types/envelope.ts'; +import { + buildExitRoomRequest, + buildJoinRoomRequest, + buildPrepareRequest, + type JoinRoomEnvironment, + type RoomIdentity, +} from '../protocol/contracts/index.ts'; +import { PlatformStore } from './stores/platform-store.ts'; +import type { InsideRoomState, PlatformState } from './stores/platform-types.ts'; + +/** The complete-envelope send capability used by platform command adapters. */ +export interface OutboundEnvelopeSendPort { + send(envelope: OutboundEnvelope): void; +} + +export interface PlatformCommandsOptions { + readonly runtimeConfig: RuntimeConfig; + readonly store: PlatformStore; + readonly send: OutboundEnvelopeSendPort; +} + +export interface JoinRoomCommand extends JoinRoomEnvironment { + readonly roomcode: string; +} + +export class UnsupportedGameHostCommandError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedGameHostCommandError'; + } +} + +/** Builds platform-owned room requests exclusively through the first-slice contracts. */ +export class PlatformCommands { + constructor(private readonly options: PlatformCommandsOptions) {} + + prepare(): void { + const state = this.options.store.getState(); + this.options.send.send(buildPrepareRequest( + this.options.runtimeConfig, + this.roomIdentity(state, this.requireInsideRoom(state)), + )); + } + + exitRoom(): void { + const state = this.options.store.getState(); + const room = this.requireInsideRoom(state); + if (room.stage !== 0) { + throw new UnsupportedGameHostCommandError('room.exit requires stage=0'); + } + this.options.send.send(buildExitRoomRequest( + this.options.runtimeConfig, + this.roomIdentity(state, room), + )); + } + + joinRoom(command: JoinRoomCommand): void { + const state = this.options.store.getState(); + if (state.room.kind !== 'outside') { + throw new UnsupportedGameHostCommandError('room.join requires player to be outside a room'); + } + const playerid = this.requireAuthenticatedPlayerId(state); + this.options.send.send(buildJoinRoomRequest( + this.options.runtimeConfig, + { playerid, roomcode: command.roomcode }, + command, + )); + } + + private requireInsideRoom(state: PlatformState): InsideRoomState { + if (state.room.kind !== 'inside') { + throw new UnsupportedGameHostCommandError('room command requires player to be inside a room'); + } + return state.room; + } + + private requireAuthenticatedPlayerId(state: PlatformState): number { + const playerid = state.players.selfPlayerId; + if (playerid === null) { + throw new UnsupportedGameHostCommandError('room command requires an authenticated player'); + } + if (state.players.entities[playerid] === undefined) { + throw new Error('players.entities: missing authenticated player'); + } + return playerid; + } + + private roomIdentity(state: PlatformState, room: InsideRoomState): RoomIdentity { + return { + playerid: this.requireAuthenticatedPlayerId(state), + roomcode: room.roomcode, + }; + } +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts new file mode 100644 index 0000000..63c05be --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts @@ -0,0 +1,289 @@ +import { APP } from '../core/constants.ts'; +import type { OutboundEnvelope } from '../core/types/envelope.ts'; +import type { + GameEntry, + GameHost, + GameHostCommand, + GameSeatMapper, + PlatformGameSnapshot, +} from '../sdk/contracts/index.ts'; +import { + PlatformCommands, + UnsupportedGameHostCommandError, + type OutboundEnvelopeSendPort, +} from './commands.ts'; +import { PlatformStore } from './stores/platform-store.ts'; +import { selectGameSnapshot } from './stores/selectors.ts'; +import type { PlatformState } from './stores/platform-types.ts'; + +const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']); + +export class GameSessionDisposedError extends Error { + constructor() { + super('GameHost session lease is disposed'); + this.name = 'GameSessionDisposedError'; + } +} + +export interface CreateGameHostAdapterOptions { + readonly gameEntry: GameEntry; + readonly store: PlatformStore; + readonly commands: PlatformCommands; + readonly send: OutboundEnvelopeSendPort; +} + +/** Structurally matches RuntimeSession's per-open GameHostLeasePort. */ +export interface GameHostLease { + readonly host: GameHost; + invalidate(): void; +} + +interface SubscriptionRecord { + active: boolean; + unsubscribeStore: (() => void) | null; +} + +type SubscriptionReleaseResult = + | { readonly threw: false } + | { readonly threw: true; readonly error: unknown }; + +class GameHostInvalidationError extends Error { + readonly errors: readonly unknown[]; + + constructor(errors: readonly unknown[]) { + super('GameHost subscription cleanup failed'); + this.name = 'GameHostInvalidationError'; + this.errors = Object.freeze([...errors]); + } +} + +class SessionBoundGameHost { + readonly host: GameHost; + + private store: PlatformStore | null; + private commands: PlatformCommands | null; + private sendPort: OutboundEnvelopeSendPort | null; + private route: string | null; + private readonly subscriptions = new Set(); + private disposed = false; + + constructor(options: CreateGameHostAdapterOptions) { + const route = requireGameRoute(options.gameEntry.route); + const state = options.store.getState(); + if (state.room.kind !== 'inside') { + throw new Error('GameHost requires player to be inside a room'); + } + const seatCount = options.gameEntry.resolveSeatCount(state.room.roomtype); + requireSeatCount(seatCount, state.room.seatPlayerIds.length); + const selfSeat = requireSeat(state.room.selfSeat, seatCount, 'self seat'); + + this.store = options.store; + this.commands = options.commands; + this.sendPort = options.send; + this.route = route; + + const seat: GameSeatMapper = Object.freeze({ + toView: (serverSeat: number): number => { + this.requireActive(); + const validated = requireSeat(serverSeat, seatCount, 'server seat'); + return (validated - selfSeat + seatCount) % seatCount; + }, + toServer: (viewSeat: number): number => { + this.requireActive(); + const validated = requireSeat(viewSeat, seatCount, 'view seat'); + return (validated + selfSeat) % seatCount; + }, + }); + + this.host = Object.freeze({ + seat, + getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(), + subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => ( + this.subscribe(listener) + ), + sendGameMessage: (rpc: string, data: unknown): void => this.sendGameMessage(rpc, data), + execute: (command: GameHostCommand): void => this.execute(command), + }); + } + + invalidate(): void { + if (this.disposed) return; + this.disposed = true; + + const subscriptions = [...this.subscriptions]; + this.subscriptions.clear(); + this.store = null; + this.commands = null; + this.sendPort = null; + this.route = null; + + const errors: unknown[] = []; + for (const record of subscriptions) { + const result = this.releaseSubscription(record); + if (result.threw) errors.push(result.error); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) throw new GameHostInvalidationError(errors); + } + + private getSnapshot(): PlatformGameSnapshot { + return selectGameSnapshot(this.requireStore().getState()); + } + + private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void { + this.requireActive(); + if (typeof listener !== 'function') { + throw new TypeError('GameHost subscribe listener must be a function'); + } + const store = this.requireStore(); + let current = selectGameSnapshot(store.getState()); + const record: SubscriptionRecord = { active: true, unsubscribeStore: null }; + const bridge = (state: PlatformState): void => { + if (this.disposed || !record.active || state.room.kind !== 'inside') return; + const next = selectGameSnapshot(state); + if (samePublicSnapshot(current, next)) return; + current = next; + listener(next); + }; + record.unsubscribeStore = store.subscribe(bridge); + this.subscriptions.add(record); + + try { + listener(current); + } catch (error) { + this.releaseSubscription(record); + throw error; + } + + return (): void => { + try { + this.releaseSubscription(record); + } catch { + // The public unsubscribe contract remains non-throwing, including after invalidation. + } + }; + } + + private sendGameMessage(rpc: string, data: unknown): void { + this.requireActive(); + if (typeof rpc !== 'string' || rpc.trim() === '') { + throw new TypeError('GameHost rpc must be a non-empty string'); + } + const sendPort = this.sendPort; + const route = this.route; + if (sendPort === null || route === null) throw new GameSessionDisposedError(); + const envelope: OutboundEnvelope = { app: APP, route, rpc, data }; + sendPort.send(envelope); + } + + private execute(command: GameHostCommand): void { + this.requireActive(); + const commands = this.commands; + if (commands === null) throw new GameSessionDisposedError(); + if (command !== null && typeof command === 'object') { + if (command.type === 'room.prepare') { + commands.prepare(); + return; + } + if (command.type === 'room.exit') { + commands.exitRoom(); + return; + } + } + throw new UnsupportedGameHostCommandError('unsupported GameHost command'); + } + + private requireStore(): PlatformStore { + this.requireActive(); + const store = this.store; + if (store === null) throw new GameSessionDisposedError(); + return store; + } + + private requireActive(): void { + if (this.disposed) throw new GameSessionDisposedError(); + } + + private releaseSubscription(record: SubscriptionRecord): SubscriptionReleaseResult { + if (!record.active) return { threw: false }; + record.active = false; + this.subscriptions.delete(record); + const unsubscribe = record.unsubscribeStore; + record.unsubscribeStore = null; + if (unsubscribe === null) return { threw: false }; + try { + unsubscribe(); + return { threw: false }; + } catch (error) { + return { threw: true, error }; + } + } +} + +export function createGameHostAdapter(options: CreateGameHostAdapterOptions): GameHostLease { + const session = new SessionBoundGameHost(options); + return Object.freeze({ + host: session.host, + invalidate: (): void => { session.invalidate(); }, + }); +} + +function requireGameRoute(route: unknown): string { + if (typeof route !== 'string' || route.trim() === '') { + throw new TypeError('GameEntry route must be a non-empty string'); + } + if (RESERVED_ROUTES.has(route)) { + throw new Error(`GameEntry reserved route: ${route}`); + } + return route; +} + +function requireSeatCount(seatCount: unknown, roomSeatCount: number): asserts seatCount is number { + if (!Number.isInteger(seatCount) || (seatCount as number) <= 0) { + throw new RangeError(`GameEntry seat count must be a positive integer: ${String(seatCount)}`); + } + if (seatCount !== roomSeatCount) { + throw new RangeError( + `GameEntry seat count ${seatCount} does not match room seat count ${roomSeatCount}`, + ); + } +} + +function requireSeat(value: unknown, seatCount: number, label: string): number { + if (!Number.isInteger(value)) { + throw new TypeError(`${label}: expected integer seat, received ${String(value)}`); + } + const seat = value as number; + if (seat < 0 || seat >= seatCount) { + throw new RangeError(`${label}: expected seat range 0..${seatCount - 1}, received ${seat}`); + } + return seat; +} + +function samePublicSnapshot(first: PlatformGameSnapshot, second: PlatformGameSnapshot): boolean { + if (first === second) return true; + if ( + first.connection.phase !== second.connection.phase + || first.self.playerId !== second.self.playerId + || first.self.seat !== second.self.seat + || first.room.roomcode !== second.room.roomcode + || first.room.roomtype !== second.room.roomtype + || first.room.stage !== second.room.stage + || first.room.needprepare !== second.room.needprepare + || first.room.infinite !== second.room.infinite + || first.seats.length !== second.seats.length + ) return false; + for (let index = 0; index < first.seats.length; index++) { + const left = first.seats[index]!; + const right = second.seats[index]!; + if ( + left.seat !== right.seat + || left.playerId !== right.playerId + || left.nickname !== right.nickname + || left.avatar !== right.avatar + || left.online !== right.online + || left.ready !== right.ready + ) return false; + } + return true; +} diff --git a/cocoscreator_projects/framework-tests/platform/commands.test.ts b/cocoscreator_projects/framework-tests/platform/commands.test.ts new file mode 100644 index 0000000..d010656 --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/commands.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts'; +import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; +import { + parseLoginResponse, +} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; + +const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ + mode: 'release', + isDebugger: false, + identity: Object.freeze({ + agentid: 'agent-from-config', + channelid: 'channel-from-config', + gameid: 'game-from-config', + marketid: 'market-from-config', + version: 41, + }), + servers: Object.freeze(['ws://fixture']), + source: 'remote', + gameserver: 'https://fixture/config', + rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), +}); + +function fixture(name: string): { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} + +function makeStore(location: 'outside' | 'inside', stage = 0): PlatformStore { + const store = new PlatformStore(); + const source = fixture(location === 'inside' ? 'player-login-room.json' : 'player-login-success.json'); + if (location === 'inside') source.data.isbattle = stage; + store.applyLoginSuccess(parseLoginResponse(source.data)); + return store; +} + +function makeCommands(store: PlatformStore): { + readonly commands: PlatformCommands; + readonly sent: OutboundEnvelope[]; +} { + const sent: OutboundEnvelope[] = []; + return { + commands: new PlatformCommands({ + runtimeConfig: RUNTIME_CONFIG, + store, + send: { send: (envelope) => { sent.push(envelope); } }, + }), + sent, + }; +} + +test('prepare and exitRoom use the first-slice builders with authoritative config and room state', () => { + const { commands, sent } = makeCommands(makeStore('inside', 0)); + + commands.prepare(); + commands.exitRoom(); + + assert.deepEqual(sent, [ + { + app: 'youle', + route: 'room', + rpc: 'player_prepare', + data: { + agentid: 'agent-from-config', + playerid: 430511, + gameid: 'game-from-config', + roomcode: '100001', + }, + }, + { + app: 'youle', + route: 'room', + rpc: 'self_exit_room', + data: { + agentid: 'agent-from-config', + playerid: 430511, + gameid: 'game-from-config', + roomcode: '100001', + }, + }, + ]); + assert.equal(commands.prepare.length, 0); + assert.equal(commands.exitRoom.length, 0); +}); + +test('joinRoom uses authenticated Store identity and requires every non-optional environment field', () => { + const { commands, sent } = makeCommands(makeStore('outside')); + const location = { latitude: 28.7, longitude: 115.8 }; + + commands.joinRoom({ + roomcode: '654321', + location, + ip: '203.0.113.7', + vipMatch: 1, + match_id: 'match-9', + }); + + assert.deepEqual(sent, [{ + app: 'youle', + route: 'agent', + rpc: 'self_join_room', + data: { + agentid: 'agent-from-config', + playerid: 430511, + gameid: 'game-from-config', + roomcode: '654321', + location, + ip: '203.0.113.7', + vipMatch: 1, + match_id: 'match-9', + }, + }]); + assert.equal((sent[0]!.data as { location: unknown }).location, location); + assert.equal(commands.joinRoom.length, 1); + + assert.throws( + () => commands.joinRoom({ roomcode: '654321', ip: '203.0.113.7' } as never), + /location/i, + ); + assert.throws( + () => commands.joinRoom({ roomcode: '654321', location } as never), + /ip/i, + ); + assert.equal(sent.length, 1); +}); + +test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => { + const unauthenticated = makeCommands(new PlatformStore()); + assert.throws( + () => unauthenticated.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }), + /authenticated|player/i, + ); + + const outside = makeCommands(makeStore('outside')); + assert.throws(() => outside.commands.prepare(), /inside.*room|room.*inside/i); + assert.throws(() => outside.commands.exitRoom(), /inside.*room|room.*inside/i); + + const inside = makeCommands(makeStore('inside', 0)); + assert.throws( + () => inside.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }), + /outside.*room|already.*room/i, + ); + + const battle = makeCommands(makeStore('inside', 1)); + assert.throws(() => battle.commands.exitRoom(), /room\.exit.*stage=0/i); + + assert.equal(unauthenticated.sent.length, 0); + assert.equal(outside.sent.length, 0); + assert.equal(inside.sent.length, 0); + assert.equal(battle.sent.length, 0); +}); diff --git a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts new file mode 100644 index 0000000..b2280fa --- /dev/null +++ b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts @@ -0,0 +1,363 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import { + createGameHostAdapter, + GameSessionDisposedError, +} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts'; +import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.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, + parseOtherJoinRoomPayload, + parsePlayerPreparePayload, +} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; +import type { + GameEntry, + GameHostCommand, +} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; + +const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ + mode: 'release', + isDebugger: false, + identity: Object.freeze({ + agentid: 17, + channelid: 18, + gameid: 'game-41', + marketid: 19, + version: 41, + }), + servers: Object.freeze(['ws://fixture']), + source: 'remote', + gameserver: 'https://fixture/config', + rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), +}); + +function fixture(name: string): { data: Record } { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} + +function roomStore(seatCount = 4, selfSeat = 1, stage = 0): PlatformStore { + const source = fixture('player-login-room.json'); + const self = (source.data.players as Array | null>)[1]!; + const players = Array.from({ length: seatCount }, () => null) as Array | null>; + players[selfSeat] = { ...self }; + source.data.players = players; + source.data.seat = selfSeat; + source.data.roomtype = [seatCount, { nested: [1, 2] }]; + source.data.isbattle = stage; + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(source.data)); + return store; +} + +function entry(route = 'fixture-route'): GameEntry { + return { + key: 'fixture', + gameId: 'game-41', + route, + resolveSeatCount: (roomtype) => roomtype[0] as number, + createModule: () => ({ + attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, + }), + }; +} + +function makeLease(options: { + readonly store?: PlatformStore; + readonly gameEntry?: GameEntry; +} = {}) { + const store = options.store ?? roomStore(); + const sent: OutboundEnvelope[] = []; + const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } }; + const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send }); + const lease = createGameHostAdapter({ + gameEntry: options.gameEntry ?? entry(), + store, + commands, + send, + }); + return { ...lease, store, sent }; +} + +test('sendGameMessage binds app and GameEntry route, rejects empty rpc, and preserves data identity', () => { + const { host, sent } = makeLease(); + const data = { card: 7 }; + + host.sendGameMessage('play-card', data); + + assert.deepEqual(sent[0], { + app: 'youle', route: 'fixture-route', rpc: 'play-card', data: { card: 7 }, + }); + assert.equal(sent[0]!.data, data); + assert.equal(host.sendGameMessage.length, 2, 'Host API 不接受 route'); + assert.throws(() => host.sendGameMessage('', {}), /rpc.*non-empty/i); + assert.throws(() => host.sendGameMessage(' ', {}), /rpc.*non-empty/i); + assert.equal(sent.length, 1); +}); + +test('adapter rejects empty and reserved GameEntry routes before exposing a Host', () => { + for (const route of ['', ' ', 'platform', 'agent', 'room']) { + assert.throws( + () => makeLease({ gameEntry: entry(route) }), + route.trim() === '' ? /route.*non-empty/i : /reserved.*route/i, + ); + } +}); + +test('execute exposes only the GameHost command union and delegates semantic commands', () => { + const { host, sent } = makeLease({ store: roomStore(4, 1, 0) }); + + host.execute({ type: 'room.prepare' }); + host.execute({ type: 'room.exit' }); + + assert.equal(sent[0]!.rpc, 'player_prepare'); + assert.equal(sent[1]!.rpc, 'self_exit_room'); + assert.throws( + () => host.execute({ type: 'room.join' } as unknown as GameHostCommand), + /unsupported.*command/i, + ); + assert.equal(sent.length, 2); +}); + +test('seat mapper binds each 2, 4, and 10 seat session and round-trips every server seat', () => { + for (const seatCount of [2, 4, 10]) { + const selfSeat = seatCount - 1; + const { host } = makeLease({ store: roomStore(seatCount, selfSeat) }); + assert.equal(host.seat.toView(selfSeat), 0); + for (let serverSeat = 0; serverSeat < seatCount; serverSeat++) { + const viewSeat = (serverSeat - selfSeat + seatCount) % seatCount; + assert.equal(host.seat.toView(serverSeat), viewSeat); + assert.equal(host.seat.toServer(viewSeat), serverSeat); + } + for (const invalid of [-1, seatCount, 0.5, Number.NaN, Number.POSITIVE_INFINITY, '0']) { + assert.throws(() => host.seat.toView(invalid as number), /seat.*range|integer/i); + assert.throws(() => host.seat.toServer(invalid as number), /seat.*range|integer/i); + } + } +}); + +test('adapter validates descriptor seat count against the canonical room session', () => { + const store = roomStore(4, 1); + for (const seatCount of [0, -1, 2.5, Number.NaN, 2]) { + assert.throws( + () => makeLease({ + store, + gameEntry: { ...entry(), resolveSeatCount: () => seatCount }, + }), + /seat count/i, + ); + } +}); + +test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roomtype', () => { + const { host, store } = makeLease(); + const state = store.getState(); + assert.equal(state.room.kind, 'inside'); + if (state.room.kind !== 'inside') return; + + const snapshot = host.getSnapshot(); + + assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']); + assert.equal(snapshot.room.roomtype, state.room.roomtype); + 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.room.roomtype), true); + assert.equal(Object.isFrozen(snapshot.room.roomtype[1]), true); + assert.equal(Object.isFrozen((snapshot.room.roomtype[1] as { nested: unknown[] }).nested), true); + assert.equal(Object.isFrozen(snapshot.seats), true); + assert.equal(Object.isFrozen(snapshot.seats[0]), true); + assert.equal('getState' in snapshot, false); + assert.equal('subscribe' in snapshot, false); + assert.equal('state' in snapshot, false); +}); + +class ObservableStore extends PlatformStore { + private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>(); + + override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { + const unsubscribeStore = super.subscribe(listener); + this.observed.add(listener); + return () => { + this.observed.delete(listener); + unsubscribeStore(); + }; + } + + emitCurrent(): void { + const state = this.getState(); + for (const listener of [...this.observed]) listener(state, state); + } +} + +test('subscribe is immediate, suppresses identical public snapshots, and isolates listener failures', () => { + const store = new ObservableStore(); + const source = fixture('player-login-room.json'); + source.data.isbattle = 0; + source.data.roomtype = [4]; + store.applyLoginSuccess(parseLoginResponse(source.data)); + const { host } = makeLease({ store }); + const received: unknown[] = []; + + host.subscribe((snapshot) => { received.push(snapshot); }); + store.emitCurrent(); + assert.equal(received.length, 1); + + const listenerFailure = new Error('listener failed'); + const reports: unknown[][] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { reports.push(args); }; + try { + assert.throws( + () => host.subscribe(() => { throw listenerFailure; }), + (error) => error === listenerFailure, + ); + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + assert.equal(received.length, 2); + assert.equal(reports.length, 0, 'failed initial subscriptions must be removed'); + } finally { + console.error = originalConsoleError; + } +}); + +test('a later listener failure does not block other Host subscribers', () => { + const { host, store } = makeLease(); + const listenerFailure = new Error('later listener failed'); + let firstCalls = 0; + let secondCalls = 0; + const reports: unknown[][] = []; + const originalConsoleError = console.error; + console.error = (...args: unknown[]) => { reports.push(args); }; + try { + host.subscribe(() => { + firstCalls++; + if (firstCalls > 1) throw listenerFailure; + }); + host.subscribe(() => { secondCalls++; }); + + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + + assert.equal(firstCalls, 2); + assert.equal(secondCalls, 2); + assert.equal(reports.length, 1); + assert.equal(reports[0]!.includes(listenerFailure), true); + } finally { + console.error = originalConsoleError; + } +}); + +class ThrowingUnsubscribeStore extends PlatformStore { + unsubscribeCalls = 0; + + override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { + const unsubscribeStore = super.subscribe(listener); + return () => { + unsubscribeStore(); + this.unsubscribeCalls++; + if (this.unsubscribeCalls === 1) throw undefined; + throw null; + }; + } +} + +test('invalidate clears every subscription and reference before reporting all cleanup failures', () => { + const store = new ThrowingUnsubscribeStore(); + const source = fixture('player-login-room.json'); + source.data.isbattle = 0; + source.data.roomtype = [4]; + store.applyLoginSuccess(parseLoginResponse(source.data)); + const { host, invalidate } = makeLease({ store }); + let callbacks = 0; + const firstUnsubscribe = host.subscribe(() => { callbacks++; }); + const secondUnsubscribe = host.subscribe(() => { callbacks++; }); + + let cleanupError: unknown; + try { + invalidate(); + } catch (error) { + cleanupError = error; + } + + assert.equal(store.unsubscribeCalls, 2); + assert.deepEqual( + (cleanupError as { errors?: readonly unknown[] })?.errors, + [undefined, null], + ); + assert.throws(() => host.getSnapshot(), GameSessionDisposedError); + assert.doesNotThrow(() => firstUnsubscribe()); + assert.doesNotThrow(() => secondUnsubscribe()); + assert.doesNotThrow(() => invalidate()); + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + assert.equal(callbacks, 2); +}); + +test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => { + const { host, invalidate, store } = makeLease(); + const calls: string[] = []; + let unsubscribeSecond = (): void => undefined; + host.subscribe(() => { + calls.push('first'); + if (calls.length > 2) { + unsubscribeSecond(); + invalidate(); + } + }); + unsubscribeSecond = host.subscribe(() => { calls.push('second'); }); + assert.deepEqual(calls, ['first', 'second']); + + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + + assert.deepEqual(calls, ['first', 'second', 'first']); + assert.doesNotThrow(() => unsubscribeSecond()); + assert.doesNotThrow(() => unsubscribeSecond()); + assert.doesNotThrow(() => invalidate()); + store.playerReady(parsePlayerPreparePayload({ seat: 2 })); + assert.deepEqual(calls, ['first', 'second', 'first']); +}); + +test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host method fails predictably', () => { + const { host, invalidate, store, sent } = makeLease(); + let callbacks = 0; + const unsubscribe = host.subscribe(() => { callbacks++; }); + assert.equal(callbacks, 1); + + invalidate(); + invalidate(); + + const disposed = (call: () => unknown) => assert.throws(call, GameSessionDisposedError); + disposed(() => host.getSnapshot()); + disposed(() => host.subscribe(() => { callbacks++; })); + disposed(() => host.subscribe(null as never)); + disposed(() => host.sendGameMessage('play-card', {})); + disposed(() => host.execute({ type: 'room.prepare' })); + disposed(() => host.seat.toView(0)); + disposed(() => host.seat.toServer(0)); + assert.doesNotThrow(() => unsubscribe()); + assert.doesNotThrow(() => unsubscribe()); + + store.playerJoin(parseOtherJoinRoomPayload({ + seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b', + sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0, + })); + assert.equal(callbacks, 1); + assert.equal(sent.length, 0); +}); diff --git a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts index 8a3d96c..7b7fe19 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts @@ -1,10 +1,16 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { test } from 'node:test'; +import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import { createGameHostAdapter } from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts'; +import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts'; +import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; +import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; import type { GameEntry, GameHost, GameModule, - PlatformGameSnapshot, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; import { assertGameContract, @@ -29,13 +35,26 @@ test('assertGameContract runs the reusable isolated session conformance suite', assert.doesNotThrow(() => assertGameContract( entry, - makeConformanceHost(() => { invalidations++; }, restoredDeskinfo), + makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo), )); assert.equal(invalidations, 2); assert.equal(restoredDeskinfo.length, 0); assert.ok(nextModuleId >= 2); }); +const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ + mode: 'release', + isDebugger: false, + identity: Object.freeze({ + agentid: 'fixture-agent', channelid: 'fixture-channel', gameid: '41', + marketid: 'fixture-market', version: 41, + }), + servers: Object.freeze(['ws://fixture']), + source: 'remote', + gameserver: 'https://fixture/config', + rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }), +}); + function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule { let host: GameHost | undefined; let restored: unknown; @@ -77,25 +96,29 @@ function deskinfoFor(cycle: number): { readonly cycle: number } { } function makeConformanceHost( + entry: GameEntry, onInvalidate: () => void, restoredDeskinfo: unknown[], ): () => GameContractHost { return () => { - const snapshot: PlatformGameSnapshot = { - connection: { phase: 'connected' }, - self: { playerId: 1, seat: 0 }, - room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 }, - seats: [], - }; + const login = JSON.parse(readFileSync( + new URL('../fixtures/contracts/player-login-room.json', import.meta.url), + 'utf8', + )) as { data: Record }; + login.data.roomtype = [4]; + login.data.isbattle = 0; + const store = new PlatformStore(); + store.applyLoginSuccess(parseLoginResponse(login.data)); + const sent: OutboundEnvelope[] = []; + const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } }; + const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send }); + const lease = createGameHostAdapter({ gameEntry: entry, store, commands, send }); return { - host: { - seat: { toView: (seat) => seat, toServer: (seat) => seat }, - getSnapshot: () => snapshot, - subscribe: () => () => {}, - sendGameMessage: () => {}, - execute: () => {}, + host: lease.host, + invalidate() { + lease.invalidate(); + onInvalidate(); }, - invalidate: onInvalidate, assertRestored(deskinfo) { assert.equal(restoredDeskinfo.shift(), deskinfo); },