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 7063323..689acec 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 @@ -34,7 +34,12 @@ export function assertGameEntry(entry: GameEntry): void { if (typeof entry.createModule !== 'function') { throw new Error('GameEntry createModule must be a function'); } - assertGameModule(entry.createModule()); + const module = entry.createModule(); + try { + assertGameModule(module); + } finally { + disposeValidationModule(module); + } } /** Owns one isolated game module for one room at a time. */ @@ -59,15 +64,20 @@ export class GameSessionHost { } this.currentState = 'attaching'; + let module: GameModule | undefined; try { - const module = this.entry.createModule(); + module = this.entry.createModule(); assertGameModule(module); this.module = module; module.attach(host); this.currentState = 'active'; } catch (error) { this.module = undefined; - this.currentState = 'idle'; + try { + module?.dispose(); + } finally { + this.currentState = 'idle'; + } throw error; } } @@ -163,3 +173,9 @@ function assertGameModule(module: unknown): asserts module is GameModule { } } } + +function disposeValidationModule(module: unknown): void { + if (!module || typeof module !== 'object') return; + const dispose = (module as Record).dispose; + if (typeof dispose === 'function') dispose.call(module); +} 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 e95018d..b284fe7 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 @@ -9,6 +9,7 @@ import { assertGameEntry, GameSessionHost } from '../runtime/game-session-host.t export interface GameContractHost { readonly host: GameHost; invalidate(): void; + assertRestored(deskinfo: unknown): void; } export type GameContractHostFactory = () => GameContractHost; @@ -24,8 +25,14 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF } const firstFactoryModule = entry.createModule(); - const secondFactoryModule = entry.createModule(); - assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules'); + let secondFactoryModule: ReturnType | undefined; + try { + secondFactoryModule = entry.createModule(); + assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules'); + } finally { + firstFactoryModule.dispose(); + if (secondFactoryModule && secondFactoryModule !== firstFactoryModule) secondFactoryModule.dispose(); + } const sessions = new GameSessionHost(entry); runCycle(sessions, makeHost(), entry, 1); @@ -49,6 +56,7 @@ function runCycle( /route/, ); sessions.restore(deskinfo); + contractHost.assertRestored(deskinfo); sessions.close(); contractHost.invalidate(); } 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 f4d11ce..8a3d96c 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts @@ -14,6 +14,7 @@ import { test('assertGameContract runs the reusable isolated session conformance suite', () => { let invalidations = 0; let nextModuleId = 0; + const restoredDeskinfo: unknown[] = []; const entry: GameEntry = { key: 'fixture', gameId: 41, @@ -23,15 +24,19 @@ test('assertGameContract runs the reusable isolated session conformance suite', if (seats === 2 || seats === 4 || seats === 10) return seats; return 4; }, - createModule: () => makeModule(++nextModuleId), + createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }), }; - assert.doesNotThrow(() => assertGameContract(entry, makeConformanceHost(() => { invalidations++; }))); + assert.doesNotThrow(() => assertGameContract( + entry, + makeConformanceHost(() => { invalidations++; }, restoredDeskinfo), + )); assert.equal(invalidations, 2); + assert.equal(restoredDeskinfo.length, 0); assert.ok(nextModuleId >= 2); }); -function makeModule(id: number): GameModule { +function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule { let host: GameHost | undefined; let restored: unknown; let eventCount = 0; @@ -50,8 +55,16 @@ function makeModule(id: number): GameModule { assert.equal(typeof data.cycle, 'number'); messageCycle = data.cycle; }, - restore(deskinfo) { restored = deskinfo; }, + restore(deskinfo) { + restored = deskinfo; + onRestore(deskinfo); + }, dispose() { + if (!host) { + assert.equal(eventCount, 0); + assert.equal(restored, undefined); + return; + } assert.ok(host); assert.equal(eventCount, 1); assert.deepEqual(restored, deskinfoFor(messageCycle!)); @@ -63,7 +76,10 @@ function deskinfoFor(cycle: number): { readonly cycle: number } { return { cycle }; } -function makeConformanceHost(onInvalidate: () => void): () => GameContractHost { +function makeConformanceHost( + onInvalidate: () => void, + restoredDeskinfo: unknown[], +): () => GameContractHost { return () => { const snapshot: PlatformGameSnapshot = { connection: { phase: 'connected' }, @@ -80,6 +96,9 @@ function makeConformanceHost(onInvalidate: () => void): () => GameContractHost { execute: () => {}, }, invalidate: onInvalidate, + assertRestored(deskinfo) { + assert.equal(restoredDeskinfo.shift(), deskinfo); + }, }; }; } 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 d9d60ba..e8d68f6 100644 --- a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -40,15 +40,21 @@ function makeEntry(createModule: () => GameModule): GameEntry { } function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule { + let attached = false; return { - attach: () => log.push('attach'), + attach: () => { + attached = true; + log.push('attach'); + }, handlePlatformEvent: (event) => log.push(`event:${event.type}`), handleGameMessage: (message) => log.push(`message:${message.rpc}`), restore: (deskinfo) => { log.push('restore'); onRestore?.(deskinfo); }, - dispose: () => log.push('dispose'), + dispose: () => { + if (attached) log.push('dispose'); + }, }; } @@ -65,6 +71,18 @@ test('assertGameEntry rejects invalid identity, routes, seat counts, and module assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/); }); +test('assertGameEntry disposes its validation module exactly once', () => { + let disposals = 0; + const entry = makeEntry(() => ({ + ...makeModule([]), + dispose: () => { disposals++; }, + })); + + assert.doesNotThrow(() => assertGameEntry(entry)); + + assert.equal(disposals, 1); +}); + test('GameSessionHost delivers room events and deskinfo to one active module in order', () => { const log: string[] = []; const deskinfo = { turn: 7 }; @@ -120,6 +138,21 @@ test('GameSessionHost rejects illegal state transitions and mismatched game rout assert.deepEqual(log, ['attach', 'message:draw']); }); +test('GameSessionHost disposes a module when attachment throws and returns to idle', () => { + let disposals = 0; + const sessions = new GameSessionHost(makeEntry(() => ({ + ...makeModule([]), + attach: () => { throw new Error('attach failed'); }, + dispose: () => { disposals++; }, + }))); + disposals = 0; + + assert.throws(() => sessions.open(makeHost()), /attach failed/); + + assert.equal(disposals, 1); + assert.equal(sessions.state, 'idle'); +}); + test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => { const log: string[] = []; const sessions = new GameSessionHost(makeEntry(() => makeModule(log)));