Files
Genarrative/server-rs/crates/spacetime-module/src/puzzle.rs

2065 lines
71 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot,
PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext};
/// 拼图 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,
level_name: String,
summary: String,
theme_tags_json: String,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
publication_status: PuzzlePublicationStatus,
play_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
}
/// 拼图创作事件类型。
///
/// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以
/// `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),
},
}
}
#[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 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 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));
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: None,
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,
});
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)?;
let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?;
let messages = list_session_messages(ctx, &row.session_id);
let draft = compile_result_draft(&anchor_pack, &messages);
// 创作中心的拼图草稿卡只是 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_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 candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
if candidates.is_empty() {
return Err("拼图候选图不能为空".to_string());
}
replace_generated_candidate(&mut draft, candidates);
draft.generation_status = "ready".to_string();
if let Some(selected) = draft
.candidates
.iter()
.find(|entry| entry.selected)
.cloned()
{
draft.selected_candidate_id = Some(selected.candidate_id);
draft.cover_image_src = Some(selected.image_src);
draft.cover_asset_id = Some(selected.asset_id);
}
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 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 draft =
apply_selected_candidate(draft, &input.candidate_id).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.level_name.clone(),
input.summary.clone(),
input.theme_tags.clone(),
)
.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.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
return Err("拼图标签数量不合法".to_string());
}
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(),
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,
publication_status: row.publication_status,
play_count: row.play_count,
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(input.updated_at_micros),
published_at: row.published_at,
};
replace_puzzle_work_profile(ctx, &row, next_row);
get_puzzle_work_detail_tx(
ctx,
PuzzleWorkGetInput {
profile_id: input.profile_id,
},
)
}
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 mut items = ctx
.db
.puzzle_work_profile()
.iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.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_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(&row)
}
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 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 run =
start_run(input.run_id.clone(), &entry_profile, 0).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,
);
run.recommended_next_profile_id = select_next_profile(
&entry_profile,
&run.played_profile_ids,
&list_published_puzzle_profiles(ctx)?,
)
.map(|value| value.profile_id.clone());
if entry_profile_row.publication_status == PuzzlePublicationStatus::Published {
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 mut run = deserialize_run(&row.snapshot_json)?;
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 = swap_pieces(&current_run, &input.first_piece_id, &input.second_piece_id)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
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(
&current_run,
&input.piece_id,
input.target_row,
input.target_col,
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
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 = build_puzzle_work_profile_from_row(
&ctx.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
let candidates = list_published_puzzle_profiles(ctx)?;
let next_profile = select_next_profile(
&current_profile,
&current_run.played_profile_ids,
&candidates,
)
.ok_or_else(|| "没有可用的下一关候选".to_string())?
.clone();
let mut next_run = module_puzzle::advance_next_level(&current_run, &next_profile)
.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,
);
next_run.recommended_next_profile_id =
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
.map(|value| value.profile_id.clone());
if let Some(next_profile_row) = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&next_profile.profile_id)
{
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 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 current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err("当前关卡尚未通关".to_string());
}
if current_level.profile_id != input.profile_id {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
if 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 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 let Some(level) = run.current_level.as_mut() {
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
}
run.leaderboard_entries = leaderboard_entries;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
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> {
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(),
level_name: row.level_name.clone(),
summary: row.summary.clone(),
theme_tags: deserialize_theme_tags(&row.theme_tags_json)?,
cover_image_src: row.cover_image_src.clone(),
cover_asset_id: row.cover_asset_id.clone(),
publication_status: row.publication_status,
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
published_at_micros: row
.published_at
.map(|value| value.to_micros_since_unix_epoch()),
play_count: row.play_count,
publish_ready: row.publish_ready,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
})
}
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 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 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(&current.session_id);
ctx.db.puzzle_agent_session().insert(next);
}
fn replace_puzzle_work_profile(
ctx: &TxContext,
current: &PuzzleWorkProfileRow,
next: PuzzleWorkProfileRow,
) {
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&current.profile_id);
ctx.db.puzzle_work_profile().insert(next);
}
fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Result<(), String> {
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,
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,
publication_status: profile.publication_status,
// 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于
// 广场消费数据,不能因为重新发布被清零。
play_count: existing.play_count.max(profile.play_count),
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,
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,
publication_status: profile.publication_status,
play_count: profile.play_count,
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,
});
Ok(())
}
fn replace_puzzle_runtime_run(
ctx: &TxContext,
current: &PuzzleRuntimeRunRow,
run: &PuzzleRunSnapshot,
updated_at_micros: i64,
) {
ctx.db.puzzle_runtime_run().run_id().delete(&current.run_id);
ctx.db.puzzle_runtime_run().insert(PuzzleRuntimeRunRow {
run_id: run.run_id.clone(),
owner_user_id: current.owner_user_id.clone(),
entry_profile_id: run.entry_profile_id.clone(),
current_profile_id: run
.current_level
.as_ref()
.map(|level| level.profile_id.clone())
.unwrap_or_else(|| current.current_profile_id.clone()),
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: resolve_puzzle_grid_size(run.cleared_level_count),
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),
});
}
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(),
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(),
publication_status: row.publication_status,
play_count: row.play_count.saturating_add(1),
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(
draft: &mut PuzzleResultDraft,
candidates: Vec<PuzzleGeneratedImageCandidate>,
) {
// 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。
draft.candidates = 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 refresh_next_profile_recommendation(
ctx: &TxContext,
run: &mut PuzzleRunSnapshot,
) -> Result<(), String> {
let current_level = match run.current_level.as_ref() {
Some(value) => value,
None => {
run.recommended_next_profile_id = None;
return Ok(());
}
};
let current_profile = build_puzzle_work_profile_from_row(
&ctx.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
)?;
run.recommended_next_profile_id = select_next_profile(
&current_profile,
&run.played_profile_ids,
&list_published_puzzle_profiles(ctx)?,
)
.map(|value| value.profile_id.clone());
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_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_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, 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,
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 {
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,
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 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(),
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()),
publication_status: PuzzlePublicationStatus::Published,
updated_at_micros: 1,
published_at_micros: Some(1),
play_count: 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(),
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()),
publication_status: PuzzlePublicationStatus::Published,
updated_at_micros: 2,
published_at_micros: Some(2),
play_count: 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);
}
}