实现游戏运行会话与桥接协议
冻结 Host/Game Bridge 双向消息合同与安全校验 为 PreviewRegistry 增加预览身份并注入宿主 bootstrap 接入 iframe 一次性会话、状态投影和暂停继续控制 补充协议、AppSurface、Rust 生命周期与共享合同测试 同步更新工作台 PRD、技术方案和共享决策记录
This commit is contained in:
@@ -141,6 +141,13 @@ struct LocalPreviewResult {
|
||||
root: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalPreviewIdentity {
|
||||
preview_id: String,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalPreviewStatus {
|
||||
@@ -156,6 +163,7 @@ struct RunnableGameVersionLaunchResult {
|
||||
manifest: GameCreationAppManifest,
|
||||
version: RunnableGameVersion,
|
||||
preview: LocalPreviewResult,
|
||||
preview_identity: LocalPreviewIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) struct PreviewRegistry {
|
||||
|
||||
struct PreviewServer {
|
||||
preview: LocalPreviewResult,
|
||||
identity: LocalPreviewIdentity,
|
||||
stop: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
@@ -16,6 +17,19 @@ impl PreviewRegistry {
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
) -> (LocalPreviewResult, Option<LocalPreviewResult>) {
|
||||
let (preview, _, previous_preview) = self.set_running_with_identity(preview, stop);
|
||||
(preview, previous_preview)
|
||||
}
|
||||
|
||||
pub(crate) fn set_running_with_identity(
|
||||
&self,
|
||||
preview: LocalPreviewResult,
|
||||
stop: mpsc::Sender<()>,
|
||||
) -> (
|
||||
LocalPreviewResult,
|
||||
LocalPreviewIdentity,
|
||||
Option<LocalPreviewResult>,
|
||||
) {
|
||||
let mut current = self.current.lock().expect("preview registry lock");
|
||||
let previous_preview = if let Some(previous) = current.take() {
|
||||
let preview = previous.preview;
|
||||
@@ -24,11 +38,21 @@ impl PreviewRegistry {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity = LocalPreviewIdentity {
|
||||
preview_id: format!("preview-{}", uuid::Uuid::new_v4().simple()),
|
||||
origin: format!("http://127.0.0.1:{}", preview.port),
|
||||
};
|
||||
*current = Some(PreviewServer {
|
||||
preview: preview.clone(),
|
||||
identity: identity.clone(),
|
||||
stop,
|
||||
});
|
||||
(preview, previous_preview)
|
||||
let registry_identity = current
|
||||
.as_ref()
|
||||
.expect("preview registry current server")
|
||||
.identity
|
||||
.clone();
|
||||
(preview, registry_identity, previous_preview)
|
||||
}
|
||||
|
||||
pub(crate) fn status(&self) -> LocalPreviewStatus {
|
||||
@@ -89,6 +113,125 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100;
|
||||
const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024;
|
||||
const GAME_RUN_BRIDGE_SCRIPT_PATH: &str = "/.genarrative/game-bridge.v1.js";
|
||||
const GAME_RUN_BRIDGE_SCRIPT_TAG: &str =
|
||||
r#"<script src="/.genarrative/game-bridge.v1.js" data-genarrative-game-bridge="v1"></script>"#;
|
||||
const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
|
||||
'use strict';
|
||||
const protocolVersion = 'game-creator-run-bridge.v1';
|
||||
const hostSchema = 'game-creator-host-message.v1';
|
||||
const runtimeSchema = 'game-creator-runtime-message.v1';
|
||||
const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request']);
|
||||
let session = null;
|
||||
let hostOrigin = null;
|
||||
let hostSequence = 0;
|
||||
let runtimeSequence = 0;
|
||||
let state = 'starting';
|
||||
const hostRequestIds = new Set();
|
||||
|
||||
const randomId = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return `runtime-request-${Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
const sameIdentity = message => session &&
|
||||
message.sessionId === session.sessionId &&
|
||||
message.previewId === session.previewId &&
|
||||
message.projectId === session.projectId &&
|
||||
message.versionId === session.versionId &&
|
||||
message.projectRevision === session.projectRevision;
|
||||
const send = (type, payload, responseTo) => {
|
||||
if (!session || !hostOrigin) return false;
|
||||
const message = {
|
||||
schemaVersion: runtimeSchema,
|
||||
protocolVersion,
|
||||
requestId: randomId(),
|
||||
sessionId: session.sessionId,
|
||||
previewId: session.previewId,
|
||||
projectId: session.projectId,
|
||||
sequence: ++runtimeSequence,
|
||||
type,
|
||||
versionId: session.versionId,
|
||||
projectRevision: session.projectRevision,
|
||||
payload,
|
||||
};
|
||||
if (responseTo) message.responseTo = responseTo;
|
||||
parent.postMessage(message, hostOrigin);
|
||||
return true;
|
||||
};
|
||||
const reportState = (nextState, summary) => {
|
||||
state = nextState;
|
||||
return send('runtime.state', summary === undefined ? { status: nextState } : { status: nextState, summary });
|
||||
};
|
||||
const reportError = (code, message, recoverable = false) =>
|
||||
send('runtime.error', { code, message, recoverable });
|
||||
const reportSlice = (sliceId, title, status, summary) =>
|
||||
send('runtime.slice', summary === undefined ? { sliceId, title, status } : { sliceId, title, status, summary });
|
||||
const reportInspection = (inspectionId, label, metrics, summary) =>
|
||||
send('runtime.inspection', summary === undefined ? { inspectionId, label, metrics } : { inspectionId, label, metrics, summary });
|
||||
|
||||
Object.defineProperty(window, 'genarrativeGameBridge', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection }),
|
||||
});
|
||||
|
||||
addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
if (event.source !== parent || !message || typeof message !== 'object' ||
|
||||
message.schemaVersion !== hostSchema || message.protocolVersion !== protocolVersion ||
|
||||
!hostTypes.has(message.type) || !Number.isSafeInteger(message.sequence) ||
|
||||
typeof message.requestId !== 'string' || hostRequestIds.has(message.requestId)) return;
|
||||
if (message.type === 'host.start') {
|
||||
if (session || message.sequence !== 1 || typeof message.sessionId !== 'string' ||
|
||||
typeof message.previewId !== 'string' || typeof message.projectId !== 'string' ||
|
||||
typeof message.versionId !== 'string' || !Number.isSafeInteger(message.projectRevision)) return;
|
||||
hostOrigin = event.origin;
|
||||
hostSequence = 1;
|
||||
hostRequestIds.add(message.requestId);
|
||||
session = {
|
||||
sessionId: message.sessionId,
|
||||
previewId: message.previewId,
|
||||
projectId: message.projectId,
|
||||
versionId: message.versionId,
|
||||
projectRevision: message.projectRevision,
|
||||
};
|
||||
send('runtime.ready', { capabilities: ['state', 'slice', 'inspection'] }, message.requestId);
|
||||
state = 'playing';
|
||||
send('runtime.state', { status: state });
|
||||
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
|
||||
return;
|
||||
}
|
||||
if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return;
|
||||
hostSequence = message.sequence;
|
||||
hostRequestIds.add(message.requestId);
|
||||
if (message.type === 'host.pause') {
|
||||
state = 'paused';
|
||||
send('runtime.state', { status: state }, message.requestId);
|
||||
} else if (message.type === 'host.resume') {
|
||||
state = 'playing';
|
||||
send('runtime.state', { status: state }, message.requestId);
|
||||
} else if (message.type === 'host.state.request') {
|
||||
send('runtime.state', { status: state }, message.requestId);
|
||||
} else if (message.type === 'host.inspection.request') {
|
||||
send('runtime.inspection', {
|
||||
inspectionId: 'host-baseline',
|
||||
label: '运行画面',
|
||||
metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight },
|
||||
}, message.requestId);
|
||||
} else if (message.type === 'host.stop') {
|
||||
state = 'stopped';
|
||||
send('runtime.state', { status: state }, message.requestId);
|
||||
}
|
||||
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
|
||||
if (message.type === 'host.stop') {
|
||||
session = null;
|
||||
hostOrigin = null;
|
||||
}
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum PreviewListenerAcceptDisposition {
|
||||
@@ -500,7 +643,8 @@ pub(crate) fn launch_local_game_runnable_version(
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
let (preview, previous_preview) = registry.set_running(preview, stop);
|
||||
let (preview, preview_identity, previous_preview) =
|
||||
registry.set_running_with_identity(preview, stop);
|
||||
if let Some(previous_preview) = previous_preview.as_ref() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
}
|
||||
@@ -525,6 +669,7 @@ pub(crate) fn launch_local_game_runnable_version(
|
||||
manifest,
|
||||
version: selected_version,
|
||||
preview,
|
||||
preview_identity,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -645,6 +790,21 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
|
||||
);
|
||||
}
|
||||
|
||||
if url_path.split('?').next() == Some(GAME_RUN_BRIDGE_SCRIPT_PATH) {
|
||||
let content_length = GAME_RUN_BRIDGE_BOOTSTRAP.len();
|
||||
let body = if is_head {
|
||||
Vec::new()
|
||||
} else {
|
||||
GAME_RUN_BRIDGE_BOOTSTRAP.as_bytes().to_vec()
|
||||
};
|
||||
return http_response(
|
||||
"200 OK",
|
||||
"text/javascript; charset=utf-8",
|
||||
&body,
|
||||
content_length,
|
||||
);
|
||||
}
|
||||
|
||||
let file_path = match resolve_preview_path(root, url_path) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
@@ -659,11 +819,41 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str)
|
||||
return http_response("404 Not Found", "text/plain", body, b"not found".len());
|
||||
}
|
||||
};
|
||||
let body = if file_path.extension().and_then(|value| value.to_str()) == Some("html") {
|
||||
inject_game_run_bridge_tag(body)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let content_length = body.len();
|
||||
let body = if is_head { Vec::new() } else { body };
|
||||
http_response("200 OK", content_type(&file_path), &body, content_length)
|
||||
}
|
||||
|
||||
fn inject_game_run_bridge_tag(body: Vec<u8>) -> Vec<u8> {
|
||||
let html = match String::from_utf8(body) {
|
||||
Ok(html) => html,
|
||||
Err(error) => return error.into_bytes(),
|
||||
};
|
||||
if html.contains("data-genarrative-game-bridge=") {
|
||||
return html.into_bytes();
|
||||
}
|
||||
let lowercase = html.to_ascii_lowercase();
|
||||
let insertion_index = lowercase
|
||||
.find("<head")
|
||||
.and_then(|start| {
|
||||
lowercase[start..]
|
||||
.find('>')
|
||||
.map(|offset| start + offset + 1)
|
||||
})
|
||||
.or_else(|| lowercase.find("</body>"))
|
||||
.unwrap_or(html.len());
|
||||
let mut injected = String::with_capacity(html.len() + GAME_RUN_BRIDGE_SCRIPT_TAG.len());
|
||||
injected.push_str(&html[..insertion_index]);
|
||||
injected.push_str(GAME_RUN_BRIDGE_SCRIPT_TAG);
|
||||
injected.push_str(&html[insertion_index..]);
|
||||
injected.into_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result<PathBuf, String> {
|
||||
let path = url_path.split('?').next().unwrap_or("/");
|
||||
let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?;
|
||||
|
||||
@@ -2414,6 +2414,41 @@ fn preview_registry_reports_status_and_stops_previous_server() {
|
||||
assert_eq!(registry.status().status, "stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_registry_rotates_p3_identity_with_the_owned_server() {
|
||||
let registry = PreviewRegistry::default();
|
||||
let (first_stop, first_receiver) = mpsc::channel();
|
||||
let (_, first_identity, previous) = registry.set_running_with_identity(
|
||||
LocalPreviewResult {
|
||||
url: "http://127.0.0.1:4101/".to_string(),
|
||||
port: 4101,
|
||||
root: "/tmp/game-one".to_string(),
|
||||
},
|
||||
first_stop,
|
||||
);
|
||||
assert!(previous.is_none());
|
||||
assert_eq!(first_identity.origin, "http://127.0.0.1:4101");
|
||||
assert!(first_identity.preview_id.starts_with("preview-"));
|
||||
|
||||
let (second_stop, _) = mpsc::channel();
|
||||
let (_, second_identity, previous) = registry.set_running_with_identity(
|
||||
LocalPreviewResult {
|
||||
url: "http://127.0.0.1:4102/".to_string(),
|
||||
port: 4102,
|
||||
root: "/tmp/game-two".to_string(),
|
||||
},
|
||||
second_stop,
|
||||
);
|
||||
assert!(first_receiver.try_recv().is_ok());
|
||||
assert_eq!(
|
||||
previous.expect("replaced preview").url,
|
||||
"http://127.0.0.1:4101/"
|
||||
);
|
||||
assert_ne!(first_identity.preview_id, second_identity.preview_id);
|
||||
assert_eq!(second_identity.origin, "http://127.0.0.1:4102");
|
||||
registry.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_preview_records_stopped_state() {
|
||||
let root = unique_project_path();
|
||||
@@ -3254,6 +3289,36 @@ fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_injects_the_host_owned_p3_bridge_without_mutating_html() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-bridge", "运行桥预览测试").expect("project init");
|
||||
let original = "<!doctype html><html><head><title>Bridge</title></head><body></body></html>";
|
||||
fs::write(root.join("game/index.html"), original).expect("write bridge fixture");
|
||||
|
||||
let html_response =
|
||||
String::from_utf8(build_preview_response(&root, "GET", "/")).expect("html response");
|
||||
assert!(html_response.contains("data-genarrative-game-bridge=\"v1\""));
|
||||
assert!(html_response.contains("/.genarrative/game-bridge.v1.js"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("game/index.html")).expect("read original html"),
|
||||
original
|
||||
);
|
||||
|
||||
let script_response = String::from_utf8(build_preview_response(
|
||||
&root,
|
||||
"GET",
|
||||
"/.genarrative/game-bridge.v1.js",
|
||||
))
|
||||
.expect("bridge response");
|
||||
assert!(script_response.contains("Content-Type: text/javascript; charset=utf-8"));
|
||||
assert!(script_response.contains("game-creator-run-bridge.v1"));
|
||||
assert!(script_response.contains("runtime.ready"));
|
||||
assert!(script_response.contains("event.source !== parent"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preview_server_drains_split_browser_headers_before_response() {
|
||||
let root = unique_project_path();
|
||||
|
||||
+193
-11
@@ -1,10 +1,49 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its small rendering adapter for focused tests. */
|
||||
/* eslint-disable react-refresh/only-export-components -- The URL guard is exported with its rendering adapter for focused tests. */
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
GameHostMessageType,
|
||||
GameRunSession,
|
||||
GameRuntimeMessage,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createGameHostMessage,
|
||||
createGameRunSession,
|
||||
type GameRunBridgeIdentity,
|
||||
GameRunBridgeRuntimeGuard,
|
||||
gameRunSessionFromRuntimeMessage,
|
||||
newGameRunBridgeRequestId,
|
||||
newGameRunSessionId,
|
||||
validateGameRunBridgeIdentity,
|
||||
} from './gameRunBridge';
|
||||
|
||||
export type LocalGamePreviewLike = {
|
||||
status?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
export type LocalGamePreviewFrameHandle = {
|
||||
pause: () => boolean;
|
||||
resume: () => boolean;
|
||||
stop: () => boolean;
|
||||
requestState: () => boolean;
|
||||
requestInspection: () => boolean;
|
||||
};
|
||||
|
||||
type ActiveBridgeSession = {
|
||||
guard: GameRunBridgeRuntimeGuard;
|
||||
identity: GameRunBridgeIdentity;
|
||||
nextHostSequence: number;
|
||||
session: GameRunSession;
|
||||
};
|
||||
|
||||
export function resolveEmbeddedPreviewUrl(
|
||||
preview: LocalGamePreviewLike | null | undefined,
|
||||
) {
|
||||
@@ -25,26 +64,169 @@ export function resolveEmbeddedPreviewUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function LocalGamePreviewFrame({
|
||||
preview,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
preview: LocalGamePreviewLike | null | undefined;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
export const LocalGamePreviewFrame = forwardRef<
|
||||
LocalGamePreviewFrameHandle,
|
||||
{
|
||||
preview: LocalGamePreviewLike | null | undefined;
|
||||
title: string;
|
||||
className?: string;
|
||||
runIdentity?: GameRunBridgeIdentity | null;
|
||||
onSessionChange?: (session: GameRunSession) => void;
|
||||
onRuntimeMessage?: (message: GameRuntimeMessage) => void;
|
||||
onBridgeError?: (message: string) => void;
|
||||
}
|
||||
>(function LocalGamePreviewFrame(
|
||||
{
|
||||
preview,
|
||||
title,
|
||||
className,
|
||||
runIdentity,
|
||||
onSessionChange,
|
||||
onRuntimeMessage,
|
||||
onBridgeError,
|
||||
},
|
||||
forwardedRef,
|
||||
) {
|
||||
const embeddedUrl = resolveEmbeddedPreviewUrl(preview);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const activeRef = useRef<ActiveBridgeSession | null>(null);
|
||||
const callbacksRef = useRef({
|
||||
onBridgeError,
|
||||
onRuntimeMessage,
|
||||
onSessionChange,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onBridgeError,
|
||||
onRuntimeMessage,
|
||||
onSessionChange,
|
||||
};
|
||||
|
||||
const sendHostMessage = useCallback((type: GameHostMessageType) => {
|
||||
const active = activeRef.current;
|
||||
const targetWindow = iframeRef.current?.contentWindow;
|
||||
if (!active || !targetWindow) {
|
||||
return false;
|
||||
}
|
||||
const requestId = newGameRunBridgeRequestId('host');
|
||||
if (!requestId || !active.guard.registerHostRequest(requestId, type)) {
|
||||
callbacksRef.current.onBridgeError?.('运行会话无法创建安全请求身份');
|
||||
return false;
|
||||
}
|
||||
const message = createGameHostMessage({
|
||||
identity: active.identity,
|
||||
sessionId: active.session.sessionId,
|
||||
requestId,
|
||||
sequence: active.nextHostSequence,
|
||||
type,
|
||||
});
|
||||
active.nextHostSequence += 1;
|
||||
targetWindow.postMessage(message, active.identity.previewOrigin);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const stopActiveSession = useCallback(
|
||||
(notify: boolean) => {
|
||||
const active = activeRef.current;
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
sendHostMessage('host.stop');
|
||||
activeRef.current = null;
|
||||
if (notify) {
|
||||
callbacksRef.current.onSessionChange?.({
|
||||
...active.session,
|
||||
status: 'stopped',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[sendHostMessage],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
pause: () => sendHostMessage('host.pause'),
|
||||
resume: () => sendHostMessage('host.resume'),
|
||||
stop: () => stopActiveSession(true),
|
||||
requestState: () => sendHostMessage('host.state.request'),
|
||||
requestInspection: () => sendHostMessage('host.inspection.request'),
|
||||
}),
|
||||
[sendHostMessage, stopActiveSession],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function receiveRuntimeMessage(event: MessageEvent<unknown>) {
|
||||
const active = activeRef.current;
|
||||
if (
|
||||
!active ||
|
||||
event.source !== iframeRef.current?.contentWindow ||
|
||||
event.origin !== active.identity.previewOrigin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = active.guard.accept(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
active.session = gameRunSessionFromRuntimeMessage(
|
||||
active.session,
|
||||
message,
|
||||
Date.now(),
|
||||
);
|
||||
callbacksRef.current.onSessionChange?.(active.session);
|
||||
callbacksRef.current.onRuntimeMessage?.(message);
|
||||
}
|
||||
window.addEventListener('message', receiveRuntimeMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', receiveRuntimeMessage);
|
||||
stopActiveSession(false);
|
||||
};
|
||||
}, [stopActiveSession]);
|
||||
|
||||
function handleLoad() {
|
||||
stopActiveSession(true);
|
||||
if (!runIdentity || !embeddedUrl) {
|
||||
return;
|
||||
}
|
||||
const identity = validateGameRunBridgeIdentity(runIdentity);
|
||||
if (!identity || new URL(embeddedUrl).origin !== identity.previewOrigin) {
|
||||
callbacksRef.current.onBridgeError?.(
|
||||
'运行预览身份与 loopback origin 不一致',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sessionId = newGameRunSessionId();
|
||||
if (!sessionId) {
|
||||
callbacksRef.current.onBridgeError?.('当前 WebView 不支持安全运行会话');
|
||||
return;
|
||||
}
|
||||
const session = createGameRunSession(identity, sessionId, Date.now());
|
||||
activeRef.current = {
|
||||
guard: new GameRunBridgeRuntimeGuard(identity, sessionId),
|
||||
identity,
|
||||
nextHostSequence: 1,
|
||||
session,
|
||||
};
|
||||
callbacksRef.current.onSessionChange?.(session);
|
||||
if (!sendHostMessage('host.start')) {
|
||||
activeRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!embeddedUrl) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={className}
|
||||
title={title}
|
||||
src={embeddedUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
|
||||
allow="autoplay; fullscreen; gamepad"
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import type {
|
||||
GameHostMessage,
|
||||
GameHostMessageType,
|
||||
GameRunSession,
|
||||
GameRuntimeInspectionMetric,
|
||||
GameRuntimeMessage,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
GAME_HOST_MESSAGE_SCHEMA_VERSION,
|
||||
GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES,
|
||||
GAME_RUN_BRIDGE_PROTOCOL_VERSION,
|
||||
GAME_RUNTIME_MESSAGE_SCHEMA_VERSION,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
export type GameRunBridgeIdentity = {
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
previewId: string;
|
||||
previewOrigin: string;
|
||||
};
|
||||
|
||||
const RUNTIME_MESSAGE_TYPES = new Set([
|
||||
'runtime.ready',
|
||||
'runtime.state',
|
||||
'runtime.error',
|
||||
'runtime.slice',
|
||||
'runtime.inspection',
|
||||
]);
|
||||
const RUNTIME_CAPABILITIES = new Set(['state', 'slice', 'inspection']);
|
||||
const RUNTIME_STATES = new Set([
|
||||
'starting',
|
||||
'playing',
|
||||
'paused',
|
||||
'completed',
|
||||
'stopped',
|
||||
]);
|
||||
const SLICE_STATES = new Set([
|
||||
'idle',
|
||||
'starting',
|
||||
'playing',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
]);
|
||||
const DANGEROUS_FIELD =
|
||||
/(?:^|[-_])(path|file|url|href|src|html|script|command|shell|tauri|invoke)(?:$|[-_])/iu;
|
||||
const HTML_OR_ACTIVE_URL =
|
||||
/<\/?[a-z][^>]*>|(?:https?|file|javascript|data):|\bhost\.[a-z]/iu;
|
||||
const WINDOWS_ABSOLUTE_PATH = /^[a-z]:[\\/]/iu;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function hasExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
) {
|
||||
const keys = Object.keys(value);
|
||||
return (
|
||||
required.every((key) => Object.hasOwn(value, key)) &&
|
||||
keys.every((key) => required.includes(key) || optional.includes(key))
|
||||
);
|
||||
}
|
||||
|
||||
function hasForbiddenControlCharacter(value: string, allowLineBreaks: boolean) {
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (
|
||||
code === 0x7f ||
|
||||
(code < 0x20 &&
|
||||
(!allowLineBreaks || (code !== 0x09 && code !== 0x0a && code !== 0x0d)))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeIdentifier(value: unknown, maxChars = 256): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maxChars &&
|
||||
value.trim() === value &&
|
||||
!hasForbiddenControlCharacter(value, false)
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeText(value: unknown, maxChars: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= maxChars &&
|
||||
!hasForbiddenControlCharacter(value, true) &&
|
||||
!HTML_OR_ACTIVE_URL.test(value) &&
|
||||
!value.startsWith('/') &&
|
||||
!value.startsWith('\\') &&
|
||||
!WINDOWS_ABSOLUTE_PATH.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function safeSerializedSize(value: unknown) {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) {
|
||||
return null;
|
||||
}
|
||||
return new TextEncoder().encode(serialized).byteLength;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function validateReadyPayload(payload: unknown) {
|
||||
if (
|
||||
!isPlainObject(payload) ||
|
||||
!hasExactKeys(payload, ['capabilities']) ||
|
||||
!Array.isArray(payload.capabilities) ||
|
||||
payload.capabilities.length > RUNTIME_CAPABILITIES.size
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const capabilities = payload.capabilities;
|
||||
return (
|
||||
new Set(capabilities).size === capabilities.length &&
|
||||
capabilities.every(
|
||||
(capability) =>
|
||||
typeof capability === 'string' && RUNTIME_CAPABILITIES.has(capability),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validateStatePayload(payload: unknown) {
|
||||
return (
|
||||
isPlainObject(payload) &&
|
||||
hasExactKeys(payload, ['status'], ['summary']) &&
|
||||
typeof payload.status === 'string' &&
|
||||
RUNTIME_STATES.has(payload.status) &&
|
||||
(payload.summary === undefined || isSafeText(payload.summary, 512))
|
||||
);
|
||||
}
|
||||
|
||||
function validateErrorPayload(payload: unknown) {
|
||||
return (
|
||||
isPlainObject(payload) &&
|
||||
hasExactKeys(payload, ['code', 'message', 'recoverable']) &&
|
||||
isSafeIdentifier(payload.code, 96) &&
|
||||
isSafeText(payload.message, 512) &&
|
||||
typeof payload.recoverable === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function validateSlicePayload(payload: unknown) {
|
||||
return (
|
||||
isPlainObject(payload) &&
|
||||
hasExactKeys(payload, ['sliceId', 'title', 'status'], ['summary']) &&
|
||||
isSafeIdentifier(payload.sliceId, 128) &&
|
||||
isSafeText(payload.title, 128) &&
|
||||
typeof payload.status === 'string' &&
|
||||
SLICE_STATES.has(payload.status) &&
|
||||
(payload.summary === undefined || isSafeText(payload.summary, 512))
|
||||
);
|
||||
}
|
||||
|
||||
function isInspectionMetric(
|
||||
value: unknown,
|
||||
): value is GameRuntimeInspectionMetric {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === 'boolean' ||
|
||||
(typeof value === 'number' && Number.isFinite(value)) ||
|
||||
isSafeText(value, 256)
|
||||
);
|
||||
}
|
||||
|
||||
function validateInspectionPayload(payload: unknown) {
|
||||
if (
|
||||
!isPlainObject(payload) ||
|
||||
!hasExactKeys(payload, ['inspectionId', 'label', 'metrics'], ['summary']) ||
|
||||
!isSafeIdentifier(payload.inspectionId, 128) ||
|
||||
!isSafeText(payload.label, 128) ||
|
||||
(payload.summary !== undefined && !isSafeText(payload.summary, 512)) ||
|
||||
!isPlainObject(payload.metrics)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const metrics = Object.entries(payload.metrics);
|
||||
return (
|
||||
metrics.length <= 32 &&
|
||||
metrics.every(
|
||||
([key, value]) =>
|
||||
isSafeIdentifier(key, 64) &&
|
||||
!DANGEROUS_FIELD.test(key) &&
|
||||
isInspectionMetric(value),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function validatePayload(type: string, payload: unknown) {
|
||||
switch (type) {
|
||||
case 'runtime.ready':
|
||||
return validateReadyPayload(payload);
|
||||
case 'runtime.state':
|
||||
return validateStatePayload(payload);
|
||||
case 'runtime.error':
|
||||
return validateErrorPayload(payload);
|
||||
case 'runtime.slice':
|
||||
return validateSlicePayload(payload);
|
||||
case 'runtime.inspection':
|
||||
return validateInspectionPayload(payload);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePreviewOrigin(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (
|
||||
url.protocol !== 'http:' ||
|
||||
url.hostname !== '127.0.0.1' ||
|
||||
!url.port ||
|
||||
url.pathname !== '/' ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateGameRunBridgeIdentity(identity: GameRunBridgeIdentity) {
|
||||
const previewOrigin = normalizePreviewOrigin(identity.previewOrigin);
|
||||
if (
|
||||
!isSafeIdentifier(identity.projectId) ||
|
||||
!isSafeIdentifier(identity.versionId) ||
|
||||
!isSafeIdentifier(identity.previewId) ||
|
||||
!Number.isSafeInteger(identity.projectRevision) ||
|
||||
identity.projectRevision <= 0 ||
|
||||
previewOrigin === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { ...identity, previewOrigin };
|
||||
}
|
||||
|
||||
function randomHex(bytesLength: number) {
|
||||
if (!globalThis.crypto?.getRandomValues) {
|
||||
return null;
|
||||
}
|
||||
const bytes = new Uint8Array(bytesLength);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
export function newGameRunSessionId() {
|
||||
const value = randomHex(16);
|
||||
return value ? `game-run-session-${value}` : null;
|
||||
}
|
||||
|
||||
export function newGameRunBridgeRequestId(direction: 'host' | 'runtime') {
|
||||
const value = randomHex(16);
|
||||
return value ? `${direction}-request-${value}` : null;
|
||||
}
|
||||
|
||||
export function createGameRunSession(
|
||||
identity: GameRunBridgeIdentity,
|
||||
sessionId: string,
|
||||
now: number,
|
||||
): GameRunSession {
|
||||
return {
|
||||
schemaVersion: 'game-creator-run-session.v1',
|
||||
protocolVersion: GAME_RUN_BRIDGE_PROTOCOL_VERSION,
|
||||
sessionId,
|
||||
previewId: identity.previewId,
|
||||
previewOrigin: identity.previewOrigin,
|
||||
projectId: identity.projectId,
|
||||
versionId: identity.versionId,
|
||||
projectRevision: identity.projectRevision,
|
||||
status: 'starting',
|
||||
stale: false,
|
||||
staleReason: null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGameHostMessage({
|
||||
identity,
|
||||
sessionId,
|
||||
requestId,
|
||||
sequence,
|
||||
type,
|
||||
}: {
|
||||
identity: GameRunBridgeIdentity;
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
sequence: number;
|
||||
type: GameHostMessageType;
|
||||
}): GameHostMessage {
|
||||
return {
|
||||
schemaVersion: GAME_HOST_MESSAGE_SCHEMA_VERSION,
|
||||
protocolVersion: GAME_RUN_BRIDGE_PROTOCOL_VERSION,
|
||||
requestId,
|
||||
sessionId,
|
||||
previewId: identity.previewId,
|
||||
projectId: identity.projectId,
|
||||
sequence,
|
||||
type,
|
||||
versionId: identity.versionId,
|
||||
projectRevision: identity.projectRevision,
|
||||
};
|
||||
}
|
||||
|
||||
function responseTypeMatches(
|
||||
runtimeType: string,
|
||||
hostType: GameHostMessageType,
|
||||
) {
|
||||
if (runtimeType === 'runtime.error') {
|
||||
return true;
|
||||
}
|
||||
if (runtimeType === 'runtime.ready') {
|
||||
return hostType === 'host.start';
|
||||
}
|
||||
if (runtimeType === 'runtime.state') {
|
||||
return [
|
||||
'host.pause',
|
||||
'host.resume',
|
||||
'host.stop',
|
||||
'host.state.request',
|
||||
].includes(hostType);
|
||||
}
|
||||
return (
|
||||
runtimeType === 'runtime.inspection' &&
|
||||
hostType === 'host.inspection.request'
|
||||
);
|
||||
}
|
||||
|
||||
export class GameRunBridgeRuntimeGuard {
|
||||
readonly #identity: GameRunBridgeIdentity;
|
||||
readonly #sessionId: string;
|
||||
readonly #pendingRequests = new Map<string, GameHostMessageType>();
|
||||
readonly #runtimeRequestIds = new Set<string>();
|
||||
#lastRuntimeSequence = 0;
|
||||
#ready = false;
|
||||
|
||||
constructor(identity: GameRunBridgeIdentity, sessionId: string) {
|
||||
this.#identity = identity;
|
||||
this.#sessionId = sessionId;
|
||||
}
|
||||
|
||||
registerHostRequest(requestId: string, type: GameHostMessageType) {
|
||||
if (
|
||||
!isSafeIdentifier(requestId, 128) ||
|
||||
this.#pendingRequests.has(requestId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.#pendingRequests.set(requestId, type);
|
||||
return true;
|
||||
}
|
||||
|
||||
accept(data: unknown): GameRuntimeMessage | null {
|
||||
const size = safeSerializedSize(data);
|
||||
if (
|
||||
size === null ||
|
||||
size > GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES ||
|
||||
!isPlainObject(data) ||
|
||||
!hasExactKeys(
|
||||
data,
|
||||
[
|
||||
'schemaVersion',
|
||||
'protocolVersion',
|
||||
'requestId',
|
||||
'sessionId',
|
||||
'previewId',
|
||||
'projectId',
|
||||
'sequence',
|
||||
'type',
|
||||
'versionId',
|
||||
'projectRevision',
|
||||
'payload',
|
||||
],
|
||||
['responseTo'],
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
data.schemaVersion !== GAME_RUNTIME_MESSAGE_SCHEMA_VERSION ||
|
||||
data.protocolVersion !== GAME_RUN_BRIDGE_PROTOCOL_VERSION ||
|
||||
data.sessionId !== this.#sessionId ||
|
||||
data.previewId !== this.#identity.previewId ||
|
||||
data.projectId !== this.#identity.projectId ||
|
||||
data.versionId !== this.#identity.versionId ||
|
||||
data.projectRevision !== this.#identity.projectRevision ||
|
||||
!isSafeIdentifier(data.requestId, 128) ||
|
||||
this.#runtimeRequestIds.has(data.requestId) ||
|
||||
!Number.isSafeInteger(data.sequence) ||
|
||||
data.sequence !== this.#lastRuntimeSequence + 1 ||
|
||||
typeof data.type !== 'string' ||
|
||||
!RUNTIME_MESSAGE_TYPES.has(data.type) ||
|
||||
!validatePayload(data.type, data.payload)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const responseTo = data.responseTo;
|
||||
if (responseTo !== undefined) {
|
||||
if (!isSafeIdentifier(responseTo, 128)) {
|
||||
return null;
|
||||
}
|
||||
const hostType = this.#pendingRequests.get(responseTo);
|
||||
if (!hostType || !responseTypeMatches(data.type, hostType)) {
|
||||
return null;
|
||||
}
|
||||
} else if (data.type === 'runtime.ready' || !this.#ready) {
|
||||
return null;
|
||||
}
|
||||
if (data.type === 'runtime.ready' && this.#ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.#lastRuntimeSequence = data.sequence;
|
||||
this.#runtimeRequestIds.add(data.requestId);
|
||||
if (responseTo !== undefined) {
|
||||
this.#pendingRequests.delete(responseTo);
|
||||
}
|
||||
if (data.type === 'runtime.ready') {
|
||||
this.#ready = true;
|
||||
}
|
||||
return data as unknown as GameRuntimeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export function gameRunSessionFromRuntimeMessage(
|
||||
session: GameRunSession,
|
||||
message: GameRuntimeMessage,
|
||||
now: number,
|
||||
): GameRunSession {
|
||||
if (message.type === 'runtime.error') {
|
||||
return { ...session, status: 'failed', updatedAt: now };
|
||||
}
|
||||
if (message.type === 'runtime.ready') {
|
||||
return { ...session, status: 'playing', updatedAt: now };
|
||||
}
|
||||
if (message.type !== 'runtime.state') {
|
||||
return { ...session, updatedAt: now };
|
||||
}
|
||||
const status =
|
||||
message.payload.status === 'completed'
|
||||
? 'paused'
|
||||
: message.payload.status === 'starting'
|
||||
? 'starting'
|
||||
: message.payload.status;
|
||||
return { ...session, status, updatedAt: now };
|
||||
}
|
||||
@@ -4524,7 +4524,7 @@ iframe.preview-frame {
|
||||
|
||||
.game-run-panels {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -4602,6 +4602,23 @@ iframe.preview-frame {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-run-panels button {
|
||||
justify-self: start;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dfb59f;
|
||||
border-radius: 999px;
|
||||
background: #fff8f4;
|
||||
color: #855c4c;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-run-panels button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
||||
@@ -29,12 +29,15 @@ import type {
|
||||
GameCreationAppAgentGroup,
|
||||
GameCreationAppManifest,
|
||||
GameCreationAppPreviewState,
|
||||
GameRunSession,
|
||||
GameRuntimeMessage,
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
RunnableGameVersion,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
LocalGamePreviewFrame,
|
||||
type LocalGamePreviewFrameHandle,
|
||||
resolveEmbeddedPreviewUrl,
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel';
|
||||
@@ -159,6 +162,10 @@ type RunnableGameVersionLaunchResult = {
|
||||
port: number;
|
||||
root: string;
|
||||
};
|
||||
previewIdentity: {
|
||||
previewId: string;
|
||||
origin: string;
|
||||
};
|
||||
};
|
||||
|
||||
const categoryOrder: ResourceCategory[] = [
|
||||
@@ -445,6 +452,14 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
const [activeRunnablePreviewVersionId, setActiveRunnablePreviewVersionId] =
|
||||
useState<string | null>(null);
|
||||
const [activePreviewIdentity, setActivePreviewIdentity] = useState<{
|
||||
previewId: string;
|
||||
origin: string;
|
||||
} | null>(null);
|
||||
const [gameRunSession, setGameRunSession] =
|
||||
useState<GameRunSession | null>(null);
|
||||
const [latestRuntimeMessage, setLatestRuntimeMessage] =
|
||||
useState<GameRuntimeMessage | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
|
||||
status: 'idle',
|
||||
resourceId: null,
|
||||
@@ -459,6 +474,7 @@ export default function ProjectDevelopmentView({
|
||||
});
|
||||
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
|
||||
const resourceCanvasRef = useRef<HTMLDivElement>(null);
|
||||
const gamePreviewFrameRef = useRef<LocalGamePreviewFrameHandle>(null);
|
||||
const resourceFocusRef = useRef<HTMLElement>(null);
|
||||
const resourceListScrollRef = useRef({ left: 0, top: 0 });
|
||||
const restoreResourceListScrollRef = useRef(false);
|
||||
@@ -478,6 +494,16 @@ export default function ProjectDevelopmentView({
|
||||
activeRunnablePreviewVersionId === currentRunnableVersionId
|
||||
? resolveEmbeddedPreviewUrl(preview)
|
||||
: null;
|
||||
const runBridgeIdentity =
|
||||
embeddedPreviewUrl && currentRunnableVersion && activePreviewIdentity
|
||||
? {
|
||||
projectId: manifest.projectId,
|
||||
versionId: currentRunnableVersion.versionId,
|
||||
projectRevision: currentRunnableVersion.projectRevision,
|
||||
previewId: activePreviewIdentity.previewId,
|
||||
previewOrigin: activePreviewIdentity.origin,
|
||||
}
|
||||
: null;
|
||||
const runAvailable = runnableVersions.length > 0 && currentRunnableVersion !== null;
|
||||
const projectedResources = useMemo(
|
||||
() => projectResourcesFromReadModels(manifest, attachments, agentResults),
|
||||
@@ -772,6 +798,9 @@ export default function ProjectDevelopmentView({
|
||||
|
||||
useEffect(() => {
|
||||
setActiveRunnablePreviewVersionId(null);
|
||||
setActivePreviewIdentity(null);
|
||||
setGameRunSession(null);
|
||||
setLatestRuntimeMessage(null);
|
||||
}, [manifest.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1001,6 +1030,9 @@ export default function ProjectDevelopmentView({
|
||||
setRunStatus('正在启动可运行版本');
|
||||
setSelectedRunnableVersionId(versionId);
|
||||
setActiveRunnablePreviewVersionId(null);
|
||||
setActivePreviewIdentity(null);
|
||||
setGameRunSession(null);
|
||||
setLatestRuntimeMessage(null);
|
||||
onPreviewChange?.(null);
|
||||
try {
|
||||
const result = await invoke<RunnableGameVersionLaunchResult>(
|
||||
@@ -1019,12 +1051,16 @@ export default function ProjectDevelopmentView({
|
||||
});
|
||||
setSelectedRunnableVersionId(result.version.versionId);
|
||||
setActiveRunnablePreviewVersionId(result.version.versionId);
|
||||
setRunStatus(`正在运行版本 ${result.version.versionId}`);
|
||||
setActivePreviewIdentity(result.previewIdentity ?? null);
|
||||
setRunStatus(`正在建立版本 ${result.version.versionId} 的运行会话`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setSelectedRunnableVersionId(manifest.currentRunnableVersionId ?? '');
|
||||
setActiveRunnablePreviewVersionId(null);
|
||||
setActivePreviewIdentity(null);
|
||||
setGameRunSession(null);
|
||||
setLatestRuntimeMessage(null);
|
||||
setRunStatus(message);
|
||||
onPreviewChange?.({ status: 'failed' });
|
||||
return false;
|
||||
@@ -1047,6 +1083,53 @@ export default function ProjectDevelopmentView({
|
||||
void launchRunnableVersion(currentRunnableVersion.versionId);
|
||||
}
|
||||
|
||||
function showResourcesView() {
|
||||
gamePreviewFrameRef.current?.stop();
|
||||
setMode('resources');
|
||||
}
|
||||
|
||||
function handleGameRunSessionChange(session: GameRunSession) {
|
||||
setGameRunSession(session);
|
||||
if (session.status === 'starting') {
|
||||
setLatestRuntimeMessage(null);
|
||||
}
|
||||
setRunStatus(
|
||||
{
|
||||
starting: '等待游戏运行时就绪',
|
||||
playing: `正在运行版本 ${session.versionId}`,
|
||||
paused: `版本 ${session.versionId} 已暂停`,
|
||||
stopped: '运行会话已停止',
|
||||
failed: '游戏运行会话失败',
|
||||
}[session.status],
|
||||
);
|
||||
}
|
||||
|
||||
function handleGameRuntimeMessage(message: GameRuntimeMessage) {
|
||||
setLatestRuntimeMessage(message);
|
||||
if (message.type === 'runtime.error') {
|
||||
setRunStatus(message.payload.message || '游戏运行时报告错误');
|
||||
} else if (message.type === 'runtime.slice') {
|
||||
setRunStatus(`测试切片:${message.payload.title} · ${message.payload.status}`);
|
||||
} else if (message.type === 'runtime.inspection') {
|
||||
setRunStatus(`已接收检查结果:${message.payload.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGameRunPause() {
|
||||
if (gameRunSession?.status === 'paused') {
|
||||
if (gamePreviewFrameRef.current?.resume()) {
|
||||
setRunStatus('正在继续运行会话');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
gameRunSession?.status === 'playing' &&
|
||||
gamePreviewFrameRef.current?.pause()
|
||||
) {
|
||||
setRunStatus('正在暂停运行会话');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="launcher-page launcher-project-development game-project-workbench"
|
||||
@@ -1071,7 +1154,7 @@ export default function ProjectDevelopmentView({
|
||||
role="tab"
|
||||
aria-selected={mode === 'resources'}
|
||||
className={mode === 'resources' ? 'is-active' : ''}
|
||||
onClick={() => setMode('resources')}
|
||||
onClick={showResourcesView}
|
||||
>
|
||||
资源管理
|
||||
</button>
|
||||
@@ -1519,8 +1602,13 @@ export default function ProjectDevelopmentView({
|
||||
<div className="game-run-preview">
|
||||
{embeddedPreviewUrl ? (
|
||||
<LocalGamePreviewFrame
|
||||
ref={gamePreviewFrameRef}
|
||||
title={`${projectName} 游戏运行画面`}
|
||||
preview={preview}
|
||||
runIdentity={runBridgeIdentity}
|
||||
onSessionChange={handleGameRunSessionChange}
|
||||
onRuntimeMessage={handleGameRuntimeMessage}
|
||||
onBridgeError={setRunStatus}
|
||||
/>
|
||||
) : (
|
||||
<div className="game-run-preview-empty">
|
||||
@@ -1557,6 +1645,40 @@ export default function ProjectDevelopmentView({
|
||||
<p>当前无可运行版本</p>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="运行会话信息">
|
||||
<header>
|
||||
<Gamepad2 size={16} aria-hidden="true" />
|
||||
运行会话
|
||||
</header>
|
||||
{gameRunSession ? (
|
||||
<dl>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{gameRunSession.status}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>协议</dt>
|
||||
<dd>{gameRunSession.protocolVersion}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>消息</dt>
|
||||
<dd>{latestRuntimeMessage?.type ?? '等待 ready'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p>等待 iframe 建立会话</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
gameRunSession?.status !== 'playing' &&
|
||||
gameRunSession?.status !== 'paused'
|
||||
}
|
||||
onClick={toggleGameRunPause}
|
||||
>
|
||||
{gameRunSession?.status === 'paused' ? '继续' : '暂停'}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
{runStatus ? (
|
||||
<p className="game-run-status" role="status">
|
||||
|
||||
@@ -2109,6 +2109,155 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('binds P3 runtime messages to the current iframe, origin, preview and reload session', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-run-bridge',
|
||||
'运行桥测试',
|
||||
);
|
||||
const version = runnableVersionFixture(
|
||||
manifest.projectId,
|
||||
'runnable-r9',
|
||||
9,
|
||||
);
|
||||
manifest.runnableVersions = [version];
|
||||
manifest.currentRunnableVersionId = version.versionId;
|
||||
const previewIdentity = {
|
||||
previewId: 'preview-run-bridge-1',
|
||||
origin: 'http://127.0.0.1:4199',
|
||||
};
|
||||
manifest.preview = {
|
||||
status: 'running',
|
||||
url: `${previewIdentity.origin}/`,
|
||||
port: 4199,
|
||||
};
|
||||
const invoke = vi.fn(async () => ({
|
||||
manifest,
|
||||
version,
|
||||
preview: {
|
||||
url: `${previewIdentity.origin}/`,
|
||||
port: 4199,
|
||||
root: '/tmp/workbench-run-bridge',
|
||||
},
|
||||
previewIdentity,
|
||||
}));
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
projectName: manifest.name,
|
||||
projectPath: '/tmp/workbench-run-bridge',
|
||||
manifest,
|
||||
attachments: [],
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
const iframe = (await screen.findByTitle(
|
||||
'运行桥测试 游戏运行画面',
|
||||
)) as HTMLIFrameElement;
|
||||
const frameWindow = iframe.contentWindow;
|
||||
if (!frameWindow) {
|
||||
throw new Error('missing iframe contentWindow');
|
||||
}
|
||||
const postMessage = vi.spyOn(frameWindow, 'postMessage');
|
||||
fireEvent.load(iframe);
|
||||
expect(postMessage).toHaveBeenCalledTimes(1);
|
||||
const start = postMessage.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(start).toMatchObject({
|
||||
protocolVersion: 'game-creator-run-bridge.v1',
|
||||
projectId: manifest.projectId,
|
||||
previewId: previewIdentity.previewId,
|
||||
sequence: 1,
|
||||
type: 'host.start',
|
||||
versionId: version.versionId,
|
||||
projectRevision: version.projectRevision,
|
||||
});
|
||||
expect(postMessage.mock.calls[0]?.[1]).toBe(previewIdentity.origin);
|
||||
|
||||
const ready = {
|
||||
schemaVersion: 'game-creator-runtime-message.v1',
|
||||
protocolVersion: 'game-creator-run-bridge.v1',
|
||||
requestId: 'runtime-request-ready',
|
||||
responseTo: start.requestId,
|
||||
sessionId: start.sessionId,
|
||||
previewId: start.previewId,
|
||||
projectId: start.projectId,
|
||||
sequence: 1,
|
||||
type: 'runtime.ready',
|
||||
versionId: start.versionId,
|
||||
projectRevision: start.projectRevision,
|
||||
payload: { capabilities: ['state', 'slice', 'inspection'] },
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: ready,
|
||||
origin: 'http://127.0.0.1:4200',
|
||||
source: frameWindow,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('等待游戏运行时就绪')).not.toBeNull();
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: ready,
|
||||
origin: previewIdentity.origin,
|
||||
source: frameWindow,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('playing')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂停' }));
|
||||
const pause = postMessage.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(pause).toMatchObject({ sequence: 2, type: 'host.pause' });
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
...ready,
|
||||
requestId: 'runtime-request-pause',
|
||||
responseTo: pause.requestId,
|
||||
sequence: 2,
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'paused' },
|
||||
},
|
||||
origin: previewIdentity.origin,
|
||||
source: frameWindow,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByText('paused')).not.toBeNull();
|
||||
|
||||
fireEvent.load(iframe);
|
||||
const reloadedStart = postMessage.mock.calls.at(-1)?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(reloadedStart).toMatchObject({ sequence: 1, type: 'host.start' });
|
||||
expect(reloadedStart.sessionId).not.toBe(start.sessionId);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: {
|
||||
...ready,
|
||||
requestId: 'runtime-request-old-session',
|
||||
responseTo: undefined,
|
||||
sequence: 3,
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing' },
|
||||
},
|
||||
origin: previewIdentity.origin,
|
||||
source: frameWindow,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('starting')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a precise runnable version launch failure without embedding a preview', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-version-failure',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameRuntimeMessage } from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createGameHostMessage,
|
||||
createGameRunSession,
|
||||
type GameRunBridgeIdentity,
|
||||
GameRunBridgeRuntimeGuard,
|
||||
gameRunSessionFromRuntimeMessage,
|
||||
validateGameRunBridgeIdentity,
|
||||
} from '../src/features/project-workspace/gameRunBridge';
|
||||
|
||||
const identity: GameRunBridgeIdentity = {
|
||||
projectId: 'project-1',
|
||||
versionId: 'runnable-r7',
|
||||
projectRevision: 7,
|
||||
previewId: 'preview-1',
|
||||
previewOrigin: 'http://127.0.0.1:4173',
|
||||
};
|
||||
|
||||
function runtimeMessage(
|
||||
overrides: Partial<GameRuntimeMessage> = {},
|
||||
): GameRuntimeMessage {
|
||||
return {
|
||||
schemaVersion: 'game-creator-runtime-message.v1',
|
||||
protocolVersion: 'game-creator-run-bridge.v1',
|
||||
requestId: 'runtime-request-1',
|
||||
responseTo: 'host-request-1',
|
||||
sessionId: 'session-1',
|
||||
previewId: identity.previewId,
|
||||
projectId: identity.projectId,
|
||||
sequence: 1,
|
||||
type: 'runtime.ready',
|
||||
versionId: identity.versionId,
|
||||
projectRevision: identity.projectRevision,
|
||||
payload: { capabilities: ['state', 'slice', 'inspection'] },
|
||||
...overrides,
|
||||
} as GameRuntimeMessage;
|
||||
}
|
||||
|
||||
describe('P3 Game Run Bridge', () => {
|
||||
it('creates a fully bound host request and accepts its exact ready response', () => {
|
||||
expect(validateGameRunBridgeIdentity(identity)).toEqual(identity);
|
||||
const host = createGameHostMessage({
|
||||
identity,
|
||||
sessionId: 'session-1',
|
||||
requestId: 'host-request-1',
|
||||
sequence: 1,
|
||||
type: 'host.start',
|
||||
});
|
||||
expect(host).toMatchObject({
|
||||
protocolVersion: 'game-creator-run-bridge.v1',
|
||||
projectId: identity.projectId,
|
||||
previewId: identity.previewId,
|
||||
type: 'host.start',
|
||||
});
|
||||
|
||||
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
||||
expect(guard.registerHostRequest(host.requestId, host.type)).toBe(true);
|
||||
expect(guard.accept(runtimeMessage())).toEqual(runtimeMessage());
|
||||
expect(
|
||||
guard.accept(
|
||||
runtimeMessage({
|
||||
requestId: 'runtime-request-2',
|
||||
responseTo: undefined,
|
||||
sequence: 2,
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing' },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type: 'runtime.state' });
|
||||
});
|
||||
|
||||
it('rejects stale identities, bad ordering and uncorrelated ready messages', () => {
|
||||
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
||||
guard.registerHostRequest('host-request-1', 'host.start');
|
||||
expect(
|
||||
guard.accept(runtimeMessage({ responseTo: 'unknown-request' })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
guard.accept(runtimeMessage({ projectId: 'other-project' })),
|
||||
).toBeNull();
|
||||
expect(guard.accept(runtimeMessage({ sequence: 2 }))).toBeNull();
|
||||
expect(guard.accept(runtimeMessage())).not.toBeNull();
|
||||
expect(
|
||||
guard.accept(
|
||||
runtimeMessage({
|
||||
requestId: 'runtime-request-2',
|
||||
responseTo: undefined,
|
||||
sequence: 2,
|
||||
previewId: 'old-preview',
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'paused' },
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces message and payload whitelists without consuming a rejected sequence', () => {
|
||||
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
|
||||
guard.registerHostRequest('host-request-1', 'host.start');
|
||||
expect(guard.accept(runtimeMessage())).not.toBeNull();
|
||||
|
||||
const rejectedPayloads = [
|
||||
{
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing', summary: '/tmp/secret' },
|
||||
},
|
||||
{
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing', summary: 'https://untrusted.example' },
|
||||
},
|
||||
{
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing', summary: '<script>run()</script>' },
|
||||
},
|
||||
{
|
||||
type: 'runtime.inspection',
|
||||
payload: {
|
||||
inspectionId: 'inspection-1',
|
||||
label: '运行画面',
|
||||
metrics: { filePath: '/tmp/secret' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'runtime.inspection',
|
||||
payload: {
|
||||
inspectionId: 'inspection-1',
|
||||
label: '运行画面',
|
||||
metrics: { nested: { command: 'host.stop' } },
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
for (const [index, rejected] of rejectedPayloads.entries()) {
|
||||
expect(
|
||||
guard.accept(
|
||||
runtimeMessage({
|
||||
requestId: `runtime-request-rejected-${index}`,
|
||||
responseTo: undefined,
|
||||
sequence: 2,
|
||||
...rejected,
|
||||
} as Partial<GameRuntimeMessage>),
|
||||
),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
guard.accept({
|
||||
...runtimeMessage({
|
||||
requestId: 'runtime-request-extra-field',
|
||||
responseTo: undefined,
|
||||
sequence: 2,
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing' },
|
||||
}),
|
||||
command: 'host.stop',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
guard.accept(
|
||||
runtimeMessage({
|
||||
requestId: 'runtime-request-2',
|
||||
responseTo: undefined,
|
||||
sequence: 2,
|
||||
type: 'runtime.inspection',
|
||||
payload: {
|
||||
inspectionId: 'inspection-1',
|
||||
label: '运行画面',
|
||||
metrics: { fps: 60, visible: true },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ type: 'runtime.inspection' });
|
||||
|
||||
const oversized = runtimeMessage({
|
||||
requestId: 'runtime-request-3',
|
||||
responseTo: undefined,
|
||||
sequence: 3,
|
||||
type: 'runtime.state',
|
||||
payload: { status: 'playing', summary: 'x'.repeat(70 * 1024) },
|
||||
});
|
||||
expect(guard.accept(oversized)).toBeNull();
|
||||
});
|
||||
|
||||
it('projects only validated runtime states into the ephemeral session', () => {
|
||||
const session = createGameRunSession(identity, 'session-1', 100);
|
||||
expect(session.status).toBe('starting');
|
||||
const ready = runtimeMessage();
|
||||
const playing = gameRunSessionFromRuntimeMessage(session, ready, 101);
|
||||
expect(playing).toMatchObject({ status: 'playing', updatedAt: 101 });
|
||||
const failed = gameRunSessionFromRuntimeMessage(
|
||||
playing,
|
||||
runtimeMessage({
|
||||
type: 'runtime.error',
|
||||
payload: { code: 'game-error', message: '运行失败', recoverable: true },
|
||||
}),
|
||||
102,
|
||||
);
|
||||
expect(failed).toMatchObject({ status: 'failed', updatedAt: 102 });
|
||||
});
|
||||
|
||||
it('accepts only exact 127.0.0.1 origins with explicit ports', () => {
|
||||
expect(
|
||||
validateGameRunBridgeIdentity({
|
||||
...identity,
|
||||
previewOrigin: 'http://localhost:4173',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateGameRunBridgeIdentity({
|
||||
...identity,
|
||||
previewOrigin: 'http://127.0.0.1:4173/path',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-08-04`(可运行版本 P0/P1)
|
||||
更新时间:`2026-08-04`(可运行版本 P0/P1,运行桥 P3)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -348,7 +348,7 @@ type GameCreationAppManifest = {
|
||||
- 运行入口只读取当前可运行版本。切换版本时先停止当前项目旧 preview、立即卸载 iframe,再复核并启动目标快照;启动成功后回写当前选择和 loopback preview。无版本、版本损坏、版本描述 revision 不一致、权限拒绝或预览启动失败分别显示明确原因。
|
||||
- 资源管理只从 `runnableVersions` 投影“可运行版本”卡。当前选择版本的全部现存绑定资产始终高亮;点击其它版本可只读查看其父子关系和历史绑定,不改变当前运行选择。
|
||||
|
||||
### 5.5 测试切片与数值参数(P2)
|
||||
### 5.5 运行会话与 Host/Game Bridge(P3)
|
||||
|
||||
```ts
|
||||
type GameTestSlice = {
|
||||
@@ -374,7 +374,11 @@ type GameTunableParameterDefinition = {
|
||||
};
|
||||
|
||||
type GameRunSession = {
|
||||
schemaVersion: 'game-creator-run-session.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
previewOrigin: `http://127.0.0.1:${number}`;
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
@@ -387,25 +391,51 @@ type GameRunSession = {
|
||||
|
||||
type GameHostMessage = {
|
||||
schemaVersion: 'game-creator-host-message.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
projectId: string;
|
||||
sequence: number;
|
||||
type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop';
|
||||
type:
|
||||
| 'host.start'
|
||||
| 'host.pause'
|
||||
| 'host.resume'
|
||||
| 'host.stop'
|
||||
| 'host.state.request'
|
||||
| 'host.inspection.request';
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
};
|
||||
|
||||
type GameRuntimeMessage = {
|
||||
schemaVersion: 'game-creator-runtime-message.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
requestId: string;
|
||||
responseTo?: string;
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
projectId: string;
|
||||
sequence: number;
|
||||
type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed';
|
||||
type:
|
||||
| 'runtime.ready'
|
||||
| 'runtime.state'
|
||||
| 'runtime.error'
|
||||
| 'runtime.slice'
|
||||
| 'runtime.inspection';
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
detail?: Record<string, unknown>;
|
||||
payload: RuntimeReadyPayload | RuntimeStatePayload | RuntimeErrorPayload
|
||||
| RuntimeSlicePayload | RuntimeInspectionPayload;
|
||||
};
|
||||
```
|
||||
|
||||
revision 不一致统一表示编辑工作树已经晚于当前 Session 绑定版本;Session 和切片标记 `stale`,但不热更新、不改写旧版本,也不把 stale 当失败。参数写入立即增加编辑态 revision;当前 preview/slice 保持旧 revision,并显示“需要重新拉起”。P1 尚不实现切片、参数和 Runtime message bridge。
|
||||
- P3 只在 P1 已启动的正式可运行版本 loopback preview 上建立会话,不接受普通工作树 preview、远程 URL 或其它项目的预览。`PreviewRegistry` 为每次服务启动生成新的 `previewId` 并返回精确 `http://127.0.0.1:<port>` origin;iframe 每次 load 生成新的 `sessionId`,旧 iframe、旧 preview、旧 session 与组件卸载后的消息全部失效。
|
||||
- 宿主发送 `host.start` 后,游戏必须用 `runtime.ready.responseTo` 关联该 request;其后 `runtime.state / runtime.error / runtime.slice / runtime.inspection` 可以作为受控主动上报,带 `responseTo` 时必须精确命中仍在等待且类型相容的 Host request。Host 与 Runtime 各自的 `sequence` 从 `1` 开始严格连续递增,重复、倒序或跳号消息丢弃。
|
||||
- 宿主只接受 `event.source === 当前 iframe.contentWindow` 且 `event.origin === PreviewRegistry 返回 origin` 的消息;随后逐项校验 protocol、schema、projectId、versionId、projectRevision、previewId 与 sessionId。iframe 重载、版本切换、preview 替换和会话停止时,先发送当前 session 的 `host.stop`,再清空请求表、序列与 Runtime 投影。
|
||||
- 单条消息结构化序列化后最多 `64 KiB`,顶层和 payload 都使用字段白名单。`runtime.ready` 只声明 `state / slice / inspection` 能力;`runtime.state` 只上报运行状态和短摘要;`runtime.error` 只上报受限错误码、文本和是否可恢复;`runtime.slice` 只上报切片身份、标题、状态和摘要;`runtime.inspection` 只上报受限 label/summary 与最多 32 个标量 metric。路径、URL、HTML、脚本、宿主命令、嵌套对象与未知字段一律拒绝,游戏消息不能触发 Tauri、文件、命令、Agent 或生成能力。
|
||||
- 预览服务在响应 HTML 时只注入宿主拥有的同源 `game-creator-run-bridge.v1` bootstrap,不修改不可变版本快照字节或摘要。bootstrap 为旧可运行版本提供 ready/state/pause/resume/stop 基线,并向游戏暴露仅能上报上述五类安全消息的 API;权限和安全结论始终由宿主校验,不信任游戏脚本自报。
|
||||
- revision 不一致继续表示编辑工作树已经晚于当前 Session 绑定版本;Session 标记 `stale`,但不热更新、不改写旧版本,也不把 stale 当失败。本次不实现 P2 资源替换与下一版本,也不实现测试切片编排、参数写入或数值面板;`runtime.slice` 只是 P3 传输层的安全上报能力。
|
||||
|
||||
### 5.6 Agent 泥点归因(P2)
|
||||
|
||||
@@ -438,13 +468,16 @@ type ProjectAgentMudPointAttribution = {
|
||||
- 当前版本绑定资源在 dependency / type 两种资源管理布局中高亮。
|
||||
- 无版本、损坏、revision 不一致和 preview 启动失败提供独立错误状态。
|
||||
|
||||
### P2
|
||||
### P2(本次跳过)
|
||||
|
||||
- 测试切片正式协议与恢复。
|
||||
- 参数注册表、立即写编辑态和预览重新拉起。
|
||||
- 泥点归因 read model。
|
||||
- Agent.md/Skill 安全合同。
|
||||
- 高风险审批 Rank 与无需审批运行合同。
|
||||
- 资源替换与下一不可变版本按独立方案后续实现;P3 不得提前写入 `resource-replacement` 版本。
|
||||
|
||||
### P3
|
||||
|
||||
- 在现有 `PreviewRegistry` 生命周期中增加 preview 身份和精确 loopback origin,不重建预览服务。
|
||||
- 工作台 iframe 建立一次性 `GameRunSession`,完成 Host/Game 双向桥、状态投影、request 关联和严格失效处理。
|
||||
- 接入 host-owned 同源 bootstrap,使当前正式可运行版本可以完成 ready/state 基线握手,并为游戏提供受限上报 API。
|
||||
- 覆盖来源窗口、origin、全身份、协议、消息类型、payload、容量、顺序、重载、切换和停止负向测试。
|
||||
|
||||
## 7. 验收
|
||||
|
||||
@@ -497,9 +530,18 @@ type ProjectAgentMudPointAttribution = {
|
||||
4. 根目录全量 Vitest、前后端 typecheck / lint / build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码、生产运维与部署门禁全部通过后,阶段七才允许提交。
|
||||
5. 本地 `.env`、`.env.local`、密钥、缓存、日志和构建产物不进入阶段七提交;提交前再次执行编码检查和 `git diff --check`。
|
||||
|
||||
### 7.6 P3 运行会话与桥验收
|
||||
|
||||
1. 启动正式可运行版本后,PreviewRegistry 返回新的 previewId 与精确 loopback origin;iframe load 创建新 session,Host 发出的第一条消息固定为 `host.start / sequence=1`。
|
||||
2. 只有当前 iframe contentWindow、精确 origin、完整身份匹配、schema/protocol 正确、sequence 连续且 request 关联合法的消息能推进会话;旧 iframe、旧端口、旧 preview/session 和其它项目消息均不改变 UI。
|
||||
3. ready/state/error/slice/inspection 五类消息按各自 payload 白名单通过;未知类型、超过 64 KiB、额外字段、嵌套数据、路径、URL、HTML、脚本或宿主命令内容被拒绝。
|
||||
4. iframe reload、切换版本、切回资源管理或组件卸载都会停止并失效旧 session;迟到消息不能复活旧状态,新 load 使用新 sessionId 和从 1 重新开始的双向 sequence。
|
||||
5. 预览响应注入的 bootstrap 不修改快照文件或摘要;旧版本至少完成 ready/playing 基线握手,pause/resume/stop 与 state/inspection request 均按 requestId 回应。
|
||||
6. 本阶段不出现资源替换、新版本写入、测试切片控件、数值微调面板或任何游戏触发的 Tauri/文件/命令能力。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;测试切片、数值参数、Host/Runtime 消息桥与泥点归因仍属于 P2。
|
||||
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;P3 只实现运行会话与 Host/Game Bridge,测试切片编排、数值参数和泥点归因仍不在本次范围。
|
||||
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
- 不开放普通用户 Agent.md/Skill。
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-04 工作台 P3 在正式可运行预览上建立一次性 Host/Game Bridge
|
||||
|
||||
- 背景:P0/P1 已用不可变快照和专用启动命令建立正式可运行版本,但运行 iframe 仍没有可验证的会话身份、消息顺序、请求关联和 reload 后失效语义;飞书需求同时明确本次跳过 P2 资源替换,只实现 P3 底层运行桥。
|
||||
- 决策:协议固定为 `game-creator-run-bridge.v1`。每次正式可运行 preview 启动由 `PreviewRegistry` 生成新的 `previewId` 和精确 `http://127.0.0.1:<port>` origin,每次 iframe load 用 Web Crypto 生成新的 `sessionId`;Host/Runtime 消息完整绑定 project/version/revision/preview/session,双向 sequence 从 1 严格连续,ready 必须关联 start,其它响应只能命中仍在等待且类型相容的 request。
|
||||
- 安全边界:Host 只接受当前 iframe `contentWindow` 和 Registry origin 的消息,再执行 64 KiB、精确字段、类型白名单与各 payload 白名单校验;文本、inspection 标量及数量均有界,并拒绝路径、URL、HTML、脚本、宿主命令和嵌套数据。合法消息只投影前端临时会话状态,不得触发 Tauri、文件、命令、Agent 或生成能力。iframe reload、版本/preview 切换、停止、返回资源管理和卸载都会先 best-effort stop,再失效旧会话。
|
||||
- 兼容策略:loopback preview 在 HTML 响应中注入宿主拥有的同源 bootstrap,不修改不可变快照文件或摘要;旧游戏至少具备 ready/state/pause/resume/stop 和 state/inspection request 基线。游戏暴露 API 只能上报 ready/state/error/slice/inspection 合同内数据,最终信任判断仍只在 Host 守卫完成。
|
||||
- 范围:本次不实现 P2 资源替换或下一版本,不实现测试切片编排、数值参数写入、微调面板或泥点归因;`runtime.slice` 仅是 P3 安全传输类型。
|
||||
- 验证方式:前端纯协议测试覆盖身份、顺序、关联、白名单、危险字段和大小限制;AppSurface 覆盖 iframe/source/origin、暂停响应、reload 新 session 与旧消息拒绝;Rust 覆盖 Registry 身份轮换、bootstrap 注入和快照字节不变,并运行 shell typecheck、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-04 工作台 P0/P1 以不可变快照建立唯一可运行版本事实源
|
||||
|
||||
- 背景:2026-08-03 资源管理阶段新增的 `manifest.versions` 只用于只读项目迭代卡,当时明确不承担运行版本切换;本轮飞书需求正式进入运行工作台 P0/P1,不能再以 `code-prototype=completed`、普通 preview、checkpoint 或前端状态推断游戏已经可运行。
|
||||
- 决策:保留 `manifest.versions` 作为通用迭代历史,新增由可信 Rust / Runtime 独占追加的 `runnableVersions` 与唯一可变选择指针 `currentRunnableVersionId`。每条记录必须绑定 project/revision、父版本、三项通过凭证、固定 entry、资源槽位和不可变 SHA-256;首版为 `initial`,后续为 `agent-revision`,已有记录在 manifest 存储边界禁止修改、删除或重排。
|
||||
- 产物边界:Runtime 只有在当前 revision 的 `preview-playtest` 传递前置任务闭包、`game.static_smoke`、`preview.validate` 与真实试玩证据全部通过后才自动登记;试玩后的发布节点不阻断登记。`game/` 与可选 `assets/` 被有界复制到 `.agent/runnable-versions/<versionId>/artifact`,版本描述与文件摘要在每次启动前复核。旧版本始终运行该快照,不运行当前工作树;快照已安装但 manifest 写入中断时,重试复用原描述时间与摘要,身份或内容冲突则失败关闭。
|
||||
- 工作台消费:运行入口只读取 `runnableVersions/currentRunnableVersionId`。切换时先停止旧 preview 并卸载 iframe,复核并启动目标快照,成功后才更新选择指针和 loopback preview;无版本、项目身份不符、快照损坏、revision 不一致和启动失败必须原样展示明确错误。资源管理只投影正式可运行版本,并持续高亮当前版本仍存在的绑定资产。
|
||||
- P0/P2 边界:同时冻结测试切片、运行会话、数值参数、资源描述及 Host/Runtime 消息的 TypeScript/Rust 合同;数值参数的 `codeMutationAllowed` 固定为 `false`,消息类型固定为 `host.* / runtime.*`。本轮不实现测试切片、数值微调、消息桥、泥点归因或资源替换,这些仍属于 P2。
|
||||
- 当时的实现边界:同时冻结测试切片、运行会话、数值参数、资源描述及 Host/Runtime 消息的 TypeScript/Rust 合同;数值参数的 `codeMutationAllowed` 固定为 `false`,消息类型固定为 `host.* / runtime.*`。该 P0/P1 变更当时不实现测试切片、数值微调、消息桥、泥点归因或资源替换;其中消息桥随后按本文件 P3 决策实现,资源替换仍属于 P2。
|
||||
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
|
||||
|
||||
## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口
|
||||
|
||||
@@ -358,13 +358,13 @@ game-project/
|
||||
|
||||
2026-07-18 起,项目开发页按《陶泥儿GameAgent-V1.0 项目开发界面需求》先落一版可交互工作台。该切片替代“正式用户项目页只有聊天与只读 Agent 列表”的旧视觉口径,但不改变 Runtime、manifest、预览和计费事实源。
|
||||
|
||||
2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
|
||||
2026-07-20 起,产品状态机、P0/P1/P2/P3 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `资源管理 / 运行` 切换。2026-08-04 起运行入口不再读取 `code-prototype` 任务状态,只读取 manifest 的正式 `runnableVersions + currentRunnableVersionId`;无版本时保持视觉不可用但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
|
||||
- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。测试切片、参数调整和 Host/Runtime message bridge 已冻结为正式 P2 合同,不再称为本地 UI 草稿,本轮不接线。
|
||||
- 运行表现层嵌入当前项目的 loopback 游戏画面。P1 由专用可运行版本命令停止旧 preview、卸载 iframe、复核版本快照并启动目标版本;普通 `preview.start` 继续服务 Agent 当前工作树验证,不能作为工作台运行授权或旧版本切换入口。2026-08-04 起 P3 在该生命周期上接入运行会话与 Host/Game Bridge;P2 资源替换与下一版本继续跳过,测试切片编排和参数调整也不在 P3 内接线。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
|
||||
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
|
||||
@@ -424,6 +424,16 @@ game-project/
|
||||
- `currentRunnableVersionId` 是唯一可变选择指针。专用 Tauri 命令在项目锁内复核项目身份、版本结构、快照描述、revision 与摘要;切换时先停止当前项目 registry server,再启动目标快照。启动失败不得回退当前工作树或继续展示旧 iframe。
|
||||
- 普通 Agent `preview.start / preview.validate` 仍对当前编辑工作树执行,Runner registry 与 Tauri 用户可见 registry 的既有隔离不变;工作台运行专用命令只能服务已登记快照。
|
||||
|
||||
### 运行会话与 Host/Game Bridge P3
|
||||
|
||||
- `PreviewRegistry` 继续是唯一进程内预览所有者,只在当前 `PreviewServer` 上增加 UUID `previewId` 与由监听端口确定的精确 `http://127.0.0.1:<port>` origin。专用可运行版本启动结果返回该身份;普通 preview API 不获得工作台运行授权。
|
||||
- 前端 `LocalGamePreviewFrame` 只在同时拿到 P1 版本身份和 Registry preview 身份时启用桥。每次 iframe load 使用 Web Crypto 生成新的 128-bit sessionId,建立独立 Host/Runtime sequence、pending request 和已消费 runtime request 集合;reload、unmount、版本/preview key 变化先 best-effort 发送 `host.stop`,再原子失效旧会话。
|
||||
- wire protocol 固定为 `game-creator-run-bridge.v1`,Host/Runtime 分别使用 `game-creator-host-message.v1` 与 `game-creator-runtime-message.v1`。所有消息携带 request/session/preview/project/version/revision 身份;Host 类型只开放 start/pause/resume/stop/state.request/inspection.request,Runtime 类型只开放 ready/state/error/slice/inspection。
|
||||
- `message` listener 先检查当前 iframe `contentWindow` 与 Registry origin,再执行 64 KiB、plain object、精确字段、JavaScript 安全正整数、连续 sequence、request/response 类型相容和 payload 专用校验。inspection metrics 只允许最多 32 个标量;全部文本有界并拒绝路径、URL、HTML/脚本和宿主命令痕迹。通过的消息只更新 React 运行会话投影,不调用 Tauri invoke,也不进入 Agent/文件/命令/生成链路。
|
||||
- loopback server 对 HTML 响应注入固定同源外部 bootstrap 标签,并在保留原文件不变的前提下提供 `/.genarrative/game-bridge.v1.js`。bootstrap 只接受 parent 发来的合法 Host envelope,锁定首个 start 的 origin 与完整身份,基线响应 ready/state 并处理 pause/resume/stop/state/inspection;同时暴露受限 reportState/reportError/reportSlice/reportInspection API,业务游戏不能指定宿主命令、文件路径或 envelope 身份。
|
||||
- 工作台显示真实 session 状态并提供 pause/resume;ready 之前保持 starting,error 进入 failed,runtime state 只按白名单状态推进。`runtime.slice` 与 `runtime.inspection` 作为安全传输与展示投影存在,不等于已实现 P2 测试切片流程、检查工具或数值编辑。
|
||||
- 定向门禁覆盖 Rust Registry 身份轮换和 HTML/bootstrap 响应;TypeScript 纯协议覆盖白名单、容量、身份、sequence、request 关联与危险 payload;AppSurface 覆盖真实 iframe source/origin、reload/session 失效、版本切换卸载和 UI 状态。最后运行 shared-contracts、Tauri project/preview、AGC typecheck、定向 Vitest、编码检查与 `git diff --check`。
|
||||
|
||||
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
@@ -604,7 +604,11 @@ export interface GameTestSlice {
|
||||
}
|
||||
|
||||
export interface GameRunSession {
|
||||
schemaVersion: 'game-creator-run-session.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
previewOrigin: string;
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
@@ -637,25 +641,90 @@ export interface GameResourceDescriptor {
|
||||
format: string;
|
||||
}
|
||||
|
||||
export const GAME_RUN_BRIDGE_PROTOCOL_VERSION =
|
||||
'game-creator-run-bridge.v1' as const;
|
||||
export const GAME_HOST_MESSAGE_SCHEMA_VERSION =
|
||||
'game-creator-host-message.v1' as const;
|
||||
export const GAME_RUNTIME_MESSAGE_SCHEMA_VERSION =
|
||||
'game-creator-runtime-message.v1' as const;
|
||||
export const GAME_RUN_BRIDGE_MAX_MESSAGE_BYTES = 64 * 1024;
|
||||
|
||||
export type GameHostMessageType =
|
||||
| 'host.start'
|
||||
| 'host.pause'
|
||||
| 'host.resume'
|
||||
| 'host.stop'
|
||||
| 'host.state.request'
|
||||
| 'host.inspection.request';
|
||||
|
||||
export interface GameHostMessage {
|
||||
schemaVersion: 'game-creator-host-message.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
projectId: string;
|
||||
sequence: number;
|
||||
type: 'host.start' | 'host.pause' | 'host.resume' | 'host.stop';
|
||||
type: GameHostMessageType;
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
}
|
||||
|
||||
export interface GameRuntimeMessage {
|
||||
export type GameRuntimeCapability = 'state' | 'slice' | 'inspection';
|
||||
|
||||
export interface GameRuntimeReadyPayload {
|
||||
capabilities: GameRuntimeCapability[];
|
||||
}
|
||||
|
||||
export interface GameRuntimeStatePayload {
|
||||
status: 'starting' | 'playing' | 'paused' | 'completed' | 'stopped';
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface GameRuntimeErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
}
|
||||
|
||||
export interface GameRuntimeSlicePayload {
|
||||
sliceId: string;
|
||||
title: string;
|
||||
status: GameTestSliceStatus;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export type GameRuntimeInspectionMetric = string | number | boolean | null;
|
||||
|
||||
export interface GameRuntimeInspectionPayload {
|
||||
inspectionId: string;
|
||||
label: string;
|
||||
summary?: string;
|
||||
metrics: Record<string, GameRuntimeInspectionMetric>;
|
||||
}
|
||||
|
||||
interface GameRuntimeMessageBase {
|
||||
schemaVersion: 'game-creator-runtime-message.v1';
|
||||
protocolVersion: 'game-creator-run-bridge.v1';
|
||||
requestId: string;
|
||||
responseTo?: string;
|
||||
sessionId: string;
|
||||
previewId: string;
|
||||
projectId: string;
|
||||
sequence: number;
|
||||
type: 'runtime.ready' | 'runtime.state' | 'runtime.completed' | 'runtime.failed';
|
||||
versionId: string;
|
||||
projectRevision: number;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type GameRuntimeMessage = GameRuntimeMessageBase &
|
||||
(
|
||||
| { type: 'runtime.ready'; payload: GameRuntimeReadyPayload }
|
||||
| { type: 'runtime.state'; payload: GameRuntimeStatePayload }
|
||||
| { type: 'runtime.error'; payload: GameRuntimeErrorPayload }
|
||||
| { type: 'runtime.slice'; payload: GameRuntimeSlicePayload }
|
||||
| { type: 'runtime.inspection'; payload: GameRuntimeInspectionPayload }
|
||||
);
|
||||
|
||||
export interface GameCreationAppManifest {
|
||||
schemaVersion: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -721,7 +721,11 @@ pub enum GameRunSessionStaleReason {
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameRunSession {
|
||||
pub schema_version: String,
|
||||
pub protocol_version: String,
|
||||
pub session_id: String,
|
||||
pub preview_id: String,
|
||||
pub preview_origin: String,
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
pub project_revision: u64,
|
||||
@@ -820,13 +824,21 @@ pub enum GameHostMessageType {
|
||||
HostResume,
|
||||
#[serde(rename = "host.stop")]
|
||||
HostStop,
|
||||
#[serde(rename = "host.state.request")]
|
||||
HostStateRequest,
|
||||
#[serde(rename = "host.inspection.request")]
|
||||
HostInspectionRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameHostMessage {
|
||||
pub schema_version: String,
|
||||
pub protocol_version: String,
|
||||
pub request_id: String,
|
||||
pub session_id: String,
|
||||
pub preview_id: String,
|
||||
pub project_id: String,
|
||||
pub sequence: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: GameHostMessageType,
|
||||
@@ -840,24 +852,31 @@ pub enum GameRuntimeMessageType {
|
||||
RuntimeReady,
|
||||
#[serde(rename = "runtime.state")]
|
||||
RuntimeState,
|
||||
#[serde(rename = "runtime.completed")]
|
||||
RuntimeCompleted,
|
||||
#[serde(rename = "runtime.failed")]
|
||||
RuntimeFailed,
|
||||
#[serde(rename = "runtime.error")]
|
||||
RuntimeError,
|
||||
#[serde(rename = "runtime.slice")]
|
||||
RuntimeSlice,
|
||||
#[serde(rename = "runtime.inspection")]
|
||||
RuntimeInspection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameRuntimeMessage {
|
||||
pub schema_version: String,
|
||||
pub protocol_version: String,
|
||||
pub request_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub response_to: Option<String>,
|
||||
pub session_id: String,
|
||||
pub preview_id: String,
|
||||
pub project_id: String,
|
||||
pub sequence: u64,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: GameRuntimeMessageType,
|
||||
pub version_id: String,
|
||||
pub project_revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<serde_json::Value>,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
|
||||
@@ -2392,10 +2411,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_p2_message_types_keep_the_frozen_wire_values() {
|
||||
fn workbench_p3_message_types_keep_the_frozen_wire_values() {
|
||||
let host = GameHostMessage {
|
||||
schema_version: "game-creator-host-message.v1".to_string(),
|
||||
protocol_version: "game-creator-run-bridge.v1".to_string(),
|
||||
request_id: "host-request-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
preview_id: "preview-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
sequence: 1,
|
||||
message_type: GameHostMessageType::HostStart,
|
||||
version_id: "runnable-r7".to_string(),
|
||||
@@ -2405,7 +2428,11 @@ mod tests {
|
||||
serde_json::to_value(host).expect("serialize host message"),
|
||||
json!({
|
||||
"schemaVersion": "game-creator-host-message.v1",
|
||||
"protocolVersion": "game-creator-run-bridge.v1",
|
||||
"requestId": "host-request-1",
|
||||
"sessionId": "session-1",
|
||||
"previewId": "preview-1",
|
||||
"projectId": "project-1",
|
||||
"sequence": 1,
|
||||
"type": "host.start",
|
||||
"versionId": "runnable-r7",
|
||||
@@ -2415,16 +2442,22 @@ mod tests {
|
||||
|
||||
let runtime = GameRuntimeMessage {
|
||||
schema_version: "game-creator-runtime-message.v1".to_string(),
|
||||
protocol_version: "game-creator-run-bridge.v1".to_string(),
|
||||
request_id: "runtime-request-1".to_string(),
|
||||
response_to: Some("host-request-1".to_string()),
|
||||
session_id: "session-1".to_string(),
|
||||
preview_id: "preview-1".to_string(),
|
||||
project_id: "project-1".to_string(),
|
||||
sequence: 2,
|
||||
message_type: GameRuntimeMessageType::RuntimeReady,
|
||||
version_id: "runnable-r7".to_string(),
|
||||
project_revision: 7,
|
||||
detail: None,
|
||||
payload: json!({ "capabilities": ["state", "slice", "inspection"] }),
|
||||
};
|
||||
let value = serde_json::to_value(runtime).expect("serialize runtime message");
|
||||
assert_eq!(value["type"], "runtime.ready");
|
||||
assert!(value.get("detail").is_none());
|
||||
assert_eq!(value["responseTo"], "host-request-1");
|
||||
assert_eq!(value["payload"]["capabilities"][0], "state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user