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 new file mode 100644 index 0000000..7063323 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/runtime/game-session-host.ts @@ -0,0 +1,165 @@ +import type { + GameEntry, + GameHost, + GameModule, + GameServerMessage, + PlatformToGameEvent, +} from '../contracts/index.ts'; + +const RESERVED_ROUTES = new Set(['platform', 'agent', 'room']); + +export type GameSessionState = + | 'idle' + | 'attaching' + | 'active' + | 'restoring' + | 'disposing' + | 'disposed'; + +/** Validates the compile-time game identity before it reaches the runtime. */ +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}`); + } + 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'); + } + assertGameModule(entry.createModule()); +} + +/** Owns one isolated game module for one room at a time. */ +export class GameSessionHost { + private module: GameModule | undefined; + private currentState: GameSessionState = 'idle'; + + constructor(readonly entry: GameEntry) { + assertGameEntry(entry); + } + + get state(): GameSessionState { + return this.currentState; + } + + open(host: GameHost): void { + if (this.currentState === 'disposed') { + throw new Error('Cannot open a disposed game session'); + } + if (this.currentState !== 'idle') { + throw new Error(`Cannot open game session while ${this.currentState}`); + } + + this.currentState = 'attaching'; + try { + const module = this.entry.createModule(); + assertGameModule(module); + this.module = module; + module.attach(host); + this.currentState = 'active'; + } catch (error) { + this.module = undefined; + this.currentState = 'idle'; + throw error; + } + } + + publish(event: PlatformToGameEvent): void { + this.requireActive('publish').handlePlatformEvent(event); + } + + dispatchGameMessage(route: string, message: GameServerMessage): void { + if (route !== this.entry.route) { + throw new Error(`Game message route mismatch: expected ${this.entry.route}, received ${route}`); + } + this.requireActive('dispatch a game message').handleGameMessage(message); + } + + restore(deskinfo: unknown): void { + const module = this.requireActive('restore'); + this.currentState = 'restoring'; + try { + module.restore(deskinfo); + } finally { + this.currentState = 'active'; + } + } + + close(): void { + if (this.currentState === 'disposed') { + throw new Error('Cannot close a disposed game session'); + } + if (this.currentState === 'idle') return; + if (this.currentState !== 'active') { + throw new Error(`Cannot close game session while ${this.currentState}`); + } + + const module = this.module; + if (!module) { + throw new Error('Active game session has no module'); + } + this.currentState = 'disposing'; + this.module = undefined; + try { + module.dispose(); + } finally { + this.currentState = 'idle'; + } + } + + dispose(): void { + if (this.currentState === 'disposed') return; + try { + if (this.currentState !== 'idle') this.close(); + } finally { + this.module = undefined; + this.currentState = 'disposed'; + } + } + + private requireActive(operation: string): GameModule { + if (this.currentState === 'disposed') { + throw new Error(`Cannot ${operation} on a disposed game session`); + } + if (this.currentState !== 'active' || !this.module) { + throw new Error(`Cannot ${operation} without an active game session`); + } + return this.module; + } +} + +function assertNonEmptyText(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`GameEntry ${field} must be a non-empty string`); + } +} + +function assertGameId(value: unknown): void { + if (typeof value === 'string') { + if (value.trim() === '') throw new Error('GameEntry gameId must be a non-empty string or number'); + return; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error('GameEntry gameId must be a non-empty string or number'); + } +} + +function assertGameModule(module: unknown): asserts module is GameModule { + if (!module || typeof module !== 'object') { + throw new Error('GameEntry module factory must return a module'); + } + const candidate = module as Record; + for (const method of ['attach', 'handlePlatformEvent', 'handleGameMessage', 'restore', 'dispose']) { + if (typeof candidate[method] !== 'function') { + throw new Error(`GameEntry module must implement ${method}`); + } + } +} 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 new file mode 100644 index 0000000..e95018d --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts @@ -0,0 +1,54 @@ +import { strict as assert } from 'node:assert'; +import type { + GameEntry, + GameHost, +} from '../contracts/index.ts'; +import { assertGameEntry, GameSessionHost } from '../runtime/game-session-host.ts'; + +/** A supplied fake host and its explicit invalidation assertion. */ +export interface GameContractHost { + readonly host: GameHost; + invalidate(): void; +} + +export type GameContractHostFactory = () => GameContractHost; + +/** Runs the shared lifecycle checks required of the app's sole GameEntry. */ +export function assertGameContract(entry: GameEntry, makeHost: GameContractHostFactory): void { + 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`); + } + + const firstFactoryModule = entry.createModule(); + const secondFactoryModule = entry.createModule(); + assert.notEqual(firstFactoryModule, secondFactoryModule, 'createModule must create isolated modules'); + + const sessions = new GameSessionHost(entry); + runCycle(sessions, makeHost(), entry, 1); + runCycle(sessions, makeHost(), entry, 2); + assert.equal(sessions.state, 'idle'); +} + +function runCycle( + sessions: GameSessionHost, + contractHost: GameContractHost, + entry: GameEntry, + cycle: number, +): void { + const roomtype = [4] as const; + const deskinfo = { cycle }; + sessions.open(contractHost.host); + sessions.publish({ type: 'room.entered', roomtype }); + sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } }); + assert.throws( + () => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }), + /route/, + ); + sessions.restore(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 new file mode 100644 index 0000000..f4d11ce --- /dev/null +++ b/cocoscreator_projects/framework-tests/sdk/game-contract-harness.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { + GameEntry, + GameHost, + GameModule, + PlatformGameSnapshot, +} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; +import { + assertGameContract, + type GameContractHost, +} from '../../YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts'; + +test('assertGameContract runs the reusable isolated session conformance suite', () => { + let invalidations = 0; + let nextModuleId = 0; + const entry: GameEntry = { + key: 'fixture', + gameId: 41, + route: 'fixture-route', + resolveSeatCount: (roomtype) => { + const seats = roomtype[0]; + if (seats === 2 || seats === 4 || seats === 10) return seats; + return 4; + }, + createModule: () => makeModule(++nextModuleId), + }; + + assert.doesNotThrow(() => assertGameContract(entry, makeConformanceHost(() => { invalidations++; }))); + assert.equal(invalidations, 2); + assert.ok(nextModuleId >= 2); +}); + +function makeModule(id: number): GameModule { + let host: GameHost | undefined; + let restored: unknown; + let eventCount = 0; + let messageCycle: number | undefined; + return { + attach(value) { host = value; }, + handlePlatformEvent(event) { + eventCount++; + if (event.type === 'room.entered') { + assert.equal(event.roomtype[0], 4); + } + }, + handleGameMessage(message) { + assert.equal(message.rpc, 'conformance.message'); + const data = message.data as { cycle: number }; + assert.equal(typeof data.cycle, 'number'); + messageCycle = data.cycle; + }, + restore(deskinfo) { restored = deskinfo; }, + dispose() { + assert.ok(host); + assert.equal(eventCount, 1); + assert.deepEqual(restored, deskinfoFor(messageCycle!)); + }, + }; +} + +function deskinfoFor(cycle: number): { readonly cycle: number } { + return { cycle }; +} + +function makeConformanceHost(onInvalidate: () => void): () => GameContractHost { + return () => { + const snapshot: PlatformGameSnapshot = { + connection: { phase: 'connected' }, + self: { playerId: 1, seat: 0 }, + room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 }, + seats: [], + }; + return { + host: { + seat: { toView: (seat) => seat, toServer: (seat) => seat }, + getSnapshot: () => snapshot, + subscribe: () => () => {}, + sendGameMessage: () => {}, + execute: () => {}, + }, + invalidate: onInvalidate, + }; + }; +} diff --git a/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts new file mode 100644 index 0000000..d9d60ba --- /dev/null +++ b/cocoscreator_projects/framework-tests/sdk/game-session-host.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { + GameEntry, + GameHost, + GameModule, + GameServerMessage, + PlatformGameSnapshot, + PlatformToGameEvent, +} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; +import { + assertGameEntry, + GameSessionHost, +} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; + +function makeHost(): GameHost { + const snapshot: PlatformGameSnapshot = { + connection: { phase: 'connected' }, + self: { playerId: 1, seat: 0 }, + room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 }, + seats: [], + }; + return { + seat: { toView: (seat) => seat, toServer: (seat) => seat }, + getSnapshot: () => snapshot, + subscribe: () => () => {}, + sendGameMessage: () => {}, + execute: () => {}, + }; +} + +function makeEntry(createModule: () => GameModule): GameEntry { + return { + key: 'fixture', + gameId: 41, + route: 'fixture-route', + resolveSeatCount: () => 4, + createModule, + }; +} + +function makeModule(log: string[], onRestore?: (deskinfo: unknown) => void): GameModule { + return { + attach: () => 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'), + }; +} + +test('assertGameEntry rejects invalid identity, routes, seat counts, and module factories', () => { + const entry = makeEntry(() => makeModule([])); + + assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/); + assert.throws(() => assertGameEntry({ ...entry, gameId: '' }), /gameId/); + 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, createModule: () => undefined as never }), /module/); +}); + +test('GameSessionHost delivers room events and deskinfo to one active module in order', () => { + const log: string[] = []; + const deskinfo = { turn: 7 }; + let restoredValue: unknown; + const sessions = new GameSessionHost(makeEntry(() => makeModule(log, (value) => { restoredValue = value; }))); + + sessions.open(makeHost()); + sessions.publish({ type: 'room.entered', roomtype: ['raw', { players: 4 }] }); + sessions.restore(deskinfo); + sessions.close(); + + assert.deepEqual(log, ['attach', 'event:room.entered', 'restore', 'dispose']); + assert.equal(restoredValue, deskinfo); + assert.equal(sessions.state, 'idle'); +}); + +test('GameSessionHost preserves raw platform events and game messages by identity', () => { + let receivedEvent: PlatformToGameEvent | undefined; + let receivedMessage: GameServerMessage | undefined; + const sessions = new GameSessionHost(makeEntry(() => ({ + attach() {}, + handlePlatformEvent(event) { receivedEvent = event; }, + handleGameMessage(message) { receivedMessage = message; }, + restore() {}, + dispose() {}, + }))); + const rawRoomtype = ['raw', { players: 4 }]; + const rawData = { cards: [3, 7] }; + const message: GameServerMessage = { rpc: 'draw', data: rawData }; + const event: PlatformToGameEvent = { type: 'room.entered', roomtype: rawRoomtype }; + + sessions.open(makeHost()); + sessions.publish(event); + sessions.dispatchGameMessage('fixture-route', message); + + assert.equal(receivedEvent, event); + assert.equal(receivedEvent?.roomtype, rawRoomtype); + assert.equal(receivedMessage, message); + assert.equal(receivedMessage?.data, rawData); +}); + +test('GameSessionHost rejects illegal state transitions and mismatched game routes', () => { + const log: string[] = []; + const sessions = new GameSessionHost(makeEntry(() => makeModule(log))); + const message: GameServerMessage = { rpc: 'draw', data: { card: 3 } }; + + assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /active/); + assert.throws(() => sessions.restore({}), /active/); + sessions.open(makeHost()); + assert.throws(() => sessions.open(makeHost()), /open/); + assert.throws(() => sessions.dispatchGameMessage('other-route', message), /route/); + sessions.dispatchGameMessage('fixture-route', message); + assert.deepEqual(log, ['attach', 'message:draw']); +}); + +test('GameSessionHost disposes a module exactly once and becomes terminal after dispose', () => { + const log: string[] = []; + const sessions = new GameSessionHost(makeEntry(() => makeModule(log))); + + sessions.open(makeHost()); + sessions.dispose(); + sessions.dispose(); + + assert.deepEqual(log, ['attach', 'dispose']); + assert.equal(sessions.state, 'disposed'); + assert.throws(() => sessions.open(makeHost()), /disposed/); + assert.throws(() => sessions.publish({ type: 'room.dissolved' }), /disposed/); +}); + +test('GameSessionHost creates a fresh module after close', () => { + const log: string[] = []; + const modules: GameModule[] = []; + const sessions = new GameSessionHost(makeEntry(() => { + const module = makeModule(log); + modules.push(module); + return module; + })); + + sessions.open(makeHost()); + sessions.close(); + sessions.open(makeHost()); + sessions.close(); + + assert.notEqual(modules[modules.length - 1], modules[modules.length - 2]); + assert.deepEqual(log, ['attach', 'dispose', 'attach', 'dispose']); +}); + +test('GameSessionHost returns to active after a restore handler throws', () => { + const log: string[] = []; + const sessions = new GameSessionHost(makeEntry(() => ({ + ...makeModule(log), + restore: () => { throw new Error('restore failed'); }, + }))); + + sessions.open(makeHost()); + assert.throws(() => sessions.restore({}), /restore failed/); + assert.equal(sessions.state, 'active'); +});