diff --git a/cocoscreator_projects/YouleNexus/assets/framework/README.md b/cocoscreator_projects/YouleNexus/assets/framework/README.md index 5a5a3c8..fd57e0d 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/README.md +++ b/cocoscreator_projects/YouleNexus/assets/framework/README.md @@ -32,6 +32,15 @@ SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-ad 连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。 切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。 +登录、进房的非零 `state` 通过必需的 `ScenePort.showServerDenial({ rpc, state, data })` +报告;`data` 保留服务器原对象,消费方依据 `showerror`、`error`、`roomcode` 等原字段处理。 +这类结果不提交成功状态、不关闭连接,调用方可重试;成功回包缺少必需字段仍是显式错误。 + +`GameEntry.resolveSeatCount` 只接收真实房间的 `roomtype`,预检只检查该方法存在; +GameHost 在进房边界验证返回值是正整数并与服务器座位数组长度相同。 +`assertGameContract(entry, samples)` 的每个样本由游戏提供 `roomtype`、`expectedSeatCount` +和对应的 `makeHost` 工厂,不要求游戏支持框架虚构的房间配置。 + 远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。 原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、 `adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。 diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts index b16679a..7d03a51 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime-session.ts @@ -19,10 +19,18 @@ export interface GameHostLeasePort { export type GameHostLeaseFactory = () => GameHostLeasePort; +/** A valid nonzero server outcome, delivered without changing canonical state. */ +export interface ServerDenial { + readonly rpc: 'player_login' | 'self_join_room'; + readonly state: number; + readonly data: Readonly>; +} + export interface RuntimeSessionOptions { readonly store: PlatformStore; readonly gameSession: GameSessionHost; readonly createGameHostLease: GameHostLeaseFactory; + readonly onServerDenial: (outcome: ServerDenial) => void; } export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked'; @@ -66,6 +74,10 @@ export class RuntimeSession { this.requireOperational(); this.loginIsPending = false; const parsed = parseLoginResponse(data); + if (parsed.state !== 0) { + this.options.onServerDenial({ rpc: 'player_login', state: parsed.state, data: parsed.raw }); + return; + } const committed = this.options.store.applyLoginSuccess(parsed); if (parsed.room === null) { this.releaseOrFault('login lobby replacement'); @@ -77,6 +89,10 @@ export class RuntimeSession { handleSelfJoin(data: unknown): void { this.requireOperational(); const parsed = parseSelfJoinRoomResponse(data); + if (parsed.state !== 0) { + this.options.onServerDenial({ rpc: 'self_join_room', state: parsed.state, data: parsed.raw }); + return; + } const committed = this.options.store.replaceRoom(parsed); this.openCommittedRoom(committed, parsed.reconnect, 'self join room open'); } @@ -150,6 +166,7 @@ export class RuntimeSession { const priorCleanupErrors = this.releaseGame(); if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors); + if (this.gameWasDisposed()) return; let lease: GameHostLeasePort; try { @@ -164,10 +181,12 @@ export class RuntimeSession { try { this.options.gameSession.open(lease.host); + if (this.gameWasDisposed()) return; this.options.gameSession.publish({ type: 'room.entered', roomtype: state.room.roomtype, }); + if (this.gameWasDisposed()) return; if (reconnect.present) this.options.gameSession.restore(reconnect.value); } catch (error) { const openErrors = extractGameSessionOpenFaultErrors(error); @@ -184,12 +203,16 @@ export class RuntimeSession { if (errors.length > 0) this.throwFault(operation, errors); } + private gameWasDisposed(): boolean { + return this.options.gameSession.state === 'disposed'; + } + private releaseGame(): unknown[] { const errors: unknown[] = []; const lease = this.activeLease; this.activeLease = null; - if (this.options.gameSession.state !== 'idle') { + if (this.options.gameSession.state !== 'idle' && !this.gameWasDisposed()) { try { this.options.gameSession.close(); } catch (error) { diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts index 7098276..169fd7a 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/runtime.ts @@ -57,6 +57,7 @@ class SceneOrderedGameSessionHost extends GameSessionHost { restore(deskinfo: unknown): void { this.showRoomAfterOpen(); + if (this.state === 'disposed') return; super.restore(deskinfo); } @@ -183,6 +184,7 @@ export class PlatformRuntime { store: this.store, gameSession, createGameHostLease: () => this.createGameHostLease(commands, send), + onServerDenial: outcome => { this.options.scene.showServerDenial(outcome); }, }); const handlers = new PlatformHandlers({ store: this.store, @@ -341,6 +343,7 @@ export class PlatformRuntime { const stateBeforeDispatch = this.store.getState(); if ( this.requireRouter().dispatch(event.message) === 'handled-platform' + && this.lifecycle === 'running' && this.store.getState() !== stateBeforeDispatch ) { this.updateSceneAfterMessage(event.message.route, event.message.rpc); @@ -357,10 +360,17 @@ export class PlatformRuntime { break; } } catch (error) { + // A lifecycle callback may stop and then throw. Preserve that error without + // replacing the requested terminal stop with a fatal lifecycle transition. + if (this.isStopped()) throw error; this.showFatalOnce(error); } } + private isStopped(): boolean { + return this.lifecycle === 'stopped'; + } + private sendLoginEnvelope(envelope: OutboundEnvelope): void { const wire = this.requireOpenWire(); const session = this.requireSession(); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts index 7e72276..8da4e07 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/scene-port.ts @@ -1,3 +1,5 @@ +import type { ServerDenial } from './runtime-session.ts'; + /** Semantic scene intents; the headless runtime never imports Cocos. */ export interface ScenePort { showLoading(): void; @@ -6,5 +8,7 @@ export interface ScenePort { showRoom(): void; showReconnect(): void; showKicked(data: unknown): void; + /** Recoverable server result; the consumer interprets the original failure payload. */ + showServerDenial(outcome: ServerDenial): void; showFatal(error: Error): void; } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts index d6bd71f..3711fb0 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts @@ -144,7 +144,7 @@ export function parseLoginResponse(value: unknown): ParsedLoginResponse { : optionalField(raw, 'playerid', '$.data', requireInteger); const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players'] .some((field) => hasOwn(raw, field)); - const room = hasRoomFields ? parseRoomEntryFields(raw) : null; + const room = state === 0 && hasRoomFields ? parseRoomEntryFields(raw) : null; return { state, diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts index f073d5e..a824dc9 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/contracts/game-entry.ts @@ -5,6 +5,7 @@ export interface GameEntry { readonly key: string; readonly gameId: string | number; readonly route: string; + /** Interprets this game's actual server roomtype; no synthetic/default input is required. */ resolveSeatCount(roomtype: readonly unknown[]): number; createModule(): GameModule; } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts index 0f35ced..3cb9aa5 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts @@ -85,10 +85,6 @@ export function assertGameEntry(entry: GameEntry): void { if (typeof entry.resolveSeatCount !== 'function') { throw new Error('GameEntry resolveSeatCount must be a function'); } - const seatCount = entry.resolveSeatCount([]); - if (!Number.isInteger(seatCount) || seatCount <= 0) { - throw new Error(`GameEntry seat count must be a positive integer: ${seatCount}`); - } if (typeof entry.createModule !== 'function') { throw new Error('GameEntry createModule must be a function'); } @@ -130,24 +126,28 @@ export class GameSessionHost { } this.currentState = 'attaching'; - let module: GameModule | undefined; try { - module = this.entry.createModule(); - assertGameModule(module); + const module = this.entry.createModule(); this.module = module; + // A factory may stop the application before returning its new instance. + if (this.isDisposed()) { + this.releaseModule(); + return; + } + assertGameModule(module); module.attach(host); - this.currentState = 'active'; + if (this.currentState === 'attaching') this.currentState = 'active'; } catch (primaryError) { - this.module = undefined; + if (!this.isDisposed()) this.currentState = 'disposing'; let cleanupFailed = false; let cleanupError: unknown; try { - disposeModuleIfPresent(module); + this.releaseModule(); } catch (error) { cleanupFailed = true; cleanupError = error; } finally { - this.currentState = 'idle'; + if (!this.isDisposed()) this.currentState = 'idle'; } if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError); throw primaryError; @@ -171,7 +171,7 @@ export class GameSessionHost { try { module.restore(deskinfo); } finally { - this.currentState = 'active'; + if (this.currentState === 'restoring') this.currentState = 'active'; } } @@ -184,27 +184,32 @@ export class GameSessionHost { throw new Error(`Cannot close game session while ${this.currentState}`); } - const module = this.module; - if (!module) { + if (!this.module) { throw new Error('Active game session has no module'); } this.currentState = 'disposing'; - this.module = undefined; try { - module.dispose(); + this.releaseModule(); } finally { - this.currentState = 'idle'; + if (!this.isDisposed()) this.currentState = 'idle'; } } dispose(): void { - if (this.currentState === 'disposed') return; - try { - if (this.currentState !== 'idle') this.close(); - } finally { - this.module = undefined; - this.currentState = 'disposed'; - } + if (this.isDisposed()) return; + // Terminal state is visible before user cleanup, including reentrant dispose. + this.currentState = 'disposed'; + this.releaseModule(); + } + + private isDisposed(): boolean { + return this.currentState === 'disposed'; + } + + private releaseModule(): void { + const module = this.module; + this.module = undefined; + disposeModuleIfPresent(module); } private requireActive(operation: string): GameModule { diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts index b284fe7..1a3bfd2 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts @@ -14,14 +14,26 @@ export interface GameContractHost { export type GameContractHostFactory = () => GameContractHost; +/** The game owns each room configuration, its expected count and matching Host fixture. */ +export interface GameContractSample { + readonly roomtype: readonly unknown[]; + readonly expectedSeatCount: number; + readonly makeHost: GameContractHostFactory; +} + /** Runs the shared lifecycle checks required of the app's sole GameEntry. */ -export function assertGameContract(entry: GameEntry, makeHost: GameContractHostFactory): void { +export function assertGameContract(entry: GameEntry, samples: readonly GameContractSample[]): void { + assert.ok(samples.length > 0, 'conformance requires game-owned roomtype samples'); assertGameEntry(entry); for (const route of ['platform', 'agent', 'room']) { assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/); } - for (const seatCount of [2, 4, 10]) { - assert.equal(entry.resolveSeatCount([seatCount]), seatCount, `roomtype must resolve ${seatCount} seats`); + for (const sample of samples) { + assert.ok( + Number.isInteger(sample.expectedSeatCount) && sample.expectedSeatCount > 0, + 'sample expectedSeatCount must be a positive integer', + ); + assert.equal(entry.resolveSeatCount(sample.roomtype), sample.expectedSeatCount); } const firstFactoryModule = entry.createModule(); @@ -35,21 +47,28 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF } const sessions = new GameSessionHost(entry); - runCycle(sessions, makeHost(), entry, 1); - runCycle(sessions, makeHost(), entry, 2); + for (const sample of samples) { + runCycle(sessions, sample, entry, 1); + runCycle(sessions, sample, entry, 2); + } assert.equal(sessions.state, 'idle'); } function runCycle( sessions: GameSessionHost, - contractHost: GameContractHost, + sample: GameContractSample, entry: GameEntry, cycle: number, ): void { - const roomtype = [4] as const; + const contractHost = sample.makeHost(); + const snapshot = contractHost.host.getSnapshot(); + assert.deepEqual(snapshot.room.roomtype, sample.roomtype, 'Host fixture must match the game sample'); + // Public seats contain occupied seats only; the mapper owns the full room range. + assert.doesNotThrow(() => contractHost.host.seat.toView(sample.expectedSeatCount - 1)); + assert.throws(() => contractHost.host.seat.toView(sample.expectedSeatCount)); const deskinfo = { cycle }; sessions.open(contractHost.host); - sessions.publish({ type: 'room.entered', roomtype }); + sessions.publish({ type: 'room.entered', roomtype: sample.roomtype }); sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } }); assert.throws( () => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }), diff --git a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts index d5c6f5f..8abc880 100644 --- a/cocoscreator_projects/framework-tests/integration/login-flow.test.ts +++ b/cocoscreator_projects/framework-tests/integration/login-flow.test.ts @@ -63,6 +63,7 @@ function setup() { showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); }, showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); }, showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); }, + showServerDenial() { assert.fail('Unexpected server denial'); }, showFatal(error) { fatals.push(error); }, }, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, diff --git a/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts index 8bb8e66..2ac7c42 100644 --- a/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts +++ b/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts @@ -74,6 +74,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te const configRequests: Array<{ url: string; init: RequestInit | undefined }> = []; const scenes: string[] = []; const fatals: Error[] = []; + const denials: Array<{ readonly rpc: string; readonly state: number; readonly data: unknown }> = []; const observations: string[] = []; const attached: GameHost[] = []; const restored: unknown[] = []; @@ -154,6 +155,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); }, showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); }, showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); }, + showServerDenial(outcome) { denials.push(outcome); }, showFatal(error) { fatals.push(error); }, }, loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock, @@ -205,6 +207,18 @@ test('real modern vertical replay preserves wire contracts, room ordering and te await push(agent, fixture('player-login-success')); assert.equal(scenes[scenes.length - 1], 'lobby'); assert.equal(runtime.state.room.kind, 'outside'); + const lobby = runtime.state; + runtime.joinRoom({ roomcode: '999999', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' }); + expectedFrames.push({ ...JOIN, data: { ...JOIN.data, roomcode: '999999' } }); + const denied = await push(agent, { + route: 'agent', rpc: 'self_join_room', + data: { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' }, + }); + assert.equal(runtime.state, lobby); + assert.equal(scenes[scenes.length - 1], 'lobby'); + assert.equal(denials.length, 1); + assert.deepEqual(denials[0], { rpc: 'self_join_room', state: 99, data: denied.data }); + assert.equal(denials[0]?.data, denied.data); runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' }); expectedFrames.push(JOIN); verifyFrames(); diff --git a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts index 281707e..bd22b1e 100644 --- a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts +++ b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts @@ -202,7 +202,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release const staleDisposeResults: unknown[] = []; const gameEntry: GameEntry = { ...entry(), - resolveSeatCount: (roomtype) => roomtype.length === 0 ? 4 : roomtype[0] as number, + resolveSeatCount: (roomtype) => roomtype[0] as number, createModule: () => { let host: GameHost | null = null; return { @@ -234,6 +234,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release const runtime = new RuntimeSession({ store, gameSession, + onServerDenial() { assert.fail('Unexpected server denial'); }, createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }), }); const login = fixture('player-login-room.json'); diff --git a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts index 484fc17..965dcd5 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime-session.test.ts @@ -25,6 +25,7 @@ function makeSession() { const store = new PlatformStore(); const log: string[] = []; const restored: unknown[] = []; + const denials: unknown[] = []; const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = []; let host: GameHost; const entry: GameEntry = { @@ -55,6 +56,7 @@ function makeSession() { const session = new RuntimeSession({ store, gameSession, + onServerDenial: outcome => { denials.push(outcome); }, createGameHostLease: () => ({ host: { seat: { toView: (seat) => seat, toServer: (seat) => seat }, @@ -66,9 +68,30 @@ function makeSession() { invalidate() {}, }), }); - return { store, gameSession, session, log, restored, events }; + return { store, gameSession, session, log, restored, events, denials }; } +test('server denials preserve an active room and module without committing a success action', () => { + const setup = makeSession(); + setup.session.handleLogin(fixture('player-login-room.json')); + const before = setup.store.getState(); + setup.log.length = 0; + const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' }; + const loginDenial = { state: 1, error: 'Login denied' }; + setup.session.handleSelfJoin(joinDenial); + setup.session.markLoginPending(); + setup.session.handleLogin(loginDenial); + assert.equal(setup.store.getState(), before); + assert.equal(setup.session.loginPending, false); + assert.equal(setup.session.state, 'operational'); + assert.equal(setup.gameSession.state, 'active'); + assert.deepEqual(setup.log, []); + assert.deepEqual(setup.denials, [ + { rpc: 'self_join_room', state: 99, data: joinDenial }, + { rpc: 'player_login', state: 1, data: loginDenial }, + ]); +}); + test('RuntimeSession commits a room login before opening, publishing, and restoring deskinfo', () => { const setup = makeSession(); const data = fixture('player-login-room.json'); @@ -220,6 +243,7 @@ function makeLeaseSession() { const sessionOptions = { store, gameSession, + onServerDenial() { assert.fail('Unexpected server denial'); }, createGameHostLease() { if (failures.has('lease-factory')) throw errors['lease-factory']; const record: LeaseRecord = { host: host(), invalidations: 0 }; diff --git a/cocoscreator_projects/framework-tests/platform/runtime.test.ts b/cocoscreator_projects/framework-tests/platform/runtime.test.ts index f43eb10..9b2fb99 100644 --- a/cocoscreator_projects/framework-tests/platform/runtime.test.ts +++ b/cocoscreator_projects/framework-tests/platform/runtime.test.ts @@ -262,6 +262,7 @@ class RecordingScene implements ScenePort { showRoom(): void { this.record('room'); } showReconnect(): void { this.record('reconnect'); } showKicked(data: unknown): void { this.record('kicked', data); } + showServerDenial(outcome: unknown): void { this.record('denied', outcome); } showFatal(error: Error): void { this.record('fatal', error); } } @@ -303,6 +304,254 @@ function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEn return { runtime, wire, scene, clock, factoryConfigs, config }; } +test('a denied join retains the lobby root, reports the raw outcome and permits a valid retry', async () => { + const { runtime, wire, scene } = makeRuntime(); + await runtime.start(); + wire.emit({ type: 'open', server: 'ws://agent' }); + runtime.login(ACCOUNT); + wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), + } }); + const lobby = runtime.state; + const denial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' }; + runtime.joinRoom({ roomcode: '999999', location: null, ip: '127.0.0.1' }); + wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: denial } }); + + assert.equal(runtime.state, lobby); + assert.equal(wire.stopCalls, 0); + assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []); + const outcome = scene.calls.find(call => call.name === 'denied')?.value; + assert.deepEqual(outcome, { rpc: 'self_join_room', state: 99, data: denial }); + assert.equal((outcome as { data: unknown }).data, denial); + + runtime.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' }); + wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'), + } }); + assert.equal(runtime.state.room.kind, 'inside'); + assert.equal(scene.calls[scene.calls.length - 1]?.name, 'room'); + runtime.stop(); +}); + +test('runtime resolves seats only from the actual game-owned server roomtype at room entry', async () => { + const roomtypes: Array = []; + const attached: GameHost[] = []; + const setup = makeRuntime(runtimeConfig(), makeGameEntry({ + resolveSeatCount(roomtype) { + roomtypes.push(roomtype); + if (roomtype.length !== 7 || roomtype[1] !== 4 || !Array.isArray(roomtype[5])) { + throw new Error('Game roomtype requires the full nested server configuration'); + } + return 4; + }, + createModule: () => ({ + attach(host) { attached.push(host); }, + handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, + }), + })); + await setup.runtime.start(); + assert.deepEqual(roomtypes, []); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } }); + assert.equal(roomtypes.length, 1); + assert.equal(attached.length, 1); + assert.equal(roomtypes[0], attached[0]!.getSnapshot().room.roomtype); + assert.equal(attached[0]!.seat.toView(1), 0); + assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); + setup.runtime.stop(); +}); + +for (const count of [0, 2.5, 2]) { + test(`runtime rejects invalid game count ${count} at the real room boundary`, async () => { + let attaches = 0; + const setup = makeRuntime(runtimeConfig(), makeGameEntry({ + resolveSeatCount: () => count, + createModule: () => ({ + attach() { attaches += 1; }, + handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {}, + }), + })); + await setup.runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } }); + assert.equal(attaches, 0); + assert.equal(setup.wire.stopCalls, 1); + const failure = setup.scene.calls.find(call => call.name === 'fatal')?.value; + assert.match(String((failure as { errors: unknown[] }).errors[0]), /seat count/); + setup.runtime.stop(); + }); +} + +test('a nonzero login outcome clears its guard, retains state and permits account retry', async () => { + const { runtime, wire, scene, clock } = makeRuntime(); + await runtime.start(); + wire.emit({ type: 'open', server: 'ws://agent' }); + const before = runtime.state; + runtime.login(ACCOUNT); + const denial = { state: 7, roomcode: 'unavailable', showerror: 1, error: 'Login denied' }; + wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: denial } }); + + assert.equal(runtime.state, before); + assert.equal(wire.stopCalls, 0); + assert.equal(clock.timers.size, 0); + const outcome = scene.calls.find(call => call.name === 'denied')?.value; + assert.deepEqual(outcome, { rpc: 'player_login', state: 7, data: denial }); + assert.equal((outcome as { data: unknown }).data, denial); + assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []); + runtime.login({ ...ACCOUNT, openid: 'retry-account' }); + assert.equal((wire.sent[wire.sent.length - 1]!.data as Record).openid, 'retry-account'); + wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'), + } }); + assert.equal(runtime.state.app.phase, 'logged-in'); + runtime.stop(); +}); + +for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as const) { + test(`stop from ${interruption} releases the actual module and host without reviving runtime`, async () => { + let runtime: PlatformRuntime; + let attachedHost: GameHost | undefined; + let moduleNumber = 0; + const disposed: number[] = []; + const events: string[] = []; + const entry = makeGameEntry({ + createModule() { + const id = moduleNumber++; + return { + attach(host) { + attachedHost = host; + host.subscribe(() => { events.push('snapshot'); }); + if (interruption === 'attach') runtime.stop(); + }, + handlePlatformEvent() { + events.push('room-event'); + if (interruption === 'room-event') runtime.stop(); + }, + handleGameMessage() { events.push('game-message'); }, + restore() { + events.push('restore'); + if (interruption === 'restore') runtime.stop(); + }, + dispose() { disposed.push(id); }, + }; + }, + }); + const setup = makeRuntime(runtimeConfig(), entry); + runtime = setup.runtime; + setup.scene.onCall = name => { + if (name === 'room' && interruption === 'room-scene') runtime.stop(); + }; + await runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + runtime.login(ACCOUNT); + setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } }); + + assert.deepEqual(disposed, [0, 1], 'validation and actual room modules each dispose once'); + assert.ok(attachedHost); + const stoppedHost = attachedHost; + assert.throws(() => stoppedHost.getSnapshot(), /disposed/); + assert.throws(() => stoppedHost.subscribe(() => {}), /disposed/); + assert.equal(setup.clock.timers.size, 0); + assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); + assert.equal(setup.wire.stopCalls, 1); + assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']); + if (interruption === 'attach') assert.deepEqual(events, ['snapshot']); + if (interruption === 'room-event' || interruption === 'room-scene') { + assert.deepEqual(events, ['snapshot', 'room-event']); + } + const stoppedState = runtime.state; + const stoppedEvents = [...events]; + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.wire.emit({ type: 'message', message: { + route: 'fixture-game', rpc: 'late', data: {}, + } }); + assert.equal(runtime.state, stoppedState); + assert.deepEqual(events, stoppedEvents); + assert.throws(() => runtime.login(ACCOUNT), /stopped/); + await assert.rejects(runtime.start(), /stopped/); + runtime.stop(); + assert.deepEqual(disposed, [0, 1]); + assert.equal(setup.wire.stopCalls, 1); + }); +} + +test('stop reentered by an old module disposal prevents replacement room activation', async () => { + let runtime: PlatformRuntime; + const attached: GameHost[] = []; + let disposals = 0; + const setup = makeRuntime(runtimeConfig(), makeGameEntry({ + createModule() { + let host: GameHost | undefined; + return { + attach(value) { host = value; attached.push(value); }, + handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, + dispose() { + if (host === undefined) return; + disposals += 1; + runtime.stop(); + }, + }; + }, + })); + runtime = setup.runtime; + await runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } }); + setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } }); + assert.equal(disposals, 1); + assert.equal(attached.length, 1); + assert.throws(() => attached[0]!.getSnapshot(), /disposed/); + assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); + assert.equal(setup.wire.stopCalls, 1); + assert.throws(() => runtime.login(ACCOUNT), /stopped/); + runtime.stop(); +}); + +test('an actual callback error after stop remains explicit without changing the stopped lifecycle', async () => { + let runtime: PlatformRuntime; + const failure = new Error('restore callback failed after stop'); + const setup = makeRuntime(runtimeConfig(), makeGameEntry({ + createModule: () => ({ + attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {}, + restore() { runtime.stop(); throw failure; }, + }), + })); + runtime = setup.runtime; + await runtime.start(); + setup.wire.emit({ type: 'open', server: 'ws://agent' }); + const thrown = thrownValue(() => setup.wire.emit({ type: 'message', message: { + route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'), + } })); + assert.deepEqual((thrown as { errors: unknown[] }).errors, [failure]); + assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []); + assert.throws(() => runtime.login(ACCOUNT), /stopped/); + runtime.stop(); + assert.equal(setup.wire.stopCalls, 1); +}); + +for (const rpc of ['player_login', 'self_join_room']) { + test(`malformed successful ${rpc} still terminates explicitly`, async () => { + const { runtime, wire, scene } = makeRuntime(); + await runtime.start(); + wire.emit({ type: 'open', server: 'ws://agent' }); + wire.emit({ type: 'message', message: { route: 'agent', rpc, data: { state: 0 } } }); + assert.equal(wire.stopCalls, 1); + assert.equal(scene.calls.filter(call => call.name === 'fatal').length, 1); + assert.deepEqual(scene.calls.filter(call => call.name === 'denied'), []); + runtime.stop(); + }); +} + function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry { let recording = false; const entry = makeGameEntry({ diff --git a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts index 7325b6f..e0b671a 100644 --- a/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/platform-handlers.test.ts @@ -82,6 +82,7 @@ function makeHandlers() { const session = new RuntimeSession({ store, gameSession, + onServerDenial() { assert.fail('Unexpected server denial'); }, createGameHostLease: () => ({ host: { seat: { toView: (seat) => seat, toServer: (seat) => seat }, @@ -156,6 +157,7 @@ function makeReentrantRouterFixture(log: string[]) { const session = new RuntimeSession({ store, gameSession, + onServerDenial() { assert.fail('Unexpected server denial'); }, createGameHostLease: () => ({ host: { seat: { toView: (seat) => seat, toServer: (seat) => seat }, 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 7b7fe19..8e0531f 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts @@ -17,7 +17,11 @@ import { type GameContractHost, } from '../../YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts'; -test('assertGameContract runs the reusable isolated session conformance suite', () => { +const GAME_ROOMTYPE = JSON.parse(readFileSync( + new URL('../fixtures/contracts/player-login-room.json', import.meta.url), 'utf8', +)).data.roomtype as readonly unknown[]; + +test('assertGameContract accepts a fixed-four game with its real nested roomtype and host fixture', () => { let invalidations = 0; let nextModuleId = 0; const restoredDeskinfo: unknown[] = []; @@ -25,17 +29,17 @@ test('assertGameContract runs the reusable isolated session conformance suite', key: 'fixture', gameId: 41, route: 'fixture-route', - resolveSeatCount: (roomtype) => { - const seats = roomtype[0]; - if (seats === 2 || seats === 4 || seats === 10) return seats; - return 4; - }, + resolveSeatCount: () => 4, createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }), }; assert.doesNotThrow(() => assertGameContract( entry, - makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo), + [{ + roomtype: GAME_ROOMTYPE, + expectedSeatCount: 4, + makeHost: makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo), + }], )); assert.equal(invalidations, 2); assert.equal(restoredDeskinfo.length, 0); @@ -65,7 +69,8 @@ function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameMod handlePlatformEvent(event) { eventCount++; if (event.type === 'room.entered') { - assert.equal(event.roomtype[0], 4); + assert.deepEqual(event.roomtype, GAME_ROOMTYPE); + assert.deepEqual(host!.getSnapshot().room.roomtype, event.roomtype); } }, handleGameMessage(message) { @@ -105,7 +110,6 @@ function makeConformanceHost( 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)); diff --git a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts index 53138b6..5955e1f 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -73,7 +73,7 @@ function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): Gam }; } -test('assertGameEntry rejects invalid identity, routes, seat counts, and module factories', () => { +test('assertGameEntry rejects invalid identity, routes, resolver presence, and module factories', () => { const entry = makeEntry(() => makeModule([])); assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/); @@ -81,11 +81,24 @@ test('assertGameEntry rejects invalid identity, routes, seat counts, and module for (const route of ['platform', 'agent', 'room']) { assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/); } - assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/); - assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 2.5 }), /seat count/); + assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: undefined as never }), /resolveSeatCount/); assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/); }); +test('GameEntry preflight does not call a resolver before its game-owned roomtype exists', () => { + const calls: Array = []; + const entry = { + ...makeEntry(() => makeModule([])), + resolveSeatCount(roomtype: readonly unknown[]) { + calls.push(roomtype); + if (roomtype[1] !== 4) throw new Error('Game roomtype requires four seats at index 1'); + return 4; + }, + }; + assert.doesNotThrow(() => new GameSessionHost(entry)); + assert.deepEqual(calls, []); +}); + test('assertGameEntry disposes its validation module exactly once', () => { let disposals = 0; const entry = makeEntry(() => ({ @@ -428,3 +441,76 @@ test('GameSessionHost returns to active after a restore handler throws', () => { assert.throws(() => sessions.restore({}), /restore failed/); assert.equal(sessions.state, 'active'); }); + +for (const callback of ['attach', 'restore'] as const) { + for (const throwsAfterStop of [false, true]) { + test(`terminal dispose during ${callback} survives callback completion (throws=${throwsAfterStop})`, () => { + let sessions: GameSessionHost; + let disposals = 0; + const failure = new Error('callback failed after disposal'); + sessions = new GameSessionHost(makeEntry(() => ({ + ...makeModule([]), + [callback]() { + sessions.dispose(); + sessions.dispose(); + assert.equal(sessions.state, 'disposed'); + if (throwsAfterStop) throw failure; + }, + dispose() { disposals += 1; }, + }))); + disposals = 0; + const invoke = () => { + sessions.open(makeHost()); + if (callback === 'restore') sessions.restore({ round: 1 }); + }; + if (throwsAfterStop) assert.equal(caughtError(invoke), failure); + else assert.doesNotThrow(invoke); + assert.equal(disposals, 1); + assert.equal(sessions.state, 'disposed'); + sessions.dispose(); + assert.equal(disposals, 1); + assert.throws(() => sessions.open(makeHost()), /disposed/); + assert.throws(() => sessions.restore({}), /disposed/); + }); + } +} + +for (const release of ['close', 'dispose'] as const) { + test(`${release} tolerates module.dispose reentering terminal disposal`, () => { + let sessions: GameSessionHost | undefined; + let disposals = 0; + sessions = new GameSessionHost(makeEntry(() => ({ + ...makeModule([]), + dispose() { + if (sessions === undefined) return; + disposals += 1; + sessions.dispose(); + }, + }))); + sessions.open(makeHost()); + assert.doesNotThrow(() => sessions![release]()); + assert.equal(sessions.state, 'disposed'); + assert.equal(disposals, 1); + sessions.dispose(); + assert.equal(disposals, 1); + }); +} + +test('terminal disposal during module creation cleans the returned module without attaching it', () => { + let sessions: GameSessionHost | undefined; + let attaches = 0; + let disposals = 0; + sessions = new GameSessionHost(makeEntry(() => { + if (sessions !== undefined) sessions.dispose(); + return { + ...makeModule([]), + attach() { attaches += 1; }, + dispose() { disposals += 1; }, + }; + })); + disposals = 0; + assert.doesNotThrow(() => sessions!.open(makeHost())); + assert.equal(disposals, 1); + assert.equal(attaches, 0); + assert.equal(sessions.state, 'disposed'); +});