实现运行工作台正式测试切片与悬停信息
Project CI / Frontend tests (pull_request) Failing after 23s
Project CI / Repository checks (pull_request) Failing after 1m4s
Project CI / Backend tests (pull_request) Successful in 4m59s
Project CI / Native shell tests (pull_request) Successful in 13m17s

新增正式测试切片合同、状态机与上一项/播放暂停/下一项控制
新增可信悬停资源解析与资源管理画布定位
扩展 Tauri 版本登记、运行 Bridge 和 Agent 生成合同
补齐 TypeScript、Rust 与 AppSurface 回归测试
同步工作台 PRD、技术方案与共享决策记录
保持 P2 资源替换、参数写入和下一版本能力未接线
This commit is contained in:
2026-08-04 17:06:24 +08:00
parent 203ce5b9e7
commit 752505547d
20 changed files with 1581 additions and 66 deletions
@@ -1329,6 +1329,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
&receipt.agent_id,
&receipt.run_id,
&receipt.report.path,
receipt.playtest_scenario,
)?;
}
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
@@ -1690,7 +1690,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
concat!(
"完成合同要求 generic-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level;",
"界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"primary-action\" 与 data-playtest-id=\"restart\" 的真实可点击控件;primary-action 必须映射游戏的真实主要玩法操作,并在动作发生时推进 state sequence,不能使用空操作或仅更新装饰 UI 的按钮;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequence,primary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退。"
"初始状态必须是 ready 且 level 为正整数;start 后状态必须推进并进入 playing,并先至少持续 2 秒保持 playing,让玩家获得可操作机会,在 primary-action 之前进入 ready、won 或 lost 都会失败;随后 primary-action 必须再次严格推进 sequence,primary-action 后 phase 可为 playing、won 或 lost,单次 won 或 lost 都是正常游戏终态,不会仅凭一次 lost 判定试玩失败;若动作后仍为 playing,则最多继续观察 3 秒,期间 won/lost 可提前形成首轮结果,始终 playing 也可在观察完成后证明非失败推进。restart 后必须再次推进,且至少持续 3 秒的稳定观察窗口内只能保持 ready 或 playing,进入 won 或 lost 都会失败;如果首轮 primary-action 结果为 lost,重开稳定后必须自动执行第二次受控尝试,恢复为 ready 时先 start 推进到 playing,随后无论重开结果原本是 ready 还是 playing,都必须再次完成至少 2 秒的 playing 操作机会,再次点击 primary-action 且严格推进 sequence;第二次必须进入 won,或保持 playing 并完成 3 秒观察,观察期间可进入 won 但不得进入 lost。两次受控尝试都进入 lost 说明游戏存在无法正常推进的固定失败,必须判定试玩失败;全部观察期间 sequence 始终不得回退。",
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stop;generic-v1 的正式 sliceId 固定为 core-gameplay,必须通过 window.genarrativeGameBridge.reportSlice 上报该 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
)
}
BrowserPlaytestScenario::LaneDefenseV1 => {
@@ -1698,7 +1699,8 @@ pub(in crate::agent) fn autonomous_playtest_contract_prompt(
"完成合同要求 lane-defense-v1 交互试玩。game/index.html 必须持续更新 <script id=\"playable-web-game-state\" type=\"application/json\">,JSON 固定包含 schemaVersion=playable-web-game-state.v1、单调递增 sequence、phase=ready|playing|won|lost、正整数 level、selectedDefenderId、defenders 数组、enemies 数组;每个 enemy 必须含非空 id、非负 lane、会随移动变化的 position、health 与正数 maxHealth。",
"界面必须清晰显示一个原创项目标题、至少两个原创防御单位选项、资源与波次状态,以及开始、加速、下一关和重开等可理解操作;玩法类型不授权复刻现有游戏,不得沿用、翻译或近似改写现有作品的角色、单位名、Logo、贴图、标志性布局或受保护视觉语言。界面必须提供 data-playtest-id=\"start\"、data-playtest-id=\"defender-option\"、data-playtest-id=\"lane-cell\"、data-playtest-id=\"speed-up\"、data-playtest-id=\"next-level\"、data-playtest-id=\"restart\" 的真实可点击控件;每个固定 data-playtest-id 在对应受控试玩步骤都必须恰好匹配一个可见且启用(disabled=false)的真实可点击 HTMLElement,同一固定值不得出现在多个控件上。",
"防御单位多选项 UI 只能给一个真实控件设置 data-playtest-id=\"defender-option\" 作为自动化入口,关卡多格 UI 只能给一个真实控件设置 data-playtest-id=\"lane-cell\" 作为自动化入口,其余选项和格子不得复用这两个固定值。",
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。"
"受控试玩会依次开始、选择并放置防御单位、加速,要求敌人移动并受伤、关卡进入 won;随后 next-level 必须让 level 增加,restart 必须再次推进 sequence 并回到 ready 或 playing。",
"还必须监听 genarrative:host-message 中的 host.slice.start / host.slice.stop;lane-defense-v1 的正式 sliceId 依次固定为 deploy-defender、resolve-wave、advance-level,必须通过 window.genarrativeGameBridge.reportSlice 上报当前 ID 的 playing、paused、completed 或 failed。游戏自身 hit-test / hover 只允许通过 reportTargetInspection 上报当前 .agent/manifest.json 已登记的 asset.id,或与该 asset.id 对应的资源槽位 slotId;离开对象调用 clearInspection,不得上报资源名称、路径、HTML 或宿主命令。"
)
}
}
@@ -928,6 +928,32 @@ fn autonomous_playtest_contract_requires_unique_visible_enabled_automation_contr
}
}
#[test]
fn autonomous_playtest_contract_freezes_formal_slice_and_hover_identifiers() {
let generic = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
for requirement in [
"host.slice.start / host.slice.stop",
"sliceId 固定为 core-gameplay",
"window.genarrativeGameBridge.reportSlice",
".agent/manifest.json 已登记的 asset.id",
"资源槽位 slotId",
"clearInspection",
] {
assert!(
generic.contains(requirement),
"missing generic P4/P5 requirement: {requirement}"
);
}
let lane = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::LaneDefenseV1);
for slice_id in ["deploy-defender", "resolve-wave", "advance-level"] {
assert!(
lane.contains(slice_id),
"missing lane-defense formal slice ID: {slice_id}"
);
}
}
#[test]
fn generic_playtest_contract_requires_play_opportunity_and_post_action_outcome() {
let prompt = autonomous_playtest_contract_prompt(BrowserPlaytestScenario::GenericV1);
@@ -32,7 +32,8 @@ use shared_contracts::game_creation_app::{
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
GameIterationVersionResourceBinding, ProjectResourceCanvasLayout,
GameIterationVersionResourceBinding, GameResourceCategory, GameResourceDescriptor,
GameTestSlice, GameTestSliceStatus, ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, RunnableGameVersion,
RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
@@ -121,12 +121,13 @@ const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
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']);
const hostTypes = new Set(['host.start', 'host.pause', 'host.resume', 'host.stop', 'host.state.request', 'host.inspection.request', 'host.slice.start', 'host.slice.stop']);
let session = null;
let hostOrigin = null;
let hostSequence = 0;
let runtimeSequence = 0;
let state = 'starting';
let currentSlice = null;
const hostRequestIds = new Set();
const randomId = () => {
@@ -165,16 +166,22 @@ const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
};
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 reportSlice = (sliceId, title, status, summary) => {
if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = status;
return 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 });
const reportTargetInspection = (targetKind, targetId) =>
send('runtime.inspection', { inspectionId: `hover-${targetKind}`, label: '', metrics: {}, targetKind, targetId });
const clearInspection = () =>
send('runtime.inspection', { inspectionId: 'hover-clear', label: '', metrics: {}, targetKind: 'none' });
Object.defineProperty(window, 'genarrativeGameBridge', {
configurable: false,
enumerable: false,
writable: false,
value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection }),
value: Object.freeze({ protocolVersion, reportState, reportError, reportSlice, reportInspection, reportTargetInspection, clearInspection }),
});
addEventListener('message', event => {
@@ -204,6 +211,9 @@ const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
return;
}
if (event.origin !== hostOrigin || !sameIdentity(message) || message.sequence !== hostSequence + 1) return;
if ((message.type === 'host.slice.start' || message.type === 'host.slice.stop') &&
(!message.payload || typeof message.payload !== 'object' || Object.keys(message.payload).length !== 1 ||
typeof message.payload.sliceId !== 'string' || !message.payload.sliceId)) return;
hostSequence = message.sequence;
hostRequestIds.add(message.requestId);
if (message.type === 'host.pause') {
@@ -220,6 +230,21 @@ const GAME_RUN_BRIDGE_BOOTSTRAP: &str = r#"(() => {
label: '运行画面',
metrics: { state, viewportWidth: innerWidth, viewportHeight: innerHeight },
}, message.requestId);
} else if (message.type === 'host.slice.start') {
currentSlice = { sliceId: message.payload.sliceId, status: 'starting' };
send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'starting' }, message.requestId);
dispatchEvent(new CustomEvent('genarrative:host-message', { detail: message }));
if (currentSlice && currentSlice.sliceId === message.payload.sliceId && currentSlice.status === 'starting') {
currentSlice.status = 'playing';
send('runtime.slice', { sliceId: currentSlice.sliceId, title: '', status: 'playing' });
}
return;
} else if (message.type === 'host.slice.stop') {
const sliceId = message.payload.sliceId;
if (currentSlice && currentSlice.sliceId === sliceId) currentSlice.status = 'paused';
send('runtime.slice', { sliceId, title: '', status: 'paused' }, message.requestId);
currentSlice = null;
clearInspection();
} else if (message.type === 'host.stop') {
state = 'stopped';
send('runtime.state', { status: state }, message.requestId);
@@ -30,6 +30,166 @@ fn runnable_version_root(root: &Path, version_id: &str) -> PathBuf {
root.join(".agent/runnable-versions").join(version_id)
}
fn runnable_test_slices(version_id: &str, scenario: BrowserPlaytestScenario) -> Vec<GameTestSlice> {
let definitions: &[(&str, &str, &str, &str)] = match scenario {
BrowserPlaytestScenario::GenericV1 => &[((
"core-gameplay",
"核心玩法",
"游戏进入 ready",
"游戏进入 won 或 lost",
))],
BrowserPlaytestScenario::LaneDefenseV1 => &[
(
"deploy-defender",
"部署防御单位",
"关卡进入 ready",
"防御单位完成部署",
),
(
"resolve-wave",
"推进当前波次",
"防御单位已部署",
"当前波次进入 won 或 lost",
),
(
"advance-level",
"进入下一关",
"当前波次已完成",
"下一关恢复 ready",
),
],
};
definitions
.iter()
.enumerate()
.map(
|(order, (slice_id, title, start_condition, end_condition))| GameTestSlice {
slice_id: (*slice_id).to_string(),
version_id: version_id.to_string(),
title: (*title).to_string(),
order: u32::try_from(order).unwrap_or(u32::MAX),
start_condition: (*start_condition).to_string(),
end_condition: (*end_condition).to_string(),
status: GameTestSliceStatus::Idle,
},
)
.collect()
}
fn runnable_resource_category(
media_type: &str,
kind: &str,
local_path: &str,
) -> Option<GameResourceCategory> {
let media_type = media_type.to_ascii_lowercase();
let kind = kind.to_ascii_lowercase();
let local_path = local_path.to_ascii_lowercase();
if media_type.starts_with("audio/")
|| [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac", ".opus"]
.iter()
.any(|extension| local_path.ends_with(extension))
|| ["audio", "bgm", "music", "sfx", "sound", "voice"]
.iter()
.any(|token| kind.contains(token))
{
return Some(GameResourceCategory::Audio);
}
if media_type.starts_with("image/")
|| media_type.starts_with("video/")
|| [
".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".mp4", ".webm",
".mov",
]
.iter()
.any(|extension| local_path.ends_with(extension))
|| [
"art",
"animation",
"character",
"icon",
"image",
"scene",
"sprite",
"ui",
"video",
]
.iter()
.any(|token| kind.contains(token))
{
return Some(GameResourceCategory::Art);
}
if media_type.starts_with("text/")
|| media_type.contains("json")
|| media_type.contains("yaml")
|| [
".md",
".markdown",
".txt",
".json",
".yaml",
".yml",
".toml",
]
.iter()
.any(|extension| local_path.ends_with(extension))
{
return Some(GameResourceCategory::Document);
}
None
}
fn runnable_resource_descriptors(
root: &Path,
manifest: &GameCreationAppManifest,
) -> Vec<GameResourceDescriptor> {
manifest
.assets
.iter()
.filter_map(|asset| {
let category =
runnable_resource_category(&asset.media_type, &asset.kind, &asset.local_path)?;
let (width, height) = if asset.media_type.starts_with("image/") {
normalize_relative_path(&asset.local_path)
.ok()
.and_then(|relative| resolve_local_project_path(root, &relative).ok())
.and_then(|path| {
let metadata = fs::metadata(&path).ok()?;
if !metadata.is_file() || metadata.len() > RUNNABLE_ARTIFACT_MAX_FILE_BYTES
{
return None;
}
fs::read(path).ok()
})
.and_then(|bytes| {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
})
.map_or((None, None), |(width, height)| (Some(width), Some(height)))
} else {
(None, None)
};
let name = Path::new(&asset.local_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(asset.id.as_str())
.to_string();
Some(GameResourceDescriptor {
resource_id: asset.id.clone(),
name,
category,
subtype: asset.kind.clone(),
width,
height,
duration_ms: None,
format: asset.media_type.clone(),
})
})
.collect()
}
fn collect_runnable_artifact_files(
base: &Path,
directory: &Path,
@@ -284,6 +444,7 @@ pub(crate) fn register_current_runnable_game_version_at(
validation_agent_id: &str,
validation_run_id: &str,
report_path: &str,
playtest_scenario: BrowserPlaytestScenario,
) -> Result<RunnableGameVersion, String> {
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_manifest(&manifest_path)?;
@@ -330,6 +491,8 @@ pub(crate) fn register_current_runnable_game_version_at(
})
.collect::<Vec<_>>();
resource_bindings.sort_by(|left, right| left.slot_id.cmp(&right.slot_id));
let test_slices = runnable_test_slices(&version_id, playtest_scenario);
let resource_descriptors = runnable_resource_descriptors(root, &manifest);
let version = RunnableGameVersion {
schema_version: RUNNABLE_GAME_VERSION_SCHEMA_VERSION.to_string(),
version_id: version_id.clone(),
@@ -340,6 +503,8 @@ pub(crate) fn register_current_runnable_game_version_at(
artifact_sha256: snapshot.artifact_sha256,
entry_path: "game/index.html".to_string(),
resource_bindings,
test_slices,
resource_descriptors,
created_reason,
validation: RunnableGameVersionValidation {
static_smoke_passed: true,
@@ -435,10 +600,28 @@ mod tests {
fs::write(root.join("game/index.html"), "<main>revision one</main>")
.expect("write game entry");
fs::write(root.join("assets/player.txt"), "player one").expect("write game asset");
let manifest = new_game_creation_app_manifest(
let mut manifest = new_game_creation_app_manifest(
format!("project-{label}"),
format!("可运行版本测试 {label}"),
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "player-document".to_string(),
kind: "design-document".to_string(),
media_type: "text/plain".to_string(),
local_path: "assets/player.txt".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
});
write_manifest(&root.join(".agent/manifest.json"), &manifest)
.expect("write project manifest");
root
@@ -453,6 +636,7 @@ mod tests {
&format!(
".agent/runtime/browser-validations/program-agent/run-{revision}/1/validation.json"
),
BrowserPlaytestScenario::GenericV1,
)
.expect("register runnable version")
}
@@ -467,6 +651,29 @@ mod tests {
first.created_reason,
RunnableGameVersionCreatedReason::Initial
);
assert_eq!(
first
.test_slices
.iter()
.map(|slice| slice.slice_id.as_str())
.collect::<Vec<_>>(),
vec!["core-gameplay"]
);
assert_eq!(first.test_slices[0].version_id, first.version_id);
assert_eq!(first.test_slices[0].status, GameTestSliceStatus::Idle);
assert_eq!(
first.resource_descriptors,
vec![GameResourceDescriptor {
resource_id: "player-document".to_string(),
name: "player.txt".to_string(),
category: GameResourceCategory::Document,
subtype: "design-document".to_string(),
width: None,
height: None,
duration_ms: None,
format: "text/plain".to_string(),
}]
);
assert_eq!(
fs::read_to_string(
root.join(".agent/runnable-versions/runnable-r1/artifact/game/index.html")
@@ -505,6 +712,33 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn runnable_test_slices_follow_the_verified_playtest_scenario() {
let generic = runnable_test_slices("runnable-r1", BrowserPlaytestScenario::GenericV1);
assert_eq!(
generic
.iter()
.map(|slice| slice.slice_id.as_str())
.collect::<Vec<_>>(),
vec!["core-gameplay"]
);
let lane = runnable_test_slices("runnable-r2", BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
lane.iter()
.map(|slice| (slice.order, slice.slice_id.as_str()))
.collect::<Vec<_>>(),
vec![
(0, "deploy-defender"),
(1, "resolve-wave"),
(2, "advance-level"),
]
);
assert!(lane.iter().all(|slice| {
slice.version_id == "runnable-r2" && slice.status == GameTestSliceStatus::Idle
}));
}
#[test]
fn runnable_snapshot_retry_reuses_original_descriptor_timestamp() {
let root = init_runnable_version_test_project("orphan-retry");
@@ -3290,7 +3290,7 @@ fn local_preview_can_serve_an_immutable_version_snapshot_instead_of_the_working_
}
#[test]
fn local_preview_injects_the_host_owned_p3_bridge_without_mutating_html() {
fn local_preview_injects_the_host_owned_runtime_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>";
@@ -3315,6 +3315,11 @@ fn local_preview_injects_the_host_owned_p3_bridge_without_mutating_html() {
assert!(script_response.contains("game-creator-run-bridge.v1"));
assert!(script_response.contains("runtime.ready"));
assert!(script_response.contains("event.source !== parent"));
assert!(script_response.contains("host.slice.start"));
assert!(script_response.contains("host.slice.stop"));
assert!(script_response.contains("reportTargetInspection"));
assert!(script_response.contains("clearInspection"));
assert!(script_response.contains("Object.keys(message.payload).length !== 1"));
fs::remove_dir_all(root).ok();
}
@@ -35,6 +35,8 @@ export type LocalGamePreviewFrameHandle = {
stop: () => boolean;
requestState: () => boolean;
requestInspection: () => boolean;
startSlice: (sliceId: string) => boolean;
stopSlice: (sliceId: string) => boolean;
};
type ActiveBridgeSession = {
@@ -101,7 +103,10 @@ export const LocalGamePreviewFrame = forwardRef<
onSessionChange,
};
const sendHostMessage = useCallback((type: GameHostMessageType) => {
const sendHostMessage = useCallback((
type: GameHostMessageType,
sliceId?: string,
) => {
const active = activeRef.current;
const targetWindow = iframeRef.current?.contentWindow;
if (!active || !targetWindow) {
@@ -118,6 +123,7 @@ export const LocalGamePreviewFrame = forwardRef<
requestId,
sequence: active.nextHostSequence,
type,
payload: sliceId ? { sliceId } : undefined,
});
active.nextHostSequence += 1;
targetWindow.postMessage(message, active.identity.previewOrigin);
@@ -152,6 +158,8 @@ export const LocalGamePreviewFrame = forwardRef<
stop: () => stopActiveSession(true),
requestState: () => sendHostMessage('host.state.request'),
requestInspection: () => sendHostMessage('host.inspection.request'),
startSlice: (sliceId) => sendHostMessage('host.slice.start', sliceId),
stopSlice: (sliceId) => sendHostMessage('host.slice.stop', sliceId),
}),
[sendHostMessage, stopActiveSession],
);
@@ -1,5 +1,6 @@
import type {
GameHostMessage,
GameHostSlicePayload,
GameHostMessageType,
GameRunSession,
GameRuntimeInspectionMetric,
@@ -182,7 +183,11 @@ function isInspectionMetric(
function validateInspectionPayload(payload: unknown) {
if (
!isPlainObject(payload) ||
!hasExactKeys(payload, ['inspectionId', 'label', 'metrics'], ['summary']) ||
!hasExactKeys(
payload,
['inspectionId', 'label', 'metrics'],
['summary', 'targetKind', 'targetId'],
) ||
!isSafeIdentifier(payload.inspectionId, 128) ||
!isSafeText(payload.label, 128) ||
(payload.summary !== undefined && !isSafeText(payload.summary, 512)) ||
@@ -190,6 +195,23 @@ function validateInspectionPayload(payload: unknown) {
) {
return false;
}
if (
payload.targetKind !== undefined &&
!['resource', 'entity', 'none'].includes(String(payload.targetKind))
) {
return false;
}
if (
payload.targetKind === 'none'
? payload.targetId !== undefined
: payload.targetKind !== undefined &&
!isSafeIdentifier(payload.targetId, 512)
) {
return false;
}
if (payload.targetKind === undefined && payload.targetId !== undefined) {
return false;
}
const metrics = Object.entries(payload.metrics);
return (
metrics.length <= 32 &&
@@ -302,14 +324,16 @@ export function createGameHostMessage({
requestId,
sequence,
type,
payload,
}: {
identity: GameRunBridgeIdentity;
sessionId: string;
requestId: string;
sequence: number;
type: GameHostMessageType;
payload?: GameHostSlicePayload;
}): GameHostMessage {
return {
const message: GameHostMessage = {
schemaVersion: GAME_HOST_MESSAGE_SCHEMA_VERSION,
protocolVersion: GAME_RUN_BRIDGE_PROTOCOL_VERSION,
requestId,
@@ -321,6 +345,10 @@ export function createGameHostMessage({
versionId: identity.versionId,
projectRevision: identity.projectRevision,
};
if (payload) {
message.payload = payload;
}
return message;
}
function responseTypeMatches(
@@ -341,6 +369,9 @@ function responseTypeMatches(
'host.state.request',
].includes(hostType);
}
if (runtimeType === 'runtime.slice') {
return hostType === 'host.slice.start' || hostType === 'host.slice.stop';
}
return (
runtimeType === 'runtime.inspection' &&
hostType === 'host.inspection.request'
@@ -455,6 +486,19 @@ export function gameRunSessionFromRuntimeMessage(
return { ...session, status: 'playing', updatedAt: now };
}
if (message.type !== 'runtime.state') {
if (message.type === 'runtime.slice') {
const status =
message.payload.status === 'completed'
? 'paused'
: message.payload.status === 'failed'
? 'failed'
: message.payload.status === 'playing'
? 'playing'
: message.payload.status === 'paused'
? 'paused'
: session.status;
return { ...session, status, updatedAt: now };
}
return { ...session, updatedAt: now };
}
const status =
@@ -0,0 +1,158 @@
import type {
GameRuntimeMessage,
GameTestSlice,
GameTestSliceStatus,
RunnableGameVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ActiveGameTestSlice = GameTestSlice & {
stale: boolean;
};
export type TrustedGameResourceInspection = {
targetKind: 'resource' | 'entity';
targetId: string;
resourceId: string;
slotId: string;
name: string;
category: 'document' | 'version' | 'art' | 'audio';
subtype: string;
format: string;
width?: number;
height?: number;
durationMs?: number;
versionId: string;
};
const transitions: Record<
GameTestSliceStatus,
ReadonlySet<GameTestSliceStatus>
> = {
idle: new Set(['starting']),
starting: new Set(['playing', 'failed']),
playing: new Set(['paused', 'completed', 'failed']),
paused: new Set(['playing', 'completed', 'failed']),
completed: new Set(['starting']),
failed: new Set(['starting']),
};
function validSliceText(value: string, maxChars: number) {
return value.length > 0 && value.length <= maxChars && value.trim() === value;
}
export function formalGameTestSlices(version: RunnableGameVersion | null) {
if (!version?.testSlices || version.testSlices.length > 64) {
return [];
}
const ids = new Set<string>();
const orders = new Set<number>();
for (const slice of version.testSlices) {
if (
slice.versionId !== version.versionId ||
slice.status !== 'idle' ||
!validSliceText(slice.sliceId, 128) ||
!validSliceText(slice.title, 128) ||
!Number.isSafeInteger(slice.order) ||
slice.order < 0 ||
ids.has(slice.sliceId) ||
orders.has(slice.order)
) {
return [];
}
ids.add(slice.sliceId);
orders.add(slice.order);
}
return [...version.testSlices].sort(
(left, right) =>
left.order - right.order || left.sliceId.localeCompare(right.sliceId),
);
}
export function beginGameTestSlice(slice: GameTestSlice): ActiveGameTestSlice {
return { ...slice, status: 'starting', stale: false };
}
export function projectGameTestSliceMessage(
active: ActiveGameTestSlice | null,
message: GameRuntimeMessage,
): ActiveGameTestSlice | null {
if (
!active ||
active.stale ||
message.type !== 'runtime.slice' ||
message.payload.sliceId !== active.sliceId
) {
return active;
}
const nextStatus = message.payload.status;
if (
nextStatus === active.status ||
transitions[active.status].has(nextStatus)
) {
return { ...active, status: nextStatus };
}
return active;
}
export function projectGameTestSliceSessionStatus(
active: ActiveGameTestSlice | null,
message: GameRuntimeMessage,
): ActiveGameTestSlice | null {
if (!active || active.stale || message.type !== 'runtime.state') {
return active;
}
const nextStatus =
message.payload.status === 'paused'
? 'paused'
: message.payload.status === 'playing'
? 'playing'
: null;
if (!nextStatus || !transitions[active.status].has(nextStatus)) {
return active;
}
return { ...active, status: nextStatus };
}
export function trustedInspectionFromRuntimeMessage(
version: RunnableGameVersion | null,
message: GameRuntimeMessage,
): TrustedGameResourceInspection | null | undefined {
if (!version || message.type !== 'runtime.inspection') {
return undefined;
}
const { targetKind, targetId } = message.payload;
if (targetKind === 'none') {
return null;
}
if ((targetKind !== 'resource' && targetKind !== 'entity') || !targetId) {
return undefined;
}
const binding = version.resourceBindings.find((candidate) =>
targetKind === 'resource'
? candidate.resourceId === targetId
: candidate.slotId === targetId,
);
if (!binding) {
return null;
}
const descriptor = version.resourceDescriptors?.find(
(candidate) => candidate.resourceId === binding.resourceId,
);
if (!descriptor) {
return null;
}
return {
targetKind,
targetId,
resourceId: descriptor.resourceId,
slotId: binding.slotId,
name: descriptor.name,
category: descriptor.category,
subtype: descriptor.subtype,
format: descriptor.format,
width: descriptor.width,
height: descriptor.height,
durationMs: descriptor.durationMs,
versionId: version.versionId,
};
}
+7 -2
View File
@@ -4424,7 +4424,7 @@ iframe.preview-frame {
.game-run-surface {
display: grid;
grid-template-rows: minmax(300px, 1fr) auto auto;
grid-template-rows: minmax(300px, 1fr) auto auto auto;
gap: 12px;
min-height: 0;
padding: 12px;
@@ -4516,6 +4516,11 @@ iframe.preview-frame {
cursor: pointer;
}
.game-run-slice-controls button:disabled {
cursor: default;
opacity: 0.45;
}
.game-run-slice-controls span {
color: #9a7d70;
font-size: 11px;
@@ -4524,7 +4529,7 @@ iframe.preview-frame {
.game-run-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
@@ -1,4 +1,6 @@
import {
ChevronLeft,
ChevronRight,
FileText,
FolderTree,
Gamepad2,
@@ -6,6 +8,8 @@ import {
Info,
ListFilter,
Music2,
Pause,
Play,
Search,
Settings2,
Sparkles,
@@ -35,6 +39,15 @@ import type {
RunnableGameVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri';
import {
beginGameTestSlice,
formalGameTestSlices,
type ActiveGameTestSlice,
type TrustedGameResourceInspection,
projectGameTestSliceMessage,
projectGameTestSliceSessionStatus,
trustedInspectionFromRuntimeMessage,
} from '../../features/project-workspace/gameTestSliceModel';
import {
LocalGamePreviewFrame,
type LocalGamePreviewFrameHandle,
@@ -390,9 +403,7 @@ const ResourceCard = memo(function ResourceCard({
type="button"
className={`game-resource-card${selected ? ' is-selected' : ''}${
currentVersion ? ' is-current-version' : ''
}${
relationState ? ` is-relation-${relationState}` : ''
}`}
}${relationState ? ` is-relation-${relationState}` : ''}`}
aria-pressed={selected}
data-resource-id={resource.id}
title="打开资源详情"
@@ -456,10 +467,16 @@ export default function ProjectDevelopmentView({
previewId: string;
origin: string;
} | null>(null);
const [gameRunSession, setGameRunSession] =
useState<GameRunSession | null>(null);
const [gameRunSession, setGameRunSession] = useState<GameRunSession | null>(
null,
);
const [latestRuntimeMessage, setLatestRuntimeMessage] =
useState<GameRuntimeMessage | null>(null);
const [selectedTestSliceId, setSelectedTestSliceId] = useState('');
const [activeTestSlice, setActiveTestSlice] =
useState<ActiveGameTestSlice | null>(null);
const [trustedInspection, setTrustedInspection] =
useState<TrustedGameResourceInspection | null>(null);
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
status: 'idle',
resourceId: null,
@@ -476,6 +493,7 @@ export default function ProjectDevelopmentView({
const resourceCanvasRef = useRef<HTMLDivElement>(null);
const gamePreviewFrameRef = useRef<LocalGamePreviewFrameHandle>(null);
const resourceFocusRef = useRef<HTMLElement>(null);
const pendingResourceLocateRef = useRef<string | null>(null);
const resourceListScrollRef = useRef({ left: 0, top: 0 });
const restoreResourceListScrollRef = useRef(false);
@@ -504,7 +522,17 @@ export default function ProjectDevelopmentView({
previewOrigin: activePreviewIdentity.origin,
}
: null;
const runAvailable = runnableVersions.length > 0 && currentRunnableVersion !== null;
const runAvailable =
runnableVersions.length > 0 && currentRunnableVersion !== null;
const testSlices = useMemo(
() => formalGameTestSlices(currentRunnableVersion),
[currentRunnableVersion],
);
const selectedTestSliceIndex = Math.max(
0,
testSlices.findIndex((slice) => slice.sliceId === selectedTestSliceId),
);
const selectedTestSlice = testSlices[selectedTestSliceIndex] ?? null;
const projectedResources = useMemo(
() => projectResourcesFromReadModels(manifest, attachments, agentResults),
[agentResults, attachments, manifest],
@@ -732,6 +760,11 @@ export default function ProjectDevelopmentView({
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const focusedResource =
resources.find((resource) => resource.id === focusedResourceId) ?? null;
const trustedInspectionResource = trustedInspection
? (resources.find(
(resource) => resource.manifestAssetId === trustedInspection.resourceId,
) ?? null)
: null;
const focusedResourceIsImage = Boolean(
focusedResource && isRasterImageResource(focusedResource),
);
@@ -796,11 +829,19 @@ export default function ProjectDevelopmentView({
setSelectedRunnableVersionId(manifest.currentRunnableVersionId ?? '');
}, [manifest.currentRunnableVersionId, manifest.projectId]);
useEffect(() => {
setSelectedTestSliceId(testSlices[0]?.sliceId ?? '');
setActiveTestSlice(null);
setTrustedInspection(null);
}, [currentRunnableVersion?.versionId]);
useEffect(() => {
setActiveRunnablePreviewVersionId(null);
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
setActiveTestSlice(null);
setTrustedInspection(null);
}, [manifest.projectId]);
useEffect(() => {
@@ -1003,6 +1044,24 @@ export default function ProjectDevelopmentView({
}
}, [focusedResource]);
useEffect(() => {
const pendingResourceId = pendingResourceLocateRef.current;
if (mode !== 'resources' || focusedResource || !pendingResourceId) {
return;
}
const card = Array.from(
resourceCanvasRef.current?.querySelectorAll<HTMLElement>(
'[data-resource-id]',
) ?? [],
).find((candidate) => candidate.dataset.resourceId === pendingResourceId);
if (!card) {
return;
}
pendingResourceLocateRef.current = null;
card.scrollIntoView?.({ block: 'center', inline: 'center' });
card.focus({ preventScroll: true });
}, [focusedResource, mode, resourceLayout.positions, selectedResourceId]);
const handleResourceSelect = useCallback((resourceId: string) => {
const canvas = resourceCanvasRef.current;
if (canvas) {
@@ -1033,6 +1092,8 @@ export default function ProjectDevelopmentView({
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
setActiveTestSlice(null);
setTrustedInspection(null);
onPreviewChange?.(null);
try {
const result = await invoke<RunnableGameVersionLaunchResult>(
@@ -1061,6 +1122,8 @@ export default function ProjectDevelopmentView({
setActivePreviewIdentity(null);
setGameRunSession(null);
setLatestRuntimeMessage(null);
setActiveTestSlice(null);
setTrustedInspection(null);
setRunStatus(message);
onPreviewChange?.({ status: 'failed' });
return false;
@@ -1085,13 +1148,34 @@ export default function ProjectDevelopmentView({
function showResourcesView() {
gamePreviewFrameRef.current?.stop();
setActiveTestSlice(null);
setTrustedInspection(null);
setMode('resources');
}
function locateTrustedInspectionResource() {
if (!trustedInspectionResource) {
setRunStatus('当前悬停资源未登记在资源管理画布中');
return;
}
const resourceId = trustedInspectionResource.id;
gamePreviewFrameRef.current?.stop();
pendingResourceLocateRef.current = resourceId;
setSearchText('');
setFocusedResourceId(null);
setSelectedResourceId(resourceId);
setActiveTestSlice(null);
setTrustedInspection(null);
setMode('resources');
setRunStatus(`已定位资源:${trustedInspectionResource.label}`);
}
function handleGameRunSessionChange(session: GameRunSession) {
setGameRunSession(session);
if (session.status === 'starting') {
setLatestRuntimeMessage(null);
setActiveTestSlice(null);
setTrustedInspection(null);
}
setRunStatus(
{
@@ -1106,12 +1190,68 @@ export default function ProjectDevelopmentView({
function handleGameRuntimeMessage(message: GameRuntimeMessage) {
setLatestRuntimeMessage(message);
setActiveTestSlice((active) =>
projectGameTestSliceSessionStatus(
projectGameTestSliceMessage(active, message),
message,
),
);
const inspection = trustedInspectionFromRuntimeMessage(
currentRunnableVersion,
message,
);
if (inspection !== undefined) {
setTrustedInspection(inspection);
}
if (message.type === 'runtime.error') {
setRunStatus(message.payload.message || '游戏运行时报告错误');
} else if (message.type === 'runtime.slice') {
setRunStatus(`测试切片:${message.payload.title} · ${message.payload.status}`);
const formalSlice = testSlices.find(
(slice) => slice.sliceId === message.payload.sliceId,
);
if (formalSlice) {
setRunStatus(
`测试切片:${formalSlice.title} · ${message.payload.status}`,
);
}
} else if (message.type === 'runtime.inspection') {
setRunStatus(`已接收检查结果:${message.payload.label}`);
if (inspection !== undefined) {
setRunStatus(inspection ? '已更新悬停资源信息' : '悬停资源信息已清除');
}
}
}
function startTestSlice(slice = selectedTestSlice) {
if (!slice || !gameRunSession || gameRunSession.status === 'failed') {
setRunStatus('当前运行会话无法启动测试切片');
return;
}
if (activeTestSlice?.stale) {
setRunStatus('测试切片已失效,请重新拉起可运行版本');
return;
}
if (
activeTestSlice &&
activeTestSlice.sliceId !== slice.sliceId &&
['starting', 'playing', 'paused'].includes(activeTestSlice.status)
) {
gamePreviewFrameRef.current?.stopSlice(activeTestSlice.sliceId);
}
setSelectedTestSliceId(slice.sliceId);
setTrustedInspection(null);
setActiveTestSlice(beginGameTestSlice(slice));
if (!gamePreviewFrameRef.current?.startSlice(slice.sliceId)) {
setActiveTestSlice(null);
setRunStatus('测试切片启动请求发送失败');
return;
}
setRunStatus(`正在启动测试切片:${slice.title}`);
}
function switchTestSlice(offset: -1 | 1) {
const target = testSlices[selectedTestSliceIndex + offset];
if (target) {
startTestSlice(target);
}
}
@@ -1130,6 +1270,29 @@ export default function ProjectDevelopmentView({
}
}
function toggleTestSlicePlayback() {
if (
!activeTestSlice ||
activeTestSlice.sliceId !== selectedTestSlice?.sliceId
) {
startTestSlice();
return;
}
if (activeTestSlice.status === 'playing') {
if (gamePreviewFrameRef.current?.pause()) {
setRunStatus(`正在暂停测试切片:${activeTestSlice.title}`);
}
return;
}
if (activeTestSlice.status === 'paused') {
if (gamePreviewFrameRef.current?.resume()) {
setRunStatus(`正在继续测试切片:${activeTestSlice.title}`);
}
return;
}
startTestSlice();
}
return (
<section
className="launcher-page launcher-project-development game-project-workbench"
@@ -1620,6 +1783,66 @@ export default function ProjectDevelopmentView({
</div>
)}
</div>
<div
className="game-run-slice-controls"
aria-label="测试切片控件"
>
<button
type="button"
disabled={
!selectedTestSlice ||
selectedTestSliceIndex === 0 ||
activeTestSlice?.stale === true
}
onClick={() => switchTestSlice(-1)}
>
<ChevronLeft size={15} aria-hidden="true" />
上一项
</button>
<button
type="button"
disabled={
!selectedTestSlice ||
gameRunSession?.status === 'failed' ||
activeTestSlice?.stale === true
}
aria-label={
activeTestSlice?.status === 'playing'
? '暂停测试切片'
: activeTestSlice?.status === 'paused'
? '继续测试切片'
: '播放测试切片'
}
onClick={toggleTestSlicePlayback}
>
{activeTestSlice?.status === 'playing' ? (
<Pause size={15} aria-hidden="true" />
) : (
<Play size={15} aria-hidden="true" />
)}
</button>
<span>
{selectedTestSlice
? `${selectedTestSliceIndex + 1} / ${testSlices.length} · ${selectedTestSlice.title} · ${
activeTestSlice?.sliceId === selectedTestSlice.sliceId
? activeTestSlice.status
: 'idle'
}`
: '当前版本未登记正式测试切片'}
</span>
<button
type="button"
disabled={
!selectedTestSlice ||
selectedTestSliceIndex >= testSlices.length - 1 ||
activeTestSlice?.stale === true
}
onClick={() => switchTestSlice(1)}
>
下一项
<ChevronRight size={15} aria-hidden="true" />
</button>
</div>
<div className="game-run-panels">
<section aria-label="可运行版本信息">
<header>
@@ -1679,6 +1902,56 @@ export default function ProjectDevelopmentView({
{gameRunSession?.status === 'paused' ? '继续' : '暂停'}
</button>
</section>
<section aria-label="悬停资源信息">
<header>
<Info size={16} aria-hidden="true" />
资源信息
</header>
{trustedInspection ? (
<>
<dl>
<div>
<dt>名称</dt>
<dd>{trustedInspection.name}</dd>
</div>
<div>
<dt>类别</dt>
<dd>
{categoryLabels[trustedInspection.category]} ·{' '}
{trustedInspection.format}
</dd>
</div>
<div>
<dt>规格</dt>
<dd>
{trustedInspection.width && trustedInspection.height
? `${trustedInspection.width} × ${trustedInspection.height}`
: trustedInspection.durationMs
? `${Math.round(trustedInspection.durationMs / 100) / 10} 秒`
: '未登记'}
</dd>
</div>
<div>
<dt>版本</dt>
<dd>{trustedInspection.versionId}</dd>
</div>
<div>
<dt>绑定</dt>
<dd>{trustedInspection.slotId}</dd>
</div>
</dl>
<button
type="button"
disabled={!trustedInspectionResource}
onClick={locateTrustedInspectionResource}
>
在资源管理中定位
</button>
</>
) : (
<p>暂无悬停资源</p>
)}
</section>
</div>
{runStatus ? (
<p className="game-run-status" role="status">
@@ -677,9 +677,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(runTab.getAttribute('data-unavailable')).toBe('true');
fireEvent.click(runTab);
expect(runTab.getAttribute('aria-selected')).toBe('false');
expect(
screen.getByText('当前无可运行版本'),
).not.toBeNull();
expect(screen.getByText('当前无可运行版本')).not.toBeNull();
expect(screen.getByLabelText('附件导入失败')).not.toBeNull();
expect(screen.getByText('broken-reference.png')).not.toBeNull();
expect(screen.getByText('图片解码失败')).not.toBeNull();
@@ -1958,7 +1956,9 @@ export function registerProjectWorkbenchFoundationTests() {
);
});
expect(screen.getByLabelText('可运行版本信息')).not.toBeNull();
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('测试切片控件').textContent).toContain(
'当前版本未登记正式测试切片',
);
expect(screen.queryByLabelText('数值微调面板')).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
@@ -1992,11 +1992,7 @@ export function registerProjectWorkbenchFoundationTests() {
'workbench-version-switch',
'版本切换测试',
);
const first = runnableVersionFixture(
manifest.projectId,
'runnable-r1',
1,
);
const first = runnableVersionFixture(manifest.projectId, 'runnable-r1', 1);
const second = runnableVersionFixture(
manifest.projectId,
'runnable-r2',
@@ -2017,23 +2013,25 @@ export function registerProjectWorkbenchFoundationTests() {
const secondLaunch = new Promise((resolve) => {
resolveSecondLaunch = resolve;
});
const invoke = vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command !== 'launch_local_game_runnable_version') {
throw new Error(`unexpected invoke ${command}`);
}
if (args?.versionId === first.versionId) {
return {
manifest,
version: first,
preview: {
url: 'http://127.0.0.1:4101',
port: 4101,
root: '/tmp/workbench-version-switch',
},
};
}
return secondLaunch;
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'launch_local_game_runnable_version') {
throw new Error(`unexpected invoke ${command}`);
}
if (args?.versionId === first.versionId) {
return {
manifest,
version: first,
preview: {
url: 'http://127.0.0.1:4101',
port: 4101,
root: '/tmp/workbench-version-switch',
},
};
}
return secondLaunch;
},
);
window.__TAURI__ = { core: { invoke } };
function StatefulWorkbench() {
@@ -2059,9 +2057,7 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await waitFor(() => {
expect(
screen
.getByTitle('版本切换测试 游戏运行画面')
.getAttribute('src'),
screen.getByTitle('版本切换测试 游戏运行画面').getAttribute('src'),
).toBe('http://127.0.0.1:4101/');
});
@@ -2070,14 +2066,11 @@ export function registerProjectWorkbenchFoundationTests() {
});
expect(screen.queryByTitle('版本切换测试 游戏运行画面')).toBeNull();
expect(screen.getByText('正在切换可运行版本')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'launch_local_game_runnable_version',
{
projectPath: '/tmp/workbench-version-switch',
expectedProjectId: manifest.projectId,
versionId: second.versionId,
},
);
expect(invoke).toHaveBeenCalledWith('launch_local_game_runnable_version', {
projectPath: '/tmp/workbench-version-switch',
expectedProjectId: manifest.projectId,
versionId: second.versionId,
});
const switchedManifest = {
...manifest,
@@ -2102,9 +2095,7 @@ export function registerProjectWorkbenchFoundationTests() {
});
await waitFor(() => {
expect(
screen
.getByTitle('版本切换测试 游戏运行画面')
.getAttribute('src'),
screen.getByTitle('版本切换测试 游戏运行画面').getAttribute('src'),
).toBe('http://127.0.0.1:4102/');
});
});
@@ -2258,6 +2249,289 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByText('starting')).not.toBeNull();
});
it('runs formal P4 slices and resolves P5 hover IDs from trusted version metadata', async () => {
const manifest = createGameCreationAppManifest(
'workbench-slices',
'切片与悬停测试',
);
manifest.assets.push({
id: 'hero-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: {
kind: 'generated',
referenceResourceIds: [],
},
});
const version = runnableVersionFixture(
manifest.projectId,
'runnable-r12',
12,
{
resourceBindings: [{ slotId: 'hero', resourceId: 'hero-art' }],
resourceDescriptors: [
{
resourceId: 'hero-art',
name: 'hero.png',
category: 'art',
subtype: 'character',
width: 128,
height: 256,
format: 'image/png',
},
],
testSlices: [
{
sliceId: 'core',
versionId: 'runnable-r12',
title: '核心玩法',
order: 0,
startCondition: '游戏 ready',
endCondition: '核心循环结束',
status: 'idle',
},
{
sliceId: 'boss',
versionId: 'runnable-r12',
title: '首领战',
order: 1,
startCondition: '进入首领关',
endCondition: '首领战结束',
status: 'idle',
},
],
},
);
manifest.runnableVersions = [version];
manifest.currentRunnableVersionId = version.versionId;
const previewIdentity = {
previewId: 'preview-slices-1',
origin: 'http://127.0.0.1:4299',
};
manifest.preview = {
status: 'running',
url: `${previewIdentity.origin}/`,
port: 4299,
};
const invoke = vi.fn(async () => ({
manifest,
version,
preview: {
url: `${previewIdentity.origin}/`,
port: 4299,
root: '/tmp/workbench-slices',
},
previewIdentity,
}));
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-slices',
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);
const start = postMessage.mock.calls[0]?.[0] as Record<string, unknown>;
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: 'runtime-ready-slices',
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'] },
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
await Promise.resolve();
});
expect(
(screen.getByRole('button', { name: '上一项' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '播放测试切片' }));
const sliceStart = postMessage.mock.calls.at(-1)?.[0] as Record<
string,
unknown
>;
expect(sliceStart).toMatchObject({
type: 'host.slice.start',
payload: { sliceId: 'core' },
});
await act(async () => {
for (const data of [
{
requestId: 'runtime-slice-playing',
responseTo: sliceStart.requestId,
sequence: 2,
type: 'runtime.slice',
payload: {
sliceId: 'core',
title: '游戏伪造标题',
status: 'playing',
},
},
{
requestId: 'runtime-hover-resource',
sequence: 3,
type: 'runtime.inspection',
payload: {
inspectionId: 'hover-resource',
label: '伪造资源名称',
metrics: { name: 'fake.png' },
targetKind: 'resource',
targetId: 'hero-art',
},
},
]) {
window.dispatchEvent(
new MessageEvent('message', {
data: {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
sessionId: start.sessionId,
previewId: start.previewId,
projectId: start.projectId,
versionId: start.versionId,
projectRevision: start.projectRevision,
...data,
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
}
await Promise.resolve();
});
expect(screen.getByLabelText('测试切片控件').textContent).toContain(
'核心玩法 · playing',
);
const resourceInfo = screen.getByLabelText('悬停资源信息');
expect(resourceInfo.textContent).toContain('hero.png');
expect(resourceInfo.textContent).toContain('128 × 256');
expect(resourceInfo.textContent).not.toContain('fake.png');
fireEvent.click(screen.getByRole('button', { name: '下一项' }));
const switchMessages = postMessage.mock.calls
.slice(-2)
.map(([message]) => message as Record<string, unknown>);
expect(switchMessages).toMatchObject([
{ type: 'host.slice.stop', payload: { sliceId: 'core' } },
{ type: 'host.slice.start', payload: { sliceId: 'boss' } },
]);
expect(screen.getByLabelText('测试切片控件').textContent).toContain(
'首领战 · starting',
);
expect(resourceInfo.textContent).toContain('暂无悬停资源');
expect(
(screen.getByRole('button', { name: '下一项' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await act(async () => {
for (const [sequence, status] of [
[4, 'playing'],
[5, 'completed'],
] as const) {
window.dispatchEvent(
new MessageEvent('message', {
data: {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: `runtime-boss-${status}`,
sessionId: start.sessionId,
previewId: start.previewId,
projectId: start.projectId,
sequence,
type: 'runtime.slice',
versionId: start.versionId,
projectRevision: start.projectRevision,
payload: { sliceId: 'boss', title: '伪造标题', status },
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
}
await Promise.resolve();
});
expect(screen.getByLabelText('测试切片控件').textContent).toContain(
'首领战 · completed',
);
expect(screen.getByLabelText('运行会话信息').textContent).toContain(
'paused',
);
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: 'runtime-hover-resource-after-complete',
sessionId: start.sessionId,
previewId: start.previewId,
projectId: start.projectId,
sequence: 6,
type: 'runtime.inspection',
versionId: start.versionId,
projectRevision: start.projectRevision,
payload: {
inspectionId: 'hover-resource',
label: '伪造资源名称',
metrics: {},
targetKind: 'resource',
targetId: 'hero-art',
},
},
origin: previewIdentity.origin,
source: frameWindow,
}),
);
await Promise.resolve();
});
fireEvent.click(screen.getByRole('button', { name: '在资源管理中定位' }));
expect(
screen
.getByRole('tab', { name: '资源管理' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
screen
.getByRole('button', { name: /hero\.png/u })
.getAttribute('aria-pressed'),
).toBe('true');
});
it('shows a precise runnable version launch failure without embedding a preview', async () => {
const manifest = createGameCreationAppManifest(
'workbench-version-failure',
@@ -55,6 +55,19 @@ describe('P3 Game Run Bridge', () => {
type: 'host.start',
});
const sliceStart = createGameHostMessage({
identity,
sessionId: 'session-1',
requestId: 'host-request-slice',
sequence: 2,
type: 'host.slice.start',
payload: { sliceId: 'core-gameplay' },
});
expect(sliceStart).toMatchObject({
type: 'host.slice.start',
payload: { sliceId: 'core-gameplay' },
});
const guard = new GameRunBridgeRuntimeGuard(identity, 'session-1');
expect(guard.registerHostRequest(host.requestId, host.type)).toBe(true);
expect(guard.accept(runtimeMessage())).toEqual(runtimeMessage());
@@ -122,6 +135,15 @@ describe('P3 Game Run Bridge', () => {
metrics: { filePath: '/tmp/secret' },
},
},
{
type: 'runtime.inspection',
payload: {
inspectionId: 'inspection-1',
label: '运行画面',
metrics: {},
targetKind: 'resource',
},
},
{
type: 'runtime.inspection',
payload: {
@@ -0,0 +1,226 @@
import { describe, expect, it } from 'vitest';
import type {
GameRuntimeMessage,
RunnableGameVersion,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
beginGameTestSlice,
formalGameTestSlices,
projectGameTestSliceMessage,
projectGameTestSliceSessionStatus,
trustedInspectionFromRuntimeMessage,
} from '../src/features/project-workspace/gameTestSliceModel';
const version: RunnableGameVersion = {
schemaVersion: 'game-creator-runnable-version.v1',
versionId: 'runnable-r7',
projectId: 'project-1',
parentVersionId: null,
projectRevision: 7,
artifactPath: '.agent/runnable-versions/runnable-r7/artifact',
artifactSha256: 'a'.repeat(64),
entryPath: 'game/index.html',
resourceBindings: [{ slotId: 'hero', resourceId: 'hero-art' }],
testSlices: [
{
sliceId: 'boss',
versionId: 'runnable-r7',
title: '首领战',
order: 2,
startCondition: '进入首领关',
endCondition: '首领战结束',
status: 'idle',
},
{
sliceId: 'core',
versionId: 'runnable-r7',
title: '核心玩法',
order: 1,
startCondition: '游戏 ready',
endCondition: '核心循环结束',
status: 'idle',
},
],
resourceDescriptors: [
{
resourceId: 'hero-art',
name: 'hero.png',
category: 'art',
subtype: 'character',
width: 128,
height: 256,
format: 'image/png',
},
],
createdReason: 'initial',
validation: {
staticSmokePassed: true,
previewValidatePassed: true,
playtestPassed: true,
agentId: 'program-agent',
runId: 'run-7',
reportPath:
'.agent/runtime/browser-validations/program-agent/run-7/1/validation.json',
},
createdAt: 700,
};
function runtimeMessage(
type: GameRuntimeMessage['type'],
payload: GameRuntimeMessage['payload'],
): GameRuntimeMessage {
return {
schemaVersion: 'game-creator-runtime-message.v1',
protocolVersion: 'game-creator-run-bridge.v1',
requestId: `request-${type}`,
sessionId: 'session-1',
previewId: 'preview-1',
projectId: version.projectId,
sequence: 1,
type,
versionId: version.versionId,
projectRevision: version.projectRevision,
payload,
} as GameRuntimeMessage;
}
describe('P4/P5 game runtime models', () => {
it('uses only valid finite version-owned slices and enforces state transitions', () => {
const slices = formalGameTestSlices(version);
expect(slices.map((slice) => slice.sliceId)).toEqual(['core', 'boss']);
const starting = beginGameTestSlice(slices[0]);
const playing = projectGameTestSliceMessage(
starting,
runtimeMessage('runtime.slice', {
sliceId: 'core',
title: '不可信标题',
status: 'playing',
}),
);
expect(playing?.status).toBe('playing');
const completed = projectGameTestSliceMessage(
playing,
runtimeMessage('runtime.slice', {
sliceId: 'core',
title: '任意标题',
status: 'completed',
}),
);
expect(completed?.status).toBe('completed');
expect(
projectGameTestSliceMessage(
completed,
runtimeMessage('runtime.slice', {
sliceId: 'core',
title: '任意标题',
status: 'playing',
}),
)?.status,
).toBe('completed');
});
it('rejects invalid version slice identities instead of inventing a fallback', () => {
expect(
formalGameTestSlices({
...version,
testSlices: [{ ...version.testSlices![0], versionId: 'other' }],
}),
).toEqual([]);
expect(formalGameTestSlices({ ...version, testSlices: undefined })).toEqual(
[],
);
expect(
formalGameTestSlices({
...version,
testSlices: version.testSlices!.map((slice) => ({
...slice,
order: 1,
})),
}),
).toEqual([]);
expect(
formalGameTestSlices({
...version,
testSlices: [{ ...version.testSlices![0], status: 'playing' }],
}),
).toEqual([]);
});
it('projects only legal pause and resume transitions from the run session', () => {
const playing = projectGameTestSliceMessage(
beginGameTestSlice(formalGameTestSlices(version)[0]),
runtimeMessage('runtime.slice', {
sliceId: 'core',
title: '',
status: 'playing',
}),
);
const paused = projectGameTestSliceSessionStatus(
playing,
runtimeMessage('runtime.state', { status: 'paused' }),
);
expect(paused?.status).toBe('paused');
expect(
projectGameTestSliceSessionStatus(
paused,
runtimeMessage('runtime.state', { status: 'playing' }),
)?.status,
).toBe('playing');
});
it('resolves hover IDs only through the current version binding and descriptor', () => {
const resolved = trustedInspectionFromRuntimeMessage(
version,
runtimeMessage('runtime.inspection', {
inspectionId: 'hover-resource',
label: '伪造名称',
metrics: { name: '伪造资源' },
targetKind: 'resource',
targetId: 'hero-art',
}),
);
expect(resolved).toMatchObject({
name: 'hero.png',
resourceId: 'hero-art',
slotId: 'hero',
width: 128,
height: 256,
});
expect(
trustedInspectionFromRuntimeMessage(
version,
runtimeMessage('runtime.inspection', {
inspectionId: 'hover-entity',
label: '',
metrics: {},
targetKind: 'entity',
targetId: 'hero',
}),
),
).toMatchObject({ resourceId: 'hero-art', slotId: 'hero' });
expect(
trustedInspectionFromRuntimeMessage(
version,
runtimeMessage('runtime.inspection', {
inspectionId: 'hover-unbound',
label: '伪造名称',
metrics: {},
targetKind: 'resource',
targetId: 'unbound',
}),
),
).toBeNull();
expect(
trustedInspectionFromRuntimeMessage(
version,
runtimeMessage('runtime.inspection', {
inspectionId: 'hover-clear',
label: '',
metrics: {},
targetKind: 'none',
}),
),
).toBeNull();
});
});
@@ -320,6 +320,8 @@ type RunnableGameVersion = {
artifactSha256: string;
entryPath: 'game/index.html';
resourceBindings: Array<{ slotId: string; resourceId: string }>;
testSlices: GameTestSlice[];
resourceDescriptors: GameResourceDescriptor[];
createdReason: 'initial' | 'agent-revision';
validation: {
staticSmokePassed: true;
@@ -403,9 +405,12 @@ type GameHostMessage = {
| 'host.resume'
| 'host.stop'
| 'host.state.request'
| 'host.inspection.request';
| 'host.inspection.request'
| 'host.slice.start'
| 'host.slice.stop';
versionId: string;
projectRevision: number;
payload?: { sliceId: string };
};
type GameRuntimeMessage = {
@@ -437,7 +442,16 @@ type GameRuntimeMessage = {
- 预览服务在响应 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)
### 5.6 正式测试切片与可信悬停信息(P4 / P5)
- `RunnableGameVersion.testSlices` 与 `resourceDescriptors` 在版本登记时由可信 Rust 随不可变快照一并冻结;旧版本字段缺失时按空列表读取,不允许前端或游戏运行时补造正式切片或资源元数据。切片 ID、顺序和标题只信任版本合同,`runtime.slice` 的同名字段只能用于身份匹配与状态上报。
- `testSlices` 最多 64 项,`sliceId` 和 `order` 在版本内唯一,全部 `versionId` 必须等于所属版本,状态初始固定为 `idle`。宿主按 `order -> sliceId` 排序,首末项分别禁用上一项 / 下一项。
- 切片状态机固定为 `idle -> starting -> playing <-> paused -> completed`,并允许 `starting / playing / paused -> failed`。切换前先发送 `host.slice.stop` 安全终止当前切片,再以 `host.slice.start` 启动目标切片;版本、项目、preview 或 iframe 身份变化时清空运行态。`runtime.slice completed` 自动把运行会话投影到暂停态。
- `stale` 是宿主根据项目 / 版本身份变化投影的只读状态;stale 会话不得继续切片控制,只允许重新拉起。P4 不写参数、不修改代码、不创建新版本,也不消费 P2 资源替换。
- P5 不读取 iframe DOM。游戏捕获可检查对象 hover 后,只能通过 `runtime.inspection` 上报 `targetKind=resource|entity` 与稳定 `targetId`;离开对象上报 `targetKind=none`。宿主只在当前版本 `resourceBindings` 中把 resource ID 或实体 slot ID 解析到 `resourceDescriptors`,匹配后展示可信名称、类别 / 格式、尺寸规格、所属版本和绑定槽位,并仅在该资源仍存在于当前资源画布时提供“在资源管理中定位”按钮;定位会停止当前一次性运行会话、清除筛选并选中对应资源卡。未匹配、离开对象、切换切片或会话失效时清空信息。
- 游戏上报的 label、summary 和 metrics 仍是不可信提示,不能覆盖宿主资源元数据,也不能触发 Tauri、文件、命令、Agent、生成或资源替换能力。
### 5.7 Agent 泥点归因(P2)
```ts
type ProjectAgentMudPointAttribution = {
@@ -479,6 +493,16 @@ type ProjectAgentMudPointAttribution = {
- 接入 host-owned 同源 bootstrap,使当前正式可运行版本可以完成 ready/state 基线握手,并为游戏提供受限上报 API。
- 覆盖来源窗口、origin、全身份、协议、消息类型、payload、容量、顺序、重载、切换和停止负向测试。
### P4
- 从当前不可变可运行版本读取正式切片列表,完成上一项 / 播放暂停 / 下一项控制与严格状态机。
- 切片完成自动暂停;切换、版本 / 项目变化、iframe reload 与 stale 会话安全清理运行态。
### P5
- 游戏只上报稳定实体 / 资源 ID,宿主只用当前版本绑定与资源描述解析可信信息。
- 离开对象、切换切片、版本 / preview / session 变化时清空信息;不读取跨 origin DOM,不信任游戏展示内容。
## 7. 验收
### 7.1 P0 验收
@@ -539,9 +563,18 @@ type ProjectAgentMudPointAttribution = {
5. 预览响应注入的 bootstrap 不修改快照文件或摘要;旧版本至少完成 ready/playing 基线握手,pause/resume/stop 与 state/inspection request 均按 requestId 回应。
6. 本阶段不出现资源替换、新版本写入、测试切片控件、数值微调面板或任何游戏触发的 Tauri/文件/命令能力。
### 7.7 P4 / P5 运行测试与信息验收
1. 切片列表只来自当前 `RunnableGameVersion.testSlices`,稳定 ID、唯一有限顺序与版本归属不合法时版本合同拒绝;空列表不伪造默认切片。
2. 首项禁用上一项、末项禁用下一项;切换先 stop 当前项再 start 目标项,completed 自动暂停,failed 和 stale 不得继续执行控制。
3. 切换版本、项目、preview、iframe reload 或返回资源管理时,切片运行态与悬停信息全部清空,迟到消息不能复活旧状态。
4. `runtime.inspection` 只有目标 ID 命中当前版本绑定表并能解析当前版本资源描述时才展示信息;游戏自报 label、summary、metric 文本不得进入可信资源字段。
5. 信息面板首版展示资源名称、类别 / 格式、尺寸规格、所属版本和绑定位置;资源仍存在于资源画布时可停止当前运行会话并一键定位对应资源卡;离开对象或上报未绑定 ID 时清空。
6. P4 / P5 全程不写参数、不修改代码、不创建下一版本、不实现资源替换,也不让游戏消息触发任何宿主副作用。
## 8. 非目标
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 实现运行版本登记、选择、切换和运行态消费;P3 只实现运行会话与 Host/Game Bridge,测试切片编排、数值参数和泥点归因仍不在本次范围。
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、手动创建版本或版本回滚。P1 已实现运行版本登记、选择、切换和运行态消费,P3 已实现运行会话与 Host/Game Bridge,P4 / P5 已实现正式测试切片和可信悬停信息;P2 的资源替换、下一版本、数值参数写入、自然语言新增微调项和泥点归因仍不在本次范围。
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`。
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,5 +1,14 @@
# 决策记录
## 2026-08-04 工作台 P4/P5 以不可变版本合同驱动正式切片与可信悬停信息
- 背景:P0/P1 已建立不可变可运行版本,P3 已建立一次性 Host/Game Bridge;运行页仍缺少来自正式合同的测试切片控制和不读取 iframe DOM 的游戏内悬停信息。本轮按需求明确跳过 P2,不能借切片或信息面板加入资源替换、参数写入、代码修改或下一版本创建。
- 决策:`RunnableGameVersion` 在可信 Rust 登记时冻结可选 `testSlices` 与 `resourceDescriptors`,旧版本缺字段按空列表处理且不补造默认切片。正式切片由已通过的 playtest 场景确定,Host 仅通过 `{ sliceId }` 白名单发送 `host.slice.start / host.slice.stop`,前端按有限状态机投影一次性状态;切换、版本 / 项目 / preview / iframe 身份变化立即归零,completed 投影为暂停。
- 悬停信任边界:游戏只上报 `targetKind=resource|entity|none` 与稳定 `targetId`。宿主只用当前版本 `resourceBindings` 和 `resourceDescriptors` 解析名称、类别 / 格式、尺寸、版本与绑定槽位,游戏自报 label、summary、metrics 不得覆盖可信字段。定位按钮只在资源仍存在于当前 read model 时启用,并在停止当前运行会话后切回资源管理、清除筛选并选中对应资源卡。
- P2 边界:不实现资源替换、数值参数写入、自然语言新增微调项、代码修改、泥点归因或下一版本;合法游戏消息继续不能触发 Tauri、文件、命令、Agent 或生成副作用。
- 验证方式:共享 Rust 合同覆盖切片身份 / 顺序 / 初态和未绑定描述拒绝;Tauri 覆盖场景切片冻结、资源描述登记及 bootstrap API;TypeScript 模型 / Bridge 覆盖状态迁移、消息白名单和可信 ID 解析;AppSurface 覆盖首末控制、stop→start、completed 暂停、伪造元数据拒绝与资源定位,并运行 shell typecheck、编码检查和 `git diff --check`。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
## 2026-08-04 工作台 P3 在正式可运行预览上建立一次性 Host/Game Bridge
- 背景:P0/P1 已用不可变快照和专用启动命令建立正式可运行版本,但运行 iframe 仍没有可验证的会话身份、消息顺序、请求关联和 reload 后失效语义;飞书需求同时明确本次跳过 P2 资源替换,只实现 P3 底层运行桥。
@@ -434,6 +434,14 @@ game-project/
- 工作台显示真实 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`。
### 正式测试切片与可信悬停信息 P4 / P5
- 可运行版本登记时把当前正式 playtest 场景投影为有限、稳定、初始 `idle` 的 `testSlices`,同时把当前资源绑定对应的名称、类别、subtype、格式和可验证尺寸冻结为 `resourceDescriptors`;两者进入不可变版本记录,旧版本缺失时按空列表读取,不由前端或 iframe 补造。
- Host Bridge 增加带 `{ sliceId }` 白名单 payload 的 `host.slice.start / host.slice.stop`。bootstrap 只做协议校验、受限事件转发和 `runtime.slice` 基线响应;真实游戏可以通过同源 `genarrative:host-message` 消费切片命令,并用受限桥 API 上报 completed / failed。Host 只接受当前合同中的切片 ID 和合法状态迁移。
- 工作台控制条使用当前版本的正式切片顺序。切换先 stop 后 start;completed 自动暂停;failed、stale、身份漂移或 reload 后控制失效。切片状态属于一次性前端运行会话,不写 manifest。
- P5 继续禁止宿主读取 iframe DOM。游戏在自身 hit-test / hover 逻辑中仅上报 `targetKind=resource|entity|none` 和稳定 `targetId`;宿主按当前版本 `resourceBindings` 的 resourceId / slotId 解析 `resourceDescriptors`,只展示宿主可信元数据。匹配资源仍存在于当前资源 read model 时,定位按钮先停止并失效当前运行会话,再清除资源筛选、切回资源管理并选中对应资源卡;未命中、离开对象、切片切换和会话失效立即清空。
- 该阶段不实现 P2:不写数值、不修改代码、不替换资源、不追加资源替换版本,也不允许消息触发 Tauri、文件、命令、Agent 或生成。
历史命令式 drag preview 句柄与局部连接索引可以保留,但项目工作台不再向资源卡传入该入口。拖动热路径、4096 张真实卡片拖动重渲染和 Chromium p95 门槛统一暂缓;当前回归只要求 Pointer Move 不改变卡片坐标、SVG path 或布局 revision。`ResizeObserver` 仍保持单图层单实例,任何实时 DOM 几何都不得通过 Tauri IPC 往返 Rust。
## 分阶段实施
@@ -580,6 +580,8 @@ export interface RunnableGameVersion {
artifactSha256: string;
entryPath: 'game/index.html';
resourceBindings: GameIterationVersionResourceBinding[];
testSlices?: GameTestSlice[];
resourceDescriptors?: GameResourceDescriptor[];
createdReason: RunnableGameVersionCreatedReason;
validation: RunnableGameVersionValidation;
createdAt: number;
@@ -633,6 +635,7 @@ export interface GameTunableParameterDefinition {
export interface GameResourceDescriptor {
resourceId: string;
name: string;
category: 'document' | 'version' | 'art' | 'audio';
subtype: string;
width?: number;
@@ -655,7 +658,13 @@ export type GameHostMessageType =
| 'host.resume'
| 'host.stop'
| 'host.state.request'
| 'host.inspection.request';
| 'host.inspection.request'
| 'host.slice.start'
| 'host.slice.stop';
export interface GameHostSlicePayload {
sliceId: string;
}
export interface GameHostMessage {
schemaVersion: 'game-creator-host-message.v1';
@@ -668,6 +677,7 @@ export interface GameHostMessage {
type: GameHostMessageType;
versionId: string;
projectRevision: number;
payload?: GameHostSlicePayload;
}
export type GameRuntimeCapability = 'state' | 'slice' | 'inspection';
@@ -701,6 +711,8 @@ export interface GameRuntimeInspectionPayload {
label: string;
summary?: string;
metrics: Record<string, GameRuntimeInspectionMetric>;
targetKind?: 'resource' | 'entity' | 'none';
targetId?: string;
}
interface GameRuntimeMessageBase {
@@ -614,6 +614,7 @@ pub struct GameCreationAppCommandRunState {
pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
pub const GAME_RUNNABLE_VERSION_MAX_TEST_SLICES: usize = 64;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -673,6 +674,10 @@ pub struct RunnableGameVersion {
pub artifact_sha256: String,
pub entry_path: String,
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub test_slices: Vec<GameTestSlice>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resource_descriptors: Vec<GameResourceDescriptor>,
pub created_reason: RunnableGameVersionCreatedReason,
pub validation: RunnableGameVersionValidation,
pub created_at: u64,
@@ -803,6 +808,7 @@ pub enum GameResourceCategory {
#[serde(rename_all = "camelCase")]
pub struct GameResourceDescriptor {
pub resource_id: String,
pub name: String,
pub category: GameResourceCategory,
pub subtype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -828,6 +834,10 @@ pub enum GameHostMessageType {
HostStateRequest,
#[serde(rename = "host.inspection.request")]
HostInspectionRequest,
#[serde(rename = "host.slice.start")]
HostSliceStart,
#[serde(rename = "host.slice.stop")]
HostSliceStop,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -844,6 +854,8 @@ pub struct GameHostMessage {
pub message_type: GameHostMessageType,
pub version_id: String,
pub project_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -1093,6 +1105,7 @@ pub fn validate_runnable_game_versions(
return Err(format!("可运行版本 {} 的资源绑定过多", version.version_id));
}
let mut slot_ids = HashSet::new();
let mut bound_resource_ids = HashSet::new();
for binding in &version.resource_bindings {
validate_iteration_version_id(&binding.slot_id, "可运行版本资源槽位 ID", 256)?;
validate_iteration_version_id(&binding.resource_id, "可运行版本资源 ID", 512)?;
@@ -1102,6 +1115,70 @@ pub fn validate_runnable_game_versions(
version.version_id, binding.slot_id
));
}
bound_resource_ids.insert(binding.resource_id.as_str());
}
if version.test_slices.len() > GAME_RUNNABLE_VERSION_MAX_TEST_SLICES {
return Err(format!(
"可运行版本 {} 的测试切片最多支持 {GAME_RUNNABLE_VERSION_MAX_TEST_SLICES} 项",
version.version_id
));
}
let mut slice_ids = HashSet::new();
let mut slice_orders = HashSet::new();
for slice in &version.test_slices {
validate_iteration_version_id(&slice.slice_id, "测试切片 ID", 128)?;
validate_iteration_version_id(&slice.title, "测试切片标题", 128)?;
validate_iteration_version_id(&slice.start_condition, "测试切片开始条件", 512)?;
validate_iteration_version_id(&slice.end_condition, "测试切片结束条件", 512)?;
if slice.version_id != version.version_id {
return Err(format!(
"可运行版本 {} 的测试切片版本身份不一致:{}",
version.version_id, slice.slice_id
));
}
if slice.status != GameTestSliceStatus::Idle {
return Err(format!(
"可运行版本 {} 的测试切片必须以 idle 登记:{}",
version.version_id, slice.slice_id
));
}
if !slice_ids.insert(slice.slice_id.as_str()) || !slice_orders.insert(slice.order) {
return Err(format!(
"可运行版本 {} 的测试切片 ID 或顺序重复",
version.version_id
));
}
}
if version.resource_descriptors.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT {
return Err(format!("可运行版本 {} 的资源描述过多", version.version_id));
}
let mut descriptor_ids = HashSet::new();
for descriptor in &version.resource_descriptors {
validate_iteration_version_id(&descriptor.resource_id, "资源描述 ID", 512)?;
validate_iteration_version_id(&descriptor.name, "资源名称", 256)?;
validate_iteration_version_id(&descriptor.subtype, "资源 subtype", 128)?;
validate_iteration_version_id(&descriptor.format, "资源格式", 128)?;
if !bound_resource_ids.contains(descriptor.resource_id.as_str()) {
return Err(format!(
"可运行版本 {} 的资源描述未绑定:{}",
version.version_id, descriptor.resource_id
));
}
if !descriptor_ids.insert(descriptor.resource_id.as_str()) {
return Err(format!(
"可运行版本 {} 的资源描述重复:{}",
version.version_id, descriptor.resource_id
));
}
if descriptor.width == Some(0)
|| descriptor.height == Some(0)
|| descriptor.duration_ms == Some(0)
{
return Err(format!(
"可运行版本 {} 的资源尺寸规格无效:{}",
version.version_id, descriptor.resource_id
));
}
}
previous_versions.insert(
version.version_id.as_str(),
@@ -2339,6 +2416,25 @@ mod tests {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
test_slices: vec![GameTestSlice {
slice_id: "core-gameplay".to_string(),
version_id: version_id.to_string(),
title: "核心玩法".to_string(),
order: 0,
start_condition: "游戏进入 ready".to_string(),
end_condition: "游戏进入 won 或 lost".to_string(),
status: GameTestSliceStatus::Idle,
}],
resource_descriptors: vec![GameResourceDescriptor {
resource_id: "asset-player".to_string(),
name: "player.png".to_string(),
category: GameResourceCategory::Art,
subtype: "character".to_string(),
width: Some(128),
height: Some(128),
duration_ms: None,
format: "image/png".to_string(),
}],
created_reason,
validation: RunnableGameVersionValidation {
static_smoke_passed: true,
@@ -2410,6 +2506,58 @@ mod tests {
);
}
#[test]
fn runnable_versions_reject_invalid_formal_slices_and_unbound_descriptors() {
let version = runnable_version_fixture(
"runnable-r1",
None,
1,
RunnableGameVersionCreatedReason::Initial,
);
let mut wrong_version = version.clone();
wrong_version.test_slices[0].version_id = "runnable-r2".to_string();
assert!(validate_runnable_game_versions(
"project-runnable",
&[wrong_version],
Some("runnable-r1")
)
.expect_err("reject a slice owned by another version")
.contains("测试切片版本身份不一致"));
let mut duplicate_order = version.clone();
let mut second_slice = duplicate_order.test_slices[0].clone();
second_slice.slice_id = "boss".to_string();
duplicate_order.test_slices.push(second_slice);
assert!(validate_runnable_game_versions(
"project-runnable",
&[duplicate_order],
Some("runnable-r1")
)
.expect_err("reject duplicate slice order")
.contains("测试切片 ID 或顺序重复"));
let mut active_at_registration = version.clone();
active_at_registration.test_slices[0].status = GameTestSliceStatus::Playing;
assert!(validate_runnable_game_versions(
"project-runnable",
&[active_at_registration],
Some("runnable-r1")
)
.expect_err("reject a non-idle registered slice")
.contains("必须以 idle 登记"));
let mut unbound_descriptor = version;
unbound_descriptor.resource_descriptors[0].resource_id = "unbound".to_string();
assert!(validate_runnable_game_versions(
"project-runnable",
&[unbound_descriptor],
Some("runnable-r1")
)
.expect_err("reject an unbound trusted descriptor")
.contains("资源描述未绑定"));
}
#[test]
fn workbench_p3_message_types_keep_the_frozen_wire_values() {
let host = GameHostMessage {
@@ -2423,6 +2571,7 @@ mod tests {
message_type: GameHostMessageType::HostStart,
version_id: "runnable-r7".to_string(),
project_revision: 7,
payload: None,
};
assert_eq!(
serde_json::to_value(host).expect("serialize host message"),