fix(platform): preserve recoverable outcomes and terminal sessions
This commit is contained in:
@@ -32,6 +32,15 @@ SDK runtime 也不导入框架实现层,平台适配由 `platform/game-host-ad
|
|||||||
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
|
连接事件也提交到同一个 Store;重连只更换 app 连接状态,保留 room/players 引用。
|
||||||
切服先发送原始 `connect_*server` 信封,进房成功再原子标记已登录;普通重连才重新发送已保存的登录信封。
|
切服先发送原始 `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,沿用无条件 `?` 缓存参数拼接。
|
远程地址唯一取自 `data.urlserver`。配置请求使用空 body 的 POST,沿用无条件 `?` 缓存参数拼接。
|
||||||
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
|
原生 settings 方法名称、调用次序和 WVJB 初始化/handler 名称由 `config/sources`、
|
||||||
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
|
`adapters/native` 实现,`framework-tests/config` 与 `framework-tests/native` 验证。
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ export interface GameHostLeasePort {
|
|||||||
|
|
||||||
export type GameHostLeaseFactory = () => 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<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeSessionOptions {
|
export interface RuntimeSessionOptions {
|
||||||
readonly store: PlatformStore;
|
readonly store: PlatformStore;
|
||||||
readonly gameSession: GameSessionHost;
|
readonly gameSession: GameSessionHost;
|
||||||
readonly createGameHostLease: GameHostLeaseFactory;
|
readonly createGameHostLease: GameHostLeaseFactory;
|
||||||
|
readonly onServerDenial: (outcome: ServerDenial) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
|
export type RuntimeSessionState = 'operational' | 'faulted' | 'kicked';
|
||||||
@@ -66,6 +74,10 @@ export class RuntimeSession {
|
|||||||
this.requireOperational();
|
this.requireOperational();
|
||||||
this.loginIsPending = false;
|
this.loginIsPending = false;
|
||||||
const parsed = parseLoginResponse(data);
|
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);
|
const committed = this.options.store.applyLoginSuccess(parsed);
|
||||||
if (parsed.room === null) {
|
if (parsed.room === null) {
|
||||||
this.releaseOrFault('login lobby replacement');
|
this.releaseOrFault('login lobby replacement');
|
||||||
@@ -77,6 +89,10 @@ export class RuntimeSession {
|
|||||||
handleSelfJoin(data: unknown): void {
|
handleSelfJoin(data: unknown): void {
|
||||||
this.requireOperational();
|
this.requireOperational();
|
||||||
const parsed = parseSelfJoinRoomResponse(data);
|
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);
|
const committed = this.options.store.replaceRoom(parsed);
|
||||||
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
|
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
|
||||||
}
|
}
|
||||||
@@ -150,6 +166,7 @@ export class RuntimeSession {
|
|||||||
|
|
||||||
const priorCleanupErrors = this.releaseGame();
|
const priorCleanupErrors = this.releaseGame();
|
||||||
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
|
if (priorCleanupErrors.length > 0) this.throwFault(operation, priorCleanupErrors);
|
||||||
|
if (this.gameWasDisposed()) return;
|
||||||
|
|
||||||
let lease: GameHostLeasePort;
|
let lease: GameHostLeasePort;
|
||||||
try {
|
try {
|
||||||
@@ -164,10 +181,12 @@ export class RuntimeSession {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.options.gameSession.open(lease.host);
|
this.options.gameSession.open(lease.host);
|
||||||
|
if (this.gameWasDisposed()) return;
|
||||||
this.options.gameSession.publish({
|
this.options.gameSession.publish({
|
||||||
type: 'room.entered',
|
type: 'room.entered',
|
||||||
roomtype: state.room.roomtype,
|
roomtype: state.room.roomtype,
|
||||||
});
|
});
|
||||||
|
if (this.gameWasDisposed()) return;
|
||||||
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const openErrors = extractGameSessionOpenFaultErrors(error);
|
const openErrors = extractGameSessionOpenFaultErrors(error);
|
||||||
@@ -184,12 +203,16 @@ export class RuntimeSession {
|
|||||||
if (errors.length > 0) this.throwFault(operation, errors);
|
if (errors.length > 0) this.throwFault(operation, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private gameWasDisposed(): boolean {
|
||||||
|
return this.options.gameSession.state === 'disposed';
|
||||||
|
}
|
||||||
|
|
||||||
private releaseGame(): unknown[] {
|
private releaseGame(): unknown[] {
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
const lease = this.activeLease;
|
const lease = this.activeLease;
|
||||||
this.activeLease = null;
|
this.activeLease = null;
|
||||||
|
|
||||||
if (this.options.gameSession.state !== 'idle') {
|
if (this.options.gameSession.state !== 'idle' && !this.gameWasDisposed()) {
|
||||||
try {
|
try {
|
||||||
this.options.gameSession.close();
|
this.options.gameSession.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class SceneOrderedGameSessionHost extends GameSessionHost {
|
|||||||
|
|
||||||
restore(deskinfo: unknown): void {
|
restore(deskinfo: unknown): void {
|
||||||
this.showRoomAfterOpen();
|
this.showRoomAfterOpen();
|
||||||
|
if (this.state === 'disposed') return;
|
||||||
super.restore(deskinfo);
|
super.restore(deskinfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ export class PlatformRuntime {
|
|||||||
store: this.store,
|
store: this.store,
|
||||||
gameSession,
|
gameSession,
|
||||||
createGameHostLease: () => this.createGameHostLease(commands, send),
|
createGameHostLease: () => this.createGameHostLease(commands, send),
|
||||||
|
onServerDenial: outcome => { this.options.scene.showServerDenial(outcome); },
|
||||||
});
|
});
|
||||||
const handlers = new PlatformHandlers({
|
const handlers = new PlatformHandlers({
|
||||||
store: this.store,
|
store: this.store,
|
||||||
@@ -341,6 +343,7 @@ export class PlatformRuntime {
|
|||||||
const stateBeforeDispatch = this.store.getState();
|
const stateBeforeDispatch = this.store.getState();
|
||||||
if (
|
if (
|
||||||
this.requireRouter().dispatch(event.message) === 'handled-platform'
|
this.requireRouter().dispatch(event.message) === 'handled-platform'
|
||||||
|
&& this.lifecycle === 'running'
|
||||||
&& this.store.getState() !== stateBeforeDispatch
|
&& this.store.getState() !== stateBeforeDispatch
|
||||||
) {
|
) {
|
||||||
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
||||||
@@ -357,10 +360,17 @@ export class PlatformRuntime {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
this.showFatalOnce(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isStopped(): boolean {
|
||||||
|
return this.lifecycle === 'stopped';
|
||||||
|
}
|
||||||
|
|
||||||
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
||||||
const wire = this.requireOpenWire();
|
const wire = this.requireOpenWire();
|
||||||
const session = this.requireSession();
|
const session = this.requireSession();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ServerDenial } from './runtime-session.ts';
|
||||||
|
|
||||||
/** Semantic scene intents; the headless runtime never imports Cocos. */
|
/** Semantic scene intents; the headless runtime never imports Cocos. */
|
||||||
export interface ScenePort {
|
export interface ScenePort {
|
||||||
showLoading(): void;
|
showLoading(): void;
|
||||||
@@ -6,5 +8,7 @@ export interface ScenePort {
|
|||||||
showRoom(): void;
|
showRoom(): void;
|
||||||
showReconnect(): void;
|
showReconnect(): void;
|
||||||
showKicked(data: unknown): void;
|
showKicked(data: unknown): void;
|
||||||
|
/** Recoverable server result; the consumer interprets the original failure payload. */
|
||||||
|
showServerDenial(outcome: ServerDenial): void;
|
||||||
showFatal(error: Error): void;
|
showFatal(error: Error): void;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -144,7 +144,7 @@ export function parseLoginResponse(value: unknown): ParsedLoginResponse {
|
|||||||
: optionalField(raw, 'playerid', '$.data', requireInteger);
|
: optionalField(raw, 'playerid', '$.data', requireInteger);
|
||||||
const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players']
|
const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players']
|
||||||
.some((field) => hasOwn(raw, field));
|
.some((field) => hasOwn(raw, field));
|
||||||
const room = hasRoomFields ? parseRoomEntryFields(raw) : null;
|
const room = state === 0 && hasRoomFields ? parseRoomEntryFields(raw) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface GameEntry {
|
|||||||
readonly key: string;
|
readonly key: string;
|
||||||
readonly gameId: string | number;
|
readonly gameId: string | number;
|
||||||
readonly route: string;
|
readonly route: string;
|
||||||
|
/** Interprets this game's actual server roomtype; no synthetic/default input is required. */
|
||||||
resolveSeatCount(roomtype: readonly unknown[]): number;
|
resolveSeatCount(roomtype: readonly unknown[]): number;
|
||||||
createModule(): GameModule;
|
createModule(): GameModule;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-22
@@ -85,10 +85,6 @@ export function assertGameEntry(entry: GameEntry): void {
|
|||||||
if (typeof entry.resolveSeatCount !== 'function') {
|
if (typeof entry.resolveSeatCount !== 'function') {
|
||||||
throw new Error('GameEntry resolveSeatCount must be a 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') {
|
if (typeof entry.createModule !== 'function') {
|
||||||
throw new Error('GameEntry createModule must be a function');
|
throw new Error('GameEntry createModule must be a function');
|
||||||
}
|
}
|
||||||
@@ -130,24 +126,28 @@ export class GameSessionHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentState = 'attaching';
|
this.currentState = 'attaching';
|
||||||
let module: GameModule | undefined;
|
|
||||||
try {
|
try {
|
||||||
module = this.entry.createModule();
|
const module = this.entry.createModule();
|
||||||
assertGameModule(module);
|
|
||||||
this.module = module;
|
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);
|
module.attach(host);
|
||||||
this.currentState = 'active';
|
if (this.currentState === 'attaching') this.currentState = 'active';
|
||||||
} catch (primaryError) {
|
} catch (primaryError) {
|
||||||
this.module = undefined;
|
if (!this.isDisposed()) this.currentState = 'disposing';
|
||||||
let cleanupFailed = false;
|
let cleanupFailed = false;
|
||||||
let cleanupError: unknown;
|
let cleanupError: unknown;
|
||||||
try {
|
try {
|
||||||
disposeModuleIfPresent(module);
|
this.releaseModule();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cleanupFailed = true;
|
cleanupFailed = true;
|
||||||
cleanupError = error;
|
cleanupError = error;
|
||||||
} finally {
|
} finally {
|
||||||
this.currentState = 'idle';
|
if (!this.isDisposed()) this.currentState = 'idle';
|
||||||
}
|
}
|
||||||
if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError);
|
if (cleanupFailed) throw new GameSessionOpenFault(primaryError, cleanupError);
|
||||||
throw primaryError;
|
throw primaryError;
|
||||||
@@ -171,7 +171,7 @@ export class GameSessionHost {
|
|||||||
try {
|
try {
|
||||||
module.restore(deskinfo);
|
module.restore(deskinfo);
|
||||||
} finally {
|
} 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}`);
|
throw new Error(`Cannot close game session while ${this.currentState}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const module = this.module;
|
if (!this.module) {
|
||||||
if (!module) {
|
|
||||||
throw new Error('Active game session has no module');
|
throw new Error('Active game session has no module');
|
||||||
}
|
}
|
||||||
this.currentState = 'disposing';
|
this.currentState = 'disposing';
|
||||||
this.module = undefined;
|
|
||||||
try {
|
try {
|
||||||
module.dispose();
|
this.releaseModule();
|
||||||
} finally {
|
} finally {
|
||||||
this.currentState = 'idle';
|
if (!this.isDisposed()) this.currentState = 'idle';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.currentState === 'disposed') return;
|
if (this.isDisposed()) return;
|
||||||
try {
|
// Terminal state is visible before user cleanup, including reentrant dispose.
|
||||||
if (this.currentState !== 'idle') this.close();
|
|
||||||
} finally {
|
|
||||||
this.module = undefined;
|
|
||||||
this.currentState = 'disposed';
|
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 {
|
private requireActive(operation: string): GameModule {
|
||||||
|
|||||||
+27
-8
@@ -14,14 +14,26 @@ export interface GameContractHost {
|
|||||||
|
|
||||||
export type GameContractHostFactory = () => 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. */
|
/** 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);
|
assertGameEntry(entry);
|
||||||
for (const route of ['platform', 'agent', 'room']) {
|
for (const route of ['platform', 'agent', 'room']) {
|
||||||
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
||||||
}
|
}
|
||||||
for (const seatCount of [2, 4, 10]) {
|
for (const sample of samples) {
|
||||||
assert.equal(entry.resolveSeatCount([seatCount]), seatCount, `roomtype must resolve ${seatCount} seats`);
|
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();
|
const firstFactoryModule = entry.createModule();
|
||||||
@@ -35,21 +47,28 @@ export function assertGameContract(entry: GameEntry, makeHost: GameContractHostF
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new GameSessionHost(entry);
|
const sessions = new GameSessionHost(entry);
|
||||||
runCycle(sessions, makeHost(), entry, 1);
|
for (const sample of samples) {
|
||||||
runCycle(sessions, makeHost(), entry, 2);
|
runCycle(sessions, sample, entry, 1);
|
||||||
|
runCycle(sessions, sample, entry, 2);
|
||||||
|
}
|
||||||
assert.equal(sessions.state, 'idle');
|
assert.equal(sessions.state, 'idle');
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCycle(
|
function runCycle(
|
||||||
sessions: GameSessionHost,
|
sessions: GameSessionHost,
|
||||||
contractHost: GameContractHost,
|
sample: GameContractSample,
|
||||||
entry: GameEntry,
|
entry: GameEntry,
|
||||||
cycle: number,
|
cycle: number,
|
||||||
): void {
|
): 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 };
|
const deskinfo = { cycle };
|
||||||
sessions.open(contractHost.host);
|
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 } });
|
sessions.dispatchGameMessage(entry.route, { rpc: 'conformance.message', data: { cycle } });
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }),
|
() => sessions.dispatchGameMessage(`${entry.route}.other`, { rpc: 'conformance.message', data: { cycle } }),
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function setup() {
|
|||||||
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
|
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
|
||||||
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
|
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
|
||||||
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
|
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
|
||||||
|
showServerDenial() { assert.fail('Unexpected server denial'); },
|
||||||
showFatal(error) { fatals.push(error); },
|
showFatal(error) { fatals.push(error); },
|
||||||
},
|
},
|
||||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
|
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
|
||||||
|
|||||||
@@ -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 configRequests: Array<{ url: string; init: RequestInit | undefined }> = [];
|
||||||
const scenes: string[] = [];
|
const scenes: string[] = [];
|
||||||
const fatals: Error[] = [];
|
const fatals: Error[] = [];
|
||||||
|
const denials: Array<{ readonly rpc: string; readonly state: number; readonly data: unknown }> = [];
|
||||||
const observations: string[] = [];
|
const observations: string[] = [];
|
||||||
const attached: GameHost[] = [];
|
const attached: GameHost[] = [];
|
||||||
const restored: unknown[] = [];
|
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'); },
|
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
|
||||||
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
|
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
|
||||||
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
|
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
|
||||||
|
showServerDenial(outcome) { denials.push(outcome); },
|
||||||
showFatal(error) { fatals.push(error); },
|
showFatal(error) { fatals.push(error); },
|
||||||
},
|
},
|
||||||
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
|
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'));
|
await push(agent, fixture('player-login-success'));
|
||||||
assert.equal(scenes[scenes.length - 1], 'lobby');
|
assert.equal(scenes[scenes.length - 1], 'lobby');
|
||||||
assert.equal(runtime.state.room.kind, 'outside');
|
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' });
|
runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' });
|
||||||
expectedFrames.push(JOIN);
|
expectedFrames.push(JOIN);
|
||||||
verifyFrames();
|
verifyFrames();
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
|
|||||||
const staleDisposeResults: unknown[] = [];
|
const staleDisposeResults: unknown[] = [];
|
||||||
const gameEntry: GameEntry = {
|
const gameEntry: GameEntry = {
|
||||||
...entry(),
|
...entry(),
|
||||||
resolveSeatCount: (roomtype) => roomtype.length === 0 ? 4 : roomtype[0] as number,
|
resolveSeatCount: (roomtype) => roomtype[0] as number,
|
||||||
createModule: () => {
|
createModule: () => {
|
||||||
let host: GameHost | null = null;
|
let host: GameHost | null = null;
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +234,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
|
|||||||
const runtime = new RuntimeSession({
|
const runtime = new RuntimeSession({
|
||||||
store,
|
store,
|
||||||
gameSession,
|
gameSession,
|
||||||
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
||||||
createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }),
|
createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }),
|
||||||
});
|
});
|
||||||
const login = fixture('player-login-room.json');
|
const login = fixture('player-login-room.json');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function makeSession() {
|
|||||||
const store = new PlatformStore();
|
const store = new PlatformStore();
|
||||||
const log: string[] = [];
|
const log: string[] = [];
|
||||||
const restored: unknown[] = [];
|
const restored: unknown[] = [];
|
||||||
|
const denials: unknown[] = [];
|
||||||
const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = [];
|
const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = [];
|
||||||
let host: GameHost;
|
let host: GameHost;
|
||||||
const entry: GameEntry = {
|
const entry: GameEntry = {
|
||||||
@@ -55,6 +56,7 @@ function makeSession() {
|
|||||||
const session = new RuntimeSession({
|
const session = new RuntimeSession({
|
||||||
store,
|
store,
|
||||||
gameSession,
|
gameSession,
|
||||||
|
onServerDenial: outcome => { denials.push(outcome); },
|
||||||
createGameHostLease: () => ({
|
createGameHostLease: () => ({
|
||||||
host: {
|
host: {
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
@@ -66,9 +68,30 @@ function makeSession() {
|
|||||||
invalidate() {},
|
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', () => {
|
test('RuntimeSession commits a room login before opening, publishing, and restoring deskinfo', () => {
|
||||||
const setup = makeSession();
|
const setup = makeSession();
|
||||||
const data = fixture('player-login-room.json');
|
const data = fixture('player-login-room.json');
|
||||||
@@ -220,6 +243,7 @@ function makeLeaseSession() {
|
|||||||
const sessionOptions = {
|
const sessionOptions = {
|
||||||
store,
|
store,
|
||||||
gameSession,
|
gameSession,
|
||||||
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
||||||
createGameHostLease() {
|
createGameHostLease() {
|
||||||
if (failures.has('lease-factory')) throw errors['lease-factory'];
|
if (failures.has('lease-factory')) throw errors['lease-factory'];
|
||||||
const record: LeaseRecord = { host: host(), invalidations: 0 };
|
const record: LeaseRecord = { host: host(), invalidations: 0 };
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ class RecordingScene implements ScenePort {
|
|||||||
showRoom(): void { this.record('room'); }
|
showRoom(): void { this.record('room'); }
|
||||||
showReconnect(): void { this.record('reconnect'); }
|
showReconnect(): void { this.record('reconnect'); }
|
||||||
showKicked(data: unknown): void { this.record('kicked', data); }
|
showKicked(data: unknown): void { this.record('kicked', data); }
|
||||||
|
showServerDenial(outcome: unknown): void { this.record('denied', outcome); }
|
||||||
showFatal(error: Error): void { this.record('fatal', error); }
|
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 };
|
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<readonly unknown[]> = [];
|
||||||
|
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<string, unknown>).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 {
|
function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry {
|
||||||
let recording = false;
|
let recording = false;
|
||||||
const entry = makeGameEntry({
|
const entry = makeGameEntry({
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function makeHandlers() {
|
|||||||
const session = new RuntimeSession({
|
const session = new RuntimeSession({
|
||||||
store,
|
store,
|
||||||
gameSession,
|
gameSession,
|
||||||
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
||||||
createGameHostLease: () => ({
|
createGameHostLease: () => ({
|
||||||
host: {
|
host: {
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
@@ -156,6 +157,7 @@ function makeReentrantRouterFixture(log: string[]) {
|
|||||||
const session = new RuntimeSession({
|
const session = new RuntimeSession({
|
||||||
store,
|
store,
|
||||||
gameSession,
|
gameSession,
|
||||||
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
||||||
createGameHostLease: () => ({
|
createGameHostLease: () => ({
|
||||||
host: {
|
host: {
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
type GameContractHost,
|
type GameContractHost,
|
||||||
} from '../../YouleNexus/assets/framework/sdk/testing/game-contract-harness.ts';
|
} 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 invalidations = 0;
|
||||||
let nextModuleId = 0;
|
let nextModuleId = 0;
|
||||||
const restoredDeskinfo: unknown[] = [];
|
const restoredDeskinfo: unknown[] = [];
|
||||||
@@ -25,17 +29,17 @@ test('assertGameContract runs the reusable isolated session conformance suite',
|
|||||||
key: 'fixture',
|
key: 'fixture',
|
||||||
gameId: 41,
|
gameId: 41,
|
||||||
route: 'fixture-route',
|
route: 'fixture-route',
|
||||||
resolveSeatCount: (roomtype) => {
|
resolveSeatCount: () => 4,
|
||||||
const seats = roomtype[0];
|
|
||||||
if (seats === 2 || seats === 4 || seats === 10) return seats;
|
|
||||||
return 4;
|
|
||||||
},
|
|
||||||
createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }),
|
createModule: () => makeModule(++nextModuleId, (deskinfo) => { restoredDeskinfo.push(deskinfo); }),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.doesNotThrow(() => assertGameContract(
|
assert.doesNotThrow(() => assertGameContract(
|
||||||
entry,
|
entry,
|
||||||
makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo),
|
[{
|
||||||
|
roomtype: GAME_ROOMTYPE,
|
||||||
|
expectedSeatCount: 4,
|
||||||
|
makeHost: makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo),
|
||||||
|
}],
|
||||||
));
|
));
|
||||||
assert.equal(invalidations, 2);
|
assert.equal(invalidations, 2);
|
||||||
assert.equal(restoredDeskinfo.length, 0);
|
assert.equal(restoredDeskinfo.length, 0);
|
||||||
@@ -65,7 +69,8 @@ function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameMod
|
|||||||
handlePlatformEvent(event) {
|
handlePlatformEvent(event) {
|
||||||
eventCount++;
|
eventCount++;
|
||||||
if (event.type === 'room.entered') {
|
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) {
|
handleGameMessage(message) {
|
||||||
@@ -105,7 +110,6 @@ function makeConformanceHost(
|
|||||||
new URL('../fixtures/contracts/player-login-room.json', import.meta.url),
|
new URL('../fixtures/contracts/player-login-room.json', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)) as { data: Record<string, unknown> };
|
)) as { data: Record<string, unknown> };
|
||||||
login.data.roomtype = [4];
|
|
||||||
login.data.isbattle = 0;
|
login.data.isbattle = 0;
|
||||||
const store = new PlatformStore();
|
const store = new PlatformStore();
|
||||||
store.applyLoginSuccess(parseLoginResponse(login.data));
|
store.applyLoginSuccess(parseLoginResponse(login.data));
|
||||||
|
|||||||
@@ -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([]));
|
const entry = makeEntry(() => makeModule([]));
|
||||||
|
|
||||||
assert.throws(() => assertGameEntry({ ...entry, key: '' }), /key/);
|
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']) {
|
for (const route of ['platform', 'agent', 'room']) {
|
||||||
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
|
||||||
}
|
}
|
||||||
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 0 }), /seat count/);
|
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: undefined as never }), /resolveSeatCount/);
|
||||||
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: () => 2.5 }), /seat count/);
|
|
||||||
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
|
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<readonly unknown[]> = [];
|
||||||
|
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', () => {
|
test('assertGameEntry disposes its validation module exactly once', () => {
|
||||||
let disposals = 0;
|
let disposals = 0;
|
||||||
const entry = makeEntry(() => ({
|
const entry = makeEntry(() => ({
|
||||||
@@ -428,3 +441,76 @@ test('GameSessionHost returns to active after a restore handler throws', () => {
|
|||||||
assert.throws(() => sessions.restore({}), /restore failed/);
|
assert.throws(() => sessions.restore({}), /restore failed/);
|
||||||
assert.equal(sessions.state, 'active');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user