This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -13,13 +13,12 @@ use module_match3d::{
Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState,
Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus,
Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at,
resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at_and_item_type_count,
stop_run_at as stop_domain_run_at,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
const MATCH3D_GENERATED_ITEM_COUNT_MVP: u32 = 3;
use serde_json::Value;
#[spacetimedb::procedure]
pub fn create_match3d_agent_session(
@@ -722,7 +721,12 @@ fn start_match3d_run_tx(
} else {
current_server_ms(ctx)
};
let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms);
let mut snapshot = build_initial_run_snapshot(
&input.run_id,
&work,
started_at_ms,
normalize_match3d_item_type_count_override(input.item_type_count_override),
);
snapshot.server_now_ms = current_server_ms(ctx);
snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms);
let now = ctx.timestamp;
@@ -838,6 +842,7 @@ fn restart_match3d_run_tx(
input: Match3DRunRestartInput,
) -> Result<Match3DRunSnapshot, String> {
let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?;
let item_type_count_override = resolve_item_type_count_override_from_run(&source);
start_match3d_run_tx(
ctx,
Match3DRunStartInput {
@@ -845,6 +850,7 @@ fn restart_match3d_run_tx(
owner_user_id: input.owner_user_id,
profile_id: source.profile_id,
started_at_ms: input.restarted_at_ms,
item_type_count_override,
},
)
}
@@ -992,19 +998,25 @@ fn build_initial_run_snapshot(
run_id: &str,
work: &Match3DWorkProfileRow,
started_at_ms: i64,
item_type_count_override: Option<u32>,
) -> Match3DRunSnapshot {
let config = parse_config_or_default(&work.config_json);
let domain_config =
let mut domain_config =
domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config());
domain_config.clear_count = module_match3d::normalize_match3d_runtime_clear_count(
domain_config.clear_count,
domain_config.difficulty,
);
let domain_started_at_ms = to_u64_ms(started_at_ms);
let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty);
let domain_run = start_run_with_seed_at(
let domain_run = start_run_with_seed_at_and_item_type_count(
run_id.to_string(),
work.owner_user_id.clone(),
work.profile_id.clone(),
&domain_config,
seed,
domain_started_at_ms,
item_type_count_override,
)
.unwrap_or_else(|_| DomainMatch3DRunSnapshot {
run_id: run_id.to_string(),
@@ -1026,6 +1038,26 @@ fn build_initial_run_snapshot(
snapshot_from_domain(&domain_run, started_at_ms)
}
fn normalize_match3d_item_type_count_override(value: u32) -> Option<u32> {
(value > 0).then_some(value)
}
fn resolve_item_type_count_override_from_run(row: &Match3DRuntimeRunRow) -> u32 {
deserialize_snapshot(&row.snapshot_json)
.ok()
.map(|snapshot| {
let mut item_type_ids = snapshot
.items
.iter()
.map(|item| item.item_type_id.clone())
.collect::<Vec<_>>();
item_type_ids.sort();
item_type_ids.dedup();
item_type_ids.len() as u32
})
.unwrap_or(0)
}
fn fallback_domain_config() -> DomainMatch3DCreatorConfig {
DomainMatch3DCreatorConfig {
theme_text: "经典消除".to_string(),
@@ -1218,7 +1250,19 @@ fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String>
if parse_tags(&row.tags_json)?.is_empty() {
return Err("match3d 发布需要至少 1 个标签".to_string());
}
validate_config(&parse_config(&row.config_json)?)
let config = parse_config(&row.config_json)?;
let required_item_types =
module_match3d::resolve_match3d_item_type_count_for_difficulty(
config.clear_count,
config.difficulty,
) as usize;
let ready_item_types = count_ready_generated_item_types(row.generated_item_assets_json.as_deref())?;
if ready_item_types < required_item_types {
return Err(format!(
"match3d 发布需要至少 {required_item_types} 种物品素材,当前已有 {ready_item_types}"
));
}
validate_config(&config)
}
fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool {
@@ -1234,6 +1278,7 @@ fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}
}
@@ -1255,10 +1300,8 @@ fn parse_config(value: &str) -> Result<Match3DCreatorConfigSnapshot, String> {
}
fn normalize_match3d_generated_item_config(
mut config: Match3DCreatorConfigSnapshot,
config: Match3DCreatorConfigSnapshot,
) -> Match3DCreatorConfigSnapshot {
// 中文注释:素材生成首版任意难度都只生成 3 件物品,草稿编译也同步收敛。
config.clear_count = MATCH3D_GENERATED_ITEM_COUNT_MVP;
config
}
@@ -1284,6 +1327,59 @@ fn normalize_generated_item_assets_json(value: Option<&str>) -> Result<Option<St
Ok(Some(to_json_string(&parsed)))
}
fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String> {
let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(0);
};
let parsed = parse_json::<Vec<Value>>(trimmed, "match3d generated_item_assets_json")?;
Ok(parsed
.iter()
.filter(|asset| {
let status_ready = asset
.get("status")
.and_then(Value::as_str)
.map(|status| matches!(status, "image_ready" | "model_ready"))
.unwrap_or(false);
let view_count = asset
.get("imageViews")
.or_else(|| asset.get("image_views"))
.and_then(Value::as_array)
.map(|views| {
views
.iter()
.filter(|view| {
view.get("imageSrc")
.or_else(|| view.get("image_src"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
|| view
.get("imageObjectKey")
.or_else(|| view.get("image_object_key"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
.count()
})
.unwrap_or(0);
let has_primary_image = asset
.get("imageSrc")
.or_else(|| asset.get("image_src"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
|| asset
.get("imageObjectKey")
.or_else(|| asset.get("image_object_key"))
.and_then(Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
status_ready && (view_count >= 5 || has_primary_image)
})
.count())
}
fn resolve_generated_item_assets_json_for_compile(
input: Option<&str>,
existing_work: Option<&Match3DWorkProfileRow>,
@@ -1695,6 +1791,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1702,7 +1799,7 @@ mod tests {
published_at: None,
generated_item_assets_json: None,
};
let snapshot = build_initial_run_snapshot("run-1", &work, 10);
let snapshot = build_initial_run_snapshot("run-1", &work, 10, None);
assert_eq!(snapshot.total_item_count, 12);
assert_eq!(snapshot.items.len(), 12);
}
@@ -1730,6 +1827,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 0,
@@ -1774,6 +1872,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1817,6 +1916,7 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
}),
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
play_count: 2,
@@ -1846,7 +1946,7 @@ mod tests {
}
#[test]
fn match3d_compile_normalizes_clear_count_to_three_item_mvp() {
fn match3d_compile_keeps_difficulty_clear_count() {
let config = normalize_match3d_generated_item_config(Match3DCreatorConfigSnapshot {
theme_text: "水果".to_string(),
reference_image_src: None,
@@ -1855,10 +1955,12 @@ mod tests {
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: true,
});
assert_eq!(config.clear_count, MATCH3D_GENERATED_ITEM_COUNT_MVP);
assert_eq!(config.clear_count, 20);
assert_eq!(config.difficulty, 8);
assert!(config.generate_click_sound);
}
#[test]

View File

@@ -138,6 +138,7 @@ pub struct Match3DRunStartInput {
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
pub item_type_count_override: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
@@ -230,6 +231,8 @@ pub struct Match3DCreatorConfigSnapshot {
pub asset_style_label: Option<String>,
#[serde(default)]
pub asset_style_prompt: Option<String>,
#[serde(default)]
pub generate_click_sound: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -15,7 +15,8 @@ use module_puzzle::{
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput,
PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft,
@@ -313,6 +314,25 @@ pub fn save_puzzle_generated_images(
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_ui_background(
ctx: &mut ProcedureContext,
input: PuzzleUiBackgroundSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session_json: Some(serialize_json(&session)),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn select_puzzle_cover_image(
ctx: &mut ProcedureContext,
@@ -1025,6 +1045,70 @@ fn save_puzzle_generated_images_tx(
)
}
fn save_puzzle_ui_background_tx(
ctx: &TxContext,
input: PuzzleUiBackgroundSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
draft.levels = levels;
module_puzzle::sync_primary_level_fields(&mut draft);
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
next_level.ui_background_image_object_key = input
.image_object_key
.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("百梦主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent.max(96),
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图 UI 背景图已生成。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn sync_generated_primary_level_name_as_default_work_title(
draft: &mut PuzzleResultDraft,
previous_work_title: &str,
@@ -1069,6 +1153,9 @@ fn select_puzzle_cover_image_tx(
level_name: target_level.level_name,
picture_description: target_level.picture_description,
picture_reference: target_level.picture_reference,
ui_background_prompt: target_level.ui_background_prompt,
ui_background_image_src: target_level.ui_background_image_src,
ui_background_image_object_key: target_level.ui_background_image_object_key,
background_music: target_level.background_music,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
@@ -2322,6 +2409,9 @@ fn build_profile_levels_from_row(
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
@@ -3337,6 +3427,9 @@ mod tests {
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: candidates.clone(),
selected_candidate_id: None,