This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -19,6 +19,8 @@ use module_match3d::{
use serde::Serialize;
use serde::de::DeserializeOwned;
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
ctx: &mut ProcedureContext,
@@ -439,7 +441,7 @@ fn compile_match3d_draft_tx(
) -> Result<Match3DAgentSessionSnapshot, String> {
require_non_empty(&input.profile_id, "match3d profile_id")?;
let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?;
let config = parse_config(&session.config_json)?;
let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?);
validate_config(&config)?;
let tags = input
.tags_json
@@ -480,6 +482,9 @@ fn compile_match3d_draft_tx(
play_count: 0,
updated_at: compiled_at,
published_at: None,
generated_item_assets_json: normalize_generated_item_assets_json(
input.generated_item_assets_json.as_deref(),
)?,
};
upsert_work(ctx, work);
replace_session(
@@ -514,9 +519,12 @@ fn update_match3d_work_tx(
let tags = parse_tags(&input.tags_json)?;
let config = Match3DCreatorConfigSnapshot {
theme_text: clean_string(&input.theme_text, "经典消除"),
reference_image_src: parse_config_or_default(&current.config_json).reference_image_src,
..parse_config_or_default(&current.config_json)
};
let config = Match3DCreatorConfigSnapshot {
clear_count: input.clear_count,
difficulty: input.difficulty,
..config
};
validate_config(&config)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
@@ -538,6 +546,7 @@ fn update_match3d_work_tx(
play_count: current.play_count,
updated_at,
published_at: current.published_at,
generated_item_assets_json: current.generated_item_assets_json.clone(),
};
let snapshot = build_work_snapshot(&next)?;
replace_work(ctx, &current, next);
@@ -944,6 +953,9 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result<Match3DWorkSnapsho
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
generated_item_assets_json: normalize_generated_item_assets_json(
row.generated_item_assets_json.as_deref(),
)?,
})
}
@@ -1157,6 +1169,7 @@ fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow {
play_count: row.play_count,
updated_at: row.updated_at,
published_at: row.published_at,
generated_item_assets_json: row.generated_item_assets_json.clone(),
}
}
@@ -1189,6 +1202,9 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
reference_image_src: None,
clear_count: 12,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}
}
@@ -1202,15 +1218,43 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
config.difficulty = config
.difficulty
.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
config.asset_style_id = normalize_optional_text(config.asset_style_id);
config.asset_style_label = normalize_optional_text(config.asset_style_label);
config.asset_style_prompt = normalize_optional_text(config.asset_style_prompt);
config
})
}
fn normalize_match3d_generated_item_config(
mut config: Match3DCreatorConfigSnapshot,
) -> Match3DCreatorConfigSnapshot {
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
config
}
fn normalize_optional_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_tags(value: &str) -> Result<Vec<String>, String> {
let parsed = parse_json::<Vec<String>>(value, "match3d tags_json")?;
Ok(normalize_tags(parsed))
}
fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<String>, String> {
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let parsed = parse_json::<serde_json::Value>(trimmed, "match3d generated_item_assets_json")?;
if !parsed.is_array() {
return Err("match3d generated_item_assets_json 必须是数组".to_string());
}
Ok(Some(to_json_string(&parsed)))
}
fn default_tags(theme_text: &str) -> Vec<String> {
normalize_tags(vec![
theme_text.to_string(),
@@ -1557,17 +1601,81 @@ mod tests {
reference_image_src: None,
clear_count: 4,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: None,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
assert_eq!(snapshot.total_item_count, 12);
assert_eq!(snapshot.items.len(), 12);
}
#[test]
fn match3d_work_snapshot_keeps_generated_item_assets_json() {
let work = Match3DWorkProfileRow {
profile_id: "profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: "session-1".to_string(),
author_display_name: "作者".to_string(),
game_name: "水果抓大鹅".to_string(),
theme_text: "水果".to_string(),
summary_text: "水果主题".to_string(),
tags_json: "[\"水果\"]".to_string(),
cover_image_src: "/cover.png".to_string(),
cover_asset_id: String::new(),
clear_count: 3,
difficulty: 3,
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 3,
difficulty: 3,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
updated_at: Timestamp::from_micros_since_unix_epoch(1),
published_at: None,
generated_item_assets_json: Some(
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"#
.to_string(),
),
};
let snapshot = build_work_snapshot(&work).expect("work snapshot should build");
assert_eq!(
snapshot.generated_item_assets_json.as_deref(),
Some(
r#"[{"imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#
)
);
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
clear_count: 20,
difficulty: 8,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
});
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
assert_eq!(config.difficulty, 8);
}
#[test]
fn match3d_domain_click_bridge_clears_three_items() {
let snapshot = Match3DRunSnapshot {

View File

@@ -58,6 +58,8 @@ pub struct Match3DWorkProfileRow {
pub(crate) play_count: u32,
pub(crate) updated_at: Timestamp,
pub(crate) published_at: Option<Timestamp>,
#[default(None::<String>)]
pub(crate) generated_item_assets_json: Option<String>,
}
#[spacetimedb::table(

View File

@@ -89,6 +89,7 @@ pub struct Match3DDraftCompileInput {
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub compiled_at_micros: i64,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -223,6 +224,12 @@ pub struct Match3DCreatorConfigSnapshot {
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
#[serde(default)]
pub asset_style_id: Option<String>,
#[serde(default)]
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -287,6 +294,7 @@ pub struct Match3DWorkSnapshot {
pub play_count: u32,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub generated_item_assets_json: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]