pub(crate) mod tables; mod types; pub use tables::*; pub use types::*; use crate::*; use module_match3d::{ Match3DClickInput as DomainMatch3DClickInput, Match3DClickRejectReason as DomainMatch3DClickRejectReason, Match3DCreatorConfig as DomainMatch3DCreatorConfig, Match3DFailureReason as DomainMatch3DFailureReason, 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_and_item_type_count, stop_run_at as stop_domain_run_at, }; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; #[spacetimedb::procedure] pub fn create_match3d_agent_session( ctx: &mut ProcedureContext, input: Match3DAgentSessionCreateInput, ) -> Match3DAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_match3d_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_match3d_agent_session( ctx: &mut ProcedureContext, input: Match3DAgentSessionGetInput, ) -> Match3DAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_match3d_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn submit_match3d_agent_message( ctx: &mut ProcedureContext, input: Match3DAgentMessageSubmitInput, ) -> Match3DAgentSessionProcedureResult { match ctx.try_with_tx(|tx| submit_match3d_agent_message_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn finalize_match3d_agent_message_turn( ctx: &mut ProcedureContext, input: Match3DAgentMessageFinalizeInput, ) -> Match3DAgentSessionProcedureResult { match ctx.try_with_tx(|tx| finalize_match3d_agent_message_turn_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn compile_match3d_draft( ctx: &mut ProcedureContext, input: Match3DDraftCompileInput, ) -> Match3DAgentSessionProcedureResult { match ctx.try_with_tx(|tx| compile_match3d_draft_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn update_match3d_work( ctx: &mut ProcedureContext, input: Match3DWorkUpdateInput, ) -> Match3DWorkProcedureResult { match ctx.try_with_tx(|tx| update_match3d_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn publish_match3d_work( ctx: &mut ProcedureContext, input: Match3DWorkPublishInput, ) -> Match3DWorkProcedureResult { match ctx.try_with_tx(|tx| publish_match3d_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn list_match3d_works( ctx: &mut ProcedureContext, input: Match3DWorksListInput, ) -> Match3DWorksProcedureResult { match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) { Ok(items) => Match3DWorksProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => Match3DWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn get_match3d_work_detail( ctx: &mut ProcedureContext, input: Match3DWorkGetInput, ) -> Match3DWorkProcedureResult { match ctx.try_with_tx(|tx| get_match3d_work_detail_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn delete_match3d_work( ctx: &mut ProcedureContext, input: Match3DWorkDeleteInput, ) -> Match3DWorksProcedureResult { match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) { Ok(items) => Match3DWorksProcedureResult { ok: true, items_json: Some(to_json_string(&items)), error_message: None, }, Err(message) => Match3DWorksProcedureResult { ok: false, items_json: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_match3d_run( ctx: &mut ProcedureContext, input: Match3DRunStartInput, ) -> Match3DRunProcedureResult { match ctx.try_with_tx(|tx| start_match3d_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn get_match3d_run( ctx: &mut ProcedureContext, input: Match3DRunGetInput, ) -> Match3DRunProcedureResult { match ctx.try_with_tx(|tx| get_match3d_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn click_match3d_item( ctx: &mut ProcedureContext, input: Match3DRunClickInput, ) -> Match3DClickItemProcedureResult { match ctx.try_with_tx(|tx| click_match3d_item_tx(tx, input.clone())) { Ok(result) => result, Err(message) => Match3DClickItemProcedureResult { ok: false, status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(), run_json: None, accepted_item_instance_id: None, cleared_item_instance_ids: Vec::new(), failure_reason: None, error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn stop_match3d_run( ctx: &mut ProcedureContext, input: Match3DRunStopInput, ) -> Match3DRunProcedureResult { match ctx.try_with_tx(|tx| stop_match3d_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn restart_match3d_run( ctx: &mut ProcedureContext, input: Match3DRunRestartInput, ) -> Match3DRunProcedureResult { match ctx.try_with_tx(|tx| restart_match3d_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn finish_match3d_time_up( ctx: &mut ProcedureContext, input: Match3DRunTimeUpInput, ) -> Match3DRunProcedureResult { match ctx.try_with_tx(|tx| finish_match3d_time_up_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } fn create_match3d_agent_session_tx( ctx: &ReducerContext, input: Match3DAgentSessionCreateInput, ) -> Result { require_non_empty(&input.session_id, "match3d session_id")?; require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; require_non_empty(&input.welcome_message_id, "match3d welcome_message_id")?; if ctx .db .match3d_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("match3d_agent_session.session_id 已存在".to_string()); } if ctx .db .match3d_agent_message() .message_id() .find(&input.welcome_message_id) .is_some() { return Err("match3d_agent_message.message_id 已存在".to_string()); } let config = input .config_json .as_deref() .map(parse_config) .transpose()? .unwrap_or_else(|| default_config_from_seed(&input.seed_text)); validate_config(&config)?; let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let welcome = input.welcome_message_text.trim(); ctx.db .match3d_agent_session() .insert(Match3DAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, progress_percent: 0, stage: MATCH3D_STAGE_COLLECTING.to_string(), config_json: to_json_string(&config), draft_json: String::new(), last_assistant_reply: welcome.to_string(), published_profile_id: String::new(), created_at, updated_at: created_at, }); ctx.db .match3d_agent_message() .insert(Match3DAgentMessageRow { message_id: input.welcome_message_id, session_id: input.session_id.clone(), role: MATCH3D_ROLE_ASSISTANT.to_string(), kind: MATCH3D_KIND_TEXT.to_string(), text: welcome.to_string(), created_at, }); get_match3d_agent_session_tx( ctx, Match3DAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_match3d_agent_session_tx( ctx: &ReducerContext, input: Match3DAgentSessionGetInput, ) -> Result { let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; build_session_snapshot(ctx, &row) } fn submit_match3d_agent_message_tx( ctx: &ReducerContext, input: Match3DAgentMessageSubmitInput, ) -> Result { require_non_empty(&input.user_message_id, "match3d user_message_id")?; require_non_empty(&input.user_message_text, "match3d user_message_text")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; if ctx .db .match3d_agent_message() .message_id() .find(&input.user_message_id) .is_some() { return Err("match3d_agent_message.user_message_id 已存在".to_string()); } let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); ctx.db .match3d_agent_message() .insert(Match3DAgentMessageRow { message_id: input.user_message_id, session_id: input.session_id.clone(), role: MATCH3D_ROLE_USER.to_string(), kind: MATCH3D_KIND_TEXT.to_string(), text: input.user_message_text.trim().to_string(), created_at: submitted_at, }); replace_session( ctx, &session, Match3DAgentSessionRow { updated_at: submitted_at, ..clone_session(&session) }, ); get_match3d_agent_session_tx( ctx, Match3DAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn finalize_match3d_agent_message_turn_tx( ctx: &ReducerContext, input: Match3DAgentMessageFinalizeInput, ) -> Result { let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); if let Some(message) = input .error_message .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { replace_session( ctx, &session, Match3DAgentSessionRow { updated_at, ..clone_session(&session) }, ); return Err(message.to_string()); } let next_config = input .config_json .as_deref() .map(parse_config) .transpose()? .unwrap_or_else(|| parse_config_or_default(&session.config_json)); validate_config(&next_config)?; let assistant_text = input .assistant_reply_text .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(&session.last_assistant_reply) .to_string(); if let Some(message_id) = input .assistant_message_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { if ctx .db .match3d_agent_message() .message_id() .find(&message_id.to_string()) .is_some() { return Err("match3d_agent_message.assistant_message_id 已存在".to_string()); } ctx.db .match3d_agent_message() .insert(Match3DAgentMessageRow { message_id: message_id.to_string(), session_id: input.session_id.clone(), role: MATCH3D_ROLE_ASSISTANT.to_string(), kind: MATCH3D_KIND_TEXT.to_string(), text: assistant_text.clone(), created_at: updated_at, }); } let next_stage = normalize_stage(&input.stage); replace_session( ctx, &session, Match3DAgentSessionRow { current_turn: session.current_turn.saturating_add(1), progress_percent: input.progress_percent.min(100), stage: next_stage, config_json: to_json_string(&next_config), last_assistant_reply: assistant_text, updated_at, ..clone_session(&session) }, ); get_match3d_agent_session_tx( ctx, Match3DAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn compile_match3d_draft_tx( ctx: &ReducerContext, input: Match3DDraftCompileInput, ) -> Result { require_non_empty(&input.profile_id, "match3d profile_id")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; let config = normalize_match3d_generated_item_config(parse_config(&session.config_json)?); validate_config(&config)?; let existing_work = ctx .db .match3d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| row.owner_user_id == input.owner_user_id); let tags = resolve_compile_tags( input.tags_json.as_deref(), existing_work.as_ref(), config.theme_text.as_str(), )?; let game_name = resolve_compile_game_name( &input.game_name, existing_work.as_ref(), config.theme_text.as_str(), ); let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref()); let draft = Match3DDraftSnapshot { profile_id: input.profile_id.clone(), game_name: game_name.clone(), theme_text: config.theme_text.clone(), summary_text: summary_text.clone(), tags: tags.clone(), clear_count: config.clear_count, difficulty: config.difficulty, }; let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let generated_item_assets_json = resolve_generated_item_assets_json_for_compile( input.generated_item_assets_json.as_deref(), existing_work.as_ref(), )?; let previous_publication_status = existing_work .as_ref() .map(|work| work.publication_status.clone()) .unwrap_or_else(|| MATCH3D_PUBLICATION_DRAFT.to_string()); let previous_play_count = existing_work .as_ref() .map(|work| work.play_count) .unwrap_or(0); let previous_published_at = existing_work.as_ref().and_then(|work| work.published_at); let cover_image_src = resolve_compile_optional_text( &input.cover_image_src, existing_work .as_ref() .map(|work| work.cover_image_src.as_str()), ); let cover_asset_id = resolve_compile_optional_text( &input.cover_asset_id, existing_work .as_ref() .map(|work| work.cover_asset_id.as_str()), ); let work = Match3DWorkProfileRow { profile_id: input.profile_id.clone(), owner_user_id: input.owner_user_id.clone(), source_session_id: input.session_id.clone(), author_display_name: clean_string(&input.author_display_name, "陶泥儿主"), game_name, theme_text: config.theme_text.clone(), summary_text, tags_json: to_json_string(&tags), cover_image_src, cover_asset_id, clear_count: config.clear_count, difficulty: config.difficulty, config_json: to_json_string(&config), publication_status: previous_publication_status, play_count: previous_play_count, updated_at: compiled_at, published_at: previous_published_at, generated_item_assets_json, }; upsert_work(ctx, work); replace_session( ctx, &session, Match3DAgentSessionRow { progress_percent: 80, stage: MATCH3D_STAGE_DRAFT_COMPILED.to_string(), draft_json: to_json_string(&draft), published_profile_id: input.profile_id, last_assistant_reply: "抓大鹅玩法草稿已生成,可以进入结果页编辑基础信息并试玩。" .to_string(), updated_at: compiled_at, ..clone_session(&session) }, ); get_match3d_agent_session_tx( ctx, Match3DAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn update_match3d_work_tx( ctx: &ReducerContext, input: Match3DWorkUpdateInput, ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let tags = parse_tags(&input.tags_json)?; let config = Match3DCreatorConfigSnapshot { theme_text: clean_string(&input.theme_text, "经典消除"), ..parse_config_or_default(¤t.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); let next = Match3DWorkProfileRow { profile_id: current.profile_id.clone(), owner_user_id: current.owner_user_id.clone(), source_session_id: current.source_session_id.clone(), author_display_name: current.author_display_name.clone(), game_name: clean_string(&input.game_name, "未命名抓大鹅"), theme_text: config.theme_text.clone(), summary_text: clean_string(&input.summary_text, "经典消除玩法"), tags_json: to_json_string(&tags), cover_image_src: input.cover_image_src.trim().to_string(), cover_asset_id: input.cover_asset_id.trim().to_string(), clear_count: config.clear_count, difficulty: config.difficulty, config_json: to_json_string(&config), publication_status: current.publication_status.clone(), 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, ¤t, next); Ok(snapshot) } fn publish_match3d_work_tx( ctx: &ReducerContext, input: Match3DWorkPublishInput, ) -> Result { let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; validate_publishable_work(¤t)?; let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); let next = Match3DWorkProfileRow { publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), ..clone_work(¤t) }; let snapshot = build_work_snapshot(&next)?; if !next.source_session_id.is_empty() { if let Some(session) = ctx .db .match3d_agent_session() .session_id() .find(&next.source_session_id) .filter(|row| row.owner_user_id == input.owner_user_id) { replace_session( ctx, &session, Match3DAgentSessionRow { progress_percent: 100, stage: MATCH3D_STAGE_PUBLISHED.to_string(), published_profile_id: next.profile_id.clone(), updated_at: published_at, ..clone_session(&session) }, ); } } replace_work(ctx, ¤t, next); Ok(snapshot) } fn list_match3d_works_tx( ctx: &ReducerContext, input: Match3DWorksListInput, ) -> Result, String> { let mut items = ctx .db .match3d_work_profile() .iter() .filter(|row| { if input.published_only { row.publication_status == MATCH3D_PUBLICATION_PUBLISHED } else { row.owner_user_id == input.owner_user_id } }) .map(|row| build_work_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); Ok(items) } fn get_match3d_work_detail_tx( ctx: &ReducerContext, input: Match3DWorkGetInput, ) -> Result { let row = ctx .db .match3d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED }) .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; build_work_snapshot(&row) } fn delete_match3d_work_tx( ctx: &ReducerContext, input: Match3DWorkDeleteInput, ) -> Result, String> { let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; ctx.db .match3d_work_profile() .profile_id() .delete(&work.profile_id); for run in ctx .db .match3d_runtime_run() .iter() .filter(|row| { row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id }) .collect::>() { ctx.db.match3d_runtime_run().run_id().delete(&run.run_id); } list_match3d_works_tx( ctx, Match3DWorksListInput { owner_user_id: input.owner_user_id, published_only: false, }, ) } fn start_match3d_run_tx( ctx: &ReducerContext, input: Match3DRunStartInput, ) -> Result { require_non_empty(&input.run_id, "match3d run_id")?; if ctx .db .match3d_runtime_run() .run_id() .find(&input.run_id) .is_some() { return Err("match3d_runtime_run.run_id 已存在".to_string()); } let work = ctx .db .match3d_work_profile() .profile_id() .find(&input.profile_id) .filter(|row| { row.owner_user_id == input.owner_user_id || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED }) .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; let started_at_ms = if input.started_at_ms > 0 { input.started_at_ms } else { current_server_ms(ctx) }; 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; ctx.db.match3d_runtime_run().insert(row_from_snapshot( &input.owner_user_id, &snapshot, now, now, )); Ok(snapshot) } fn get_match3d_run_tx( ctx: &ReducerContext, input: Match3DRunGetInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = deserialize_snapshot(&row.snapshot_json)?; let next = confirm_time_up_if_needed(ctx, &row, snapshot, current_server_ms(ctx))?; Ok(next) } fn click_match3d_item_tx( ctx: &ReducerContext, input: Match3DRunClickInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = deserialize_snapshot(&row.snapshot_json)?; let server_now_ms = current_server_ms(ctx); let snapshot = confirm_time_up_if_needed(ctx, &row, snapshot, server_now_ms)?; if snapshot.status != MATCH3D_RUN_RUNNING { return Ok(click_result( MATCH3D_CLICK_RUN_FINISHED, snapshot, None, Vec::new(), )); } if snapshot.snapshot_version != input.client_snapshot_version { return Ok(click_result( MATCH3D_CLICK_VERSION_CONFLICT, snapshot, None, Vec::new(), )); } let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); let confirmation = confirm_domain_click_at( &domain_run, &DomainMatch3DClickInput { run_id: input.run_id.clone(), owner_user_id: input.owner_user_id.clone(), item_instance_id: input.item_instance_id.clone(), client_action_id: clean_string(&input.client_event_id, "match3d-action"), snapshot_version: input.client_snapshot_version as u64, clicked_at_ms: to_u64_ms(server_now_ms), }, ) .map_err(|error| error.to_string())?; let next = snapshot_from_domain(&confirmation.run, server_now_ms); let status = if confirmation.accepted { MATCH3D_CLICK_ACCEPTED } else { match confirmation.reject_reason { Some(DomainMatch3DClickRejectReason::RunNotActive) => MATCH3D_CLICK_RUN_FINISHED, Some(DomainMatch3DClickRejectReason::SnapshotVersionMismatch) => { MATCH3D_CLICK_VERSION_CONFLICT } Some(DomainMatch3DClickRejectReason::ItemNotFound) | Some(DomainMatch3DClickRejectReason::ItemNotInBoard) => { MATCH3D_CLICK_REJECTED_ALREADY_MOVED } Some(DomainMatch3DClickRejectReason::ItemNotClickable) => { MATCH3D_CLICK_REJECTED_NOT_CLICKABLE } Some(DomainMatch3DClickRejectReason::TrayFull) => MATCH3D_CLICK_REJECTED_TRAY_FULL, None => MATCH3D_CLICK_REJECTED_NOT_CLICKABLE, } }; if confirmation.accepted || status == MATCH3D_CLICK_REJECTED_TRAY_FULL || next.status != snapshot.status || next.snapshot_version != snapshot.snapshot_version { persist_snapshot(ctx, &row, &next, server_now_ms); } Ok(click_result( status, next, confirmation.accepted.then_some(input.item_instance_id), confirmation.cleared_item_instance_ids, )) } fn stop_match3d_run_tx( ctx: &ReducerContext, input: Match3DRunStopInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx)); let snapshot = deserialize_snapshot(&row.snapshot_json)?; let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(stopped_at_ms)); let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string()); let next = snapshot_from_domain(&domain_run, stopped_at_ms); persist_snapshot(ctx, &row, &next, stopped_at_ms); Ok(next) } fn restart_match3d_run_tx( ctx: &ReducerContext, input: Match3DRunRestartInput, ) -> Result { 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 { run_id: input.next_run_id, owner_user_id: input.owner_user_id, profile_id: source.profile_id, started_at_ms: input.restarted_at_ms, item_type_count_override, }, ) } fn finish_match3d_time_up_tx( ctx: &ReducerContext, input: Match3DRunTimeUpInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = deserialize_snapshot(&row.snapshot_json)?; let finished_at_ms = input.finished_at_ms.max(current_server_ms(ctx)); let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(finished_at_ms)); let next = snapshot_from_domain(&domain_run, finished_at_ms); persist_snapshot(ctx, &row, &next, finished_at_ms); Ok(next) } fn find_owned_session( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(session_id, "match3d session_id")?; require_non_empty(owner_user_id, "match3d owner_user_id")?; ctx.db .match3d_agent_session() .session_id() .find(&session_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "match3d_agent_session 不存在".to_string()) } fn find_owned_work( ctx: &ReducerContext, profile_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(profile_id, "match3d profile_id")?; require_non_empty(owner_user_id, "match3d owner_user_id")?; ctx.db .match3d_work_profile() .profile_id() .find(&profile_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "match3d_work_profile 不存在".to_string()) } fn find_owned_run( ctx: &ReducerContext, run_id: &str, owner_user_id: &str, ) -> Result { require_non_empty(run_id, "match3d run_id")?; require_non_empty(owner_user_id, "match3d owner_user_id")?; ctx.db .match3d_runtime_run() .run_id() .find(&run_id.to_string()) .filter(|row| row.owner_user_id == owner_user_id) .ok_or_else(|| "match3d_runtime_run 不存在".to_string()) } fn build_session_snapshot( ctx: &ReducerContext, row: &Match3DAgentSessionRow, ) -> Result { let mut messages = ctx .db .match3d_agent_message() .iter() .filter(|message| message.session_id == row.session_id) .map(|message| Match3DAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, role: message.role, kind: message.kind, text: message.text, created_at_micros: message.created_at.to_micros_since_unix_epoch(), }) .collect::>(); messages.sort_by(|left, right| { left.created_at_micros .cmp(&right.created_at_micros) .then_with(|| left.message_id.cmp(&right.message_id)) }); let config = parse_config(&row.config_json)?; let draft = if row.draft_json.trim().is_empty() { None } else { Some(parse_json::( &row.draft_json, "match3d draft_json", )?) }; Ok(Match3DAgentSessionSnapshot { 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, stage: row.stage.clone(), config, draft, messages, last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: empty_to_none(&row.published_profile_id), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), }) } fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result { let config = parse_config(&row.config_json)?; let tags = parse_tags(&row.tags_json)?; Ok(Match3DWorkSnapshot { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), game_name: row.game_name.clone(), theme_text: row.theme_text.clone(), summary_text: row.summary_text.clone(), tags, cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), clear_count: row.clear_count, difficulty: row.difficulty, config, publication_status: row.publication_status.clone(), publish_ready: is_work_publish_ready(row), play_count: row.play_count, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), 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(), )?, }) } fn build_initial_run_snapshot( run_id: &str, work: &Match3DWorkProfileRow, started_at_ms: i64, item_type_count_override: Option, ) -> Match3DRunSnapshot { let config = parse_config_or_default(&work.config_json); 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_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(), profile_id: work.profile_id.clone(), owner_user_id: work.owner_user_id.clone(), status: DomainMatch3DRunStatus::Running, started_at_ms: domain_started_at_ms, duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, clear_count: work.clear_count.max(1), total_item_count: work.clear_count.max(1).saturating_mul(3), cleared_item_count: 0, board_version: 1, items: Vec::new(), tray_slots: Vec::new(), failure_reason: None, last_confirmed_action_id: None, }); snapshot_from_domain(&domain_run, started_at_ms) } fn normalize_match3d_item_type_count_override(value: u32) -> Option { (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::>(); 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(), reference_image_src: None, clear_count: 1, difficulty: 3, } } fn confirm_time_up_if_needed( ctx: &ReducerContext, row: &Match3DRuntimeRunRow, snapshot: Match3DRunSnapshot, server_now_ms: i64, ) -> Result { if snapshot.status != MATCH3D_RUN_RUNNING || compute_remaining_ms(&snapshot, server_now_ms) > 0 { let mut next = snapshot; next.server_now_ms = server_now_ms; next.remaining_ms = compute_remaining_ms(&next, server_now_ms); return Ok(next); } let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(server_now_ms)); let next = snapshot_from_domain(&domain_run, server_now_ms); persist_snapshot(ctx, row, &next, server_now_ms); Ok(next) } fn persist_snapshot( ctx: &ReducerContext, row: &Match3DRuntimeRunRow, snapshot: &Match3DRunSnapshot, server_now_ms: i64, ) { let updated_at = Timestamp::from_micros_since_unix_epoch(server_now_ms.saturating_mul(1000)); let next = row_from_snapshot(&row.owner_user_id, snapshot, row.created_at, updated_at); ctx.db.match3d_runtime_run().run_id().delete(&row.run_id); ctx.db.match3d_runtime_run().insert(next); } fn row_from_snapshot( owner_user_id: &str, snapshot: &Match3DRunSnapshot, created_at: Timestamp, updated_at: Timestamp, ) -> Match3DRuntimeRunRow { let finished_at_ms = if snapshot.status == MATCH3D_RUN_RUNNING { 0 } else { snapshot.server_now_ms }; let elapsed_ms = if finished_at_ms > 0 { finished_at_ms.saturating_sub(snapshot.started_at_ms) } else { snapshot .server_now_ms .saturating_sub(snapshot.started_at_ms) }; Match3DRuntimeRunRow { run_id: snapshot.run_id.clone(), owner_user_id: owner_user_id.to_string(), profile_id: snapshot.profile_id.clone(), status: snapshot.status.clone(), snapshot_version: snapshot.snapshot_version, started_at_ms: snapshot.started_at_ms, duration_limit_ms: snapshot.duration_limit_ms, finished_at_ms, elapsed_ms, clear_count: snapshot.clear_count, total_item_count: snapshot.total_item_count, cleared_item_count: snapshot.cleared_item_count, failure_reason: snapshot.failure_reason.clone().unwrap_or_default(), snapshot_json: to_json_string(snapshot), created_at, updated_at, } } fn click_result( status: &str, snapshot: Match3DRunSnapshot, accepted_item_instance_id: Option, cleared_item_instance_ids: Vec, ) -> Match3DClickItemProcedureResult { Match3DClickItemProcedureResult { ok: true, status: status.to_string(), run_json: Some(to_json_string(&snapshot)), accepted_item_instance_id, cleared_item_instance_ids, failure_reason: snapshot.failure_reason, error_message: None, } } fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) { if ctx .db .match3d_work_profile() .profile_id() .find(&work.profile_id) .is_some() { ctx.db .match3d_work_profile() .profile_id() .delete(&work.profile_id); } ctx.db.match3d_work_profile().insert(work); } fn replace_session( ctx: &ReducerContext, current: &Match3DAgentSessionRow, next: Match3DAgentSessionRow, ) { ctx.db .match3d_agent_session() .session_id() .delete(¤t.session_id); ctx.db.match3d_agent_session().insert(next); } fn replace_work( ctx: &ReducerContext, current: &Match3DWorkProfileRow, next: Match3DWorkProfileRow, ) { ctx.db .match3d_work_profile() .profile_id() .delete(¤t.profile_id); ctx.db.match3d_work_profile().insert(next); } fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow { Match3DAgentSessionRow { 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, stage: row.stage.clone(), config_json: row.config_json.clone(), draft_json: row.draft_json.clone(), last_assistant_reply: row.last_assistant_reply.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow { Match3DWorkProfileRow { profile_id: row.profile_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), game_name: row.game_name.clone(), theme_text: row.theme_text.clone(), summary_text: row.summary_text.clone(), tags_json: row.tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), clear_count: row.clear_count, difficulty: row.difficulty, config_json: row.config_json.clone(), publication_status: row.publication_status.clone(), 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(), } } fn validate_config(config: &Match3DCreatorConfigSnapshot) -> Result<(), String> { domain_config_from_snapshot(config) .map(|_| ()) .map_err(|error| error.to_string()) } fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> { if row.game_name.trim().is_empty() { return Err("match3d 发布需要填写游戏名称".to_string()); } if row.cover_image_src.trim().is_empty() { return Err("match3d 发布需要封面图".to_string()); } if parse_tags(&row.tags_json)?.is_empty() { return Err("match3d 发布需要至少 1 个标签".to_string()); } 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 { validate_publishable_work(row).is_ok() } fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot { Match3DCreatorConfigSnapshot { theme_text: clean_string(seed_text, "经典消除"), reference_image_src: None, clear_count: 12, difficulty: 3, asset_style_id: None, asset_style_label: None, asset_style_prompt: None, generate_click_sound: false, } } fn parse_config_or_default(value: &str) -> Match3DCreatorConfigSnapshot { parse_config(value).unwrap_or_else(|_| default_config_from_seed("经典消除")) } fn parse_config(value: &str) -> Result { parse_json(value, "match3d config_json").map(|mut config: Match3DCreatorConfigSnapshot| { config.theme_text = clean_string(&config.theme_text, "经典消除"); 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( config: Match3DCreatorConfigSnapshot, ) -> Match3DCreatorConfigSnapshot { config } fn normalize_optional_text(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn parse_tags(value: &str) -> Result, String> { let parsed = parse_json::>(value, "match3d tags_json")?; Ok(normalize_tags(parsed)) } fn normalize_generated_item_assets_json(value: Option<&str>) -> Result, String> { let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(None); }; let parsed = parse_json::(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 count_ready_generated_item_types(value: Option<&str>) -> Result { let Some(trimmed) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(0); }; let parsed = parse_json::>(trimmed, "match3d generated_item_assets_json")?; Ok(parsed .iter() .filter(|asset| { let status_ready = asset .get("status") .and_then(Value::as_str) .map(|status| status == "image_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); status_ready && view_count >= 5 }) .count()) } fn resolve_generated_item_assets_json_for_compile( input: Option<&str>, existing_work: Option<&Match3DWorkProfileRow>, ) -> Result, String> { if input.is_some() { return normalize_generated_item_assets_json(input); } Ok(existing_work.and_then(|work| work.generated_item_assets_json.clone())) } fn resolve_compile_tags( input_tags_json: Option<&str>, existing_work: Option<&Match3DWorkProfileRow>, theme_text: &str, ) -> Result, String> { input_tags_json .or_else(|| existing_work.map(|work| work.tags_json.as_str())) .map(parse_tags) .transpose() .map(|tags| { tags.filter(|items| !items.is_empty()) .unwrap_or_else(|| default_tags(theme_text)) }) } fn resolve_compile_game_name( input_game_name: &Option, existing_work: Option<&Match3DWorkProfileRow>, theme_text: &str, ) -> String { clean_optional(input_game_name) .or_else(|| { existing_work .map(|work| clean_string(&work.game_name, "")) .filter(|value| !value.is_empty()) }) .unwrap_or_else(|| format!("{theme_text}抓大鹅")) } fn resolve_compile_summary_text( input_summary_text: &Option, existing_work: Option<&Match3DWorkProfileRow>, ) -> String { input_summary_text .as_deref() .map(str::trim) .map(str::to_string) .or_else(|| existing_work.map(|work| work.summary_text.clone())) .unwrap_or_default() .to_string() } fn resolve_compile_optional_text(input: &Option, existing: Option<&str>) -> String { clean_optional(input) .or_else(|| { existing .map(|value| clean_string(value, "")) .filter(|value| !value.is_empty()) }) .unwrap_or_default() } fn default_tags(theme_text: &str) -> Vec { normalize_tags(vec![ theme_text.to_string(), "抓大鹅".to_string(), "消除".to_string(), ]) } fn normalize_tags(tags: Vec) -> Vec { let mut result = Vec::new(); for tag in tags { let trimmed = tag.trim(); if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { result.push(trimmed.to_string()); } if result.len() >= 6 { break; } } result } fn normalize_stage(value: &str) -> String { match value.trim() { MATCH3D_STAGE_READY_TO_COMPILE => MATCH3D_STAGE_READY_TO_COMPILE.to_string(), MATCH3D_STAGE_DRAFT_COMPILED => MATCH3D_STAGE_DRAFT_COMPILED.to_string(), MATCH3D_STAGE_PUBLISHED => MATCH3D_STAGE_PUBLISHED.to_string(), _ => MATCH3D_STAGE_COLLECTING.to_string(), } } fn domain_config_from_snapshot( config: &Match3DCreatorConfigSnapshot, ) -> Result { module_match3d::build_creator_config( &config.theme_text, config.reference_image_src.clone(), config.clear_count, config.difficulty, ) } fn snapshot_from_domain(run: &DomainMatch3DRunSnapshot, server_now_ms: i64) -> Match3DRunSnapshot { Match3DRunSnapshot { run_id: run.run_id.clone(), profile_id: run.profile_id.clone(), status: domain_status_to_text(run.status).to_string(), snapshot_version: run.board_version.min(u32::MAX as u64) as u32, started_at_ms: run.started_at_ms.min(i64::MAX as u64) as i64, duration_limit_ms: run.duration_limit_ms.min(i64::MAX as u64) as i64, server_now_ms, remaining_ms: run.remaining_ms.min(i64::MAX as u64) as i64, clear_count: run.clear_count, total_item_count: run.total_item_count, cleared_item_count: run.cleared_item_count, tray_slots: run .tray_slots .iter() .map(snapshot_tray_slot_from_domain) .collect(), items: run.items.iter().map(snapshot_item_from_domain).collect(), failure_reason: run .failure_reason .map(domain_failure_to_text) .map(str::to_string), } } fn domain_snapshot_from_snapshot( snapshot: &Match3DRunSnapshot, owner_user_id: &str, ) -> DomainMatch3DRunSnapshot { DomainMatch3DRunSnapshot { run_id: snapshot.run_id.clone(), profile_id: snapshot.profile_id.clone(), owner_user_id: owner_user_id.to_string(), status: domain_status_from_text(&snapshot.status), started_at_ms: to_u64_ms(snapshot.started_at_ms), duration_limit_ms: to_u64_ms(snapshot.duration_limit_ms), remaining_ms: to_u64_ms(snapshot.remaining_ms), clear_count: snapshot.clear_count, total_item_count: snapshot.total_item_count, cleared_item_count: snapshot.cleared_item_count, board_version: snapshot.snapshot_version as u64, items: snapshot .items .iter() .map(domain_item_from_snapshot) .collect(), tray_slots: snapshot .tray_slots .iter() .map(domain_tray_slot_from_snapshot) .collect(), failure_reason: snapshot .failure_reason .as_deref() .map(domain_failure_from_text), last_confirmed_action_id: None, } } fn snapshot_item_from_domain(item: &DomainMatch3DItemSnapshot) -> Match3DItemSnapshot { Match3DItemSnapshot { item_instance_id: item.item_instance_id.clone(), item_type_id: item.item_type_id.clone(), visual_key: item.visual_key.clone(), x: item.x, y: item.y, radius: item.radius, layer: item.layer, state: domain_item_state_to_text(item.state).to_string(), clickable: item.clickable, } } fn domain_item_from_snapshot(item: &Match3DItemSnapshot) -> DomainMatch3DItemSnapshot { DomainMatch3DItemSnapshot { item_instance_id: item.item_instance_id.clone(), item_type_id: item.item_type_id.clone(), visual_key: item.visual_key.clone(), x: item.x, y: item.y, radius: item.radius, layer: item.layer, state: domain_item_state_from_text(&item.state), clickable: item.clickable, tray_slot_index: None, } } fn snapshot_tray_slot_from_domain(slot: &DomainMatch3DTraySlot) -> Match3DTraySlotSnapshot { Match3DTraySlotSnapshot { slot_index: slot.slot_index, item_instance_id: slot.item_instance_id.clone(), item_type_id: slot.item_type_id.clone(), visual_key: slot.visual_key.clone(), } } fn domain_tray_slot_from_snapshot(slot: &Match3DTraySlotSnapshot) -> DomainMatch3DTraySlot { DomainMatch3DTraySlot { slot_index: slot.slot_index, item_instance_id: slot.item_instance_id.clone(), item_type_id: slot.item_type_id.clone(), visual_key: slot.visual_key.clone(), } } fn domain_status_to_text(status: DomainMatch3DRunStatus) -> &'static str { match status { DomainMatch3DRunStatus::Running => MATCH3D_RUN_RUNNING, DomainMatch3DRunStatus::Won => MATCH3D_RUN_WON, DomainMatch3DRunStatus::Failed => MATCH3D_RUN_FAILED, DomainMatch3DRunStatus::Stopped => MATCH3D_RUN_STOPPED, } } fn domain_status_from_text(value: &str) -> DomainMatch3DRunStatus { match value { MATCH3D_RUN_WON | "won" => DomainMatch3DRunStatus::Won, MATCH3D_RUN_FAILED | "failed" => DomainMatch3DRunStatus::Failed, MATCH3D_RUN_STOPPED | "stopped" => DomainMatch3DRunStatus::Stopped, _ => DomainMatch3DRunStatus::Running, } } fn domain_failure_to_text(reason: DomainMatch3DFailureReason) -> &'static str { match reason { DomainMatch3DFailureReason::TimeUp => MATCH3D_FAILURE_TIME_UP, DomainMatch3DFailureReason::TrayFull => MATCH3D_FAILURE_TRAY_FULL, } } fn domain_failure_from_text(value: &str) -> DomainMatch3DFailureReason { match value { MATCH3D_FAILURE_TRAY_FULL | "tray_full" => DomainMatch3DFailureReason::TrayFull, _ => DomainMatch3DFailureReason::TimeUp, } } fn domain_item_state_to_text(state: DomainMatch3DItemState) -> &'static str { match state { DomainMatch3DItemState::InBoard => MATCH3D_ITEM_IN_BOARD, DomainMatch3DItemState::InTray => MATCH3D_ITEM_IN_TRAY, DomainMatch3DItemState::Cleared => MATCH3D_ITEM_CLEARED, } } fn domain_item_state_from_text(value: &str) -> DomainMatch3DItemState { match value { MATCH3D_ITEM_IN_TRAY | "in_tray" => DomainMatch3DItemState::InTray, MATCH3D_ITEM_CLEARED | "cleared" => DomainMatch3DItemState::Cleared, _ => DomainMatch3DItemState::InBoard, } } fn deterministic_run_seed( run_id: &str, profile_id: &str, clear_count: u32, difficulty: u32, ) -> u64 { let mut seed = 0xcbf2_9ce4_8422_2325_u64; for byte in run_id.bytes().chain(profile_id.bytes()) { seed ^= byte as u64; seed = seed.wrapping_mul(0x0000_0100_0000_01b3); } seed ^ ((clear_count as u64) << 32) ^ difficulty as u64 } fn to_u64_ms(value: i64) -> u64 { value.max(0) as u64 } fn compute_remaining_ms(snapshot: &Match3DRunSnapshot, server_now_ms: i64) -> i64 { snapshot .duration_limit_ms .saturating_sub(server_now_ms.saturating_sub(snapshot.started_at_ms)) .max(0) } fn current_server_ms(ctx: &ReducerContext) -> i64 { ctx.timestamp .to_micros_since_unix_epoch() .saturating_div(1000) } fn clean_optional(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) } fn clean_string(value: &str, fallback: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { fallback.to_string() } else { trimmed.to_string() } } fn empty_to_none(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn require_non_empty(value: &str, label: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{label} 不能为空")) } else { Ok(()) } } fn parse_json(value: &str, label: &str) -> Result { serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) } fn deserialize_snapshot(value: &str) -> Result { parse_json(value, "match3d snapshot_json") } fn to_json_string(value: &T) -> String { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult { Match3DAgentSessionProcedureResult { ok: true, session_json: Some(to_json_string(&session)), error_message: None, } } fn session_error(message: String) -> Match3DAgentSessionProcedureResult { Match3DAgentSessionProcedureResult { ok: false, session_json: None, error_message: Some(message), } } fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { Match3DWorkProcedureResult { ok: true, work_json: Some(to_json_string(&work)), error_message: None, } } fn work_error(message: String) -> Match3DWorkProcedureResult { Match3DWorkProcedureResult { ok: false, work_json: None, error_message: Some(message), } } fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { Match3DRunProcedureResult { ok: true, run_json: Some(to_json_string(&run)), error_message: None, } } fn run_error(message: String) -> Match3DRunProcedureResult { Match3DRunProcedureResult { ok: false, run_json: None, error_message: Some(message), } } #[cfg(test)] mod tests { use super::*; #[test] fn match3d_total_items_follow_clear_count() { 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: 4, difficulty: 3, config_json: to_json_string(&Match3DCreatorConfigSnapshot { theme_text: "水果".to_string(), reference_image_src: None, clear_count: 4, difficulty: 3, 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, 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, None); 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, generate_click_sound: false, }), 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_without_asset_payload_preserves_existing_generated_assets() { let existing = 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: String::new(), tags_json: "[\"水果\"]".to_string(), cover_image_src: String::new(), 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, generate_click_sound: false, }), publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), play_count: 2, 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 preserved = resolve_generated_item_assets_json_for_compile(None, Some(&existing)).unwrap(); assert_eq!( preserved.as_deref(), existing.generated_item_assets_json.as_deref() ); } #[test] fn match3d_publish_ready_requires_five_image_views_per_item() { let base_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: 8, difficulty: 2, config_json: to_json_string(&Match3DCreatorConfigSnapshot { theme_text: "水果".to_string(), reference_image_src: None, clear_count: 8, difficulty: 2, 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, 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"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"# .to_string(), ), }; let error = validate_publishable_work(&base_work).unwrap_err(); assert!(error.contains("当前已有 0 种")); let ready_assets = (1..=3) .map(|index| { let views = (1..=5) .map(|view_index| { format!( r#"{{"imageSrc":"/generated-match3d-assets/session/profile/items/i{index}/views/view-{view_index:02}.png"}}"# ) }) .collect::>() .join(","); format!( r#"{{"itemId":"match3d-item-{index}","itemName":"物品{index}","imageViews":[{views}],"status":"image_ready"}}"# ) }) .collect::>() .join(","); let ready_work = Match3DWorkProfileRow { generated_item_assets_json: Some(format!("[{ready_assets}]")), ..base_work }; assert!(validate_publishable_work(&ready_work).is_ok()); } #[test] fn match3d_compile_without_metadata_payload_preserves_existing_metadata() { let existing = 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: "cover-asset-1".to_string(), 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, generate_click_sound: false, }), publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), play_count: 2, updated_at: Timestamp::from_micros_since_unix_epoch(1), published_at: None, generated_item_assets_json: None, }; let input_game_name = None; let input_summary_text = None; let input_tags_json = None; let input_cover_image_src = None; let input_cover_asset_id = None; let tags = resolve_compile_tags(input_tags_json, Some(&existing), "水果").unwrap(); let game_name = resolve_compile_game_name(&input_game_name, Some(&existing), "水果"); let summary_text = resolve_compile_summary_text(&input_summary_text, Some(&existing)); let cover_image_src = resolve_compile_optional_text(&input_cover_image_src, Some(&existing.cover_image_src)); let cover_asset_id = resolve_compile_optional_text(&input_cover_asset_id, Some(&existing.cover_asset_id)); assert_eq!(game_name, "果园大鹅宴"); assert_eq!(summary_text, "保留描述"); assert_eq!(tags, vec!["水果".to_string(), "轻量休闲".to_string()]); assert_eq!(cover_image_src, "/cover.png"); assert_eq!(cover_asset_id, "cover-asset-1"); } #[test] fn match3d_compile_keeps_difficulty_clear_count() { 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, generate_click_sound: true, }); assert_eq!(config.clear_count, 20); assert_eq!(config.difficulty, 8); assert!(config.generate_click_sound); } #[test] fn match3d_domain_click_bridge_clears_three_items() { let snapshot = Match3DRunSnapshot { run_id: "run-1".to_string(), profile_id: "profile-1".to_string(), status: MATCH3D_RUN_RUNNING.to_string(), snapshot_version: 1, started_at_ms: 0, duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, server_now_ms: 0, remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, clear_count: 1, total_item_count: 3, cleared_item_count: 0, tray_slots: (0..MATCH3D_TRAY_SLOT_COUNT) .map(|slot_index| Match3DTraySlotSnapshot { slot_index, item_instance_id: (slot_index < 2).then(|| format!("item-{slot_index}")), item_type_id: (slot_index < 3).then(|| "type-1".to_string()), visual_key: (slot_index < 3).then(|| "visual-1".to_string()), }) .collect(), items: (0..3) .map(|index| Match3DItemSnapshot { item_instance_id: format!("item-{index}"), item_type_id: "type-1".to_string(), visual_key: "visual-1".to_string(), x: 0.0, y: 0.0, radius: 0.1, layer: index, state: if index < 2 { MATCH3D_ITEM_IN_TRAY.to_string() } else { MATCH3D_ITEM_IN_BOARD.to_string() }, clickable: index == 2, }) .collect(), failure_reason: None, }; let domain_run = domain_snapshot_from_snapshot(&snapshot, "user-1"); let confirmation = confirm_domain_click_at( &domain_run, &DomainMatch3DClickInput { run_id: "run-1".to_string(), owner_user_id: "user-1".to_string(), item_instance_id: "item-2".to_string(), client_action_id: "action-1".to_string(), snapshot_version: 1, clicked_at_ms: 10, }, ) .expect("domain click should be confirmed"); let next = snapshot_from_domain(&confirmation.run, 10); assert!(confirmation.accepted); assert_eq!(confirmation.cleared_item_instance_ids.len(), 3); assert!( next.tray_slots .iter() .all(|slot| slot.item_instance_id.is_none()) ); assert!( next.items .iter() .all(|item| item.state == MATCH3D_ITEM_CLEARED) ); } }