505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import { remoteFixture } from '../fixtures/remote-config.ts';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { test } from 'node:test';
|
|
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
|
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
|
import {
|
|
createGameHostAdapter,
|
|
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,
|
|
} from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
|
|
import {
|
|
parseLoginResponse,
|
|
parseOtherJoinRoomPayload,
|
|
parsePlayerPreparePayload,
|
|
} 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',
|
|
isDebugger: false,
|
|
identity: Object.freeze({
|
|
agentid: 17,
|
|
channelid: 18,
|
|
gameid: 'game-41',
|
|
marketid: 19,
|
|
version: 41,
|
|
}),
|
|
servers: Object.freeze(['ws://fixture']),
|
|
source: 'remote',
|
|
gameserver: 'https://fixture/config',
|
|
...remoteFixture(),
|
|
});
|
|
|
|
function fixture(name: string): { data: Record<string, unknown> } {
|
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
|
}
|
|
|
|
function roomStore(seatCount = 4, selfSeat = 1, stage = 0): PlatformStore {
|
|
const source = fixture('player-login-room.json');
|
|
const self = (source.data.players as Array<Record<string, unknown> | null>)[1]!;
|
|
const players = Array.from({ length: seatCount }, () => null) as Array<Record<string, unknown> | null>;
|
|
players[selfSeat] = { ...self };
|
|
source.data.players = players;
|
|
source.data.seat = selfSeat;
|
|
source.data.roomtype = [seatCount, { nested: [1, 2] }];
|
|
source.data.isbattle = stage;
|
|
const store = new PlatformStore();
|
|
store.applyLoginSuccess(parseLoginResponse(source.data));
|
|
return store;
|
|
}
|
|
|
|
function entry(route = 'fixture-route'): GameEntry {
|
|
return {
|
|
key: 'fixture',
|
|
gameId: 'game-41',
|
|
route,
|
|
resolveSeatCount: (roomtype) => roomtype[0] as number,
|
|
createModule: () => ({
|
|
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
|
|
}),
|
|
};
|
|
}
|
|
|
|
function makeLease(options: {
|
|
readonly store?: PlatformStore;
|
|
readonly gameEntry?: GameEntry;
|
|
} = {}) {
|
|
const store = options.store ?? roomStore();
|
|
const sent: OutboundEnvelope[] = [];
|
|
const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } };
|
|
const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send });
|
|
const lease = createGameHostAdapter({
|
|
gameEntry: options.gameEntry ?? entry(),
|
|
store,
|
|
commands,
|
|
send,
|
|
});
|
|
return { ...lease, store, sent };
|
|
}
|
|
|
|
test('sendGameMessage binds app and GameEntry route, rejects empty rpc, and preserves data identity', () => {
|
|
const { host, sent } = makeLease();
|
|
const data = { card: 7 };
|
|
|
|
host.sendGameMessage('play-card', data);
|
|
|
|
assert.deepEqual(sent[0], {
|
|
app: 'youle', route: 'fixture-route', rpc: 'play-card', data: { card: 7 },
|
|
});
|
|
assert.equal(sent[0]!.data, data);
|
|
assert.equal(host.sendGameMessage.length, 2, 'Host API 不接受 route');
|
|
assert.throws(() => host.sendGameMessage('', {}), /rpc.*non-empty/i);
|
|
assert.throws(() => host.sendGameMessage(' ', {}), /rpc.*non-empty/i);
|
|
assert.equal(sent.length, 1);
|
|
});
|
|
|
|
test('adapter rejects empty and reserved GameEntry routes before exposing a Host', () => {
|
|
for (const route of ['', ' ', 'platform', 'agent', 'room']) {
|
|
assert.throws(
|
|
() => makeLease({ gameEntry: entry(route) }),
|
|
route.trim() === '' ? /route.*non-empty/i : /reserved.*route/i,
|
|
);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
test('execute exposes only the GameHost command union and delegates semantic commands', () => {
|
|
const { host, sent } = makeLease({ store: roomStore(4, 1, 0) });
|
|
|
|
host.execute({ type: 'room.prepare' });
|
|
host.execute({ type: 'room.exit' });
|
|
|
|
assert.equal(sent[0]!.rpc, 'player_prepare');
|
|
assert.equal(sent[1]!.rpc, 'self_exit_room');
|
|
assert.throws(
|
|
() => host.execute({ type: 'room.join' } as unknown as GameHostCommand),
|
|
/unsupported.*command/i,
|
|
);
|
|
assert.equal(sent.length, 2);
|
|
});
|
|
|
|
test('seat mapper binds each 2, 4, and 10 seat session and round-trips every server seat', () => {
|
|
for (const seatCount of [2, 4, 10]) {
|
|
const selfSeat = seatCount - 1;
|
|
const { host } = makeLease({ store: roomStore(seatCount, selfSeat) });
|
|
assert.equal(host.seat.toView(selfSeat), 0);
|
|
for (let serverSeat = 0; serverSeat < seatCount; serverSeat++) {
|
|
const viewSeat = (serverSeat - selfSeat + seatCount) % seatCount;
|
|
assert.equal(host.seat.toView(serverSeat), viewSeat);
|
|
assert.equal(host.seat.toServer(viewSeat), serverSeat);
|
|
}
|
|
for (const invalid of [-1, seatCount, 0.5, Number.NaN, Number.POSITIVE_INFINITY, '0']) {
|
|
assert.throws(() => host.seat.toView(invalid as number), /seat.*range|integer/i);
|
|
assert.throws(() => host.seat.toServer(invalid as number), /seat.*range|integer/i);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('adapter validates descriptor seat count against the canonical room session', () => {
|
|
const store = roomStore(4, 1);
|
|
for (const seatCount of [0, -1, 2.5, Number.NaN, 2]) {
|
|
assert.throws(
|
|
() => makeLease({
|
|
store,
|
|
gameEntry: { ...entry(), resolveSeatCount: () => seatCount },
|
|
}),
|
|
/seat count/i,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roomtype', () => {
|
|
const { host, store } = makeLease();
|
|
const state = store.getState();
|
|
assert.equal(state.room.kind, 'inside');
|
|
if (state.room.kind !== 'inside') return;
|
|
|
|
const snapshot = host.getSnapshot();
|
|
|
|
assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']);
|
|
assert.equal(snapshot.room.roomtype, state.room.roomtype);
|
|
assert.equal(Object.isFrozen(snapshot), true);
|
|
assert.equal(Object.isFrozen(snapshot.connection), true);
|
|
assert.equal(Object.isFrozen(snapshot.self), true);
|
|
assert.equal(Object.isFrozen(snapshot.room), true);
|
|
assert.equal(Object.isFrozen(snapshot.room.roomtype), true);
|
|
assert.equal(Object.isFrozen(snapshot.room.roomtype[1]), true);
|
|
assert.equal(Object.isFrozen((snapshot.room.roomtype[1] as { nested: unknown[] }).nested), true);
|
|
assert.equal(Object.isFrozen(snapshot.seats), true);
|
|
assert.equal(Object.isFrozen(snapshot.seats[0]), true);
|
|
assert.equal('getState' in snapshot, false);
|
|
assert.equal('subscribe' in snapshot, false);
|
|
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[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,
|
|
onServerDenial() { assert.fail('Unexpected server denial'); },
|
|
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>();
|
|
|
|
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
|
|
const unsubscribeStore = super.subscribe(listener);
|
|
this.observed.add(listener);
|
|
return () => {
|
|
this.observed.delete(listener);
|
|
unsubscribeStore();
|
|
};
|
|
}
|
|
|
|
emitCurrent(): void {
|
|
const state = this.getState();
|
|
for (const listener of [...this.observed]) listener(state, state);
|
|
}
|
|
}
|
|
|
|
test('subscribe is immediate, suppresses identical public snapshots, and isolates listener failures', () => {
|
|
const store = new ObservableStore();
|
|
const source = fixture('player-login-room.json');
|
|
source.data.isbattle = 0;
|
|
source.data.roomtype = [4];
|
|
store.applyLoginSuccess(parseLoginResponse(source.data));
|
|
const { host } = makeLease({ store });
|
|
const received: unknown[] = [];
|
|
|
|
host.subscribe((snapshot) => { received.push(snapshot); });
|
|
store.emitCurrent();
|
|
assert.equal(received.length, 1);
|
|
|
|
const listenerFailure = new Error('listener failed');
|
|
const reports: unknown[][] = [];
|
|
const originalConsoleError = console.error;
|
|
console.error = (...args: unknown[]) => { reports.push(args); };
|
|
try {
|
|
assert.throws(
|
|
() => host.subscribe(() => { throw listenerFailure; }),
|
|
(error) => error === listenerFailure,
|
|
);
|
|
store.playerJoin(parseOtherJoinRoomPayload({
|
|
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
|
|
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
|
}));
|
|
assert.equal(received.length, 2);
|
|
assert.equal(reports.length, 0, 'failed initial subscriptions must be removed');
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
test('a later listener failure does not block other Host subscribers', () => {
|
|
const { host, store } = makeLease();
|
|
const listenerFailure = new Error('later listener failed');
|
|
let firstCalls = 0;
|
|
let secondCalls = 0;
|
|
const reports: unknown[][] = [];
|
|
const originalConsoleError = console.error;
|
|
console.error = (...args: unknown[]) => { reports.push(args); };
|
|
try {
|
|
host.subscribe(() => {
|
|
firstCalls++;
|
|
if (firstCalls > 1) throw listenerFailure;
|
|
});
|
|
host.subscribe(() => { secondCalls++; });
|
|
|
|
store.playerJoin(parseOtherJoinRoomPayload({
|
|
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
|
|
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
|
}));
|
|
|
|
assert.equal(firstCalls, 2);
|
|
assert.equal(secondCalls, 2);
|
|
assert.equal(reports.length, 1);
|
|
assert.equal(reports[0]!.includes(listenerFailure), true);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
class ThrowingUnsubscribeStore extends PlatformStore {
|
|
unsubscribeCalls = 0;
|
|
|
|
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
|
|
const unsubscribeStore = super.subscribe(listener);
|
|
return () => {
|
|
unsubscribeStore();
|
|
this.unsubscribeCalls++;
|
|
if (this.unsubscribeCalls === 1) throw undefined;
|
|
throw null;
|
|
};
|
|
}
|
|
}
|
|
|
|
test('invalidate clears every subscription and reference before reporting all cleanup failures', () => {
|
|
const store = new ThrowingUnsubscribeStore();
|
|
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 });
|
|
let callbacks = 0;
|
|
const firstUnsubscribe = host.subscribe(() => { callbacks++; });
|
|
const secondUnsubscribe = host.subscribe(() => { callbacks++; });
|
|
|
|
let cleanupError: unknown;
|
|
try {
|
|
invalidate();
|
|
} catch (error) {
|
|
cleanupError = error;
|
|
}
|
|
|
|
assert.equal(store.unsubscribeCalls, 2);
|
|
assert.deepEqual(
|
|
(cleanupError as { errors?: readonly unknown[] })?.errors,
|
|
[undefined, null],
|
|
);
|
|
assert.throws(() => host.getSnapshot(), GameSessionDisposedError);
|
|
assert.doesNotThrow(() => firstUnsubscribe());
|
|
assert.doesNotThrow(() => secondUnsubscribe());
|
|
assert.doesNotThrow(() => invalidate());
|
|
store.playerJoin(parseOtherJoinRoomPayload({
|
|
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
|
|
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
|
}));
|
|
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[] = [];
|
|
let unsubscribeSecond = (): void => undefined;
|
|
host.subscribe(() => {
|
|
calls.push('first');
|
|
if (calls.length > 2) {
|
|
unsubscribeSecond();
|
|
invalidate();
|
|
}
|
|
});
|
|
unsubscribeSecond = host.subscribe(() => { calls.push('second'); });
|
|
assert.deepEqual(calls, ['first', 'second']);
|
|
|
|
store.playerJoin(parseOtherJoinRoomPayload({
|
|
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
|
|
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
|
}));
|
|
|
|
assert.deepEqual(calls, ['first', 'second', 'first']);
|
|
assert.doesNotThrow(() => unsubscribeSecond());
|
|
assert.doesNotThrow(() => unsubscribeSecond());
|
|
assert.doesNotThrow(() => invalidate());
|
|
store.playerReady(parsePlayerPreparePayload({ seat: 2 }));
|
|
assert.deepEqual(calls, ['first', 'second', 'first']);
|
|
});
|
|
|
|
test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host method fails predictably', () => {
|
|
const { host, invalidate, store, sent } = makeLease();
|
|
let callbacks = 0;
|
|
const unsubscribe = host.subscribe(() => { callbacks++; });
|
|
assert.equal(callbacks, 1);
|
|
|
|
invalidate();
|
|
invalidate();
|
|
|
|
const disposed = (call: () => unknown) => assert.throws(call, GameSessionDisposedError);
|
|
disposed(() => host.getSnapshot());
|
|
disposed(() => host.subscribe(() => { callbacks++; }));
|
|
disposed(() => host.subscribe(null as never));
|
|
disposed(() => host.sendGameMessage('play-card', {}));
|
|
disposed(() => host.execute({ type: 'room.prepare' }));
|
|
disposed(() => host.seat.toView(0));
|
|
disposed(() => host.seat.toServer(0));
|
|
assert.doesNotThrow(() => unsubscribe());
|
|
assert.doesNotThrow(() => unsubscribe());
|
|
|
|
store.playerJoin(parseOtherJoinRoomPayload({
|
|
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
|
|
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
|
|
}));
|
|
assert.equal(callbacks, 1);
|
|
assert.equal(sent.length, 0);
|
|
});
|