补齐游戏场景后端集成契约

归一空白场景生成选项

接入场景权威画布素材类型

统一完美像素迁移哈希口径

补充场景后端回归测试
This commit is contained in:
2026-08-06 12:46:19 +08:00
parent d07cdce7bc
commit 0c05faeac3
3 changed files with 187 additions and 18 deletions
@@ -1718,6 +1718,28 @@ fn build_editor_scene_generation_inputs(
json!({ "fields": fields, "references": references })
}
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default)
}
fn normalize_editor_scene_generation_options(
model: Option<&str>,
aspect_ratio: Option<&str>,
image_size: Option<&str>,
) -> EditorGenerationOptions {
normalize_editor_generation_options(
Some(normalize_editor_scene_optional_text(
model,
EDITOR_IMAGE_MODEL_NANOBANANA2,
)),
Some(normalize_editor_scene_optional_text(aspect_ratio, "16:9")),
Some(normalize_editor_scene_optional_text(image_size, "1K")),
)
}
pub async fn generate_editor_scene(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -1725,13 +1747,10 @@ pub async fn generate_editor_scene(
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
let generation_options = normalize_editor_generation_options(
payload
.model
.as_deref()
.or(Some(EDITOR_IMAGE_MODEL_NANOBANANA2)),
payload.aspect_ratio.as_deref().or(Some("16:9")),
payload.image_size.as_deref().or(Some("1K")),
let generation_options = normalize_editor_scene_generation_options(
payload.model.as_deref(),
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let prompt = build_editor_scene_prompt(
payload.scene_content.as_str(),
@@ -18286,6 +18305,32 @@ mod tests {
assert_eq!(profile.slot, "scene");
}
#[test]
fn scene_generation_blank_options_match_omitted_defaults() {
let omitted = normalize_editor_scene_generation_options(None, None, None);
let blank =
normalize_editor_scene_generation_options(Some(" "), Some(" \t "), Some("\n "));
let explicit = normalize_editor_scene_generation_options(
Some(EDITOR_IMAGE_MODEL_NANOBANANA2),
Some("16:9"),
Some("1K"),
);
assert_eq!(omitted, explicit);
assert_eq!(blank, explicit);
assert_eq!(explicit.model, EDITOR_IMAGE_MODEL_NANOBANANA2);
assert_eq!(explicit.aspect_ratio, "16:9");
assert_eq!(explicit.image_size, "1K");
assert_eq!(
crate::editor_generation_config::editor_image_generation_mud_points(
Some("scene"),
Some(explicit.model),
Some(explicit.image_size),
),
12,
);
}
#[test]
fn scene_generation_rebuilds_fields_and_only_keeps_reference_rows() {
let payload: EditorSceneGenerateRequest = serde_json::from_value(json!({
@@ -22,10 +22,9 @@ use crate::{
generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing,
get_editor_project, list_editor_projects, list_public_editor_project_resources,
load_recent_editor_project, remove_editor_image_background, rename_editor_project,
save_editor_project_layout, snap_editor_image_to_pixel_art,
split_editor_icon_spritesheet, submit_editor_asset_showcase,
toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder,
update_editor_project_resource_showcase,
save_editor_project_layout, snap_editor_image_to_pixel_art, split_editor_icon_spritesheet,
submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset,
update_editor_asset_folder, update_editor_project_resource_showcase,
},
state::AppState,
};
@@ -32,8 +32,9 @@ const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [
"assetKind",
"generationInputs",
];
const EDITOR_CANVAS_ASSET_KINDS: [&str; 11] = [
const EDITOR_CANVAS_ASSET_KINDS: [&str; 12] = [
"spec",
"scene",
"character",
"character-animation",
"icon",
@@ -8828,14 +8829,13 @@ fn validate_editor_pixel_art_planned_canvas_layout(
.filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE)
.ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?;
let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?;
let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?;
let current_structured_integrity =
canvas_layout_integrity(current_structured_json.as_str())?;
verify_migration_layout(
let current_resource_asset_kinds =
project_resource_asset_kinds(ctx, project_id, owner_user_id);
verify_active_structured_canvas_migration(
&active_migration,
canvas.revision,
current_structured_hash.as_str(),
&current_structured_integrity,
current_structured_json.as_str(),
&current_resource_asset_kinds,
)?;
}
normalize_layout_json(layers_json).and_then(|layers_json| {
@@ -8850,6 +8850,18 @@ fn validate_editor_pixel_art_planned_canvas_layout(
})
}
fn verify_active_structured_canvas_migration(
migration: &EditorCanvasLayoutMigration,
revision: u64,
structured_json: &str,
resource_asset_kinds: &BTreeMap<String, Option<String>>,
) -> Result<(), String> {
let structured_hash =
canonical_layout_sha256_with_resource_asset_kinds(structured_json, resource_asset_kinds)?;
let integrity = canvas_layout_integrity(structured_json)?;
verify_migration_layout(migration, revision, structured_hash.as_str(), &integrity)
}
fn backfill_editor_canvas_layout(
ctx: &ReducerContext,
input: EditorCanvasLayoutMigrationInput,
@@ -10000,6 +10012,48 @@ mod tests {
));
}
#[test]
fn pixel_art_canvas_completion_accepts_scene_asset_kind() {
let layout = json!([{
"itemType": "generation-dialog",
"layerId": "generation-dialog:dialog-1",
"resourceId": "generation-dialog:dialog-1",
"dialog": {
"id": "dialog-1",
"mode": "quick-edit",
"status": "generating",
"placeholder": {
"x": 100,
"y": 50,
"width": 200,
"height": 100,
"originalWidth": 200,
"originalHeight": 100
}
}
}]);
let mut resource = pixel_art_project_resource();
resource.asset_kind = Some("scene".to_string());
let applied = match plan_editor_pixel_art_canvas_layout(
layout.to_string().as_str(),
&pixel_art_canvas_completion(),
&resource,
)
.expect("scene pixel-art completion should apply")
{
EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json,
_ => panic!("scene completion must apply"),
};
let parsed = parse_structured_canvas_layout(applied.as_str())
.expect("scene result layer should be accepted by structured layout parsing");
let extension: JsonValue = serde_json::from_str(&parsed.layers[0].item_json)
.expect("scene result layer extension");
assert_eq!(parsed.layers.len(), 1);
assert_eq!(extension["assetKind"], json!("scene"));
}
#[test]
fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() {
let layout = json!([{
@@ -11578,6 +11632,77 @@ mod tests {
);
}
#[test]
fn structured_canvas_scene_resource_kind_survives_canonical_normalization() {
let layout = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#;
let resource_asset_kinds =
BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]);
let canonical =
canonical_layout_json_with_resource_asset_kinds(layout, &resource_asset_kinds)
.expect("scene resource kind should canonicalize");
let parsed = parse_structured_canvas_layout(canonical.as_str())
.expect("canonical scene layout should remain structurally valid");
let rebuilt = serialize_structured_canvas_layout(&parsed)
.expect("canonical scene layout should persist structurally");
let rebuilt: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt scene layout");
assert_eq!(
parsed.layers[0].asset_kind_override.as_deref(),
Some("scene")
);
assert_eq!(rebuilt[0]["assetKindOverride"], json!("scene"));
}
#[test]
fn active_migration_verification_uses_scene_resource_kind() {
let now = Timestamp::from_micros_since_unix_epoch(1_000_000);
let structured_json = r#"[{"layerId":"layer-1","resourceId":"resource-scene"}]"#;
let resource_asset_kinds =
BTreeMap::from([("resource-scene".to_string(), Some("scene".to_string()))]);
let structured_hash = canonical_layout_sha256_with_resource_asset_kinds(
structured_json,
&resource_asset_kinds,
)
.expect("scene-aware migration hash");
let integrity = canvas_layout_integrity(structured_json).expect("scene layout integrity");
let migration = EditorCanvasLayoutMigration {
canvas_id: "canvas-1".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
source_layout_sha256: "source".to_string(),
structured_layout_sha256: structured_hash,
verified_revision: 7,
layer_count: integrity.layer_count,
dialog_count: integrity.dialog_count,
resource_refs_sha256: integrity.resource_refs_sha256,
migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED,
status: EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE.to_string(),
migrated_at: now,
activated_at: Some(now),
rolled_back_at: None,
updated_at: now,
};
verify_active_structured_canvas_migration(
&migration,
7,
structured_json,
&resource_asset_kinds,
)
.expect("scene resource kind should match the active migration hash");
assert!(
verify_active_structured_canvas_migration(
&migration,
7,
structured_json,
&BTreeMap::new(),
)
.is_err(),
"dropping the scene resource kind must still be detected as drift"
);
}
#[test]
fn structured_canvas_allows_distinct_layers_to_share_one_resource() {
let shared = r#"[