feat: integrate room framework and isolated subgame bundles

Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
This commit is contained in:
2026-09-09 03:13:18 +08:00
parent a7ec8aa6a7
commit 35216f75e7
1028 changed files with 835556 additions and 21415 deletions
@@ -0,0 +1,49 @@
import type {GameSeatMapper} from './game-seat-mapper.ts';
import type {PlatformGameSnapshot} from './snapshots.ts';
import type {JsonValue} from './roomtype.ts';
export type InputCancelReason = 'user' | 'scope-ended' | 'replaced';
export type InputResult<T> = {readonly kind:'confirmed';readonly value:T}
| {readonly kind:'cancelled';readonly reason:InputCancelReason};
export interface OwnerHandle {close():void;}
export interface FeedbackOwnerPort {
beginLoading():OwnerHandle;
/** Player-facing business text only; diagnostics have no route through this port. */
showBusinessTip(message:string,time:number):OwnerHandle;
}
export interface RoomCapabilities {
readonly room: {
readonly seat:GameSeatMapper;
getSnapshot():PlatformGameSnapshot;
subscribe(listener:(snapshot:PlatformGameSnapshot)=>void):()=>void;
prepare():void;
requestExit():void;
applyDissolution():void;
};
readonly messages:{send(rpc:string,data:JsonValue):void};
readonly ui:{
beginLoading():OwnerHandle;
showBusinessTip(message:string,time:number):OwnerHandle;
requestNumber(request:{readonly message:string}):Promise<InputResult<number>>;
requestDigitText(request:{readonly message:string}):Promise<InputResult<string>>;
requestMultiplier(request:{readonly values:readonly number[]}):Promise<InputResult<readonly number[]>>;
cancelNumber():void;
cancelMultiplier():void;
};
readonly scope:{
readonly active:boolean;
/** Register cleanup; returned function unregisters it without executing it. */
defer(cleanup:()=>void):()=>void;
};
// Audio is absent until a real room-owned audio adapter is installed.
}
/** Presentation-only bridge. Cancellation callbacks are optional for legacy callers. */
export interface NumericToolsPort {
openNumberInput(request:{readonly message:string;readonly begin0?:boolean;readonly onConfirm:(value:number|string)=>void;readonly onCancel?:()=>void}):void;
closeNumberInput():void;
showMultiplier(request:{readonly values:readonly number[];readonly onConfirm:(values:readonly number[])=>void;readonly onCancel?:()=>void}):void;
hideMultiplier():void;
close():void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "59d9a08e-2863-4aa6-93fa-75eae1905267",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
import type {GameEntry} from './game-entry.ts';
import type {GameRoomProjection,GameRoomProjectionSource} from './game-room-projection.ts';
import type {PlatformGameSnapshot} from './snapshots.ts';
import type {RoomChatConfig} from './room-chat.ts';
export interface RoomPresentationActions {prepare():void;exit():void;}
export interface RoomPresentation {
render(snapshot:PlatformGameSnapshot,actions:RoomPresentationActions,projection?:GameRoomProjection|null):void;
close():void;
}
export interface GameBinding {
readonly entry:GameEntry;
mount(view:RoomPresentation):void;
readonly restoredSnapshot:unknown;
readonly roomProjection:GameRoomProjectionSource|null;
}
/** Application-owned registration. The platform consumes the selected definition only. */
export interface GameDefinition {
readonly config:Readonly<{versionResource:string;assetRoot:string}>;
readonly roomMenu:Readonly<{mainSceneButton:boolean;vipInfinite:boolean}>;
readonly key:string;
readonly route:string;
readonly createGame:(gameId:string|number)=>GameBinding;
readonly chat:RoomChatConfig;
readonly createPageClass:string;
readonly roomViewClass:string;
readonly resources:{readonly createRoom:string;readonly room:string;readonly roomScene:string};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9c55d95a-dd90-4464-a021-65cb90c08155",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,15 +1,29 @@
import type { GameSeatMapper } from './game-seat-mapper.ts';
import type { PlatformGameSnapshot } from './snapshots.ts';
import type {RoomCapabilities} from './game-capabilities.ts';
export type GameHostCommand =
| {readonly type:'ui.open-number-input';readonly message:string;readonly begin0?:boolean;readonly onConfirm:(value:number|string)=>void}
| {readonly type:'ui.close-number-input'}
| {readonly type:'ui.show-multiplier';readonly values:readonly number[];readonly onConfirm:(values:readonly number[])=>void}
| {readonly type:'ui.hide-multiplier'}
| { readonly type:'room.apply-dissolution' }
| { readonly type: 'room.prepare' }
| { readonly type: 'room.exit' };
/** The only platform capability surface available to a game module. */
export interface GameHost {
/** Optional only for legacy test/host adapters; production hosts always provide it. */
readonly capabilities?:RoomCapabilities;
readonly seat: GameSeatMapper;
getSnapshot(): PlatformGameSnapshot;
subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void;
sendGameMessage(rpc: string, data: unknown): void;
execute(command: GameHostCommand): void;
}
/** Explicit migration boundary; absence is an integration error, never a fallback. */
export function requireRoomCapabilities(host:GameHost):RoomCapabilities {
if(!host.capabilities)throw new Error('Game host must provide room capabilities');
return host.capabilities;
}
@@ -1,8 +1,11 @@
import type { GameHost } from './game-host.ts';
import type {GameRoomProjectionSource} from './game-room-projection.ts';
import type { GameServerMessage, PlatformToGameEvent } from './platform-events.ts';
/** Per-room game implementation with an explicit lifecycle. */
export interface GameModule {
/** Absent/null explicitly means this game has no public gameplay projection. */
readonly roomProjection?:GameRoomProjectionSource|null;
attach(host: GameHost): void;
handlePlatformEvent(event: PlatformToGameEvent): void;
handleGameMessage(message: GameServerMessage): void;
@@ -0,0 +1,13 @@
/** Owned by the game model. These facts never overwrite platform readiness or assets. */
export interface GameRoomFacts {
readonly roundPhase:string;
readonly selfParticipation:'playing'|'waiting'|'spectating';
readonly scoresBySeat:Readonly<Record<number,number>>;
/** A game may only add restrictions, never enable a platform-forbidden command. */
readonly restrictions:Readonly<Partial<Record<'prepare'|'start'|'exit',false>>>;
}
export interface GameRoomProjection extends GameRoomFacts {readonly revision:number;}
export interface GameRoomProjectionSource {
getSnapshot():GameRoomProjection;
subscribe(listener:(projection:GameRoomProjection)=>void):()=>void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "6a623110-c503-4e7e-9b26-692ec40256b7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,9 +1,11 @@
export * from './game-entry.ts';
export * from './create-room-page.ts';
export * from './game-host.ts';
export * from './game-host.ts';
export * from './game-capabilities.ts';
export * from './game-room-projection.ts';
export * from './game-module.ts';
export * from './game-seat-mapper.ts';
export * from './platform-events.ts';
export * from './snapshots.ts';
export type {Roomtype} from './roomtype.ts';
export type {JsonValue,Roomtype} from './roomtype.ts';
@@ -1,7 +1,18 @@
export type RoomEntryInput =
| {readonly kind:'created';readonly sourceRpc:'create_room';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'login-waiting';readonly sourceRpc:'player_login';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'login-restored';readonly sourceRpc:'player_login';readonly raw:Readonly<Record<string,unknown>>;readonly deskinfo:unknown}
| {readonly kind:'joined-waiting'|'joined-started';readonly sourceRpc:'self_join_room';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'joined-snapshot';readonly sourceRpc:'self_join_room';readonly raw:Readonly<Record<string,unknown>>;readonly deskinfo:unknown};
export interface RoomStartMessage {readonly route:string;readonly rpc:string;readonly data:unknown;}
import type {Roomtype} from './roomtype.ts';
/** Platform lifecycle events delivered to the current game module. */
export type PlatformToGameEvent =
| { readonly type: 'room.entered'; readonly roomtype: Roomtype }
/** Explicit settlement integration point. The module must interpret deskfree;
* platform preserves the active session and does not invent a result screen. */
| {readonly type:'room.dissolution-confirmed';readonly freeNow:boolean;readonly deskfree?:Readonly<Record<string,unknown>>;readonly tips?:string;readonly time?:number}
| { readonly type: 'room.entered'; readonly roomtype: Roomtype;readonly entry?:RoomEntryInput }
| {readonly type:'room.started';readonly sourceRpc:'self_join_room'|'other_join_room'|'player_prepare'|'self_makewar'|'other_makewar';readonly message:RoomStartMessage}
| { readonly type: 'room.player-joined'; readonly seat: number }
| { readonly type: 'room.player-left'; readonly seat: number }
| { readonly type: 'room.player-ready'; readonly seat: number }
@@ -0,0 +1,10 @@
export interface RoomChatPhrase {readonly text:string;readonly audio:string;}
export interface RoomChatConfig {
readonly phrases:readonly RoomChatPhrase[];
/** Legacy maxLength counts JavaScript UTF-16 code units. */
readonly limitLength:number;
readonly showChatMs:number;
readonly announcementLimit:number;
readonly voiceHistoryTag:number;
readonly bannedWords:readonly string[];
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c8f3ca02-72aa-44e5-a249-c9d88fe9b5cd",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,2 +1,3 @@
/** Opaque subgame wire configuration. The game defines its contents. */
export type Roomtype = string | readonly unknown[];
/** Opaque game-owned JSON transport value. */
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {readonly [key:string]:JsonValue};
export type Roomtype = JsonValue;
@@ -0,0 +1,42 @@
import type {GameRoomFacts,GameRoomProjection,GameRoomProjectionSource} from './contracts/game-room-projection.ts';
function snapshot(facts:GameRoomFacts,revision:number):GameRoomProjection {
if(typeof facts.roundPhase!=='string'||facts.roundPhase.length===0)throw TypeError('Game round phase must be explicit');
if(!['playing','waiting','spectating'].includes(facts.selfParticipation))throw TypeError('Invalid game participation');
const scores:Record<number,number>={};
for(const [seat,score] of Object.entries(facts.scoresBySeat)){
if(!/^(0|[1-9][0-9]*)$/.test(seat)||!Number.isSafeInteger(Number(seat))||!Number.isFinite(score))throw TypeError('Game score requires a server seat and finite score');
scores[Number(seat)]=score;
}
const restrictions:Partial<Record<'prepare'|'start'|'exit',false>>={};
for(const [action,value] of Object.entries(facts.restrictions)){
if(!['prepare','start','exit'].includes(action)||value!==false)throw TypeError('Game restrictions may only disable known commands');
restrictions[action as keyof typeof restrictions]=false;
}
return Object.freeze({revision,roundPhase:facts.roundPhase,selfParticipation:facts.selfParticipation,
scoresBySeat:Object.freeze(scores),restrictions:Object.freeze(restrictions)});
}
/** The owner keeps commit/dispose; consumers receive only source. Each commit is a complete version. */
export function createGameRoomProjection(initial:GameRoomFacts):{
readonly source:GameRoomProjectionSource;commit(facts:GameRoomFacts):void;dispose():void;
}{
let current=snapshot(initial,0);let disposed=false;let notifying=false;
const listeners=new Set<(projection:GameRoomProjection)=>void>();
const queue:GameRoomProjection[]=[];
const active=()=>{if(disposed)throw Error('Game projection is disposed');};
const source:GameRoomProjectionSource=Object.freeze({getSnapshot:()=>{active();return current;},subscribe:(listener:(projection:GameRoomProjection)=>void)=>{
active();if(typeof listener!=='function')throw TypeError('Projection listener must be a function');listeners.add(listener);
try{listener(current);}catch(error){listeners.delete(listener);throw error;}
return ()=>{listeners.delete(listener);};
}});
return Object.freeze({source,commit:(facts:GameRoomFacts)=>{
active();const next=snapshot(facts,current.revision+1);current=next;queue.push(next);
if(notifying)return;notifying=true;const errors:unknown[]=[];
try{while(queue.length&&!disposed){const version=queue.shift()!;for(const listener of [...listeners]){
if(disposed)break;if(!listeners.has(listener))continue;
try{listener(version);}catch(error){errors.push(error);}
}}}finally{notifying=false;}
if(errors.length)throw Object.assign(new Error('Game projection notification failed'),{errors:Object.freeze(errors)});
},dispose:()=>{disposed=true;queue.length=0;listeners.clear();}});
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5d4c6bdf-207c-4da3-9f77-6c80aa78dd76",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,6 @@
import type {GameRoomProjection} from './contracts/game-room-projection.ts';
/** Missing game projection means no additional restrictions, never extra permission. */
export function gameAllowsRoomAction(platformAllowed:boolean,game:GameRoomProjection|null,action:'prepare'|'start'|'exit'):boolean {
return platformAllowed&&(game===null||game.restrictions[action]!==false);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "378da623-0982-4ab4-97ef-6ebbac2a1d06",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,5 @@
import type {PlatformGameSnapshot} from './contracts/snapshots.ts';
/** GameUI's in-room exit/application action; unlimited rooms retain ordinary exit. */
export function roomExitAction(room:PlatformGameSnapshot['room']):{command:'room.exit'|'room.apply-dissolution';label:string}{
return room.stage===1&&room.infinite!==1?{command:'room.apply-dissolution',label:'申请解散'}:{command:'room.exit',label:'退出房间'};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2fb0e986-b14a-481d-baff-e88b670ff0c0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -77,7 +77,7 @@ export function isGameSessionOpenFault(value: unknown): value is GameSessionOpen
return extractGameSessionOpenFaultErrors(value) !== null;
}
/** Validates the compile-time game identity before it reaches the runtime. */
/** Validates declarations only; module contracts are checked on the real instance in open(). */
export function assertGameEntry(entry: GameEntry): void {
assertNonEmptyText(entry.key, 'key');
assertGameId(entry.gameId);
@@ -88,12 +88,6 @@ export function assertGameEntry(entry: GameEntry): void {
if (typeof entry.createModule !== 'function') {
throw new Error('GameEntry createModule must be a function');
}
const module = entry.createModule();
try {
assertGameModule(module);
} finally {
disposeModuleIfPresent(module);
}
}
/** The single GameEntry route admission check shared by runtime adapters. */
@@ -107,6 +101,10 @@ export function assertGameRoute(route: unknown): asserts route is string {
/** Owns one isolated game module for one room at a time. */
export class GameSessionHost {
private module: GameModule | undefined;
/** Null explicitly means the current game does not provide public gameplay facts. */
get roomProjection():import('../contracts/game-room-projection.ts').GameRoomProjectionSource|null {
return this.module?.roomProjection??null;
}
private currentState: GameSessionState = 'idle';
constructor(readonly entry: GameEntry) {