3503 lines
125 KiB
Rust
3503 lines
125 KiB
Rust
use crate::runtime::{
|
||
ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput,
|
||
PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays,
|
||
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, 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 serde_json::from_str as json_from_str;
|
||
use serde_json::json;
|
||
use serde_json::to_string as json_to_string;
|
||
use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext};
|
||
|
||
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,
|
||
}
|
||
|
||
/// 拼图创作事件类型。
|
||
///
|
||
/// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以
|
||
/// `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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn select_puzzle_cover_image(
|
||
ctx: &mut ProcedureContext,
|
||
input: PuzzleSelectCoverImageInput,
|
||
) -> PuzzleAgentSessionProcedureResult {
|
||
match ctx.try_with_tx(|tx| select_puzzle_cover_image_tx(tx, input.clone())) {
|
||
Ok(session) => PuzzleAgentSessionProcedureResult {
|
||
ok: true,
|
||
session_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: None,
|
||
error_message: Some(message),
|
||
},
|
||
}
|
||
}
|
||
|
||
#[spacetimedb::procedure]
|
||
pub fn 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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&items)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorksProcedureResult {
|
||
ok: false,
|
||
items_json: None,
|
||
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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&items)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorksProcedureResult {
|
||
ok: false,
|
||
items_json: None,
|
||
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_json: Some(serialize_json(&items)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorksProcedureResult {
|
||
ok: false,
|
||
items_json: None,
|
||
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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&session)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleAgentSessionProcedureResult {
|
||
ok: false,
|
||
session_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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_json: Some(serialize_json(&item)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleWorkProcedureResult {
|
||
ok: false,
|
||
item_json: 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_json: Some(serialize_json(&run)),
|
||
error_message: None,
|
||
},
|
||
Err(message) => PuzzleRunProcedureResult {
|
||
ok: false,
|
||
run_json: 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;
|
||
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())?;
|
||
|
||
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 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 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,
|
||
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()
|
||
.iter()
|
||
.filter(|row| row.owner_user_id == 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 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());
|
||
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()
|
||
.iter()
|
||
.filter(|message| message.session_id == *session_id)
|
||
.collect::<Vec<_>>()
|
||
{
|
||
ctx.db
|
||
.puzzle_agent_message()
|
||
.message_id()
|
||
.delete(&message.message_id);
|
||
}
|
||
}
|
||
for run in ctx
|
||
.db
|
||
.puzzle_runtime_run()
|
||
.iter()
|
||
.filter(|run| {
|
||
run.owner_user_id == input.owner_user_id && 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 mut items = ctx
|
||
.db
|
||
.puzzle_work_profile()
|
||
.iter()
|
||
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
|
||
.map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros))
|
||
.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,
|
||
¤t_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(
|
||
¤t_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(
|
||
¤t_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(¤t_level.profile_id)
|
||
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
|
||
let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?;
|
||
let same_work_next_profile =
|
||
selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||
.map(|level| profile_for_single_level(¤t_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(
|
||
¤t_profile,
|
||
¤t_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(
|
||
¤t_run,
|
||
next_profile,
|
||
micros_to_millis(input.advanced_at_micros),
|
||
)
|
||
} else {
|
||
module_puzzle::advance_to_new_work_first_level_at(
|
||
¤t_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(
|
||
¤t_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(
|
||
¤t_run,
|
||
micros_to_millis(input.used_at_micros),
|
||
)
|
||
.map_err(|error| error.to_string())?,
|
||
"extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at(
|
||
¤t_run,
|
||
micros_to_millis(input.used_at_micros),
|
||
)
|
||
.map_err(|error| error.to_string())?,
|
||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||
¤t_run,
|
||
false,
|
||
micros_to_millis(input.used_at_micros),
|
||
)
|
||
.map_err(|error| error.to_string())?,
|
||
"reference" => module_puzzle::set_puzzle_run_paused_at(
|
||
¤t_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_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_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(),
|
||
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()
|
||
.iter()
|
||
.filter(|message| message.session_id == session_id)
|
||
.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(¤t.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(¤t.profile_id);
|
||
ctx.db.puzzle_work_profile().insert(next);
|
||
}
|
||
|
||
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
|
||
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(&profile.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(&profile.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(¤t.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, ¤t.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, ¤t_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(¤t_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()
|
||
.iter()
|
||
.filter(|row| row.publication_status == 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(¤t_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(¤t_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(¤t_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(¤t_profile, &run.played_profile_ids, &candidates, 3)
|
||
.into_iter()
|
||
.map(|candidate| build_recommended_next_work(¤t_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()
|
||
.iter()
|
||
.filter(|row| row.profile_id == profile_id && row.grid_size == 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 {
|
||
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_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(),
|
||
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,
|
||
is_current_player: false,
|
||
},
|
||
PuzzleLeaderboardEntry {
|
||
rank: 0,
|
||
nickname: "玩家 A".to_string(),
|
||
elapsed_ms: 3100,
|
||
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);
|
||
}
|
||
}
|