From 471e2e94793265f0598e74af1cb5e2441f4108fb Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 07:48:23 +0800 Subject: [PATCH] fix(platform): isolate game host leases --- .../assets/framework/platform/commands.ts | 4 +- .../framework/platform/game-host-adapter.ts | 78 +++++++--- .../sdk/runtime/game-session-host.ts | 16 +- .../framework-tests/platform/commands.test.ts | 25 ++- .../platform/game-host-adapter.test.ts | 147 ++++++++++++++++++ 5 files changed, 237 insertions(+), 33 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts index fafcdcd..0014dc1 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/commands.ts @@ -47,8 +47,8 @@ export class PlatformCommands { 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'); + if (room.stage !== 0 && room.infinite !== 1) { + throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1'); } this.options.send.send(buildExitRoomRequest( this.options.runtimeConfig, diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts index 63c05be..ba59dca 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/game-host-adapter.ts @@ -7,6 +7,7 @@ import type { GameSeatMapper, PlatformGameSnapshot, } from '../sdk/contracts/index.ts'; +import { assertGameRoute } from '../sdk/runtime/game-session-host.ts'; import { PlatformCommands, UnsupportedGameHostCommandError, @@ -16,8 +17,6 @@ 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'); @@ -47,6 +46,13 @@ type SubscriptionReleaseResult = | { readonly threw: false } | { readonly threw: true; readonly error: unknown }; +interface BoundRoomGeneration { + readonly roomcode: string; + readonly roomtype: readonly unknown[]; + readonly selfSeat: number; + readonly seatCount: number; +} + class GameHostInvalidationError extends Error { readonly errors: readonly unknown[]; @@ -64,11 +70,14 @@ class SessionBoundGameHost { private commands: PlatformCommands | null; private sendPort: OutboundEnvelopeSendPort | null; private route: string | null; + private roomGeneration: BoundRoomGeneration | null; private readonly subscriptions = new Set(); + private invalidationErrors: unknown[] | null = null; private disposed = false; constructor(options: CreateGameHostAdapterOptions) { - const route = requireGameRoute(options.gameEntry.route); + const route = options.gameEntry.route; + assertGameRoute(route); const state = options.store.getState(); if (state.room.kind !== 'inside') { throw new Error('GameHost requires player to be inside a room'); @@ -81,15 +90,21 @@ class SessionBoundGameHost { this.commands = options.commands; this.sendPort = options.send; this.route = route; + this.roomGeneration = { + roomcode: state.room.roomcode, + roomtype: state.room.roomtype, + selfSeat, + seatCount, + }; const seat: GameSeatMapper = Object.freeze({ toView: (serverSeat: number): number => { - this.requireActive(); + this.requireCurrentRoom(); const validated = requireSeat(serverSeat, seatCount, 'server seat'); return (validated - selfSeat + seatCount) % seatCount; }, toServer: (viewSeat: number): number => { - this.requireActive(); + this.requireCurrentRoom(); const validated = requireSeat(viewSeat, seatCount, 'view seat'); return (validated + selfSeat) % seatCount; }, @@ -116,18 +131,24 @@ class SessionBoundGameHost { this.commands = null; this.sendPort = null; this.route = null; + this.roomGeneration = null; const errors: unknown[] = []; - for (const record of subscriptions) { - const result = this.releaseSubscription(record); - if (result.threw) errors.push(result.error); + this.invalidationErrors = errors; + try { + for (const record of subscriptions) { + const result = this.releaseSubscription(record); + if (result.threw) errors.push(result.error); + } + } finally { + this.invalidationErrors = null; } if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new GameHostInvalidationError(errors); } private getSnapshot(): PlatformGameSnapshot { - return selectGameSnapshot(this.requireStore().getState()); + return selectGameSnapshot(this.requireCurrentRoom()); } private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void { @@ -136,10 +157,10 @@ class SessionBoundGameHost { throw new TypeError('GameHost subscribe listener must be a function'); } const store = this.requireStore(); - let current = selectGameSnapshot(store.getState()); + let current = selectGameSnapshot(this.requireCurrentRoom()); const record: SubscriptionRecord = { active: true, unsubscribeStore: null }; const bridge = (state: PlatformState): void => { - if (this.disposed || !record.active || state.room.kind !== 'inside') return; + if (this.disposed || !record.active || !this.isCurrentRoom(state)) return; const next = selectGameSnapshot(state); if (samePublicSnapshot(current, next)) return; current = next; @@ -157,7 +178,10 @@ class SessionBoundGameHost { return (): void => { try { - this.releaseSubscription(record); + const result = this.releaseSubscription(record); + if (result.threw && this.invalidationErrors !== null) { + this.invalidationErrors.push(result.error); + } } catch { // The public unsubscribe contract remains non-throwing, including after invalidation. } @@ -165,7 +189,7 @@ class SessionBoundGameHost { } private sendGameMessage(rpc: string, data: unknown): void { - this.requireActive(); + this.requireCurrentRoom(); if (typeof rpc !== 'string' || rpc.trim() === '') { throw new TypeError('GameHost rpc must be a non-empty string'); } @@ -177,7 +201,7 @@ class SessionBoundGameHost { } private execute(command: GameHostCommand): void { - this.requireActive(); + this.requireCurrentRoom(); const commands = this.commands; if (commands === null) throw new GameSessionDisposedError(); if (command !== null && typeof command === 'object') { @@ -200,6 +224,22 @@ class SessionBoundGameHost { return store; } + private requireCurrentRoom(): PlatformState { + const state = this.requireStore().getState(); + if (!this.isCurrentRoom(state)) throw new GameSessionDisposedError(); + return state; + } + + private isCurrentRoom(state: PlatformState): boolean { + const generation = this.roomGeneration; + return generation !== null + && state.room.kind === 'inside' + && state.room.roomcode === generation.roomcode + && state.room.roomtype === generation.roomtype + && state.room.selfSeat === generation.selfSeat + && state.room.seatPlayerIds.length === generation.seatCount; + } + private requireActive(): void { if (this.disposed) throw new GameSessionDisposedError(); } @@ -228,16 +268,6 @@ export function createGameHostAdapter(options: CreateGameHostAdapterOptions): Ga }); } -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)}`); 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 974f98f..8ac0dda 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 @@ -5,8 +5,9 @@ import type { GameServerMessage, PlatformToGameEvent, } from '../contracts/index.ts'; +import { Route } from '../../core/constants.ts'; -const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']); +const RESERVED_ROUTES = new Set(Object.values(Route)); const GAME_SESSION_OPEN_FAULT_BRAND = Symbol.for( 'youle.framework.sdk.GameSessionOpenFault', ); @@ -81,10 +82,7 @@ export function isGameSessionOpenFault(value: unknown): value is GameSessionOpen export function assertGameEntry(entry: GameEntry): void { assertNonEmptyText(entry.key, 'key'); assertGameId(entry.gameId); - assertNonEmptyText(entry.route, 'route'); - if (RESERVED_ROUTES.has(entry.route)) { - throw new Error(`GameEntry reserved route: ${entry.route}`); - } + assertGameRoute(entry.route); if (typeof entry.resolveSeatCount !== 'function') { throw new Error('GameEntry resolveSeatCount must be a function'); } @@ -103,6 +101,14 @@ export function assertGameEntry(entry: GameEntry): void { } } +/** The single GameEntry route admission check shared by runtime adapters. */ +export function assertGameRoute(route: unknown): asserts route is string { + assertNonEmptyText(route, 'route'); + if (RESERVED_ROUTES.has(route)) { + throw new Error(`GameEntry reserved route: ${route}`); + } +} + /** Owns one isolated game module for one room at a time. */ export class GameSessionHost { private module: GameModule | undefined; diff --git a/cocoscreator_projects/framework-tests/platform/commands.test.ts b/cocoscreator_projects/framework-tests/platform/commands.test.ts index d010656..49a96b6 100644 --- a/cocoscreator_projects/framework-tests/platform/commands.test.ts +++ b/cocoscreator_projects/framework-tests/platform/commands.test.ts @@ -29,10 +29,13 @@ 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 { +function makeStore(location: 'outside' | 'inside', stage = 0, infinite = 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; + if (location === 'inside') { + source.data.isbattle = stage; + source.data.infinite = infinite; + } store.applyLoginSuccess(parseLoginResponse(source.data)); return store; } @@ -127,6 +130,24 @@ test('joinRoom uses authenticated Store identity and requires every non-optional assert.equal(sent.length, 1); }); +test('exitRoom sends self_exit_room while an infinite room is in battle', () => { + const { commands, sent } = makeCommands(makeStore('inside', 1, 1)); + + commands.exitRoom(); + + assert.deepEqual(sent, [{ + app: 'youle', + route: 'room', + rpc: 'self_exit_room', + data: { + agentid: 'agent-from-config', + playerid: 430511, + gameid: 'game-from-config', + roomcode: '100001', + }, + }]); +}); + test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => { const unauthenticated = makeCommands(new PlatformStore()); assert.throws( 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 b2280fa..31f22a0 100644 --- a/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts +++ b/cocoscreator_projects/framework-tests/platform/game-host-adapter.test.ts @@ -8,6 +8,7 @@ import { GameSessionDisposedError, } from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts'; import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts'; +import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import type { PlatformState, @@ -19,8 +20,13 @@ import { } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; import type { GameEntry, + GameHost, GameHostCommand, } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; +import { + assertGameRoute, + GameSessionHost, +} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ mode: 'release', @@ -110,6 +116,22 @@ test('adapter rejects empty and reserved GameEntry routes before exposing a Host } }); +test('Task 1 route validator is the shared authority for game-route admission', () => { + assert.doesNotThrow(() => assertGameRoute('fixture-route')); + assert.throws(() => assertGameRoute(''), /route.*non-empty/i); + for (const route of ['platform', 'agent', 'room']) { + assert.throws(() => assertGameRoute(route), /reserved.*route/i); + } + + const validatorSource = readFileSync(new URL( + '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts', + import.meta.url, + ), 'utf8'); + assert.match(validatorSource, /import \{ Route \} from '\.\.\/\.\.\/core\/constants\.ts';/); + assert.match(validatorSource, /new Set\(Object\.values\(Route\)\)/); + assert.doesNotMatch(validatorSource, /new Set\(\[['"]platform['"]/); +}); + test('execute exposes only the GameHost command union and delegates semantic commands', () => { const { host, sent } = makeLease({ store: roomStore(4, 1, 0) }); @@ -179,6 +201,74 @@ test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roo assert.equal('state' in snapshot, false); }); +test('RuntimeSession room replacement keeps the old lease isolated until release and exposes only the fresh lease', () => { + const store = new PlatformStore(); + const sent: OutboundEnvelope[] = []; + const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } }; + const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send }); + const attachedHosts: GameHost[] = []; + const staleDisposeResults: unknown[] = []; + const gameEntry: GameEntry = { + ...entry(), + resolveSeatCount: (roomtype) => roomtype.length === 0 ? 4 : roomtype[0] as number, + createModule: () => { + let host: GameHost | null = null; + return { + attach(value) { + host = value; + attachedHosts.push(value); + }, + handlePlatformEvent() {}, + handleGameMessage() {}, + restore() {}, + dispose() { + if (host === null) return; + for (const call of [ + () => host!.getSnapshot(), + () => host!.sendGameMessage('stale-play', { room: 'old' }), + () => host!.execute({ type: 'room.prepare' }), + ]) { + try { + staleDisposeResults.push(call()); + } catch (error) { + staleDisposeResults.push(error); + } + } + }, + }; + }, + }; + const gameSession = new GameSessionHost(gameEntry); + const runtime = new RuntimeSession({ + store, + gameSession, + createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }), + }); + const login = fixture('player-login-room.json'); + login.data.roomtype = [4, { generation: 'A' }]; + login.data.isbattle = 0; + runtime.handleLogin(login.data); + const oldHost = attachedHosts[0]!; + const oldRoomcodes: string[] = []; + oldHost.subscribe((snapshot) => { oldRoomcodes.push(snapshot.room.roomcode); }); + + const replacement = fixture('self-join-room.json'); + replacement.data.roomcode = '200002'; + replacement.data.roomtype = [4, { generation: 'B' }]; + replacement.data.deskwar = 0; + delete replacement.data.deskinfo; + runtime.handleSelfJoin(replacement.data); + + assert.deepEqual(oldRoomcodes, ['100001']); + assert.equal(staleDisposeResults.length, 3); + for (const result of staleDisposeResults) { + assert.ok(result instanceof GameSessionDisposedError); + } + assert.deepEqual(sent, []); + assert.equal(attachedHosts.length, 2); + assert.equal(attachedHosts[1]!.getSnapshot().room.roomcode, '200002'); +}); + class ObservableStore extends PlatformStore { private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>(); @@ -307,6 +397,63 @@ test('invalidate clears every subscription and reference before reporting all cl assert.equal(callbacks, 2); }); +class ReentrantThrowingUnsubscribeStore extends PlatformStore { + readonly nestedFailure = { source: 'subscription-B' }; + readonly cleanupCalls: string[] = []; + secondPublicUnsubscribe: (() => void) | null = null; + private subscriptionIndex = 0; + + override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void { + const index = this.subscriptionIndex++; + const unsubscribeStore = super.subscribe(listener); + if (index === 0) { + return () => { + this.cleanupCalls.push('A'); + this.secondPublicUnsubscribe!(); + unsubscribeStore(); + throw undefined; + }; + } + return () => { + this.cleanupCalls.push('B'); + unsubscribeStore(); + throw this.nestedFailure; + }; + } +} + +test('invalidate collects a reentrant public-unsubscribe failure in occurrence order exactly once', () => { + const store = new ReentrantThrowingUnsubscribeStore(); + 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 }); + const firstPublicUnsubscribe = host.subscribe(() => {}); + const secondPublicUnsubscribe = host.subscribe(() => {}); + store.secondPublicUnsubscribe = secondPublicUnsubscribe; + + let didThrow = false; + let cleanupError: unknown; + try { + invalidate(); + } catch (error) { + didThrow = true; + cleanupError = error; + } + + assert.equal(didThrow, true); + assert.deepEqual(store.cleanupCalls, ['A', 'B']); + const errors = (cleanupError as { errors?: readonly unknown[] })?.errors; + assert.equal(errors?.[0], store.nestedFailure); + assert.equal(errors?.[1], undefined); + assert.equal(errors?.length, 2); + assert.doesNotThrow(() => firstPublicUnsubscribe()); + assert.doesNotThrow(() => secondPublicUnsubscribe()); + assert.deepEqual(store.cleanupCalls, ['A', 'B']); + assert.doesNotThrow(() => invalidate()); +}); + test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => { const { host, invalidate, store } = makeLease(); const calls: string[] = [];