pub(crate) mod tables; mod types; pub use tables::*; pub use types::*; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json}; use module_puzzle_clear::{ PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout, parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs, retry_puzzle_clear_level, start_puzzle_clear_run, }; use serde::Serialize; use serde::de::DeserializeOwned; use spacetimedb::AnonymousViewContext; use std::collections::BTreeMap; #[spacetimedb::view(accessor = puzzle_clear_gallery_view, public)] pub fn puzzle_clear_gallery_view(ctx: &AnonymousViewContext) -> Vec { let mut items = ctx .db .puzzle_clear_work_profile() .by_puzzle_clear_work_publication_status() .filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED) .filter(|row| row.visible) .filter_map(|row| match build_gallery_view_row(&row) { Ok(item) => Some(item), Err(error) => { log::warn!( "拼消消公开广场 view 跳过损坏作品 profile_id={}: {}", row.profile_id, error ); None } }) .collect::>(); items.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); items } #[spacetimedb::view(accessor = puzzle_clear_gallery_card_view, public)] pub fn puzzle_clear_gallery_card_view( ctx: &AnonymousViewContext, ) -> Vec { puzzle_clear_gallery_view(ctx) .into_iter() .map(|row| PuzzleClearGalleryCardViewRow { public_work_code: build_puzzle_clear_public_work_code(&row.profile_id), work_id: row.work_id, profile_id: row.profile_id, owner_user_id: row.owner_user_id, author_display_name: row.author_display_name, work_title: row.work_title, work_description: row.work_description, theme_prompt: row.theme_prompt, cover_image_src: row.cover_image_src, publication_status: row.publication_status, play_count: row.play_count, updated_at_micros: row.updated_at_micros, published_at_micros: row.published_at_micros, generation_status: row.generation_status, }) .collect() } #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct PuzzleClearGalleryViewRow { pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub source_session_id: String, pub author_display_name: String, pub work_title: String, pub work_description: String, pub theme_prompt: String, pub generate_board_background: bool, pub board_background_asset: Option, pub board_background_prompt: String, pub card_back_image_src: Option, pub atlas_asset: PuzzleClearImageAssetSnapshot, pub pattern_groups: Vec, pub card_assets: Vec, pub cover_image_src: Option, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, pub generation_status: String, pub updated_at_micros: i64, pub published_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct PuzzleClearGalleryCardViewRow { pub public_work_code: String, pub work_id: String, pub profile_id: String, pub owner_user_id: String, pub author_display_name: String, pub work_title: String, pub work_description: String, pub theme_prompt: String, pub cover_image_src: Option, pub publication_status: String, pub play_count: u32, pub updated_at_micros: i64, pub published_at_micros: Option, pub generation_status: String, } #[spacetimedb::procedure] pub fn create_puzzle_clear_agent_session( ctx: &mut ProcedureContext, input: PuzzleClearAgentSessionCreateInput, ) -> PuzzleClearAgentSessionProcedureResult { match ctx.try_with_tx(|tx| create_puzzle_clear_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_puzzle_clear_agent_session( ctx: &mut ProcedureContext, input: PuzzleClearAgentSessionGetInput, ) -> PuzzleClearAgentSessionProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_clear_agent_session_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn compile_puzzle_clear_draft( ctx: &mut ProcedureContext, input: PuzzleClearDraftCompileInput, ) -> PuzzleClearAgentSessionProcedureResult { match ctx.try_with_tx(|tx| compile_puzzle_clear_draft_tx(tx, input.clone())) { Ok(session) => session_result(session), Err(message) => session_error(message), } } #[spacetimedb::procedure] pub fn get_puzzle_clear_work_profile( ctx: &mut ProcedureContext, input: PuzzleClearWorkGetInput, ) -> PuzzleClearWorkProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_clear_work_profile_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn update_puzzle_clear_work( ctx: &mut ProcedureContext, input: PuzzleClearWorkUpdateInput, ) -> PuzzleClearWorkProcedureResult { match ctx.try_with_tx(|tx| update_puzzle_clear_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn publish_puzzle_clear_work( ctx: &mut ProcedureContext, input: PuzzleClearWorkPublishInput, ) -> PuzzleClearWorkProcedureResult { match ctx.try_with_tx(|tx| publish_puzzle_clear_work_tx(tx, input.clone())) { Ok(work) => work_result(work), Err(message) => work_error(message), } } #[spacetimedb::procedure] pub fn list_puzzle_clear_works( ctx: &mut ProcedureContext, input: PuzzleClearWorksListInput, ) -> PuzzleClearWorksProcedureResult { match ctx.try_with_tx(|tx| list_puzzle_clear_works_tx(tx, input.clone())) { Ok(items) => PuzzleClearWorksProcedureResult { ok: true, items, error_message: None, }, Err(message) => PuzzleClearWorksProcedureResult { ok: false, items: Vec::new(), error_message: Some(message), }, } } #[spacetimedb::procedure] pub fn start_puzzle_clear_runtime_run( ctx: &mut ProcedureContext, input: PuzzleClearRunStartInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| start_puzzle_clear_runtime_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn get_puzzle_clear_runtime_run( ctx: &mut ProcedureContext, input: PuzzleClearRunGetInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| get_puzzle_clear_runtime_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn swap_puzzle_clear_cards( ctx: &mut ProcedureContext, input: PuzzleClearRunSwapInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| swap_puzzle_clear_cards_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn retry_puzzle_clear_level_run( ctx: &mut ProcedureContext, input: PuzzleClearRunRetryLevelInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| retry_puzzle_clear_level_run_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn advance_puzzle_clear_next_level( ctx: &mut ProcedureContext, input: PuzzleClearRunNextLevelInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| advance_puzzle_clear_next_level_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } #[spacetimedb::procedure] pub fn mark_puzzle_clear_level_time_up( ctx: &mut ProcedureContext, input: PuzzleClearRunTimeUpInput, ) -> PuzzleClearRunProcedureResult { match ctx.try_with_tx(|tx| mark_puzzle_clear_level_time_up_tx(tx, input.clone())) { Ok(run) => run_result(run), Err(message) => run_error(message), } } fn create_puzzle_clear_agent_session_tx( ctx: &ReducerContext, input: PuzzleClearAgentSessionCreateInput, ) -> Result { require_non_empty(&input.session_id, "puzzle_clear session_id")?; require_non_empty(&input.owner_user_id, "puzzle_clear owner_user_id")?; require_non_empty(&input.work_title, "work_title")?; require_non_empty(&input.theme_prompt, "theme_prompt")?; if ctx .db .puzzle_clear_agent_session() .session_id() .find(&input.session_id) .is_some() { return Err("puzzle_clear_agent_session.session_id 已存在".to_string()); } let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let draft = PuzzleClearDraftSnapshot { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: None, work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME), work_description: input.work_description.trim().to_string(), theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME), generate_board_background: input.generate_board_background, board_background_asset: input .board_background_asset_json .as_deref() .map(parse_json) .transpose()?, board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt), card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), atlas_asset: None, pattern_groups: Vec::new(), card_assets: Vec::new(), generation_status: PUZZLE_CLEAR_GENERATION_DRAFT.to_string(), }; let owner_user_id = input.owner_user_id.clone(); let session_id = input.session_id.clone(); ctx.db .puzzle_clear_agent_session() .insert(PuzzleClearAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id, status: PUZZLE_CLEAR_GENERATION_DRAFT.to_string(), draft_json: to_json_string(&draft), published_profile_id: String::new(), created_at, updated_at: created_at, }); get_puzzle_clear_agent_session_tx( ctx, PuzzleClearAgentSessionGetInput { session_id, owner_user_id, }, ) } fn get_puzzle_clear_agent_session_tx( ctx: &ReducerContext, input: PuzzleClearAgentSessionGetInput, ) -> Result { let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; build_session_snapshot(&row) } fn compile_puzzle_clear_draft_tx( ctx: &ReducerContext, input: PuzzleClearDraftCompileInput, ) -> Result { require_non_empty(&input.profile_id, "puzzle_clear profile_id")?; let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) { return mark_puzzle_clear_generation_failed_tx(ctx, input, session); } let pattern_groups: Vec = input .pattern_groups_json .as_deref() .map(parse_json) .transpose()? .ok_or_else(|| "puzzle_clear pattern_groups 缺少真实生成资产".to_string())?; let atlas_asset: PuzzleClearImageAssetSnapshot = input .atlas_asset_json .as_deref() .map(parse_json) .transpose()? .ok_or_else(|| "puzzle_clear atlas_asset 缺少真实生成资产".to_string())?; let card_assets: Vec = input .card_assets_json .as_deref() .map(parse_json) .transpose()? .ok_or_else(|| "puzzle_clear card_assets 缺少真实生成资产".to_string())?; if card_assets.is_empty() { return Err("puzzle_clear card_assets 不能为空".to_string()); } if !is_real_puzzle_clear_asset( atlas_asset.asset_object_id.as_str(), atlas_asset.image_object_key.as_str(), atlas_asset.image_src.as_str(), ) { return Err("puzzle_clear atlas_asset 缺少真实生成资产".to_string()); } if card_assets.iter().any(|asset| { !is_real_puzzle_clear_asset( asset.asset_object_id.as_str(), asset.image_object_key.as_str(), asset.image_src.as_str(), ) }) { return Err("puzzle_clear card_assets 缺少真实生成资产".to_string()); } let board_background_asset = input .board_background_asset_json .as_deref() .map(parse_json) .transpose()?; let generation_status = input .generation_status .clone() .unwrap_or_else(|| PUZZLE_CLEAR_GENERATION_READY.to_string()); let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let draft = PuzzleClearDraftSnapshot { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: Some(input.profile_id.clone()), work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME), work_description: input.work_description.trim().to_string(), theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME), generate_board_background: input.generate_board_background, board_background_asset: board_background_asset.clone(), board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt), card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), atlas_asset: Some(atlas_asset.clone()), pattern_groups: pattern_groups.clone(), card_assets: card_assets.clone(), generation_status: generation_status.clone(), }; let row = PuzzleClearWorkProfileRow { profile_id: input.profile_id.clone(), work_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, "拼消消玩家"), work_title: draft.work_title.clone(), work_description: draft.work_description.clone(), theme_prompt: draft.theme_prompt.clone(), generate_board_background: draft.generate_board_background, board_background_asset_json: board_background_asset .as_ref() .map(to_json_string) .unwrap_or_default(), board_background_prompt: clean_optional(&draft.board_background_prompt), card_back_image_src: PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string(), atlas_asset_json: to_json_string(&atlas_asset), pattern_groups_json: to_json_string(&pattern_groups), card_assets_json: to_json_string(&card_assets), cover_image_src: cover_image_src(&board_background_asset, &atlas_asset), generation_status, publication_status: PUZZLE_CLEAR_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: compiled_at, published_at: None, visible: true, }; upsert_work(ctx, row); replace_session( ctx, &session, PuzzleClearAgentSessionRow { status: draft.generation_status.clone(), draft_json: to_json_string(&draft), published_profile_id: input.profile_id, updated_at: compiled_at, ..clone_session(&session) }, ); get_puzzle_clear_agent_session_tx( ctx, PuzzleClearAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn mark_puzzle_clear_generation_failed_tx( ctx: &ReducerContext, input: PuzzleClearDraftCompileInput, session: PuzzleClearAgentSessionRow, ) -> Result { let failed_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); let mut draft = if session.draft_json.trim().is_empty() { None } else { parse_json::(&session.draft_json).ok() } .unwrap_or_else(|| PuzzleClearDraftSnapshot { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: Some(input.profile_id.clone()), work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME), work_description: input.work_description.trim().to_string(), theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME), generate_board_background: input.generate_board_background, board_background_asset: input .board_background_asset_json .as_deref() .and_then(|json| parse_json::(json).ok()), board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt), card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), atlas_asset: None, pattern_groups: Vec::new(), card_assets: Vec::new(), generation_status: PUZZLE_CLEAR_GENERATION_FAILED.to_string(), }); draft.profile_id = Some(input.profile_id.clone()); draft.work_title = clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME); draft.work_description = input.work_description.trim().to_string(); draft.theme_prompt = clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME); draft.generate_board_background = input.generate_board_background; draft.board_background_prompt = clean_string(&input.board_background_prompt, &input.theme_prompt); let existing_board_background_asset = draft.board_background_asset.take(); draft.board_background_asset = input .board_background_asset_json .as_deref() .map(parse_json) .transpose()? .or(existing_board_background_asset); draft.generation_status = PUZZLE_CLEAR_GENERATION_FAILED.to_string(); replace_session( ctx, &session, PuzzleClearAgentSessionRow { status: PUZZLE_CLEAR_GENERATION_FAILED.to_string(), draft_json: to_json_string(&draft), updated_at: failed_at, ..clone_session(&session) }, ); get_puzzle_clear_agent_session_tx( ctx, PuzzleClearAgentSessionGetInput { session_id: input.session_id, owner_user_id: input.owner_user_id, }, ) } fn get_puzzle_clear_work_profile_tx( ctx: &ReducerContext, input: PuzzleClearWorkGetInput, ) -> Result { let row = find_work(ctx, &input.profile_id)?; if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id { return Err("无权访问该 puzzle_clear work".to_string()); } build_work_snapshot(&row) } fn update_puzzle_clear_work_tx( ctx: &ReducerContext, input: PuzzleClearWorkUpdateInput, ) -> Result { let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let board_background_asset = input .board_background_asset_json .as_deref() .map(parse_json::) .transpose()?; let atlas_asset = parse_json::(&row.atlas_asset_json)?; let mut next = clone_work(&row); next.work_title = clean_string(&input.work_title, &row.work_title); next.work_description = input.work_description.trim().to_string(); next.theme_prompt = clean_string(&input.theme_prompt, &row.theme_prompt); next.generate_board_background = input.generate_board_background; let next_board_background_prompt = clean_string(&input.board_background_prompt, &input.theme_prompt); next.board_background_prompt = clean_optional(&next_board_background_prompt); next.board_background_asset_json = board_background_asset .as_ref() .map(to_json_string) .unwrap_or_default(); next.cover_image_src = cover_image_src(&board_background_asset, &atlas_asset); next.updated_at = updated_at; replace_work(ctx, &row, next); let updated = find_work(ctx, &row.profile_id)?; sync_session_from_work(ctx, &updated, updated_at)?; build_work_snapshot(&updated) } fn publish_puzzle_clear_work_tx( ctx: &ReducerContext, input: PuzzleClearWorkPublishInput, ) -> Result { let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; let snapshot = build_work_snapshot(&row)?; if !snapshot.publish_ready { return Err("拼消消发布需要 atlas、切片卡牌和标题齐备".to_string()); } let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); replace_work( ctx, &row, PuzzleClearWorkProfileRow { publication_status: PUZZLE_CLEAR_PUBLICATION_PUBLISHED.to_string(), updated_at: published_at, published_at: Some(published_at), ..clone_work(&row) }, ); if let Some(session) = ctx .db .puzzle_clear_agent_session() .session_id() .find(&row.source_session_id) { replace_session( ctx, &session, PuzzleClearAgentSessionRow { status: PUZZLE_CLEAR_GENERATION_READY.to_string(), updated_at: published_at, ..clone_session(&session) }, ); } let updated = find_work(ctx, &row.profile_id)?; build_work_snapshot(&updated) } fn list_puzzle_clear_works_tx( ctx: &ReducerContext, input: PuzzleClearWorksListInput, ) -> Result, String> { let mut rows = if input.owner_user_id.trim().is_empty() { ctx.db .puzzle_clear_work_profile() .iter() .collect::>() } else { ctx.db .puzzle_clear_work_profile() .by_puzzle_clear_work_owner_user_id() .filter(input.owner_user_id.as_str()) .collect::>() }; if input.published_only { rows.retain(|row| row.publication_status == PUZZLE_CLEAR_PUBLICATION_PUBLISHED); } rows.sort_by(|left, right| { right .updated_at .cmp(&left.updated_at) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); rows.into_iter() .map(|row| build_work_snapshot(&row)) .collect() } fn start_puzzle_clear_runtime_run_tx( ctx: &ReducerContext, input: PuzzleClearRunStartInput, ) -> Result { require_non_empty(&input.run_id, "puzzle_clear run_id")?; let work = find_work(ctx, &input.profile_id)?; if work.publication_status != PUZZLE_CLEAR_PUBLICATION_PUBLISHED { return Err("拼消消 runtime 只能启动已发布作品".to_string()); } let cards = domain_cards_from_work(&work)?; let (board, deck) = build_level_board_and_deck(1, &work.profile_id, &cards)?; let domain_run = start_puzzle_clear_run( input.run_id.clone(), input.owner_user_id.clone(), input.profile_id.clone(), board, deck, input.started_at_ms.max(0) as u64, ) .map_err(|error| error.to_string())?; upsert_run(ctx, &domain_run, input.started_at_ms); increment_work_play_count(ctx, &work, input.started_at_ms); insert_event( ctx, input.client_event_id, input.owner_user_id.clone(), input.profile_id, input.run_id, PUZZLE_CLEAR_EVENT_RUN_STARTED, None, input.started_at_ms, ); build_runtime_snapshot(&domain_run) } fn get_puzzle_clear_runtime_run_tx( ctx: &ReducerContext, input: PuzzleClearRunGetInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; build_runtime_snapshot(&snapshot) } fn swap_puzzle_clear_cards_tx( ctx: &ReducerContext, input: PuzzleClearRunSwapInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; let next = apply_puzzle_clear_swap( &snapshot, PuzzleClearMove { from_row: input.from_row, from_col: input.from_col, to_row: input.to_row, to_col: input.to_col, }, input.swapped_at_ms.max(0) as u64, ) .map_err(|error| error.to_string())?; replace_run(ctx, &row, &next, input.swapped_at_ms); insert_event( ctx, input.client_action_id, input.owner_user_id.clone(), next.profile_id.clone(), input.run_id, PUZZLE_CLEAR_EVENT_SWAP, Some(runtime_event_result(&snapshot, &next, input.swapped_at_ms)), input.swapped_at_ms, ); insert_terminal_runtime_event_if_needed( ctx, &snapshot, &next, input.owner_user_id, input.swapped_at_ms, ); build_runtime_snapshot(&next) } fn retry_puzzle_clear_level_run_tx( ctx: &ReducerContext, input: PuzzleClearRunRetryLevelInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; let work = find_work(ctx, &snapshot.profile_id)?; let cards = domain_cards_from_work(&work)?; let (board, deck) = build_level_board_and_deck( snapshot.level_index, &format!("{}-retry-{}", snapshot.profile_id, input.restarted_at_ms), &cards, )?; let next = retry_puzzle_clear_level(&snapshot, board, deck, input.restarted_at_ms.max(0) as u64) .map_err(|error| error.to_string())?; replace_run(ctx, &row, &next, input.restarted_at_ms); insert_event( ctx, input.client_action_id, input.owner_user_id.clone(), next.profile_id.clone(), input.run_id, PUZZLE_CLEAR_EVENT_RETRY_LEVEL, None, input.restarted_at_ms, ); build_runtime_snapshot(&next) } fn advance_puzzle_clear_next_level_tx( ctx: &ReducerContext, input: PuzzleClearRunNextLevelInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; let next_level = snapshot.level_index.saturating_add(1); let work = find_work(ctx, &snapshot.profile_id)?; let cards = domain_cards_from_work(&work)?; let (board, deck) = build_level_board_and_deck( next_level, &format!("{}-level-{next_level}", snapshot.profile_id), &cards, )?; let next = advance_puzzle_clear_level(&snapshot, board, deck, input.started_at_ms.max(0) as u64) .map_err(|error| error.to_string())?; replace_run(ctx, &row, &next, input.started_at_ms); insert_event( ctx, input.client_action_id, input.owner_user_id.clone(), next.profile_id.clone(), input.run_id, PUZZLE_CLEAR_EVENT_NEXT_LEVEL, Some(next.level_index.to_string()), input.started_at_ms, ); build_runtime_snapshot(&next) } fn mark_puzzle_clear_level_time_up_tx( ctx: &ReducerContext, input: PuzzleClearRunTimeUpInput, ) -> Result { let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; let snapshot = parse_json::(&row.snapshot_json)?; let next = fail_puzzle_clear_level_on_timeout(&snapshot, input.occurred_at_ms.max(0) as u64) .map_err(|error| error.to_string())?; replace_run(ctx, &row, &next, input.occurred_at_ms); insert_event( ctx, input.client_action_id, input.owner_user_id.clone(), next.profile_id.clone(), input.run_id, PUZZLE_CLEAR_EVENT_TIME_UP, Some(runtime_event_result(&snapshot, &next, input.occurred_at_ms)), input.occurred_at_ms, ); insert_terminal_runtime_event_if_needed( ctx, &snapshot, &next, input.owner_user_id, input.occurred_at_ms, ); build_runtime_snapshot(&next) } fn build_gallery_view_row( row: &PuzzleClearWorkProfileRow, ) -> Result { let work = build_work_snapshot(row)?; Ok(PuzzleClearGalleryViewRow { work_id: work.work_id, profile_id: work.profile_id, owner_user_id: work.owner_user_id, source_session_id: work.source_session_id, author_display_name: work.author_display_name, work_title: work.work_title, work_description: work.work_description, theme_prompt: work.theme_prompt, generate_board_background: work.generate_board_background, board_background_asset: work.board_background_asset, board_background_prompt: work.board_background_prompt, card_back_image_src: work.card_back_image_src, atlas_asset: work.atlas_asset, pattern_groups: work.pattern_groups, card_assets: work.card_assets, cover_image_src: work.cover_image_src, publication_status: work.publication_status, publish_ready: work.publish_ready, play_count: work.play_count, generation_status: work.generation_status, updated_at_micros: work.updated_at_micros, published_at_micros: work.published_at_micros, }) } pub fn build_puzzle_clear_public_work_code(profile_id: &str) -> String { let normalized = profile_id .chars() .filter(|character| character.is_ascii_alphanumeric()) .flat_map(|character| character.to_uppercase()) .collect::(); let suffix_source = if normalized.is_empty() { "00000000".to_string() } else { normalized }; let suffix = if suffix_source.len() > 8 { suffix_source[suffix_source.len() - 8..].to_string() } else { format!("{suffix_source:0>8}") }; format!("PC-{suffix}") } fn build_session_snapshot( row: &PuzzleClearAgentSessionRow, ) -> Result { Ok(PuzzleClearAgentSessionSnapshot { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), status: row.status.clone(), draft: clean_optional(&row.draft_json) .map(|value| parse_json(&value)) .transpose()?, published_profile_id: clean_optional(&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: &PuzzleClearWorkProfileRow) -> Result { let atlas_asset = parse_json(&row.atlas_asset_json)?; let card_assets = parse_json_or_default(&row.card_assets_json); let pattern_groups = parse_json_or_default(&row.pattern_groups_json); Ok(PuzzleClearWorkSnapshot { work_id: row.work_id.clone(), 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(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), theme_prompt: row.theme_prompt.clone(), generate_board_background: row.generate_board_background, board_background_asset: clean_optional(&row.board_background_asset_json) .map(|value| parse_json(&value)) .transpose()?, board_background_prompt: row .board_background_prompt .as_deref() .and_then(clean_optional) .unwrap_or_default(), card_back_image_src: clean_optional(&row.card_back_image_src), atlas_asset, pattern_groups, card_assets, cover_image_src: clean_optional(&row.cover_image_src), publication_status: row.publication_status.clone(), publish_ready: is_publish_ready(row), play_count: row.play_count, generation_status: row.generation_status.clone(), 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()), }) } fn sync_session_from_work( ctx: &ReducerContext, work: &PuzzleClearWorkProfileRow, updated_at: Timestamp, ) -> Result<(), String> { let Some(session) = ctx .db .puzzle_clear_agent_session() .session_id() .find(&work.source_session_id) else { return Ok(()); }; let draft = PuzzleClearDraftSnapshot { template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), profile_id: Some(work.profile_id.clone()), work_title: work.work_title.clone(), work_description: work.work_description.clone(), theme_prompt: work.theme_prompt.clone(), generate_board_background: work.generate_board_background, board_background_asset: clean_optional(&work.board_background_asset_json) .map(|value| parse_json(&value)) .transpose()?, board_background_prompt: work .board_background_prompt .as_deref() .and_then(clean_optional) .unwrap_or_default(), card_back_image_src: clean_optional(&work.card_back_image_src), atlas_asset: Some(parse_json(&work.atlas_asset_json)?), pattern_groups: parse_json_or_default(&work.pattern_groups_json), card_assets: parse_json_or_default(&work.card_assets_json), generation_status: work.generation_status.clone(), }; replace_session( ctx, &session, PuzzleClearAgentSessionRow { status: work.generation_status.clone(), draft_json: to_json_string(&draft), updated_at, ..clone_session(&session) }, ); Ok(()) } fn build_level_board_and_deck( level_index: u32, seed: &str, all_cards: &[PuzzleClearCard], ) -> Result<(PuzzleClearBoard, PuzzleClearDeck), String> { let level = puzzle_clear_level_configs() .into_iter() .find(|config| config.level_index == level_index) .ok_or_else(|| "拼消消关卡不存在".to_string())?; let allowed = ordered_level_cards( all_cards .iter() .filter(|card| level.unlocked_shapes.contains(&card.shape)) .cloned() .collect::>(), seed, level.target_clears as usize, ); let board = create_puzzle_clear_board(&level, seed, allowed.clone()) .map_err(|error| error.to_string())?; let board_total = (level.board_size * level.board_size) as usize; let mut ready_columns = vec![Vec::new(); level.board_size as usize]; for (index, card) in allowed.into_iter().skip(board_total).enumerate() { ready_columns[index % level.board_size as usize].push(card); } Ok((board, PuzzleClearDeck { ready_columns })) } fn ordered_level_cards( cards: Vec, seed: &str, target_groups: usize, ) -> Vec { let mut groups: BTreeMap> = BTreeMap::new(); for card in cards { groups.entry(card.group_id.clone()).or_default().push(card); } let mut grouped = groups.into_values().collect::>(); grouped.sort_by(|left, right| { let left_key = left .first() .map(|card| stable_level_group_key(seed, &card.group_id)) .unwrap_or_default(); let right_key = right .first() .map(|card| stable_level_group_key(seed, &card.group_id)) .unwrap_or_default(); left_key.cmp(&right_key) }); grouped .into_iter() .take(target_groups) .flat_map(|mut group| { group.sort_by_key(|card| (card.part_y, card.part_x)); group }) .collect() } fn stable_level_group_key(seed: &str, group_id: &str) -> u64 { let mut state = 0xcbf2_9ce4_8422_2325u64; for byte in seed.bytes().chain(group_id.bytes()) { state ^= u64::from(byte); state = state.wrapping_mul(0x1000_0000_01b3); } state } fn domain_cards_from_work(row: &PuzzleClearWorkProfileRow) -> Result, String> { let cards = parse_json::>(&row.card_assets_json)?; Ok(cards .into_iter() .map(|card| PuzzleClearCard { card_id: card.card_id, group_id: card.group_id, shape: parse_puzzle_clear_shape_kind(&card.shape), orientation: parse_puzzle_clear_orientation(&card.orientation), part_x: card.part_x, part_y: card.part_y, image_src: card.image_src, image_object_key: card.image_object_key, asset_object_id: card.asset_object_id, source_atlas_cell: card.source_atlas_cell, }) .collect()) } fn build_runtime_snapshot( snapshot: &DomainRunSnapshot, ) -> Result { let level = puzzle_clear_level_configs() .into_iter() .find(|config| config.level_index == snapshot.level_index) .ok_or_else(|| "拼消消 runtime 关卡不存在".to_string())?; Ok(PuzzleClearRuntimeSnapshot { run_id: snapshot.run_id.clone(), profile_id: snapshot.profile_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), status: snapshot.status.as_str().to_string(), level_index: snapshot.level_index, clears_done: snapshot.clears_done, target_clears: level.target_clears, level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, level_started_at_ms: snapshot.level_started_at_ms, board: PuzzleClearBoardSnapshot { rows: snapshot.board.rows, cols: snapshot.board.cols, cells: snapshot .board .cells .iter() .map(|cell| PuzzleClearBoardCellSnapshot { row: cell.row, col: cell.col, card: cell.card.as_ref().map(card_asset_from_domain), locked_group_id: cell.locked_group_id.clone(), }) .collect(), }, ready_columns: snapshot .deck .ready_columns .iter() .map(|column| column.iter().map(card_asset_from_domain).collect()) .collect(), started_at_ms: snapshot.started_at_ms, finished_at_ms: snapshot.finished_at_ms, }) } fn card_asset_from_domain(card: &PuzzleClearCard) -> PuzzleClearCardAssetSnapshot { PuzzleClearCardAssetSnapshot { card_id: card.card_id.clone(), group_id: card.group_id.clone(), shape: card.shape.as_str().to_string(), orientation: card.orientation.as_str().to_string(), part_x: card.part_x, part_y: card.part_y, image_src: card.image_src.clone(), image_object_key: card.image_object_key.clone(), asset_object_id: card.asset_object_id.clone(), source_atlas_cell: card.source_atlas_cell.clone(), } } #[cfg(test)] fn default_pattern_groups() -> Vec { module_puzzle_clear::plan_puzzle_clear_pattern_groups(128) .unwrap_or_default() .into_iter() .map(|group| PuzzleClearPatternGroupSnapshot { group_id: group.group_id, shape: group.shape.as_str().to_string(), width: group.width, height: group.height, atlas_x: group.atlas_x, atlas_y: group.atlas_y, atlas_width: group.atlas_width, atlas_height: group.atlas_height, }) .collect() } #[cfg(test)] fn default_atlas_asset(profile_id: &str, prompt: &str) -> PuzzleClearImageAssetSnapshot { PuzzleClearImageAssetSnapshot { asset_id: format!("{profile_id}-atlas"), image_src: format!("/generated-puzzle-clear-assets/{profile_id}/atlas.png"), image_object_key: format!("generated-puzzle-clear-assets/{profile_id}/atlas.png"), asset_object_id: format!("{profile_id}-atlas-object"), generation_provider: "deterministic-placeholder".to_string(), prompt: prompt.to_string(), width: 3072, height: 3072, } } #[cfg(test)] fn default_card_assets( profile_id: &str, groups: &[PuzzleClearPatternGroupSnapshot], ) -> Vec { let domain_groups = groups .iter() .map(|group| module_puzzle_clear::PuzzleClearPatternGroup { group_id: group.group_id.clone(), shape: parse_puzzle_clear_shape_kind(&group.shape), width: group.width, height: group.height, atlas_x: group.atlas_x, atlas_y: group.atlas_y, atlas_width: group.atlas_width, atlas_height: group.atlas_height, }) .collect::>(); module_puzzle_clear::build_cards_from_groups( &domain_groups, &format!("/generated-puzzle-clear-assets/{profile_id}/cards"), ) .into_iter() .map(|card| card_asset_from_domain(&card)) .collect() } fn cover_image_src( board_background_asset: &Option, atlas_asset: &PuzzleClearImageAssetSnapshot, ) -> String { board_background_asset .as_ref() .map(|asset| asset.image_src.clone()) .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| atlas_asset.image_src.clone()) } fn find_owned_session( ctx: &ReducerContext, session_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .puzzle_clear_agent_session() .session_id() .find(&session_id.to_string()) .ok_or_else(|| "puzzle_clear_agent_session 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该 puzzle_clear session".to_string()); } Ok(row) } fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result { ctx.db .puzzle_clear_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "puzzle_clear_work_profile 不存在".to_string()) } fn find_owned_work( ctx: &ReducerContext, profile_id: &str, owner_user_id: &str, ) -> Result { let row = find_work(ctx, profile_id)?; if row.owner_user_id != owner_user_id { return Err("无权访问该 puzzle_clear work".to_string()); } Ok(row) } fn find_owned_run( ctx: &ReducerContext, run_id: &str, owner_user_id: &str, ) -> Result { let row = ctx .db .puzzle_clear_runtime_run() .run_id() .find(&run_id.to_string()) .ok_or_else(|| "puzzle_clear_runtime_run 不存在".to_string())?; if row.owner_user_id != owner_user_id { return Err("无权访问该 puzzle_clear run".to_string()); } Ok(row) } fn upsert_work(ctx: &ReducerContext, row: PuzzleClearWorkProfileRow) { if let Some(old) = ctx .db .puzzle_clear_work_profile() .profile_id() .find(&row.profile_id) { ctx.db.puzzle_clear_work_profile().delete(old); } ctx.db.puzzle_clear_work_profile().insert(row); } fn replace_work( ctx: &ReducerContext, old: &PuzzleClearWorkProfileRow, next: PuzzleClearWorkProfileRow, ) { ctx.db.puzzle_clear_work_profile().delete(clone_work(old)); ctx.db.puzzle_clear_work_profile().insert(next); } fn replace_session( ctx: &ReducerContext, old: &PuzzleClearAgentSessionRow, next: PuzzleClearAgentSessionRow, ) { ctx.db .puzzle_clear_agent_session() .delete(clone_session(old)); ctx.db.puzzle_clear_agent_session().insert(next); } fn upsert_run(ctx: &ReducerContext, snapshot: &DomainRunSnapshot, updated_at_ms: i64) { if let Some(old) = ctx .db .puzzle_clear_runtime_run() .run_id() .find(&snapshot.run_id) { ctx.db.puzzle_clear_runtime_run().delete(old); } let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); ctx.db .puzzle_clear_runtime_run() .insert(run_row_from_snapshot(snapshot, created_at, created_at)); } fn replace_run( ctx: &ReducerContext, old: &PuzzleClearRuntimeRunRow, snapshot: &DomainRunSnapshot, updated_at_ms: i64, ) { ctx.db.puzzle_clear_runtime_run().delete(clone_run(old)); ctx.db .puzzle_clear_runtime_run() .insert(run_row_from_snapshot( snapshot, old.created_at, Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)), )); } fn run_row_from_snapshot( snapshot: &DomainRunSnapshot, created_at: Timestamp, updated_at: Timestamp, ) -> PuzzleClearRuntimeRunRow { PuzzleClearRuntimeRunRow { run_id: snapshot.run_id.clone(), owner_user_id: snapshot.owner_user_id.clone(), profile_id: snapshot.profile_id.clone(), status: snapshot.status.as_str().to_string(), level_index: snapshot.level_index, clears_done: snapshot.clears_done, snapshot_json: to_json_string(snapshot), started_at_ms: snapshot.started_at_ms as i64, finished_at_ms: snapshot .finished_at_ms .map(|value| value as i64) .unwrap_or(0), created_at, updated_at, } } fn increment_work_play_count( ctx: &ReducerContext, row: &PuzzleClearWorkProfileRow, played_at_ms: i64, ) { replace_work( ctx, row, PuzzleClearWorkProfileRow { play_count: row.play_count.saturating_add(1), updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)), ..clone_work(row) }, ); } fn insert_event( ctx: &ReducerContext, event_id: String, owner_user_id: String, profile_id: String, run_id: String, event_type: &str, result: Option, occurred_at_ms: i64, ) { let event_id = clean_optional(&event_id).unwrap_or_else(|| { format!( "puzzle-clear-event-{}-{}-{}", run_id, event_type, occurred_at_ms ) }); if ctx .db .puzzle_clear_event() .event_id() .find(&event_id) .is_some() { return; } ctx.db.puzzle_clear_event().insert(PuzzleClearEventRow { event_id, owner_user_id, profile_id, run_id, event_type: event_type.to_string(), result: result.unwrap_or_default(), occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)), }); } fn insert_terminal_runtime_event_if_needed( ctx: &ReducerContext, previous: &DomainRunSnapshot, next: &DomainRunSnapshot, owner_user_id: String, occurred_at_ms: i64, ) { if previous.status == next.status { return; } let event_type = match next.status { module_puzzle_clear::PuzzleClearRunStatus::LevelCleared => { Some(PUZZLE_CLEAR_EVENT_LEVEL_COMPLETED) } module_puzzle_clear::PuzzleClearRunStatus::Finished => { Some(PUZZLE_CLEAR_EVENT_RUN_FINISHED) } module_puzzle_clear::PuzzleClearRunStatus::LevelFailed => { Some(PUZZLE_CLEAR_EVENT_LEVEL_FAILED) } module_puzzle_clear::PuzzleClearRunStatus::Playing => None, }; let Some(event_type) = event_type else { return; }; insert_event( ctx, format!("{}:{}:{}", next.run_id, event_type, next.level_index), owner_user_id, next.profile_id.clone(), next.run_id.clone(), event_type, Some(runtime_event_result(previous, next, occurred_at_ms)), occurred_at_ms, ); } fn runtime_event_result( previous: &DomainRunSnapshot, next: &DomainRunSnapshot, occurred_at_ms: i64, ) -> String { let elapsed_ms = occurred_at_ms .max(0) .saturating_sub(next.level_started_at_ms as i64); json!({ "status": next.status.as_str(), "levelIndex": next.level_index, "clearsDone": next.clears_done, "clearDelta": next.clears_done.saturating_sub(previous.clears_done), "elapsedMs": elapsed_ms, }) .to_string() } fn is_publish_ready(row: &PuzzleClearWorkProfileRow) -> bool { !row.work_title.trim().is_empty() && !row.atlas_asset_json.trim().is_empty() && !row.pattern_groups_json.trim().is_empty() && !row.card_assets_json.trim().is_empty() && row.generation_status == PUZZLE_CLEAR_GENERATION_READY && parse_json::(&row.atlas_asset_json) .map(|asset| { is_real_puzzle_clear_asset( asset.asset_object_id.as_str(), asset.image_object_key.as_str(), asset.image_src.as_str(), ) }) .unwrap_or(false) && parse_json::>(&row.card_assets_json) .map(|assets| { !assets.is_empty() && assets.iter().all(|asset| { is_real_puzzle_clear_asset( asset.asset_object_id.as_str(), asset.image_object_key.as_str(), asset.image_src.as_str(), ) }) }) .unwrap_or(false) } fn is_real_puzzle_clear_asset( asset_object_id: &str, image_object_key: &str, image_src: &str, ) -> bool { asset_object_id.starts_with("assetobj_") && image_object_key.starts_with("generated-puzzle-clear-assets/") && image_src.starts_with("/generated-puzzle-clear-assets/") } fn require_non_empty(value: &str, label: &str) -> Result<(), String> { if value.trim().is_empty() { Err(format!("{label} 不能为空")) } else { Ok(()) } } fn clean_optional(value: &str) -> Option { let value = value.trim(); if value.is_empty() { None } else { Some(value.to_string()) } } fn clean_string(value: &str, fallback: &str) -> String { clean_optional(value).unwrap_or_else(|| fallback.to_string()) } fn parse_json(value: &str) -> Result where T: DeserializeOwned, { serde_json::from_str(value).map_err(|error| error.to_string()) } fn parse_json_or_default(value: &str) -> T where T: DeserializeOwned + Default, { serde_json::from_str(value).unwrap_or_default() } fn to_json_string(value: &T) -> String where T: Serialize, { serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) } fn session_result( session: PuzzleClearAgentSessionSnapshot, ) -> PuzzleClearAgentSessionProcedureResult { PuzzleClearAgentSessionProcedureResult { ok: true, session: Some(session), error_message: None, } } fn session_error(message: String) -> PuzzleClearAgentSessionProcedureResult { PuzzleClearAgentSessionProcedureResult { ok: false, session: None, error_message: Some(message), } } fn work_result(work: PuzzleClearWorkSnapshot) -> PuzzleClearWorkProcedureResult { PuzzleClearWorkProcedureResult { ok: true, work: Some(work), error_message: None, } } fn work_error(message: String) -> PuzzleClearWorkProcedureResult { PuzzleClearWorkProcedureResult { ok: false, work: None, error_message: Some(message), } } fn run_result(run: PuzzleClearRuntimeSnapshot) -> PuzzleClearRunProcedureResult { PuzzleClearRunProcedureResult { ok: true, run: Some(run), error_message: None, } } fn run_error(message: String) -> PuzzleClearRunProcedureResult { PuzzleClearRunProcedureResult { ok: false, run: None, error_message: Some(message), } } fn clone_session(row: &PuzzleClearAgentSessionRow) -> PuzzleClearAgentSessionRow { PuzzleClearAgentSessionRow { session_id: row.session_id.clone(), owner_user_id: row.owner_user_id.clone(), status: row.status.clone(), draft_json: row.draft_json.clone(), published_profile_id: row.published_profile_id.clone(), created_at: row.created_at, updated_at: row.updated_at, } } fn clone_work(row: &PuzzleClearWorkProfileRow) -> PuzzleClearWorkProfileRow { PuzzleClearWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_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(), work_title: row.work_title.clone(), work_description: row.work_description.clone(), theme_prompt: row.theme_prompt.clone(), generate_board_background: row.generate_board_background, board_background_asset_json: row.board_background_asset_json.clone(), board_background_prompt: row.board_background_prompt.clone(), card_back_image_src: row.card_back_image_src.clone(), atlas_asset_json: row.atlas_asset_json.clone(), pattern_groups_json: row.pattern_groups_json.clone(), card_assets_json: row.card_assets_json.clone(), cover_image_src: row.cover_image_src.clone(), generation_status: row.generation_status.clone(), publication_status: row.publication_status.clone(), play_count: row.play_count, updated_at: row.updated_at, published_at: row.published_at, visible: row.visible, } } fn clone_run(row: &PuzzleClearRuntimeRunRow) -> PuzzleClearRuntimeRunRow { PuzzleClearRuntimeRunRow { run_id: row.run_id.clone(), owner_user_id: row.owner_user_id.clone(), profile_id: row.profile_id.clone(), status: row.status.clone(), level_index: row.level_index, clears_done: row.clears_done, snapshot_json: row.snapshot_json.clone(), started_at_ms: row.started_at_ms, finished_at_ms: row.finished_at_ms, created_at: row.created_at, updated_at: row.updated_at, } } #[cfg(test)] mod tests { use super::*; #[test] fn puzzle_clear_publish_ready_rejects_placeholder_assets() { let now = Timestamp::from_micros_since_unix_epoch(1_780_000_000_000_000); let groups = default_pattern_groups(); let atlas = default_atlas_asset("puzzle-clear-profile-placeholder", "占位主题"); let cards = default_card_assets("puzzle-clear-profile-placeholder", &groups); let row = PuzzleClearWorkProfileRow { profile_id: "puzzle-clear-profile-placeholder".to_string(), work_id: "puzzle-clear-profile-placeholder".to_string(), owner_user_id: "user-1".to_string(), source_session_id: "puzzle-clear-session-placeholder".to_string(), author_display_name: "拼消消玩家".to_string(), work_title: "占位拼消消".to_string(), work_description: String::new(), theme_prompt: "占位主题".to_string(), generate_board_background: false, board_background_asset_json: String::new(), board_background_prompt: None, card_back_image_src: PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string(), atlas_asset_json: to_json_string(&atlas), pattern_groups_json: to_json_string(&groups), card_assets_json: to_json_string(&cards), cover_image_src: atlas.image_src, generation_status: PUZZLE_CLEAR_GENERATION_READY.to_string(), publication_status: PUZZLE_CLEAR_PUBLICATION_DRAFT.to_string(), play_count: 0, updated_at: now, published_at: None, visible: true, }; assert!(!is_publish_ready(&row)); } }