feat(platform): expose restricted game host and commands
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
import type { RuntimeConfig } from '../config/runtime-config.ts';
|
||||||
|
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||||
|
import {
|
||||||
|
buildExitRoomRequest,
|
||||||
|
buildJoinRoomRequest,
|
||||||
|
buildPrepareRequest,
|
||||||
|
type JoinRoomEnvironment,
|
||||||
|
type RoomIdentity,
|
||||||
|
} from '../protocol/contracts/index.ts';
|
||||||
|
import { PlatformStore } from './stores/platform-store.ts';
|
||||||
|
import type { InsideRoomState, PlatformState } from './stores/platform-types.ts';
|
||||||
|
|
||||||
|
/** The complete-envelope send capability used by platform command adapters. */
|
||||||
|
export interface OutboundEnvelopeSendPort {
|
||||||
|
send(envelope: OutboundEnvelope): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformCommandsOptions {
|
||||||
|
readonly runtimeConfig: RuntimeConfig;
|
||||||
|
readonly store: PlatformStore;
|
||||||
|
readonly send: OutboundEnvelopeSendPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRoomCommand extends JoinRoomEnvironment {
|
||||||
|
readonly roomcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnsupportedGameHostCommandError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'UnsupportedGameHostCommandError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds platform-owned room requests exclusively through the first-slice contracts. */
|
||||||
|
export class PlatformCommands {
|
||||||
|
constructor(private readonly options: PlatformCommandsOptions) {}
|
||||||
|
|
||||||
|
prepare(): void {
|
||||||
|
const state = this.options.store.getState();
|
||||||
|
this.options.send.send(buildPrepareRequest(
|
||||||
|
this.options.runtimeConfig,
|
||||||
|
this.roomIdentity(state, this.requireInsideRoom(state)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
this.options.send.send(buildExitRoomRequest(
|
||||||
|
this.options.runtimeConfig,
|
||||||
|
this.roomIdentity(state, room),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(command: JoinRoomCommand): void {
|
||||||
|
const state = this.options.store.getState();
|
||||||
|
if (state.room.kind !== 'outside') {
|
||||||
|
throw new UnsupportedGameHostCommandError('room.join requires player to be outside a room');
|
||||||
|
}
|
||||||
|
const playerid = this.requireAuthenticatedPlayerId(state);
|
||||||
|
this.options.send.send(buildJoinRoomRequest(
|
||||||
|
this.options.runtimeConfig,
|
||||||
|
{ playerid, roomcode: command.roomcode },
|
||||||
|
command,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireInsideRoom(state: PlatformState): InsideRoomState {
|
||||||
|
if (state.room.kind !== 'inside') {
|
||||||
|
throw new UnsupportedGameHostCommandError('room command requires player to be inside a room');
|
||||||
|
}
|
||||||
|
return state.room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireAuthenticatedPlayerId(state: PlatformState): number {
|
||||||
|
const playerid = state.players.selfPlayerId;
|
||||||
|
if (playerid === null) {
|
||||||
|
throw new UnsupportedGameHostCommandError('room command requires an authenticated player');
|
||||||
|
}
|
||||||
|
if (state.players.entities[playerid] === undefined) {
|
||||||
|
throw new Error('players.entities: missing authenticated player');
|
||||||
|
}
|
||||||
|
return playerid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomIdentity(state: PlatformState, room: InsideRoomState): RoomIdentity {
|
||||||
|
return {
|
||||||
|
playerid: this.requireAuthenticatedPlayerId(state),
|
||||||
|
roomcode: room.roomcode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { APP } from '../core/constants.ts';
|
||||||
|
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||||
|
import type {
|
||||||
|
GameEntry,
|
||||||
|
GameHost,
|
||||||
|
GameHostCommand,
|
||||||
|
GameSeatMapper,
|
||||||
|
PlatformGameSnapshot,
|
||||||
|
} from '../sdk/contracts/index.ts';
|
||||||
|
import {
|
||||||
|
PlatformCommands,
|
||||||
|
UnsupportedGameHostCommandError,
|
||||||
|
type OutboundEnvelopeSendPort,
|
||||||
|
} from './commands.ts';
|
||||||
|
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');
|
||||||
|
this.name = 'GameSessionDisposedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGameHostAdapterOptions {
|
||||||
|
readonly gameEntry: GameEntry;
|
||||||
|
readonly store: PlatformStore;
|
||||||
|
readonly commands: PlatformCommands;
|
||||||
|
readonly send: OutboundEnvelopeSendPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structurally matches RuntimeSession's per-open GameHostLeasePort. */
|
||||||
|
export interface GameHostLease {
|
||||||
|
readonly host: GameHost;
|
||||||
|
invalidate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionRecord {
|
||||||
|
active: boolean;
|
||||||
|
unsubscribeStore: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionReleaseResult =
|
||||||
|
| { readonly threw: false }
|
||||||
|
| { readonly threw: true; readonly error: unknown };
|
||||||
|
|
||||||
|
class GameHostInvalidationError extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
|
||||||
|
constructor(errors: readonly unknown[]) {
|
||||||
|
super('GameHost subscription cleanup failed');
|
||||||
|
this.name = 'GameHostInvalidationError';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionBoundGameHost {
|
||||||
|
readonly host: GameHost;
|
||||||
|
|
||||||
|
private store: PlatformStore | null;
|
||||||
|
private commands: PlatformCommands | null;
|
||||||
|
private sendPort: OutboundEnvelopeSendPort | null;
|
||||||
|
private route: string | null;
|
||||||
|
private readonly subscriptions = new Set<SubscriptionRecord>();
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
constructor(options: CreateGameHostAdapterOptions) {
|
||||||
|
const route = requireGameRoute(options.gameEntry.route);
|
||||||
|
const state = options.store.getState();
|
||||||
|
if (state.room.kind !== 'inside') {
|
||||||
|
throw new Error('GameHost requires player to be inside a room');
|
||||||
|
}
|
||||||
|
const seatCount = options.gameEntry.resolveSeatCount(state.room.roomtype);
|
||||||
|
requireSeatCount(seatCount, state.room.seatPlayerIds.length);
|
||||||
|
const selfSeat = requireSeat(state.room.selfSeat, seatCount, 'self seat');
|
||||||
|
|
||||||
|
this.store = options.store;
|
||||||
|
this.commands = options.commands;
|
||||||
|
this.sendPort = options.send;
|
||||||
|
this.route = route;
|
||||||
|
|
||||||
|
const seat: GameSeatMapper = Object.freeze({
|
||||||
|
toView: (serverSeat: number): number => {
|
||||||
|
this.requireActive();
|
||||||
|
const validated = requireSeat(serverSeat, seatCount, 'server seat');
|
||||||
|
return (validated - selfSeat + seatCount) % seatCount;
|
||||||
|
},
|
||||||
|
toServer: (viewSeat: number): number => {
|
||||||
|
this.requireActive();
|
||||||
|
const validated = requireSeat(viewSeat, seatCount, 'view seat');
|
||||||
|
return (validated + selfSeat) % seatCount;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.host = Object.freeze({
|
||||||
|
seat,
|
||||||
|
getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(),
|
||||||
|
subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => (
|
||||||
|
this.subscribe(listener)
|
||||||
|
),
|
||||||
|
sendGameMessage: (rpc: string, data: unknown): void => this.sendGameMessage(rpc, data),
|
||||||
|
execute: (command: GameHostCommand): void => this.execute(command),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
if (this.disposed) return;
|
||||||
|
this.disposed = true;
|
||||||
|
|
||||||
|
const subscriptions = [...this.subscriptions];
|
||||||
|
this.subscriptions.clear();
|
||||||
|
this.store = null;
|
||||||
|
this.commands = null;
|
||||||
|
this.sendPort = null;
|
||||||
|
this.route = null;
|
||||||
|
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
for (const record of subscriptions) {
|
||||||
|
const result = this.releaseSubscription(record);
|
||||||
|
if (result.threw) errors.push(result.error);
|
||||||
|
}
|
||||||
|
if (errors.length === 1) throw errors[0];
|
||||||
|
if (errors.length > 1) throw new GameHostInvalidationError(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSnapshot(): PlatformGameSnapshot {
|
||||||
|
return selectGameSnapshot(this.requireStore().getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void {
|
||||||
|
this.requireActive();
|
||||||
|
if (typeof listener !== 'function') {
|
||||||
|
throw new TypeError('GameHost subscribe listener must be a function');
|
||||||
|
}
|
||||||
|
const store = this.requireStore();
|
||||||
|
let current = selectGameSnapshot(store.getState());
|
||||||
|
const record: SubscriptionRecord = { active: true, unsubscribeStore: null };
|
||||||
|
const bridge = (state: PlatformState): void => {
|
||||||
|
if (this.disposed || !record.active || state.room.kind !== 'inside') return;
|
||||||
|
const next = selectGameSnapshot(state);
|
||||||
|
if (samePublicSnapshot(current, next)) return;
|
||||||
|
current = next;
|
||||||
|
listener(next);
|
||||||
|
};
|
||||||
|
record.unsubscribeStore = store.subscribe(bridge);
|
||||||
|
this.subscriptions.add(record);
|
||||||
|
|
||||||
|
try {
|
||||||
|
listener(current);
|
||||||
|
} catch (error) {
|
||||||
|
this.releaseSubscription(record);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
try {
|
||||||
|
this.releaseSubscription(record);
|
||||||
|
} catch {
|
||||||
|
// The public unsubscribe contract remains non-throwing, including after invalidation.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendGameMessage(rpc: string, data: unknown): void {
|
||||||
|
this.requireActive();
|
||||||
|
if (typeof rpc !== 'string' || rpc.trim() === '') {
|
||||||
|
throw new TypeError('GameHost rpc must be a non-empty string');
|
||||||
|
}
|
||||||
|
const sendPort = this.sendPort;
|
||||||
|
const route = this.route;
|
||||||
|
if (sendPort === null || route === null) throw new GameSessionDisposedError();
|
||||||
|
const envelope: OutboundEnvelope = { app: APP, route, rpc, data };
|
||||||
|
sendPort.send(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private execute(command: GameHostCommand): void {
|
||||||
|
this.requireActive();
|
||||||
|
const commands = this.commands;
|
||||||
|
if (commands === null) throw new GameSessionDisposedError();
|
||||||
|
if (command !== null && typeof command === 'object') {
|
||||||
|
if (command.type === 'room.prepare') {
|
||||||
|
commands.prepare();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.type === 'room.exit') {
|
||||||
|
commands.exitRoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new UnsupportedGameHostCommandError('unsupported GameHost command');
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireStore(): PlatformStore {
|
||||||
|
this.requireActive();
|
||||||
|
const store = this.store;
|
||||||
|
if (store === null) throw new GameSessionDisposedError();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireActive(): void {
|
||||||
|
if (this.disposed) throw new GameSessionDisposedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseSubscription(record: SubscriptionRecord): SubscriptionReleaseResult {
|
||||||
|
if (!record.active) return { threw: false };
|
||||||
|
record.active = false;
|
||||||
|
this.subscriptions.delete(record);
|
||||||
|
const unsubscribe = record.unsubscribeStore;
|
||||||
|
record.unsubscribeStore = null;
|
||||||
|
if (unsubscribe === null) return { threw: false };
|
||||||
|
try {
|
||||||
|
unsubscribe();
|
||||||
|
return { threw: false };
|
||||||
|
} catch (error) {
|
||||||
|
return { threw: true, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGameHostAdapter(options: CreateGameHostAdapterOptions): GameHostLease {
|
||||||
|
const session = new SessionBoundGameHost(options);
|
||||||
|
return Object.freeze({
|
||||||
|
host: session.host,
|
||||||
|
invalidate: (): void => { session.invalidate(); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
if (seatCount !== roomSeatCount) {
|
||||||
|
throw new RangeError(
|
||||||
|
`GameEntry seat count ${seatCount} does not match room seat count ${roomSeatCount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSeat(value: unknown, seatCount: number, label: string): number {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
throw new TypeError(`${label}: expected integer seat, received ${String(value)}`);
|
||||||
|
}
|
||||||
|
const seat = value as number;
|
||||||
|
if (seat < 0 || seat >= seatCount) {
|
||||||
|
throw new RangeError(`${label}: expected seat range 0..${seatCount - 1}, received ${seat}`);
|
||||||
|
}
|
||||||
|
return seat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePublicSnapshot(first: PlatformGameSnapshot, second: PlatformGameSnapshot): boolean {
|
||||||
|
if (first === second) return true;
|
||||||
|
if (
|
||||||
|
first.connection.phase !== second.connection.phase
|
||||||
|
|| first.self.playerId !== second.self.playerId
|
||||||
|
|| first.self.seat !== second.self.seat
|
||||||
|
|| first.room.roomcode !== second.room.roomcode
|
||||||
|
|| first.room.roomtype !== second.room.roomtype
|
||||||
|
|| first.room.stage !== second.room.stage
|
||||||
|
|| first.room.needprepare !== second.room.needprepare
|
||||||
|
|| first.room.infinite !== second.room.infinite
|
||||||
|
|| first.seats.length !== second.seats.length
|
||||||
|
) return false;
|
||||||
|
for (let index = 0; index < first.seats.length; index++) {
|
||||||
|
const left = first.seats[index]!;
|
||||||
|
const right = second.seats[index]!;
|
||||||
|
if (
|
||||||
|
left.seat !== right.seat
|
||||||
|
|| left.playerId !== right.playerId
|
||||||
|
|| left.nickname !== right.nickname
|
||||||
|
|| left.avatar !== right.avatar
|
||||||
|
|| left.online !== right.online
|
||||||
|
|| left.ready !== right.ready
|
||||||
|
) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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 { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts';
|
||||||
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
|
import {
|
||||||
|
parseLoginResponse,
|
||||||
|
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||||
|
|
||||||
|
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||||
|
mode: 'release',
|
||||||
|
isDebugger: false,
|
||||||
|
identity: Object.freeze({
|
||||||
|
agentid: 'agent-from-config',
|
||||||
|
channelid: 'channel-from-config',
|
||||||
|
gameid: 'game-from-config',
|
||||||
|
marketid: 'market-from-config',
|
||||||
|
version: 41,
|
||||||
|
}),
|
||||||
|
servers: Object.freeze(['ws://fixture']),
|
||||||
|
source: 'remote',
|
||||||
|
gameserver: 'https://fixture/config',
|
||||||
|
rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(name: string): { data: Record<string, unknown> } {
|
||||||
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStore(location: 'outside' | 'inside', stage = 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;
|
||||||
|
store.applyLoginSuccess(parseLoginResponse(source.data));
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCommands(store: PlatformStore): {
|
||||||
|
readonly commands: PlatformCommands;
|
||||||
|
readonly sent: OutboundEnvelope[];
|
||||||
|
} {
|
||||||
|
const sent: OutboundEnvelope[] = [];
|
||||||
|
return {
|
||||||
|
commands: new PlatformCommands({
|
||||||
|
runtimeConfig: RUNTIME_CONFIG,
|
||||||
|
store,
|
||||||
|
send: { send: (envelope) => { sent.push(envelope); } },
|
||||||
|
}),
|
||||||
|
sent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('prepare and exitRoom use the first-slice builders with authoritative config and room state', () => {
|
||||||
|
const { commands, sent } = makeCommands(makeStore('inside', 0));
|
||||||
|
|
||||||
|
commands.prepare();
|
||||||
|
commands.exitRoom();
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [
|
||||||
|
{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'player_prepare',
|
||||||
|
data: {
|
||||||
|
agentid: 'agent-from-config',
|
||||||
|
playerid: 430511,
|
||||||
|
gameid: 'game-from-config',
|
||||||
|
roomcode: '100001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'self_exit_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'agent-from-config',
|
||||||
|
playerid: 430511,
|
||||||
|
gameid: 'game-from-config',
|
||||||
|
roomcode: '100001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(commands.prepare.length, 0);
|
||||||
|
assert.equal(commands.exitRoom.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joinRoom uses authenticated Store identity and requires every non-optional environment field', () => {
|
||||||
|
const { commands, sent } = makeCommands(makeStore('outside'));
|
||||||
|
const location = { latitude: 28.7, longitude: 115.8 };
|
||||||
|
|
||||||
|
commands.joinRoom({
|
||||||
|
roomcode: '654321',
|
||||||
|
location,
|
||||||
|
ip: '203.0.113.7',
|
||||||
|
vipMatch: 1,
|
||||||
|
match_id: 'match-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(sent, [{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'agent',
|
||||||
|
rpc: 'self_join_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'agent-from-config',
|
||||||
|
playerid: 430511,
|
||||||
|
gameid: 'game-from-config',
|
||||||
|
roomcode: '654321',
|
||||||
|
location,
|
||||||
|
ip: '203.0.113.7',
|
||||||
|
vipMatch: 1,
|
||||||
|
match_id: 'match-9',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assert.equal((sent[0]!.data as { location: unknown }).location, location);
|
||||||
|
assert.equal(commands.joinRoom.length, 1);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => commands.joinRoom({ roomcode: '654321', ip: '203.0.113.7' } as never),
|
||||||
|
/location/i,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => commands.joinRoom({ roomcode: '654321', location } as never),
|
||||||
|
/ip/i,
|
||||||
|
);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => {
|
||||||
|
const unauthenticated = makeCommands(new PlatformStore());
|
||||||
|
assert.throws(
|
||||||
|
() => unauthenticated.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }),
|
||||||
|
/authenticated|player/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
const outside = makeCommands(makeStore('outside'));
|
||||||
|
assert.throws(() => outside.commands.prepare(), /inside.*room|room.*inside/i);
|
||||||
|
assert.throws(() => outside.commands.exitRoom(), /inside.*room|room.*inside/i);
|
||||||
|
|
||||||
|
const inside = makeCommands(makeStore('inside', 0));
|
||||||
|
assert.throws(
|
||||||
|
() => inside.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }),
|
||||||
|
/outside.*room|already.*room/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
const battle = makeCommands(makeStore('inside', 1));
|
||||||
|
assert.throws(() => battle.commands.exitRoom(), /room\.exit.*stage=0/i);
|
||||||
|
|
||||||
|
assert.equal(unauthenticated.sent.length, 0);
|
||||||
|
assert.equal(outside.sent.length, 0);
|
||||||
|
assert.equal(inside.sent.length, 0);
|
||||||
|
assert.equal(battle.sent.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
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 { 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,
|
||||||
|
GameHostCommand,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.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',
|
||||||
|
rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { test } from 'node:test';
|
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 } from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
|
||||||
|
import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts';
|
||||||
|
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||||
|
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||||
import type {
|
import type {
|
||||||
GameEntry,
|
GameEntry,
|
||||||
GameHost,
|
GameHost,
|
||||||
GameModule,
|
GameModule,
|
||||||
PlatformGameSnapshot,
|
|
||||||
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
import {
|
import {
|
||||||
assertGameContract,
|
assertGameContract,
|
||||||
@@ -29,13 +35,26 @@ test('assertGameContract runs the reusable isolated session conformance suite',
|
|||||||
|
|
||||||
assert.doesNotThrow(() => assertGameContract(
|
assert.doesNotThrow(() => assertGameContract(
|
||||||
entry,
|
entry,
|
||||||
makeConformanceHost(() => { invalidations++; }, restoredDeskinfo),
|
makeConformanceHost(entry, () => { invalidations++; }, restoredDeskinfo),
|
||||||
));
|
));
|
||||||
assert.equal(invalidations, 2);
|
assert.equal(invalidations, 2);
|
||||||
assert.equal(restoredDeskinfo.length, 0);
|
assert.equal(restoredDeskinfo.length, 0);
|
||||||
assert.ok(nextModuleId >= 2);
|
assert.ok(nextModuleId >= 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||||
|
mode: 'release',
|
||||||
|
isDebugger: false,
|
||||||
|
identity: Object.freeze({
|
||||||
|
agentid: 'fixture-agent', channelid: 'fixture-channel', gameid: '41',
|
||||||
|
marketid: 'fixture-market', version: 41,
|
||||||
|
}),
|
||||||
|
servers: Object.freeze(['ws://fixture']),
|
||||||
|
source: 'remote',
|
||||||
|
gameserver: 'https://fixture/config',
|
||||||
|
rawConfig: Object.freeze({ data: Object.freeze({ urlserver: 'fixture' }) }),
|
||||||
|
});
|
||||||
|
|
||||||
function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule {
|
function makeModule(id: number, onRestore: (deskinfo: unknown) => void): GameModule {
|
||||||
let host: GameHost | undefined;
|
let host: GameHost | undefined;
|
||||||
let restored: unknown;
|
let restored: unknown;
|
||||||
@@ -77,25 +96,29 @@ function deskinfoFor(cycle: number): { readonly cycle: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeConformanceHost(
|
function makeConformanceHost(
|
||||||
|
entry: GameEntry,
|
||||||
onInvalidate: () => void,
|
onInvalidate: () => void,
|
||||||
restoredDeskinfo: unknown[],
|
restoredDeskinfo: unknown[],
|
||||||
): () => GameContractHost {
|
): () => GameContractHost {
|
||||||
return () => {
|
return () => {
|
||||||
const snapshot: PlatformGameSnapshot = {
|
const login = JSON.parse(readFileSync(
|
||||||
connection: { phase: 'connected' },
|
new URL('../fixtures/contracts/player-login-room.json', import.meta.url),
|
||||||
self: { playerId: 1, seat: 0 },
|
'utf8',
|
||||||
room: { roomcode: 'fixture', roomtype: [], stage: 0, needprepare: 0, infinite: 0 },
|
)) as { data: Record<string, unknown> };
|
||||||
seats: [],
|
login.data.roomtype = [4];
|
||||||
};
|
login.data.isbattle = 0;
|
||||||
|
const store = new PlatformStore();
|
||||||
|
store.applyLoginSuccess(parseLoginResponse(login.data));
|
||||||
|
const sent: OutboundEnvelope[] = [];
|
||||||
|
const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } };
|
||||||
|
const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send });
|
||||||
|
const lease = createGameHostAdapter({ gameEntry: entry, store, commands, send });
|
||||||
return {
|
return {
|
||||||
host: {
|
host: lease.host,
|
||||||
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
invalidate() {
|
||||||
getSnapshot: () => snapshot,
|
lease.invalidate();
|
||||||
subscribe: () => () => {},
|
onInvalidate();
|
||||||
sendGameMessage: () => {},
|
|
||||||
execute: () => {},
|
|
||||||
},
|
},
|
||||||
invalidate: onInvalidate,
|
|
||||||
assertRestored(deskinfo) {
|
assertRestored(deskinfo) {
|
||||||
assert.equal(restoredDeskinfo.shift(), deskinfo);
|
assert.equal(restoredDeskinfo.shift(), deskinfo);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user