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:
@@ -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;
|
||||
}
|
||||
+9
@@ -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};
|
||||
}
|
||||
+9
@@ -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;
|
||||
|
||||
+13
@@ -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;
|
||||
}
|
||||
+9
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user