Files
Genarrative/server-rs/crates/spacetime-module/src/puzzle.rs
高物 7b37271f17 Puzzle: support history images & partial generation
Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
2026-05-19 10:02:13 +08:00

3845 lines
137 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::runtime::{
ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput,
PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays,
count_recent_public_work_plays_for_profiles, grant_profile_wallet_points,
record_public_work_like, record_public_work_play, upsert_profile_played_work,
upsert_profile_save_archive,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK,
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed,
build_result_preview, compile_result_draft_from_seed, create_work_profile, infer_anchor_pack,
normalize_puzzle_draft, normalize_puzzle_levels, normalize_theme_tags, publish_work_profile,
replace_puzzle_level, select_next_profiles, selected_profile_level_after_runtime_level,
selected_puzzle_level, tag_similarity_score,
};
use module_runtime::RuntimeProfileWalletLedgerSourceType;
use module_runtime::visible_runtime_profile_user_tags;
use serde_json::from_str as json_from_str;
use serde_json::json;
use serde_json::to_string as json_to_string;
use spacetimedb::{
AnonymousViewContext, ProcedureContext, SpacetimeType, Table, Timestamp, TxContext,
};
use crate::auth::user_account;
const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0;
/// 拼图 Agent session 真相表。
/// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。
#[spacetimedb::table(
accessor = puzzle_agent_session,
index(accessor = by_puzzle_agent_session_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct PuzzleAgentSessionRow {
#[primary_key]
session_id: String,
owner_user_id: String,
seed_text: String,
current_turn: u32,
progress_percent: u32,
stage: PuzzleAgentStage,
anchor_pack_json: String,
draft_json: Option<String>,
last_assistant_reply: Option<String>,
published_profile_id: Option<String>,
created_at: Timestamp,
updated_at: Timestamp,
}
/// 拼图 Agent 消息真相表。
#[spacetimedb::table(
accessor = puzzle_agent_message,
index(accessor = by_puzzle_agent_message_session_id, btree(columns = [session_id]))
)]
pub struct PuzzleAgentMessageRow {
#[primary_key]
message_id: String,
session_id: String,
role: PuzzleAgentMessageRole,
kind: PuzzleAgentMessageKind,
text: String,
created_at: Timestamp,
}
/// 已发布与草稿作品统一作品表。
#[spacetimedb::table(
accessor = puzzle_work_profile,
index(accessor = by_puzzle_work_owner_user_id, btree(columns = [owner_user_id])),
index(accessor = by_puzzle_work_publication_status, btree(columns = [publication_status]))
)]
pub struct PuzzleWorkProfileRow {
#[primary_key]
profile_id: String,
work_id: String,
owner_user_id: String,
source_session_id: Option<String>,
author_display_name: String,
work_title: String,
work_description: String,
level_name: String,
summary: String,
theme_tags_json: String,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
levels_json: String,
publication_status: PuzzlePublicationStatus,
play_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_total_half_points: u64,
#[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)]
point_incentive_claimed_points: u64,
}
/// 拼图广场公开详情兼容投影。
///
/// 该 view 返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段。
/// 公开列表主路径应订阅更轻量的 `puzzle_gallery_card_view`。
#[spacetimedb::view(accessor = puzzle_gallery_view, public)]
pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec<PuzzleWorkProfile> {
let mut items = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter_map(
|row| match build_puzzle_work_profile_from_row_without_recent_count(&row) {
Ok(profile) => Some(profile),
Err(error) => {
log::warn!(
"拼图广场 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
},
)
.collect::<Vec<_>>();
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
items
}
/// 拼图广场公开列表卡片投影。
///
/// 该 view 只暴露前端列表首屏需要的公开卡片字段,不携带 levels / anchor_pack
/// 等详情级载荷,供 api-server 热点缓存订阅和组装列表窗口。
#[spacetimedb::view(accessor = puzzle_gallery_card_view, public)]
pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec<PuzzleGalleryCardViewRow> {
let mut items = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.filter_map(|row| match build_puzzle_gallery_card_view_row(&row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"拼图广场卡片 view 跳过损坏的作品投影 profile_id={}: {}",
row.profile_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
items
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct PuzzleGalleryCardViewRow {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub work_title: String,
pub work_description: String,
pub level_name: String,
pub summary: String,
pub theme_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub publication_status: PuzzlePublicationStatus,
pub updated_at_micros: i64,
pub published_at_micros: Option<i64>,
pub play_count: u32,
pub remix_count: u32,
pub like_count: u32,
pub point_incentive_total_half_points: u64,
pub point_incentive_claimed_points: u64,
pub publish_ready: bool,
pub generation_status: Option<String>,
}
/// 拼图创作事件类型。
///
/// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以
/// `puzzle_work_profile` 和 `puzzle_agent_session` 为准。
#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)]
pub enum PuzzleEventKind {
WorkPublished,
}
#[spacetimedb::table(
accessor = puzzle_event,
public,
event,
index(accessor = by_puzzle_event_profile_id, btree(columns = [profile_id])),
index(accessor = by_puzzle_event_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct PuzzleEvent {
#[primary_key]
event_id: String,
profile_id: String,
work_id: String,
session_id: Option<String>,
owner_user_id: String,
event_kind: PuzzleEventKind,
occurred_at: Timestamp,
}
/// 运行态 run 快照表。
#[spacetimedb::table(
accessor = puzzle_runtime_run,
index(accessor = by_puzzle_runtime_run_owner_user_id, btree(columns = [owner_user_id]))
)]
pub struct PuzzleRuntimeRunRow {
#[primary_key]
run_id: String,
owner_user_id: String,
entry_profile_id: String,
current_profile_id: String,
cleared_level_count: u32,
current_level_index: u32,
current_grid_size: u32,
played_profile_ids_json: String,
previous_level_tags_json: String,
snapshot_json: String,
created_at: Timestamp,
updated_at: Timestamp,
}
/// 拼图关卡真实成绩表。
/// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。
#[spacetimedb::table(
accessor = puzzle_leaderboard_entry,
index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])),
index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size]))
)]
pub struct PuzzleLeaderboardEntryRow {
#[primary_key]
entry_id: String,
profile_id: String,
grid_size: u32,
user_id: String,
nickname: String,
best_elapsed_ms: u64,
last_run_id: String,
updated_at: Timestamp,
}
#[spacetimedb::procedure]
pub fn create_puzzle_agent_session(
ctx: &mut ProcedureContext,
input: PuzzleAgentSessionCreateInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| create_puzzle_agent_session_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_puzzle_agent_session(
ctx: &mut ProcedureContext,
input: PuzzleAgentSessionGetInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| get_puzzle_agent_session_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_agent_message(
ctx: &mut ProcedureContext,
input: module_puzzle::PuzzleAgentMessageSubmitInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| submit_puzzle_agent_message_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn finalize_puzzle_agent_message_turn(
ctx: &mut ProcedureContext,
input: PuzzleAgentMessageFinalizeInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn compile_puzzle_agent_draft(
ctx: &mut ProcedureContext,
input: PuzzleDraftCompileInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| compile_puzzle_agent_draft_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
/// 保存拼图入口表单草稿。
/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。
#[spacetimedb::procedure]
pub fn save_puzzle_form_draft(
ctx: &mut ProcedureContext,
input: PuzzleFormDraftSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_generated_images(
ctx: &mut ProcedureContext,
input: PuzzleGeneratedImagesSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_generated_images_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn save_puzzle_ui_background(
ctx: &mut ProcedureContext,
input: PuzzleUiBackgroundSaveInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn select_puzzle_cover_image(
ctx: &mut ProcedureContext,
input: PuzzleSelectCoverImageInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| select_puzzle_cover_image_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn publish_puzzle_work(
ctx: &mut ProcedureContext,
input: PuzzlePublishInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| publish_puzzle_work_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_puzzle_works(
ctx: &mut ProcedureContext,
input: PuzzleWorksListInput,
) -> PuzzleWorksProcedureResult {
match ctx.try_with_tx(|tx| list_puzzle_works_tx(tx, input.clone())) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_puzzle_work_detail(
ctx: &mut ProcedureContext,
input: PuzzleWorkGetInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| get_puzzle_work_detail_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn update_puzzle_work(
ctx: &mut ProcedureContext,
input: PuzzleWorkUpsertInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| update_puzzle_work_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn delete_puzzle_work(
ctx: &mut ProcedureContext,
input: PuzzleWorkDeleteInput,
) -> PuzzleWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureResult {
match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items,
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_puzzle_gallery_detail(
ctx: &mut ProcedureContext,
input: PuzzleWorkGetInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| get_puzzle_gallery_detail_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn record_puzzle_work_like(
ctx: &mut ProcedureContext,
input: PuzzleWorkLikeInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn remix_puzzle_work(
ctx: &mut ProcedureContext,
input: PuzzleWorkRemixInput,
) -> PuzzleAgentSessionProcedureResult {
match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) {
Ok(session) => PuzzleAgentSessionProcedureResult {
ok: true,
session: Some(session),
error_message: None,
},
Err(message) => PuzzleAgentSessionProcedureResult {
ok: false,
session: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn start_puzzle_run(
ctx: &mut ProcedureContext,
input: PuzzleRunStartInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| start_puzzle_run_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_puzzle_run(
ctx: &mut ProcedureContext,
input: PuzzleRunGetInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| get_puzzle_run_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn swap_puzzle_pieces(
ctx: &mut ProcedureContext,
input: PuzzleRunSwapInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| swap_puzzle_pieces_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn drag_puzzle_piece_or_group(
ctx: &mut ProcedureContext,
input: PuzzleRunDragInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| drag_puzzle_piece_or_group_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn advance_puzzle_next_level(
ctx: &mut ProcedureContext,
input: PuzzleRunNextLevelInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| advance_puzzle_next_level_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn update_puzzle_run_pause(
ctx: &mut ProcedureContext,
input: PuzzleRunPauseInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn use_puzzle_runtime_prop(
ctx: &mut ProcedureContext,
input: PuzzleRunPropInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn claim_puzzle_work_point_incentive(
ctx: &mut ProcedureContext,
input: PuzzleWorkPointIncentiveClaimInput,
) -> PuzzleWorkProcedureResult {
match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) {
Ok(item) => PuzzleWorkProcedureResult {
ok: true,
item: Some(item),
error_message: None,
},
Err(message) => PuzzleWorkProcedureResult {
ok: false,
item: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_leaderboard_entry(
ctx: &mut ProcedureContext,
input: PuzzleLeaderboardSubmitInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run: Some(run),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run: None,
error_message: Some(message),
},
}
}
fn create_puzzle_agent_session_tx(
ctx: &TxContext,
input: PuzzleAgentSessionCreateInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
ensure_session_missing(ctx, &input.session_id)?;
ensure_message_missing(ctx, &input.welcome_message_id)?;
let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text));
let initial_form_draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text));
ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
seed_text: input.seed_text.clone(),
current_turn: 1,
// 中文注释:欢迎语和初始锚点推断不计入创作进度,新会话必须从 0% 开始。
progress_percent: 0,
stage: PuzzleAgentStage::CollectingAnchors,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: Some(serialize_json(&initial_form_draft)),
last_assistant_reply: Some(input.welcome_message_text.clone()),
published_profile_id: None,
created_at,
updated_at: created_at,
});
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: input.welcome_message_id,
session_id: input.session_id.clone(),
role: PuzzleAgentMessageRole::Assistant,
kind: PuzzleAgentMessageKind::Chat,
text: input.welcome_message_text,
created_at,
});
upsert_puzzle_draft_work_profile(
ctx,
&input.session_id,
&input.owner_user_id,
&initial_form_draft,
input.created_at_micros,
)?;
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn get_puzzle_agent_session_tx(
ctx: &TxContext,
input: PuzzleAgentSessionGetInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
build_puzzle_agent_session_snapshot(ctx, &row)
}
fn submit_puzzle_agent_message_tx(
ctx: &TxContext,
input: module_puzzle::PuzzleAgentMessageSubmitInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
ensure_message_missing(ctx, &input.user_message_id)?;
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: input.user_message_id.clone(),
session_id: input.session_id.clone(),
role: PuzzleAgentMessageRole::User,
kind: PuzzleAgentMessageKind::Chat,
text: input.user_message_text.clone(),
created_at: submitted_at,
});
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn finalize_puzzle_agent_message_turn_tx(
ctx: &TxContext,
input: PuzzleAgentMessageFinalizeInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
if let Some(error_message) = input
.error_message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent,
stage: row.stage,
anchor_pack_json: row.anchor_pack_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,
},
);
return Err(error_message.to_string());
}
let assistant_message_id = input
.assistant_message_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "拼图 assistant_message_id 不能为空".to_string())?
.to_string();
let assistant_reply_text = input
.assistant_reply_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "拼图 assistant_reply_text 不能为空".to_string())?
.to_string();
ensure_message_missing(ctx, &assistant_message_id)?;
let next_anchor_pack = deserialize_anchor_pack(&input.anchor_pack_json)?;
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
role: PuzzleAgentMessageRole::Assistant,
kind: PuzzleAgentMessageKind::Chat,
text: assistant_reply_text.clone(),
created_at: updated_at,
});
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn.saturating_add(1),
progress_percent: input.progress_percent.min(100),
stage: input.stage,
anchor_pack_json: serialize_json(&next_anchor_pack),
draft_json: row.draft_json.clone(),
last_assistant_reply: Some(assistant_reply_text),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn compile_puzzle_agent_draft_tx(
ctx: &TxContext,
input: PuzzleDraftCompileInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
if row.seed_text.trim().is_empty() {
return Err("请先填写拼图作品信息".to_string());
}
let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text));
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text));
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
// 每次编译结果页时同步 upsert保证后续能按 source_session_id 恢复聊天。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.compiled_at_micros,
)?;
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: 88,
stage: PuzzleAgentStage::DraftReady,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some(
"拼图结果页草稿已经生成,可以开始出图并确认标签。".to_string(),
),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: compiled_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn save_puzzle_form_draft_tx(
ctx: &TxContext,
input: PuzzleFormDraftSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
if row.stage != PuzzleAgentStage::CollectingAnchors {
return get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
);
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text));
let draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text));
upsert_puzzle_draft_work_profile(
ctx,
&input.session_id,
&input.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: input.seed_text,
current_turn: row.current_turn,
progress_percent: 0,
stage: PuzzleAgentStage::CollectingAnchors,
anchor_pack_json: serialize_json(&anchor_pack),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: row.last_assistant_reply.clone(),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn save_puzzle_generated_images_tx(
ctx: &TxContext,
input: PuzzleGeneratedImagesSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
let previous_primary_level_name = draft.level_name.clone();
let previous_work_title = draft.work_title.clone();
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
draft.levels = levels;
draft = normalize_completed_puzzle_level_generation_status(draft);
module_puzzle::sync_primary_level_fields(&mut draft);
// 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_primary_level_name,
);
}
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
if candidates.is_empty() {
return Err("拼图候选图不能为空".to_string());
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
replace_generated_candidate(&mut next_level.candidates, candidates);
next_level.generation_status = "ready".to_string();
if let Some(selected) = next_level
.candidates
.iter()
.find(|entry| entry.selected)
.cloned()
{
next_level.selected_candidate_id = Some(selected.candidate_id);
next_level.cover_image_src = Some(selected.image_src);
next_level.cover_asset_id = Some(selected.asset_id);
}
draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
draft = normalize_completed_puzzle_level_generation_status(draft);
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
// 结果页草稿封面和候选图发生变化后,草稿卡需要同步刷新。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: 94,
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图图片已经生成,并已替换当前正式图。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn save_puzzle_ui_background_tx(
ctx: &TxContext,
input: PuzzleUiBackgroundSaveInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。
draft.levels = levels;
draft = normalize_completed_puzzle_level_generation_status(draft);
module_puzzle::sync_primary_level_fields(&mut draft);
}
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let mut next_level = target_level;
next_level.ui_background_prompt = Some(input.prompt.trim().to_string());
next_level.ui_background_image_src = Some(input.image_src.trim().to_string());
next_level.ui_background_image_object_key = input.image_object_key.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
});
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let draft = normalize_completed_puzzle_level_generation_status(draft);
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.saved_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: row.progress_percent.max(96),
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图 UI 背景图已生成。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: saved_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn sync_generated_primary_level_name_as_default_work_title(
draft: &mut PuzzleResultDraft,
previous_work_title: &str,
previous_primary_level_name: &str,
) {
if previous_work_title.trim().is_empty()
|| previous_work_title.trim() == previous_primary_level_name.trim()
{
draft.work_title = draft.level_name.clone();
}
}
fn normalize_completed_puzzle_level_generation_status(
mut draft: PuzzleResultDraft,
) -> PuzzleResultDraft {
draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels);
module_puzzle::sync_primary_level_fields(&mut draft);
draft
}
fn normalize_completed_puzzle_levels_generation_status(
mut levels: Vec<module_puzzle::PuzzleDraftLevel>,
) -> Vec<module_puzzle::PuzzleDraftLevel> {
for level in &mut levels {
if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level)
{
level.generation_status = "ready".to_string();
}
}
levels
}
fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool {
let has_cover = level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let has_selected_candidate = level
.selected_candidate_id
.as_deref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == candidate_id)
})
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
let has_fallback_candidate = level
.candidates
.last()
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
has_cover || has_selected_candidate || has_fallback_candidate
}
fn select_puzzle_cover_image_tx(
ctx: &TxContext,
input: PuzzleSelectCoverImageInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let draft = deserialize_draft_required(&row.draft_json)?;
let target_level = selected_puzzle_level(&draft, input.level_id.as_deref())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
let level_draft = PuzzleResultDraft {
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
level_name: target_level.level_name.clone(),
summary: draft.summary.clone(),
theme_tags: draft.theme_tags.clone(),
forbidden_directives: draft.forbidden_directives.clone(),
creator_intent: draft.creator_intent.clone(),
anchor_pack: draft.anchor_pack.clone(),
candidates: target_level.candidates.clone(),
selected_candidate_id: target_level.selected_candidate_id.clone(),
cover_image_src: target_level.cover_image_src.clone(),
cover_asset_id: target_level.cover_asset_id.clone(),
generation_status: target_level.generation_status.clone(),
levels: vec![target_level.clone()],
form_draft: None,
};
let selected_level_draft = apply_selected_candidate(level_draft, &input.candidate_id)
.map_err(|error| error.to_string())?;
let next_level = module_puzzle::PuzzleDraftLevel {
level_id: target_level.level_id,
level_name: target_level.level_name,
picture_description: target_level.picture_description,
picture_reference: target_level.picture_reference,
ui_background_prompt: target_level.ui_background_prompt,
ui_background_image_src: target_level.ui_background_image_src,
ui_background_image_object_key: target_level.ui_background_image_object_key,
background_music: target_level.background_music,
candidates: selected_level_draft.candidates,
selected_candidate_id: selected_level_draft.selected_candidate_id,
cover_image_src: selected_level_draft.cover_image_src,
cover_asset_id: selected_level_draft.cover_asset_id,
generation_status: selected_level_draft.generation_status,
};
let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?;
let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros);
let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
// 选定正式封面后,创作中心草稿卡要立即反映最新正式图。
upsert_puzzle_draft_work_profile(
ctx,
&row.session_id,
&row.owner_user_id,
&draft,
input.selected_at_micros,
)?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: 96,
stage: next_stage,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("正式拼图图片已确定,可以准备发布。".to_string()),
published_profile_id: row.published_profile_id.clone(),
created_at: row.created_at,
updated_at: selected_at,
},
);
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
},
)
}
fn publish_puzzle_work_tx(
ctx: &TxContext,
input: PuzzlePublishInput,
) -> Result<PuzzleWorkProfile, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let draft = deserialize_draft_required(&row.draft_json)?;
let draft = apply_publish_overrides_to_draft(
&draft,
input.work_title.clone(),
input.work_description.clone(),
input.level_name.clone(),
input.summary.clone(),
input.theme_tags.clone(),
deserialize_optional_levels_input(input.levels_json.as_deref())?,
)
.map_err(|error| error.to_string())?;
let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id);
let mut profile = create_work_profile(
work_id,
profile_id,
input.owner_user_id.clone(),
Some(input.session_id.clone()),
input.author_display_name.clone(),
&draft,
input.published_at_micros,
)
.map_err(|error| error.to_string())?;
profile = publish_work_profile(profile, &draft, input.published_at_micros)
.map_err(|error| error.to_string())?;
upsert_puzzle_work_profile(ctx, profile.clone())?;
replace_puzzle_agent_session(
ctx,
&row,
PuzzleAgentSessionRow {
session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
seed_text: row.seed_text.clone(),
current_turn: row.current_turn,
progress_percent: 100,
stage: PuzzleAgentStage::Published,
anchor_pack_json: row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("拼图作品已经发布到广场。".to_string()),
published_profile_id: Some(profile.profile_id.clone()),
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.published_at_micros),
},
);
emit_puzzle_work_published_event(ctx, &profile, input.published_at_micros);
Ok(profile)
}
fn list_puzzle_works_tx(
ctx: &TxContext,
input: PuzzleWorksListInput,
) -> Result<Vec<PuzzleWorkProfile>, String> {
let mut items = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_owner_user_id()
.filter(&input.owner_user_id)
.map(|row| build_puzzle_work_profile_from_row(&row))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items)
}
fn get_puzzle_work_detail_tx(
ctx: &TxContext,
input: PuzzleWorkGetInput,
) -> Result<PuzzleWorkProfile, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
build_puzzle_work_profile_from_row(&row)
}
fn update_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkUpsertInput,
) -> Result<PuzzleWorkProfile, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.owner_user_id != input.owner_user_id {
return Err("无权修改该拼图作品".to_string());
}
let theme_tags = normalize_theme_tags(input.theme_tags);
if theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
return Err("拼图标签数量不合法".to_string());
}
let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())?
.map(|levels| {
normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string())
})
.transpose()?
.unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default());
levels = normalize_completed_puzzle_levels_generation_status(levels);
let preview_draft = PuzzleResultDraft {
work_title: input.work_title.clone(),
work_description: input.work_description.clone(),
level_name: input.level_name.clone(),
summary: input.summary.clone(),
theme_tags: theme_tags.clone(),
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
candidates: levels
.first()
.map(|level| level.candidates.clone())
.unwrap_or_default(),
selected_candidate_id: levels
.first()
.and_then(|level| level.selected_candidate_id.clone()),
cover_image_src: input.cover_image_src.clone(),
cover_asset_id: input.cover_asset_id.clone(),
generation_status: levels
.first()
.map(|level| level.generation_status.clone())
.unwrap_or_else(|| "idle".to_string()),
levels: levels.clone(),
form_draft: None,
};
let next_row = PuzzleWorkProfileRow {
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: input.work_title,
work_description: input.work_description,
level_name: input.level_name,
summary: input.summary,
theme_tags_json: serialize_json(&theme_tags),
cover_image_src: input.cover_image_src,
cover_asset_id: input.cover_asset_id,
levels_json: serialize_json(&levels),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: build_result_preview(&preview_draft, Some(&row.author_display_name))
.publish_ready,
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros),
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
sync_puzzle_source_session_draft_from_work(ctx, &row, &preview_draft, input.updated_at_micros)?;
get_puzzle_work_detail_tx(
ctx,
PuzzleWorkGetInput {
profile_id: input.profile_id,
},
)
}
fn sync_puzzle_source_session_draft_from_work(
ctx: &TxContext,
work_row: &PuzzleWorkProfileRow,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<(), String> {
let Some(session_id) = work_row.source_session_id.as_ref() else {
return Ok(());
};
let Some(session_row) = ctx.db.puzzle_agent_session().session_id().find(session_id) else {
return Ok(());
};
if session_row.owner_user_id != work_row.owner_user_id {
return Ok(());
}
let normalized_draft = normalize_puzzle_draft(draft.clone());
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
let next_stage = if session_row.stage == PuzzleAgentStage::Published {
PuzzleAgentStage::Published
} else if build_result_preview(&normalized_draft, Some(&work_row.author_display_name))
.publish_ready
{
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
};
replace_puzzle_agent_session(
ctx,
&session_row,
PuzzleAgentSessionRow {
session_id: session_row.session_id.clone(),
owner_user_id: session_row.owner_user_id.clone(),
seed_text: session_row.seed_text.clone(),
current_turn: session_row.current_turn,
progress_percent: session_row.progress_percent.max(94),
stage: next_stage,
anchor_pack_json: session_row.anchor_pack_json.clone(),
draft_json: Some(serialize_json(&normalized_draft)),
last_assistant_reply: session_row.last_assistant_reply.clone(),
published_profile_id: session_row.published_profile_id.clone(),
created_at: session_row.created_at,
updated_at,
},
);
Ok(())
}
fn delete_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkDeleteInput,
) -> Result<Vec<PuzzleWorkProfile>, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.owner_user_id != input.owner_user_id {
return Err("无权删除该拼图作品".to_string());
}
// 删除作品时同步清理来源 Agent 会话和运行快照,保持创作页列表与运行态数据一致。
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&row.profile_id);
if let Some(session_id) = row.source_session_id.as_ref() {
if let Some(session) = ctx.db.puzzle_agent_session().session_id().find(session_id) {
ctx.db
.puzzle_agent_session()
.session_id()
.delete(&session.session_id);
}
for message in ctx
.db
.puzzle_agent_message()
.by_puzzle_agent_message_session_id()
.filter(session_id)
.collect::<Vec<_>>()
{
ctx.db
.puzzle_agent_message()
.message_id()
.delete(&message.message_id);
}
}
for run in ctx
.db
.puzzle_runtime_run()
.by_puzzle_runtime_run_owner_user_id()
.filter(&input.owner_user_id)
.filter(|run| run.entry_profile_id == input.profile_id)
.collect::<Vec<_>>()
{
ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id);
}
list_puzzle_works_tx(
ctx,
PuzzleWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let rows = ctx
.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.collect::<Vec<_>>();
let profile_ids = rows
.iter()
.map(|row| row.profile_id.clone())
.collect::<Vec<_>>();
let recent_play_counts =
count_recent_public_work_plays_for_profiles(ctx, "puzzle", &profile_ids, now_micros);
let mut items = rows
.iter()
.map(|row| build_puzzle_work_profile_from_row_with_recent_counts(row, &recent_play_counts))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items)
}
fn get_puzzle_gallery_detail_tx(
ctx: &TxContext,
input: PuzzleWorkGetInput,
) -> Result<PuzzleWorkProfile, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("拼图作品尚未发布".to_string());
}
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
&row,
ctx.timestamp.to_micros_since_unix_epoch(),
)
}
fn record_puzzle_work_like_tx(
ctx: &TxContext,
input: PuzzleWorkLikeInput,
) -> Result<PuzzleWorkProfile, String> {
let profile_id = input.profile_id.trim();
let user_id = input.user_id.trim();
if profile_id.is_empty() || user_id.is_empty() {
return Err("拼图 like 参数不能为空".to_string());
}
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?;
let inserted_like = record_public_work_like(
ctx,
PublicWorkLikeRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: row.owner_user_id.clone(),
profile_id: row.profile_id.clone(),
user_id: user_id.to_string(),
liked_at_micros: input.liked_at_micros,
},
)?;
let current_row = if inserted_like {
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
let next_row = PuzzleWorkProfileRow {
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(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count.saturating_add(1),
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
created_at: row.created_at,
updated_at: liked_at,
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图点赞更新失败".to_string())?
} else {
row
};
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
&current_row,
ctx.timestamp.to_micros_since_unix_epoch(),
)
}
fn remix_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkRemixInput,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let source_profile_id = input.source_profile_id.trim();
let target_owner_user_id = input.target_owner_user_id.trim();
let target_session_id = input.target_session_id.trim();
let target_profile_id = input.target_profile_id.trim();
let target_work_id = input.target_work_id.trim();
if source_profile_id.is_empty()
|| target_owner_user_id.is_empty()
|| target_session_id.is_empty()
|| target_profile_id.is_empty()
|| target_work_id.is_empty()
{
return Err("拼图 remix 参数不能为空".to_string());
}
if input.author_display_name.trim().is_empty() {
return Err("拼图 remix 作者名不能为空".to_string());
}
ensure_session_missing(ctx, target_session_id)?;
ensure_message_missing(ctx, input.welcome_message_id.trim())?;
if ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&target_profile_id.to_string())
.is_some()
{
return Err("拼图 remix 目标作品已存在".to_string());
}
let source = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&source_profile_id.to_string())
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.ok_or_else(|| "拼图已发布源作品不存在".to_string())?;
let source_profile = build_puzzle_work_profile_from_row(&source)?;
let remixed_at = Timestamp::from_micros_since_unix_epoch(input.remixed_at_micros);
replace_puzzle_work_profile(
ctx,
&source,
PuzzleWorkProfileRow {
profile_id: source.profile_id.clone(),
work_id: source.work_id.clone(),
owner_user_id: source.owner_user_id.clone(),
source_session_id: source.source_session_id.clone(),
author_display_name: source.author_display_name.clone(),
work_title: source.work_title.clone(),
work_description: source.work_description.clone(),
level_name: source.level_name.clone(),
summary: source.summary.clone(),
theme_tags_json: source.theme_tags_json.clone(),
cover_image_src: source.cover_image_src.clone(),
cover_asset_id: source.cover_asset_id.clone(),
levels_json: source.levels_json.clone(),
publication_status: source.publication_status,
play_count: source.play_count,
remix_count: source.remix_count.saturating_add(1),
like_count: source.like_count,
point_incentive_total_half_points: source.point_incentive_total_half_points,
point_incentive_claimed_points: source.point_incentive_claimed_points,
anchor_pack_json: source.anchor_pack_json.clone(),
publish_ready: source.publish_ready,
created_at: source.created_at,
updated_at: remixed_at,
published_at: source.published_at,
},
);
let draft = PuzzleResultDraft {
work_title: source_profile.work_title.clone(),
work_description: source_profile.work_description.clone(),
level_name: source_profile.level_name.clone(),
summary: source_profile.summary.clone(),
theme_tags: source_profile.theme_tags.clone(),
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack: source_profile.anchor_pack.clone(),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: source_profile.cover_image_src.clone(),
cover_asset_id: source_profile.cover_asset_id.clone(),
generation_status: "ready".to_string(),
levels: source_profile.levels.clone(),
form_draft: None,
};
ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow {
session_id: target_session_id.to_string(),
owner_user_id: target_owner_user_id.to_string(),
seed_text: source_profile.summary.clone(),
current_turn: 1,
progress_percent: 88,
stage: PuzzleAgentStage::DraftReady,
anchor_pack_json: serialize_json(&source_profile.anchor_pack),
draft_json: Some(serialize_json(&draft)),
last_assistant_reply: Some("已从公开作品 Remix 出新的拼图草稿。".to_string()),
published_profile_id: None,
created_at: remixed_at,
updated_at: remixed_at,
});
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id: input.welcome_message_id,
session_id: target_session_id.to_string(),
role: PuzzleAgentMessageRole::Assistant,
kind: PuzzleAgentMessageKind::Summary,
text: "已复制公开作品为你的草稿。".to_string(),
created_at: remixed_at,
});
ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow {
profile_id: target_profile_id.to_string(),
work_id: target_work_id.to_string(),
owner_user_id: target_owner_user_id.to_string(),
source_session_id: Some(target_session_id.to_string()),
author_display_name: input.author_display_name.trim().to_string(),
work_title: source_profile.work_title,
work_description: source_profile.work_description,
level_name: source_profile.level_name,
summary: source_profile.summary,
theme_tags_json: serialize_json(&source_profile.theme_tags),
cover_image_src: source_profile.cover_image_src,
cover_asset_id: source_profile.cover_asset_id,
levels_json: serialize_json(&source_profile.levels),
publication_status: PuzzlePublicationStatus::Draft,
play_count: 0,
remix_count: 0,
like_count: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
anchor_pack_json: serialize_json(&source_profile.anchor_pack),
publish_ready: true,
created_at: remixed_at,
updated_at: remixed_at,
published_at: None,
});
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
session_id: target_session_id.to_string(),
owner_user_id: target_owner_user_id.to_string(),
},
)
}
fn start_puzzle_run_tx(
ctx: &TxContext,
input: PuzzleRunStartInput,
) -> Result<PuzzleRunSnapshot, String> {
if ctx
.db
.puzzle_runtime_run()
.run_id()
.find(&input.run_id)
.is_some()
{
return Err("拼图 run 已存在".to_string());
}
let entry_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "入口拼图作品不存在".to_string())?;
// 结果页试玩允许作者启动自己的草稿 run公开入口仍必须保持已发布状态。
let is_owner_draft_preview = entry_profile_row.publication_status
== PuzzlePublicationStatus::Draft
&& entry_profile_row.owner_user_id == input.owner_user_id;
if entry_profile_row.publication_status != PuzzlePublicationStatus::Published
&& !is_owner_draft_preview
{
return Err("入口拼图作品未发布".to_string());
}
let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
if entry_profile.cover_image_src.is_none() {
return Err("入口拼图作品缺少正式图片".to_string());
}
if entry_profile.theme_tags.is_empty() {
return Err("入口拼图作品缺少标签".to_string());
}
let mut cleared_level_count = 0;
if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? {
cleared_level_count =
module_puzzle::resolve_restart_cleared_level_count(&entry_profile, &level.level_id);
entry_profile = profile_for_single_level(&entry_profile, &level);
}
let started_at_ms = micros_to_millis(input.started_at_micros);
let mut run = module_puzzle::start_run_at(
input.run_id.clone(),
&entry_profile,
cleared_level_count,
started_at_ms,
)
.map_err(|error| error.to_string())?;
let current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
current_profile_id.as_str(),
current_grid_size,
);
refresh_next_level_handoff(ctx, &mut run)?;
if entry_profile_row.publication_status == PuzzlePublicationStatus::Published {
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: entry_profile_row.owner_user_id.clone(),
profile_id: entry_profile_row.profile_id.clone(),
played_at_micros: input.started_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
upsert_puzzle_profile_played_work(
ctx,
&input.owner_user_id,
&entry_profile_row,
input.started_at_micros,
)?;
}
insert_puzzle_runtime_run(ctx, &run, &input.owner_user_id, input.started_at_micros)?;
Ok(run)
}
fn get_puzzle_run_tx(
ctx: &TxContext,
input: PuzzleRunGetInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut run = module_puzzle::resolve_puzzle_run_timer_at(
deserialize_run(&row.snapshot_json)?,
micros_to_millis(now_micros),
);
refresh_next_level_handoff(ctx, &mut run)?;
if serialize_json(&run) != row.snapshot_json {
replace_puzzle_runtime_run(ctx, &row, &run, now_micros);
}
if let Some((profile_id, grid_size)) = run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(run)
}
fn swap_puzzle_pieces_tx(
ctx: &TxContext,
input: PuzzleRunSwapInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let mut next_run = module_puzzle::swap_pieces_at(
&current_run,
&input.first_piece_id,
&input.second_piece_id,
micros_to_millis(input.swapped_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros);
Ok(next_run)
}
fn drag_puzzle_piece_or_group_tx(
ctx: &TxContext,
input: PuzzleRunDragInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let mut next_run = module_puzzle::drag_piece_or_group_at(
&current_run,
&input.piece_id,
input.target_row,
input.target_col,
micros_to_millis(input.dragged_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros);
Ok(next_run)
}
fn advance_puzzle_next_level_tx(
ctx: &TxContext,
input: PuzzleRunNextLevelInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let current_level = current_run
.current_level
.as_ref()
.ok_or_else(|| "拼图关卡不存在".to_string())?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err("当前关卡尚未通关".to_string());
}
let current_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
let current_profile = build_puzzle_work_profile_from_row(&current_profile_row)?;
let same_work_next_profile =
selected_profile_level_after_runtime_level(&current_profile, current_level)
.map(|level| profile_for_single_level(&current_profile, &level));
let candidates = if same_work_next_profile.is_none() {
list_published_puzzle_profiles(ctx)?
} else {
Vec::new()
};
let similar_work_next_profile = if same_work_next_profile.is_none() {
let selected_candidates = select_next_profiles(
&current_profile,
&current_run.played_profile_ids,
&candidates,
3,
);
Some(
if let Some(target_profile_id) = input.target_profile_id.as_ref().and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}) {
selected_candidates
.into_iter()
.find(|candidate| candidate.profile_id == target_profile_id)
.cloned()
.ok_or_else(|| "目标拼图作品不在当前下一关候选中".to_string())?
} else {
selected_candidates
.into_iter()
.next()
.cloned()
.ok_or_else(|| "没有可用的下一关候选".to_string())?
},
)
} else {
None
};
let next_profile = same_work_next_profile
.as_ref()
.or(similar_work_next_profile.as_ref())
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
let mut next_run = if same_work_next_profile.is_some() {
module_puzzle::advance_next_level_at(
&current_run,
next_profile,
micros_to_millis(input.advanced_at_micros),
)
} else {
module_puzzle::advance_to_new_work_first_level_at(
&current_run,
next_profile,
micros_to_millis(input.advanced_at_micros),
)
}
.map_err(|error| error.to_string())?;
let next_grid_size = next_run.current_grid_size;
let next_profile_id = next_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&next_profile_id,
next_grid_size,
);
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some(next_profile_row) = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&next_profile.profile_id)
{
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: next_profile_row.owner_user_id.clone(),
profile_id: next_profile_row.profile_id.clone(),
played_at_micros: input.advanced_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros);
upsert_puzzle_profile_played_work(
ctx,
&input.owner_user_id,
&next_profile_row,
input.advanced_at_micros,
)?;
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.advanced_at_micros);
Ok(next_run)
}
fn update_puzzle_run_pause_tx(
ctx: &TxContext,
input: PuzzleRunPauseInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = module_puzzle::set_puzzle_run_paused_at(
&current_run,
input.paused,
micros_to_millis(input.updated_at_micros),
)
.map_err(|error| error.to_string())?;
let mut hydrated_run = next_run;
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros);
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn use_puzzle_runtime_prop_tx(
ctx: &TxContext,
input: PuzzleRunPropInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = match input.prop_kind.as_str() {
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"reference" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
true,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
_ => return Err("未知拼图道具".to_string()),
};
let mut hydrated_run = next_run;
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
if let Some(profile_id) = hydrated_run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
{
accrue_puzzle_point_incentive(
ctx,
&profile_id,
&input.owner_user_id,
input.spent_points,
input.used_at_micros,
)?;
}
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros);
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn claim_puzzle_work_point_incentive_tx(
ctx: &TxContext,
input: PuzzleWorkPointIncentiveClaimInput,
) -> Result<PuzzleWorkProfile, String> {
let profile_id = input.profile_id.trim();
let owner_user_id = input.owner_user_id.trim();
if profile_id.is_empty() || owner_user_id.is_empty() {
return Err("拼图积分激励参数不能为空".to_string());
}
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.owner_user_id != owner_user_id {
return Err("无权领取该作品的积分激励".to_string());
}
let claimable_points = module_puzzle::puzzle_point_incentive_claimable_points(
row.point_incentive_total_half_points,
row.point_incentive_claimed_points,
);
if claimable_points == 0 {
return Err("暂无可领取积分激励".to_string());
}
let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros);
let next_row = PuzzleWorkProfileRow {
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(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row
.point_incentive_claimed_points
.saturating_add(claimable_points),
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
created_at: row.created_at,
updated_at: claimed_at,
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
grant_profile_wallet_points(
ctx,
owner_user_id,
claimable_points,
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim,
&format!(
"puzzle_author_incentive_claim:{}:{}:{}",
profile_id, owner_user_id, input.claimed_at_micros
),
claimed_at,
)?;
let updated = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.ok_or_else(|| "拼图积分激励领取更新失败".to_string())?;
build_puzzle_work_profile_from_row(&updated)
}
fn submit_puzzle_leaderboard_entry_tx(
ctx: &TxContext,
input: PuzzleLeaderboardSubmitInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let mut run = deserialize_run(&row.snapshot_json)?;
let current_level = run
.current_level
.as_ref()
.ok_or_else(|| "拼图关卡不存在".to_string())?;
if input.profile_id.trim().is_empty() {
return Err("提交成绩的拼图作品不能为空".to_string());
}
if !module_puzzle::is_supported_puzzle_grid_size(input.grid_size) {
return Err("提交成绩的网格规格无效".to_string());
}
let matches_service_level =
current_level.profile_id == input.profile_id && current_level.grid_size == input.grid_size;
if current_level.profile_id == input.profile_id && current_level.grid_size != input.grid_size {
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
}
let current_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "提交成绩的拼图作品不存在".to_string())?;
if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
if current_profile_row.publication_status != PuzzlePublicationStatus::Published {
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
&input.profile_id,
input.grid_size,
);
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
return Ok(run);
}
let nickname = input.nickname.trim();
if nickname.is_empty() {
return Err("排行榜昵称不能为空".to_string());
}
upsert_puzzle_leaderboard_entry(
ctx,
&input.owner_user_id,
&input.profile_id,
input.grid_size,
nickname,
input.elapsed_ms.max(1_000),
&input.run_id,
input.submitted_at_micros,
);
add_profile_observed_play_time(
ctx,
&input.owner_user_id,
&format!("puzzle:{}", input.profile_id),
input.elapsed_ms.max(1_000),
input.submitted_at_micros,
)?;
let leaderboard_entries = list_puzzle_leaderboard_entries(
ctx,
&input.profile_id,
input.grid_size,
&input.owner_user_id,
10,
);
if matches_service_level {
if let Some(level) = run.current_level.as_mut() {
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
// 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。
level.status = PuzzleRuntimeLevelStatus::Cleared;
level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros));
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
}
run.cleared_level_count = run.cleared_level_count.max(run.current_level_index);
} else {
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
// 前端通过 local-next-level 推进到第二关后,服务端旧 run 可能仍停在上一关。
// 此时只返回真实榜单,前端会把榜单合并回当前本地关卡,不能用旧棋盘覆盖前端状态。
log::info!(
"puzzle leaderboard submitted for frontend-only level: run_id={}, service_profile_id={}, submitted_profile_id={}",
input.run_id,
current_level.profile_id,
input.profile_id
);
}
run.leaderboard_entries = leaderboard_entries;
refresh_next_level_handoff(ctx, &mut run)?;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) -> bool {
run.recommended_next_profile_id
.as_ref()
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|| run
.next_level_profile_id
.as_ref()
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|| run
.recommended_next_works
.iter()
.any(|candidate| candidate.profile_id == profile_id)
|| run
.played_profile_ids
.iter()
.any(|played_profile_id| played_profile_id == profile_id)
}
fn build_puzzle_agent_session_snapshot(
ctx: &TxContext,
row: &PuzzleAgentSessionRow,
) -> Result<PuzzleAgentSessionSnapshot, String> {
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
let draft = deserialize_optional_draft(&row.draft_json)?;
let messages = list_session_messages(ctx, &row.session_id);
let result_preview = draft
.as_ref()
.map(|value| build_result_preview(value, Some("陶泥儿主")));
Ok(PuzzleAgentSessionSnapshot {
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,
anchor_pack,
draft,
messages,
last_assistant_reply: row.last_assistant_reply.clone(),
published_profile_id: row.published_profile_id.clone(),
suggested_actions: build_puzzle_suggested_actions(row.stage),
result_preview,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
}
fn build_puzzle_work_profile_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
build_puzzle_work_profile_from_row_without_recent_count(row)
}
fn build_puzzle_work_profile_from_row_with_recent_count(
ctx: &TxContext,
row: &PuzzleWorkProfileRow,
now_micros: i64,
) -> Result<PuzzleWorkProfile, String> {
let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?;
profile.recent_play_count_7d =
count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros);
Ok(profile)
}
fn build_puzzle_work_profile_from_row_with_recent_counts(
row: &PuzzleWorkProfileRow,
recent_play_counts: &std::collections::HashMap<String, u32>,
) -> Result<PuzzleWorkProfile, String> {
let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?;
profile.recent_play_count_7d = recent_play_counts
.get(&row.profile_id)
.copied()
.unwrap_or(0);
Ok(profile)
}
fn build_puzzle_work_profile_from_row_without_recent_count(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
Ok(PuzzleWorkProfile {
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: if row.work_title.trim().is_empty() {
row.level_name.clone()
} else {
row.work_title.clone()
},
work_description: if row.work_description.trim().is_empty() {
row.summary.clone()
} else {
row.work_description.clone()
},
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags: deserialize_theme_tags(&row.theme_tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels: build_profile_levels_from_row(row)?,
publication_status: row.publication_status,
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()),
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
recent_play_count_7d: 0,
publish_ready: row.publish_ready,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
})
}
fn build_puzzle_gallery_card_view_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleGalleryCardViewRow, String> {
let levels = build_profile_levels_from_row(row)?;
Ok(PuzzleGalleryCardViewRow {
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: if row.work_title.trim().is_empty() {
row.level_name.clone()
} else {
row.work_title.clone()
},
work_description: if row.work_description.trim().is_empty() {
row.summary.clone()
} else {
row.work_description.clone()
},
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags: deserialize_theme_tags(&row.theme_tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
publication_status: row.publication_status,
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()),
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
publish_ready: row.publish_ready,
generation_status: resolve_puzzle_gallery_generation_status(&levels),
})
}
fn resolve_puzzle_gallery_generation_status(
levels: &[module_puzzle::PuzzleDraftLevel],
) -> Option<String> {
if levels.iter().any(has_completed_puzzle_level_image) {
return Some("ready".to_string());
}
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "generating")
.or_else(|| {
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| *status == "ready")
})
.or_else(|| {
levels
.iter()
.map(|level| level.generation_status.trim())
.find(|status| !status.is_empty())
})
.map(str::to_string)
}
fn build_profile_levels_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<Vec<module_puzzle::PuzzleDraftLevel>, String> {
let levels = deserialize_levels_json(&row.levels_json)?;
if !levels.is_empty() {
return Ok(levels);
}
Ok(vec![module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: row.level_name.clone(),
picture_description: row.summary.clone(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
generation_status: if row.cover_image_src.is_some() {
"ready".to_string()
} else {
"idle".to_string()
},
}])
}
fn selected_profile_level(
profile: &PuzzleWorkProfile,
level_id: Option<&str>,
) -> Result<Option<module_puzzle::PuzzleDraftLevel>, String> {
let Some(level_id) = level_id.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}) else {
return Ok(None);
};
profile
.levels
.iter()
.find(|level| level.level_id == level_id)
.cloned()
.map(Some)
.ok_or_else(|| "入口拼图关卡不存在".to_string())
}
fn profile_for_single_level(
profile: &PuzzleWorkProfile,
level: &module_puzzle::PuzzleDraftLevel,
) -> PuzzleWorkProfile {
let mut next_profile = profile.clone();
next_profile.level_name = level.level_name.clone();
next_profile.cover_image_src = level.cover_image_src.clone();
next_profile.cover_asset_id = level.cover_asset_id.clone();
next_profile.levels = vec![level.clone()];
next_profile
}
fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) {
let stable_suffix = session_id
.strip_prefix("puzzle-session-")
.unwrap_or(session_id);
(
format!("puzzle-work-{stable_suffix}"),
format!("puzzle-profile-{stable_suffix}"),
)
}
fn micros_to_millis(value: i64) -> u64 {
if value <= 0 {
return 0;
}
(value as u64).saturating_div(1_000)
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
owner_user_id: &str,
draft: &PuzzleResultDraft,
updated_at_micros: i64,
) -> Result<(), String> {
let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(session_id);
if let Some(existing) = ctx.db.puzzle_work_profile().profile_id().find(&profile_id) {
if existing.publication_status == PuzzlePublicationStatus::Published {
return Ok(());
}
let mut profile = create_work_profile(
work_id,
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
existing.author_display_name.clone(),
draft,
updated_at_micros,
)
.map_err(|error| error.to_string())?;
profile.play_count = existing.play_count;
profile.remix_count = existing.remix_count;
profile.like_count = existing.like_count;
profile.point_incentive_total_half_points = existing.point_incentive_total_half_points;
profile.point_incentive_claimed_points = existing.point_incentive_claimed_points;
return upsert_puzzle_work_profile(ctx, profile);
}
let profile = create_work_profile(
work_id,
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"陶泥儿主".to_string(),
draft,
updated_at_micros,
)
.map_err(|error| error.to_string())?;
upsert_puzzle_work_profile(ctx, profile)
}
fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec<PuzzleAgentMessageSnapshot> {
let mut items = ctx
.db
.puzzle_agent_message()
.by_puzzle_agent_message_session_id()
.filter(&session_id.to_string())
.map(|message| PuzzleAgentMessageSnapshot {
message_id: message.message_id.clone(),
session_id: message.session_id.clone(),
role: message.role,
kind: message.kind,
text: message.text.clone(),
created_at_micros: message.created_at.to_micros_since_unix_epoch(),
})
.collect::<Vec<_>>();
items.sort_by(|left, right| left.created_at_micros.cmp(&right.created_at_micros));
items
}
fn build_puzzle_suggested_actions(
stage: PuzzleAgentStage,
) -> Vec<module_puzzle::PuzzleAgentSuggestedAction> {
match stage {
PuzzleAgentStage::CollectingAnchors => vec![module_puzzle::PuzzleAgentSuggestedAction {
id: "compile-draft".to_string(),
action_type: "compile_puzzle_draft".to_string(),
label: "进入结果页".to_string(),
}],
PuzzleAgentStage::DraftReady | PuzzleAgentStage::ImageRefining => vec![
module_puzzle::PuzzleAgentSuggestedAction {
id: "generate-images".to_string(),
action_type: "generate_puzzle_images".to_string(),
label: "生成候选图".to_string(),
},
module_puzzle::PuzzleAgentSuggestedAction {
id: "publish-work".to_string(),
action_type: "publish_puzzle_work".to_string(),
label: "发布作品".to_string(),
},
],
PuzzleAgentStage::ReadyToPublish => vec![module_puzzle::PuzzleAgentSuggestedAction {
id: "publish-work".to_string(),
action_type: "publish_puzzle_work".to_string(),
label: "发布作品".to_string(),
}],
PuzzleAgentStage::Published => Vec::new(),
}
}
fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> {
if ctx
.db
.puzzle_agent_session()
.session_id()
.find(&session_id.to_string())
.is_some()
{
return Err("拼图 session 已存在".to_string());
}
Ok(())
}
fn ensure_message_missing(ctx: &TxContext, message_id: &str) -> Result<(), String> {
if ctx
.db
.puzzle_agent_message()
.message_id()
.find(&message_id.to_string())
.is_some()
{
return Err("拼图消息已存在".to_string());
}
Ok(())
}
fn get_owned_session_row(
ctx: &TxContext,
session_id: &str,
owner_user_id: &str,
) -> Result<PuzzleAgentSessionRow, String> {
let row = ctx
.db
.puzzle_agent_session()
.session_id()
.find(&session_id.to_string())
.ok_or_else(|| "拼图 session 不存在".to_string())?;
if row.owner_user_id != owner_user_id {
return Err("无权访问该拼图 session".to_string());
}
Ok(row)
}
fn get_owned_run_row(
ctx: &TxContext,
run_id: &str,
owner_user_id: &str,
) -> Result<PuzzleRuntimeRunRow, String> {
let row = ctx
.db
.puzzle_runtime_run()
.run_id()
.find(&run_id.to_string())
.ok_or_else(|| "拼图 run 不存在".to_string())?;
if row.owner_user_id != owner_user_id {
return Err("无权访问该拼图 run".to_string());
}
Ok(row)
}
fn replace_puzzle_agent_session(
ctx: &TxContext,
current: &PuzzleAgentSessionRow,
next: PuzzleAgentSessionRow,
) {
ctx.db
.puzzle_agent_session()
.session_id()
.delete(&current.session_id);
ctx.db.puzzle_agent_session().insert(next);
}
fn replace_puzzle_work_profile(
ctx: &TxContext,
current: &PuzzleWorkProfileRow,
next: PuzzleWorkProfileRow,
) {
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&current.profile_id);
ctx.db.puzzle_work_profile().insert(next);
}
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
let levels = normalize_completed_puzzle_levels_generation_status(profile.levels);
if let Some(existing) = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile.profile_id)
{
replace_puzzle_work_profile(
ctx,
&existing,
PuzzleWorkProfileRow {
profile_id: profile.profile_id,
work_id: profile.work_id,
owner_user_id: profile.owner_user_id,
source_session_id: profile.source_session_id,
author_display_name: profile.author_display_name,
work_title: profile.work_title,
work_description: profile.work_description,
level_name: profile.level_name,
summary: profile.summary,
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&levels),
publication_status: profile.publication_status,
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
play_count: existing.play_count.max(profile.play_count),
remix_count: existing.remix_count.max(profile.remix_count),
like_count: existing.like_count.max(profile.like_count),
point_incentive_total_half_points: existing
.point_incentive_total_half_points
.max(profile.point_incentive_total_half_points),
point_incentive_claimed_points: existing
.point_incentive_claimed_points
.max(profile.point_incentive_claimed_points),
anchor_pack_json: serialize_json(&profile.anchor_pack),
publish_ready: profile.publish_ready,
created_at: existing.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros),
published_at: profile
.published_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
},
);
return Ok(());
}
ctx.db.puzzle_work_profile().insert(PuzzleWorkProfileRow {
profile_id: profile.profile_id,
work_id: profile.work_id,
owner_user_id: profile.owner_user_id,
source_session_id: profile.source_session_id,
author_display_name: profile.author_display_name,
work_title: profile.work_title,
work_description: profile.work_description,
level_name: profile.level_name,
summary: profile.summary,
theme_tags_json: serialize_json(&profile.theme_tags),
cover_image_src: profile.cover_image_src,
cover_asset_id: profile.cover_asset_id,
levels_json: serialize_json(&levels),
publication_status: profile.publication_status,
play_count: profile.play_count,
remix_count: profile.remix_count,
like_count: profile.like_count,
point_incentive_total_half_points: profile.point_incentive_total_half_points,
point_incentive_claimed_points: profile.point_incentive_claimed_points,
anchor_pack_json: serialize_json(&profile.anchor_pack),
publish_ready: profile.publish_ready,
created_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros),
updated_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros),
published_at: profile
.published_at_micros
.map(Timestamp::from_micros_since_unix_epoch),
});
Ok(())
}
fn emit_puzzle_work_published_event(
ctx: &TxContext,
profile: &PuzzleWorkProfile,
occurred_at_micros: i64,
) {
ctx.db.puzzle_event().insert(PuzzleEvent {
event_id: format!(
"pzevt_{}_{}_published",
profile.profile_id, occurred_at_micros
),
profile_id: profile.profile_id.clone(),
work_id: profile.work_id.clone(),
session_id: profile.source_session_id.clone(),
owner_user_id: profile.owner_user_id.clone(),
event_kind: PuzzleEventKind::WorkPublished,
occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_micros),
});
}
fn insert_puzzle_runtime_run(
ctx: &TxContext,
run: &PuzzleRunSnapshot,
owner_user_id: &str,
created_at_micros: i64,
) -> Result<(), String> {
let timestamp = Timestamp::from_micros_since_unix_epoch(created_at_micros);
let current_profile_id = run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
.ok_or_else(|| "拼图关卡不存在".to_string())?;
ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow {
run_id: run.run_id.clone(),
owner_user_id: owner_user_id.to_string(),
entry_profile_id: run.entry_profile_id.clone(),
current_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids_json: serialize_json(&run.played_profile_ids),
previous_level_tags_json: serialize_json(&run.previous_level_tags),
snapshot_json: serialize_json(run),
created_at: timestamp,
updated_at: timestamp,
});
upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?;
Ok(())
}
fn replace_puzzle_runtime_run(
ctx: &TxContext,
current: &PuzzleRuntimeRunRow,
run: &PuzzleRunSnapshot,
updated_at_micros: i64,
) {
ctx.db.puzzle_runtime_run().run_id().delete(&current.run_id);
ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow {
run_id: run.run_id.clone(),
owner_user_id: current.owner_user_id.clone(),
entry_profile_id: run.entry_profile_id.clone(),
current_profile_id: run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
.unwrap_or_else(|| current.current_profile_id.clone()),
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids_json: serialize_json(&run.played_profile_ids),
previous_level_tags_json: serialize_json(&run.previous_level_tags),
snapshot_json: serialize_json(run),
created_at: current.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
if let Err(error) =
upsert_puzzle_profile_save_archive(ctx, run, &current.owner_user_id, updated_at_micros)
{
log::warn!("拼图存档投影同步失败: {}", error);
}
}
fn upsert_puzzle_profile_save_archive(
ctx: &TxContext,
run: &PuzzleRunSnapshot,
user_id: &str,
saved_at_micros: i64,
) -> Result<(), String> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(());
}
let Some(current_level) = run.current_level.as_ref() else {
return Ok(());
};
let world_key = format!("puzzle:{}", run.entry_profile_id);
let target = resolve_puzzle_archive_target(ctx, run, current_level)?;
let work_title = resolve_puzzle_archive_work_title(ctx, &target.profile_id, &target.level_name);
let subtitle = build_puzzle_archive_subtitle(target.level_index, &target.level_name);
// 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。
let game_state_json = json_to_string(&json!({
"runtimeKind": "puzzle",
"runId": run.run_id,
"entryProfileId": run.entry_profile_id,
"currentProfileId": target.profile_id.clone(),
"currentLevelIndex": target.level_index,
"currentLevelId": target.level_id.clone(),
"status": target.status.as_str(),
}))
.unwrap_or_else(|_| "{}".to_string());
upsert_profile_save_archive(
ctx,
ProfileSaveArchiveUpsertInput {
user_id: user_id.to_string(),
world_key,
owner_user_id: target.owner_user_id,
profile_id: Some(run.entry_profile_id.clone()),
world_type: Some("PUZZLE".to_string()),
world_name: work_title,
subtitle,
summary_text: puzzle_archive_summary_text(target.status),
cover_image_src: target.cover_image_src,
bottom_tab: "puzzle".to_string(),
game_state_json,
current_story_json: None,
saved_at_micros,
},
)
}
struct PuzzleArchiveTarget {
profile_id: String,
level_index: u32,
level_id: Option<String>,
level_name: String,
status: PuzzleRuntimeLevelStatus,
cover_image_src: Option<String>,
owner_user_id: Option<String>,
}
fn resolve_puzzle_archive_target(
ctx: &TxContext,
run: &PuzzleRunSnapshot,
current_level: &module_puzzle::PuzzleRuntimeLevelSnapshot,
) -> Result<PuzzleArchiveTarget, String> {
// 中文注释:通关后若已经算出同作品下一关,存档页直接投影到下一关入口;
// 跨作品候选需要玩家选择,不能在存档里提前替玩家切换作品。
let owner_user_id = resolve_puzzle_current_owner_user_id(ctx, &current_level.profile_id);
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Ok(PuzzleArchiveTarget {
profile_id: current_level.profile_id.clone(),
level_index: current_level.level_index,
level_id: current_level.level_id.clone(),
level_name: current_level.level_name.clone(),
status: current_level.status,
cover_image_src: current_level.cover_image_src.clone(),
owner_user_id,
});
}
let Some(next_level_id) = run
.next_level_id
.as_deref()
.filter(|value| !value.trim().is_empty())
else {
return Ok(PuzzleArchiveTarget {
profile_id: current_level.profile_id.clone(),
level_index: current_level.level_index,
level_id: current_level.level_id.clone(),
level_name: current_level.level_name.clone(),
status: current_level.status,
cover_image_src: current_level.cover_image_src.clone(),
owner_user_id,
});
};
if run.next_level_profile_id.as_deref() != Some(current_level.profile_id.as_str())
|| run.next_level_mode != PUZZLE_NEXT_LEVEL_MODE_SAME_WORK
{
return Ok(PuzzleArchiveTarget {
profile_id: current_level.profile_id.clone(),
level_index: current_level.level_index,
level_id: current_level.level_id.clone(),
level_name: current_level.level_name.clone(),
status: current_level.status,
cover_image_src: current_level.cover_image_src.clone(),
owner_user_id,
});
}
let current_profile = build_puzzle_work_profile_from_row(
&ctx.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
let next_level = current_profile
.levels
.iter()
.find(|level| level.level_id == next_level_id)
.cloned()
.ok_or_else(|| "下一关拼图关卡不存在".to_string())?;
Ok(PuzzleArchiveTarget {
profile_id: current_profile.profile_id,
level_index: current_level.level_index.saturating_add(1),
level_id: Some(next_level.level_id),
level_name: next_level.level_name,
status: PuzzleRuntimeLevelStatus::Playing,
cover_image_src: next_level.cover_image_src,
owner_user_id,
})
}
fn resolve_puzzle_archive_work_title(
ctx: &TxContext,
profile_id: &str,
fallback_level_name: &str,
) -> String {
// 中文注释:存档主标题必须是作品名;历史数据或异常行缺失作品名时才回退到关卡名。
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.map(|row| {
let title = row.work_title.trim();
if title.is_empty() {
fallback_level_name.to_string()
} else {
title.to_string()
}
})
.unwrap_or_else(|| fallback_level_name.to_string())
}
fn build_puzzle_archive_subtitle(level_index: u32, level_name: &str) -> String {
let level_label = format!("{level_index}");
let level_name = level_name.trim();
if level_name.is_empty() {
level_label
} else {
format!("{level_label} · {level_name}")
}
}
fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option<String> {
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.map(|row| row.owner_user_id)
}
fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String {
match status {
PuzzleRuntimeLevelStatus::Cleared => "关卡已完成",
PuzzleRuntimeLevelStatus::Failed => "关卡失败",
PuzzleRuntimeLevelStatus::Playing => "拼图进行中",
}
.to_string()
}
fn accrue_puzzle_point_incentive(
ctx: &TxContext,
profile_id: &str,
player_user_id: &str,
spent_points: u64,
updated_at_micros: i64,
) -> Result<(), String> {
if spent_points == 0 {
return Ok(());
}
let Some(row) = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
else {
return Ok(());
};
if row.publication_status != PuzzlePublicationStatus::Published
|| row.owner_user_id == player_user_id
{
return Ok(());
}
replace_puzzle_work_profile(
ctx,
&row,
PuzzleWorkProfileRow {
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(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points:
module_puzzle::puzzle_point_incentive_total_after_spend(
row.point_incentive_total_half_points,
spent_points,
),
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
published_at: row.published_at,
},
);
Ok(())
}
fn increment_puzzle_profile_play_count(
ctx: &TxContext,
row: &PuzzleWorkProfileRow,
updated_at_micros: i64,
) {
replace_puzzle_work_profile(
ctx,
row,
PuzzleWorkProfileRow {
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(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags_json: row.theme_tags_json.clone(),
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
levels_json: row.levels_json.clone(),
publication_status: row.publication_status,
play_count: row.play_count.saturating_add(1),
remix_count: row.remix_count,
like_count: row.like_count,
point_incentive_total_half_points: row.point_incentive_total_half_points,
point_incentive_claimed_points: row.point_incentive_claimed_points,
anchor_pack_json: row.anchor_pack_json.clone(),
publish_ready: row.publish_ready,
created_at: row.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
published_at: row.published_at,
},
);
}
fn upsert_puzzle_profile_played_work(
ctx: &TxContext,
user_id: &str,
row: &PuzzleWorkProfileRow,
played_at_micros: i64,
) -> Result<(), String> {
// 拼图正式游玩以作品 profile_id 作为公开作品号,用户侧明细按 world_key 去重。
upsert_profile_played_work(
ctx,
ProfilePlayedWorkUpsertInput {
user_id: user_id.to_string(),
world_key: format!("puzzle:{}", row.profile_id),
owner_user_id: Some(row.owner_user_id.clone()),
profile_id: Some(row.profile_id.clone()),
world_type: Some("PUZZLE".to_string()),
world_title: row.level_name.clone(),
world_subtitle: row.summary.clone(),
played_at_micros,
},
)
}
fn replace_generated_candidate(
candidates_slot: &mut Vec<PuzzleGeneratedImageCandidate>,
candidates: Vec<PuzzleGeneratedImageCandidate>,
) {
// 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。
*candidates_slot = candidates
.into_iter()
.take(1)
.map(|mut candidate| {
candidate.selected = true;
candidate
})
.collect();
}
fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
ctx.db
.puzzle_work_profile()
.by_puzzle_work_publication_status()
.filter(PuzzlePublicationStatus::Published)
.map(|row| build_puzzle_work_profile_from_row(&row))
.collect()
}
fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) {
run.recommended_next_profile_id = None;
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string();
run.next_level_profile_id = None;
run.next_level_id = None;
run.recommended_next_works = Vec::new();
}
fn build_recommended_next_work(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
) -> PuzzleRecommendedNextWork {
PuzzleRecommendedNextWork {
profile_id: candidate.profile_id.clone(),
level_name: candidate.level_name.clone(),
author_display_name: candidate.author_display_name.clone(),
theme_tags: candidate.theme_tags.clone(),
cover_image_src: candidate.cover_image_src.clone(),
similarity_score: tag_similarity_score(&current_profile.theme_tags, &candidate.theme_tags),
}
}
fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> {
let current_level = match run.current_level.as_ref() {
Some(value) => value,
None => {
reset_next_level_handoff(run);
return Ok(());
}
};
let current_profile = build_puzzle_work_profile_from_row(
&ctx.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
reset_next_level_handoff(run);
return Ok(());
}
if let Some(next_level) =
selected_profile_level_after_runtime_level(&current_profile, current_level)
{
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string();
run.next_level_profile_id = Some(current_profile.profile_id.clone());
run.next_level_id = Some(next_level.level_id);
run.recommended_next_profile_id = Some(current_profile.profile_id.clone());
run.recommended_next_works = Vec::new();
return Ok(());
}
let candidates = list_published_puzzle_profiles(ctx)?;
let recommended_next_works =
select_next_profiles(&current_profile, &run.played_profile_ids, &candidates, 3)
.into_iter()
.map(|candidate| build_recommended_next_work(&current_profile, candidate))
.collect::<Vec<_>>();
if recommended_next_works.is_empty() {
reset_next_level_handoff(run);
return Ok(());
}
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
run.next_level_profile_id = recommended_next_works
.first()
.map(|candidate| candidate.profile_id.clone());
run.next_level_id = None;
run.recommended_next_profile_id = run.next_level_profile_id.clone();
run.recommended_next_works = recommended_next_works;
Ok(())
}
fn hydrate_puzzle_leaderboard_entries(
ctx: &TxContext,
run: &mut PuzzleRunSnapshot,
current_user_id: &str,
profile_id: &str,
grid_size: u32,
) {
let leaderboard_entries =
list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10);
run.leaderboard_entries = leaderboard_entries.clone();
if let Some(level) = run.current_level.as_mut() {
level.leaderboard_entries = leaderboard_entries;
}
}
fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String {
format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}")
}
fn upsert_puzzle_leaderboard_entry(
ctx: &TxContext,
user_id: &str,
profile_id: &str,
grid_size: u32,
nickname: &str,
elapsed_ms: u64,
run_id: &str,
updated_at_micros: i64,
) {
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
if let Some(existing) = ctx.db.puzzle_leaderboard_entry().entry_id().find(&entry_id) {
let should_replace = elapsed_ms < existing.best_elapsed_ms
|| (elapsed_ms == existing.best_elapsed_ms
&& updated_at.to_micros_since_unix_epoch()
< existing.updated_at.to_micros_since_unix_epoch());
let next_row = PuzzleLeaderboardEntryRow {
entry_id: existing.entry_id.clone(),
profile_id: existing.profile_id.clone(),
grid_size: existing.grid_size,
user_id: existing.user_id.clone(),
nickname: nickname.to_string(),
best_elapsed_ms: if should_replace {
elapsed_ms
} else {
existing.best_elapsed_ms
},
last_run_id: if should_replace {
run_id.to_string()
} else {
existing.last_run_id.clone()
},
updated_at,
};
ctx.db
.puzzle_leaderboard_entry()
.entry_id()
.delete(&existing.entry_id);
ctx.db.puzzle_leaderboard_entry().insert(next_row);
return;
}
ctx.db
.puzzle_leaderboard_entry()
.insert(PuzzleLeaderboardEntryRow {
entry_id,
profile_id: profile_id.to_string(),
grid_size,
user_id: user_id.to_string(),
nickname: nickname.to_string(),
best_elapsed_ms: elapsed_ms,
last_run_id: run_id.to_string(),
updated_at,
});
}
fn list_puzzle_leaderboard_entries(
ctx: &TxContext,
profile_id: &str,
grid_size: u32,
current_user_id: &str,
limit: usize,
) -> Vec<PuzzleLeaderboardEntry> {
let mut rows = ctx
.db
.puzzle_leaderboard_entry()
.by_puzzle_leaderboard_profile_grid()
.filter((profile_id, grid_size))
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.best_elapsed_ms
.cmp(&right.best_elapsed_ms)
.then_with(|| left.updated_at.cmp(&right.updated_at))
.then_with(|| left.user_id.cmp(&right.user_id))
});
rows.into_iter()
.take(limit)
.enumerate()
.map(|(index, row)| PuzzleLeaderboardEntry {
visible_tags: ctx
.db
.user_account()
.user_id()
.find(&row.user_id)
.map(|account| {
visible_runtime_profile_user_tags(
account.user_tags.as_deref().unwrap_or_default(),
)
})
.unwrap_or_default(),
rank: index as u32 + 1,
nickname: row.nickname,
elapsed_ms: row.best_elapsed_ms,
is_current_player: row.user_id == current_user_id,
})
.collect()
}
fn serialize_json<T: ::serde::Serialize>(value: &T) -> String {
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
}
fn deserialize_anchor_pack(value: &str) -> Result<PuzzleAnchorPack, String> {
json_from_str(value).map_err(|error| format!("拼图 anchor_pack JSON 非法: {error}"))
}
fn deserialize_optional_draft(value: &Option<String>) -> Result<Option<PuzzleResultDraft>, String> {
value
.as_ref()
.map(|raw| {
json_from_str(raw)
.map(normalize_puzzle_draft)
.map_err(|error| format!("拼图 draft JSON 非法: {error}"))
})
.transpose()
}
fn deserialize_draft_required(value: &Option<String>) -> Result<PuzzleResultDraft, String> {
deserialize_optional_draft(value)?.ok_or_else(|| "拼图 draft 尚未生成".to_string())
}
fn deserialize_theme_tags(value: &str) -> Result<Vec<String>, String> {
json_from_str(value).map_err(|error| format!("拼图 theme_tags JSON 非法: {error}"))
}
fn deserialize_levels_json(value: &str) -> Result<Vec<module_puzzle::PuzzleDraftLevel>, String> {
if value.trim().is_empty() {
return Ok(Vec::new());
}
json_from_str(value)
.map(normalize_completed_puzzle_levels_generation_status)
.map_err(|error| format!("拼图 levels JSON 非法: {error}"))
}
fn deserialize_optional_levels_input(
value: Option<&str>,
) -> Result<Option<Vec<module_puzzle::PuzzleDraftLevel>>, String> {
value
.map(|raw| deserialize_levels_json(raw))
.transpose()
.map(|levels| levels.filter(|items| !items.is_empty()))
}
fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
json_from_str(value).map_err(|error| format!("拼图 run snapshot JSON 非法: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use module_puzzle::{
PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft,
empty_anchor_pack, recommendation_score, tag_similarity_score,
};
#[test]
fn puzzle_json_round_trip_keeps_snapshot_shape() {
let snapshot = PuzzleRunSnapshot {
run_id: "run-1".to_string(),
entry_profile_id: "profile-1".to_string(),
cleared_level_count: 0,
current_level_index: 1,
current_grid_size: 3,
played_profile_ids: vec!["profile-1".to_string()],
previous_level_tags: vec!["蒸汽城市".to_string()],
current_level: None,
recommended_next_profile_id: None,
next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
};
let serialized = serialize_json(&snapshot);
let parsed = deserialize_run(&serialized).expect("run json should parse");
assert_eq!(parsed.run_id, "run-1");
}
#[test]
fn puzzle_preview_is_publishable_with_complete_draft() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
let draft = compile_result_draft(&anchor_pack, &[]);
let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000_000)
.expect("candidates should build");
let draft = apply_selected_candidate(
PuzzleResultDraft {
levels: vec![module_puzzle::PuzzleDraftLevel {
level_id: "puzzle-level-1".to_string(),
level_name: draft.level_name.clone(),
picture_description: draft
.levels
.first()
.map(|level| level.picture_description.clone())
.unwrap_or_default(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: candidates.clone(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
}],
candidates,
..draft
},
"session-1-candidate-1",
)
.expect("draft should select");
let preview = build_result_preview(&draft, Some("作者"));
assert!(preview.publish_ready);
}
#[test]
fn puzzle_generated_images_replace_existing_candidate() {
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.candidates = vec![PuzzleGeneratedImageCandidate {
candidate_id: "session-1-candidate-1".to_string(),
image_src: "/generated-puzzle-assets/session-1/old/cover.png".to_string(),
asset_id: "asset-old".to_string(),
prompt: "旧提示词".to_string(),
actual_prompt: Some("旧提示词".to_string()),
source_type: "generated".to_string(),
selected: true,
}];
replace_generated_candidate(
&mut draft.candidates,
vec![PuzzleGeneratedImageCandidate {
candidate_id: "session-1-candidate-2".to_string(),
image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(),
asset_id: "asset-new".to_string(),
prompt: "新提示词".to_string(),
actual_prompt: Some("新提示词".to_string()),
source_type: "generated".to_string(),
selected: true,
}],
);
assert_eq!(draft.candidates.len(), 1);
assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-2");
assert!(draft.candidates[0].selected);
}
#[test]
fn generated_first_level_name_defaults_work_title_when_previous_title_is_fallback() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let mut draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
let previous_level_name = draft.level_name.clone();
let previous_work_title = draft.work_title.clone();
draft.levels[0].level_name = "雨夜猫街".to_string();
module_puzzle::sync_primary_level_fields(&mut draft);
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_level_name,
);
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "雨夜猫街");
}
#[test]
fn generated_first_level_name_keeps_manual_work_title() {
let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None);
let mut draft = compile_result_draft_from_seed(
&anchor_pack,
&[],
Some("画面描述:一只猫在雨夜灯牌下回头。"),
);
let previous_level_name = draft.level_name.clone();
let previous_work_title = "我的猫街合集".to_string();
draft.work_title = previous_work_title.clone();
draft.levels[0].level_name = "雨夜猫街".to_string();
module_puzzle::sync_primary_level_fields(&mut draft);
sync_generated_primary_level_name_as_default_work_title(
&mut draft,
&previous_work_title,
&previous_level_name,
);
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "我的猫街合集");
}
#[test]
fn puzzle_recommendation_score_prefers_same_author_weight() {
let left = PuzzleWorkProfile {
work_id: "work-a".to_string(),
profile_id: "profile-a".to_string(),
owner_user_id: "owner-a".to_string(),
source_session_id: None,
author_display_name: "作者".to_string(),
work_title: "A".to_string(),
work_description: String::new(),
level_name: "A".to_string(),
summary: String::new(),
theme_tags: vec!["雨夜".to_string(), "猫咪".to_string()],
cover_image_src: Some("/a.png".to_string()),
cover_asset_id: Some("asset-a".to_string()),
levels: Vec::new(),
publication_status: PuzzlePublicationStatus::Published,
updated_at_micros: 1,
published_at_micros: Some(1),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
};
let right = PuzzleWorkProfile {
owner_user_id: "owner-a".to_string(),
profile_id: "profile-b".to_string(),
work_id: "work-b".to_string(),
work_title: "B".to_string(),
work_description: String::new(),
level_name: "B".to_string(),
theme_tags: vec!["雨夜".to_string(), "蒸汽城市".to_string()],
cover_image_src: Some("/b.png".to_string()),
cover_asset_id: Some("asset-b".to_string()),
levels: Vec::new(),
publication_status: PuzzlePublicationStatus::Published,
updated_at_micros: 2,
published_at_micros: Some(2),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
source_session_id: None,
author_display_name: "作者".to_string(),
summary: String::new(),
};
assert!(
recommendation_score(&left, &right)
> tag_similarity_score(&left.theme_tags, &right.theme_tags)
);
}
#[test]
fn puzzle_leaderboard_entries_sort_by_elapsed_time() {
let mut entries = vec![
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 B".to_string(),
elapsed_ms: 5200,
visible_tags: Vec::new(),
is_current_player: false,
},
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 A".to_string(),
elapsed_ms: 3100,
visible_tags: Vec::new(),
is_current_player: true,
},
];
entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms));
for (index, entry) in entries.iter_mut().enumerate() {
entry.rank = index as u32 + 1;
}
assert_eq!(entries[0].nickname, "玩家 A");
assert_eq!(entries[0].rank, 1);
assert_eq!(entries[1].nickname, "玩家 B");
assert_eq!(entries[1].rank, 2);
}
}