Files
Genarrative/server-rs/crates/api-server/src/match3d/mappers.rs
高物 3931442249 Enforce Genarrative play-type SOP and update docs
Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows.
2026-05-20 12:12:00 +08:00

582 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(super) fn map_match3d_agent_session_response(
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
Match3DAgentSessionSnapshotResponse {
session_id: session.session_id,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage.clone(),
anchor_pack: map_match3d_anchor_pack_response_for_turn(
session.anchor_pack,
session.current_turn,
session.stage.as_str(),
),
config: session.config.map(map_match3d_config_response),
draft: session.draft.map(map_match3d_draft_response),
messages: session
.messages
.into_iter()
.map(map_match3d_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
updated_at: session.updated_at,
}
}
pub(super) fn map_match3d_agent_session_response_with_assets(
session: Match3DAgentSessionRecord,
generated_item_assets: &[Match3DGeneratedItemAsset],
) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() {
if generated_item_assets.is_empty() {
return response;
}
draft.generated_item_assets = generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect();
if draft
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
draft.cover_image_src = resolve_match3d_default_cover_image_src(generated_item_assets);
}
let background_asset = find_match3d_generated_background_asset(generated_item_assets);
apply_match3d_background_asset_to_agent_draft(draft, background_asset);
}
response
}
pub(super) fn map_match3d_anchor_pack_response_for_turn(
anchor: Match3DAnchorPackRecord,
current_turn: u32,
stage: &str,
) -> Match3DAnchorPackResponse {
let is_ready = matches!(
stage,
"ReadyToCompile"
| "ready_to_compile"
| "DraftCompiled"
| "draft_compiled"
| "draft_ready"
| "ReadyToPublish"
| "ready_to_publish"
| "Published"
| "published"
);
let collected_count = if is_ready { 3 } else { current_turn.min(3) };
Match3DAnchorPackResponse {
theme: map_match3d_anchor_item_response_for_collected(anchor.theme, collected_count >= 1),
clear_count: map_match3d_anchor_item_response_for_collected(
anchor.clear_count,
collected_count >= 2,
),
difficulty: map_match3d_anchor_item_response_for_collected(
anchor.difficulty,
collected_count >= 3,
),
}
}
pub(super) fn map_match3d_anchor_item_response(
anchor: Match3DAnchorItemRecord,
) -> Match3DAnchorItemResponse {
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_match3d_anchor_item_response_for_collected(
anchor: Match3DAnchorItemRecord,
collected: bool,
) -> Match3DAnchorItemResponse {
if collected {
return map_match3d_anchor_item_response(anchor);
}
Match3DAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: String::new(),
status: "missing".to_string(),
}
}
pub(super) fn map_match3d_config_response(
config: Match3DCreatorConfigRecord,
) -> Match3DCreatorConfigResponse {
Match3DCreatorConfigResponse {
theme_text: config.theme_text,
reference_image_src: config.reference_image_src,
clear_count: config.clear_count,
difficulty: config.difficulty,
asset_style_id: config.asset_style_id,
asset_style_label: config.asset_style_label,
asset_style_prompt: config.asset_style_prompt,
generate_click_sound: config.generate_click_sound,
}
}
pub(super) fn map_match3d_draft_response(
draft: Match3DResultDraftRecord,
) -> Match3DResultDraftResponse {
// 中文注释session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
let generated_item_assets =
parse_match3d_generated_item_assets(draft.generated_item_assets_json.as_deref())
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
let mut response = Match3DResultDraftResponse {
profile_id: draft.profile_id,
game_name: draft.game_name,
theme_text: draft.theme_text,
summary_text: Some(draft.summary_text.clone()),
summary: draft.summary_text,
tags: draft.tags,
cover_image_src: draft.cover_image_src,
reference_image_src: draft.reference_image_src,
clear_count: draft.clear_count,
difficulty: draft.difficulty,
total_item_count: draft.total_item_count,
publish_ready: draft.publish_ready,
blockers: draft.blockers,
background_prompt: None,
background_image_src: None,
background_image_object_key: None,
generated_background_asset: None,
generated_item_assets: generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect(),
};
if response
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
}
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
response
}
pub(super) fn map_match3d_generated_item_asset_for_agent(
asset: Match3DGeneratedItemAsset,
) -> Match3DAgentGeneratedItemAssetResponse {
Match3DAgentGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
item_size: asset.item_size,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_agent)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_agent),
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_generated_item_asset_for_work(
asset: Match3DGeneratedItemAssetJson,
) -> shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedItemAssetResponse {
item_id: asset.item_id,
item_name: asset.item_name,
item_size: asset
.item_size
.or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())),
image_src: asset.image_src,
image_object_key: asset.image_object_key,
image_views: asset
.image_views
.into_iter()
.map(map_match3d_image_view_for_work)
.collect(),
model_src: asset.model_src,
model_object_key: asset.model_object_key,
model_file_name: asset.model_file_name,
task_uuid: asset.task_uuid,
subscription_key: asset.subscription_key,
sound_prompt: asset.sound_prompt,
background_music_title: asset.background_music_title,
background_music_style: asset.background_music_style,
background_music_prompt: asset.background_music_prompt,
background_music: asset.background_music,
click_sound: asset.click_sound,
background_asset: asset
.background_asset
.map(map_match3d_background_asset_for_work),
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_image_view_for_agent(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_agent::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_image_view_for_work(
view: Match3DGeneratedItemImageView,
) -> shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_image_view_from_work(
view: shared_contracts::match3d_works::Match3DGeneratedItemImageViewResponse,
) -> Match3DGeneratedItemImageView {
Match3DGeneratedItemImageView {
view_id: view.view_id,
view_index: view.view_index,
image_src: view.image_src,
image_object_key: view.image_object_key,
}
}
pub(super) fn map_match3d_background_asset_for_agent(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_agent::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
pub(super) fn map_match3d_background_asset_for_work(
asset: Match3DGeneratedBackgroundAsset,
) -> shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
shared_contracts::match3d_works::Match3DGeneratedBackgroundAssetResponse {
prompt: asset.prompt,
image_src: asset.image_src,
image_object_key: asset.image_object_key,
container_prompt: asset.container_prompt,
container_image_src: asset.container_image_src,
container_image_object_key: asset.container_image_object_key,
status: asset.status,
error: asset.error,
}
}
pub(super) fn find_match3d_generated_background_asset(
assets: &[Match3DGeneratedItemAsset],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
pub(super) fn resolve_match3d_default_cover_image_src(
assets: &[Match3DGeneratedItemAsset],
) -> Option<String> {
find_match3d_generated_background_asset(assets).and_then(|asset| {
asset
.container_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
asset
.container_image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
.or_else(|| {
asset
.image_object_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
})
})
}
pub(super) fn find_match3d_generated_background_asset_json(
assets: &[Match3DGeneratedItemAssetJson],
) -> Option<Match3DGeneratedBackgroundAsset> {
assets
.iter()
.find_map(|asset| asset.background_asset.clone())
}
pub(super) fn apply_match3d_background_asset_to_agent_draft(
draft: &mut Match3DResultDraftResponse,
background_asset: Option<Match3DGeneratedBackgroundAsset>,
) {
if let Some(asset) = background_asset {
draft.background_prompt = Some(asset.prompt.clone());
draft.background_image_src = asset.image_src.clone();
draft.background_image_object_key = asset.image_object_key.clone();
draft.generated_background_asset = Some(map_match3d_background_asset_for_agent(asset));
}
}
pub(super) fn build_match3d_work_profile_record_with_assets(
mut item: Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAsset],
) -> Match3DWorkProfileRecord {
item.generated_item_assets_json = serialize_match3d_generated_item_assets(assets);
if let Some(background_asset) = find_match3d_generated_background_asset(assets) {
item.cover_image_src = item.cover_image_src.or_else(|| {
background_asset
.container_image_src
.clone()
.or(background_asset.container_image_object_key.clone())
.or(background_asset.image_src.clone())
.or(background_asset.image_object_key.clone())
});
}
item
}
fn match3d_text_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| asset.image_views.iter().any(|view| {
match3d_text_present(view.image_src.as_ref())
|| match3d_text_present(view.image_object_key.as_ref())
})
}
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Option<String> {
if item.publication_status.eq_ignore_ascii_case("published") {
return Some("ready".to_string());
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)
{
return Some("generating".to_string());
}
Some("ready".to_string())
}
pub(super) fn map_match3d_message_response(
message: Match3DAgentMessageRecord,
) -> Match3DAgentMessageResponse {
Match3DAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_match3d_work_summary_response(
item: Match3DWorkProfileRecord,
) -> Match3DWorkSummaryResponse {
let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generation_status = resolve_match3d_work_generation_status(
&item,
&generated_item_asset_json,
background_asset.as_ref(),
);
let generated_background_asset = background_asset
.clone()
.map(map_match3d_background_asset_for_work);
let generated_item_assets = generated_item_asset_json
.into_iter()
.map(map_match3d_generated_item_asset_for_work)
.collect();
Match3DWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
game_name: item.game_name,
theme_text: item.theme_text,
summary: item.summary,
tags: item.tags,
cover_image_src: item.cover_image_src,
reference_image_src: item.reference_image_src,
clear_count: item.clear_count,
difficulty: item.difficulty,
publication_status: item.publication_status,
play_count: item.play_count,
updated_at: item.updated_at,
published_at: item.published_at,
publish_ready: item.publish_ready,
generation_status,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset
.as_ref()
.and_then(|asset| asset.image_src.clone()),
background_image_object_key: background_asset
.as_ref()
.and_then(|asset| asset.image_object_key.clone()),
generated_background_asset,
generated_item_assets,
}
}
pub(super) fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": MATCH3D_AGENT_PROVIDER,
"message": message.into(),
}))
}
pub(super) fn map_match3d_work_profile_response(
item: Match3DWorkProfileRecord,
) -> Match3DWorkProfileResponse {
Match3DWorkProfileResponse {
summary: map_match3d_work_summary_response(item),
}
}
pub(super) fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse {
Match3DRunSnapshotResponse {
run_id: run.run_id,
profile_id: run.profile_id,
owner_user_id: run.owner_user_id,
status: normalize_match3d_run_status(run.status.as_str()).to_string(),
snapshot_version: run.snapshot_version,
started_at_ms: run.started_at_ms,
duration_limit_ms: run.duration_limit_ms,
server_now_ms: run.server_now_ms,
remaining_ms: run.remaining_ms,
clear_count: run.clear_count,
total_item_count: run.total_item_count,
cleared_item_count: run.cleared_item_count,
items: run
.items
.into_iter()
.map(map_match3d_item_response)
.collect(),
tray_slots: run
.tray_slots
.into_iter()
.map(map_match3d_tray_slot_response)
.collect(),
failure_reason: run
.failure_reason
.map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()),
last_confirmed_action_id: run.last_confirmed_action_id,
}
}
pub(super) fn map_match3d_item_response(
item: Match3DItemSnapshotRecord,
) -> Match3DItemSnapshotResponse {
Match3DItemSnapshotResponse {
item_instance_id: item.item_instance_id,
item_type_id: item.item_type_id,
visual_key: item.visual_key,
x: item.x,
y: item.y,
radius: item.radius,
layer: item.layer,
state: normalize_match3d_item_state(item.state.as_str()).to_string(),
clickable: item.clickable,
tray_slot_index: item.tray_slot_index,
}
}
pub(super) fn map_match3d_tray_slot_response(
slot: Match3DTraySlotRecord,
) -> Match3DTraySlotResponse {
Match3DTraySlotResponse {
slot_index: slot.slot_index,
item_instance_id: slot.item_instance_id,
item_type_id: slot.item_type_id,
visual_key: slot.visual_key,
}
}
pub(super) fn map_match3d_click_confirmation_response(
confirmation: Match3DClickConfirmationRecord,
) -> Match3DClickConfirmationResponse {
Match3DClickConfirmationResponse {
accepted: confirmation.accepted,
reject_reason: confirmation
.reject_reason
.map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()),
entered_slot_index: confirmation.entered_slot_index,
cleared_item_instance_ids: confirmation.cleared_item_instance_ids,
run: map_match3d_run_response(confirmation.run),
}
}