1
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session};
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
|
||||
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
|
||||
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
|
||||
upsert_profile_played_work, PublicWorkLikeRecordInput,
|
||||
};
|
||||
use crate::*;
|
||||
|
||||
@@ -123,6 +124,32 @@ pub fn record_big_fish_play(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn record_big_fish_like(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: BigFishWorkLikeRecordInput,
|
||||
) -> BigFishWorksProcedureResult {
|
||||
match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) {
|
||||
Ok(items) => match serde_json::to_string(&items) {
|
||||
Ok(items_json) => BigFishWorksProcedureResult {
|
||||
ok: true,
|
||||
items_json: Some(items_json),
|
||||
error_message: None,
|
||||
},
|
||||
Err(error) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(error.to_string()),
|
||||
},
|
||||
},
|
||||
Err(message) => BigFishWorksProcedureResult {
|
||||
ok: false,
|
||||
items_json: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn remix_big_fish_work(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -712,6 +739,60 @@ pub(crate) fn record_big_fish_play_tx(
|
||||
list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input())
|
||||
}
|
||||
|
||||
pub(crate) fn record_big_fish_like_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishWorkLikeRecordInput,
|
||||
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
|
||||
let session_id = input.session_id.trim();
|
||||
let user_id = input.user_id.trim();
|
||||
if session_id.is_empty() || user_id.is_empty() {
|
||||
return Err("big_fish like 参数不能为空".to_string());
|
||||
}
|
||||
let session = ctx
|
||||
.db
|
||||
.big_fish_creation_session()
|
||||
.session_id()
|
||||
.find(&session_id.to_string())
|
||||
.filter(|row| row.stage == BigFishCreationStage::Published)
|
||||
.ok_or_else(|| "big_fish 已发布作品不存在,无法点赞".to_string())?;
|
||||
let inserted_like = record_public_work_like(
|
||||
ctx,
|
||||
PublicWorkLikeRecordInput {
|
||||
source_type: "big-fish".to_string(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
profile_id: session.session_id.clone(),
|
||||
user_id: user_id.to_string(),
|
||||
liked_at_micros: input.liked_at_micros,
|
||||
},
|
||||
)?;
|
||||
|
||||
if inserted_like {
|
||||
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
|
||||
let next_session = BigFishCreationSession {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: session.owner_user_id.clone(),
|
||||
seed_text: session.seed_text.clone(),
|
||||
current_turn: session.current_turn,
|
||||
progress_percent: session.progress_percent,
|
||||
stage: session.stage,
|
||||
anchor_pack_json: session.anchor_pack_json.clone(),
|
||||
draft_json: session.draft_json.clone(),
|
||||
asset_coverage_json: session.asset_coverage_json.clone(),
|
||||
last_assistant_reply: session.last_assistant_reply.clone(),
|
||||
publish_ready: session.publish_ready,
|
||||
play_count: session.play_count,
|
||||
remix_count: session.remix_count,
|
||||
like_count: session.like_count.saturating_add(1),
|
||||
published_at: session.published_at,
|
||||
created_at: session.created_at,
|
||||
updated_at: liked_at,
|
||||
};
|
||||
replace_big_fish_session(ctx, &session, next_session);
|
||||
}
|
||||
|
||||
list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input())
|
||||
}
|
||||
|
||||
fn remix_big_fish_work_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: BigFishWorkRemixInput,
|
||||
|
||||
@@ -2217,6 +2217,27 @@ pub fn record_custom_world_profile_play(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn record_custom_world_profile_like(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: module_custom_world::CustomWorldProfileLikeRecordInput,
|
||||
) -> CustomWorldLibraryMutationResult {
|
||||
match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) {
|
||||
Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult {
|
||||
ok: true,
|
||||
entry: Some(entry),
|
||||
gallery_entry: Some(gallery_entry),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => CustomWorldLibraryMutationResult {
|
||||
ok: false,
|
||||
entry: None,
|
||||
gallery_entry: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn list_custom_world_works(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -2856,7 +2877,9 @@ fn list_custom_world_profile_snapshots(
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
|
||||
fn build_custom_world_profile_list_snapshot(
|
||||
row: &CustomWorldProfile,
|
||||
) -> CustomWorldProfileSnapshot {
|
||||
let mut snapshot = build_custom_world_profile_snapshot(row);
|
||||
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
||||
snapshot
|
||||
@@ -3256,6 +3279,89 @@ fn record_custom_world_profile_play_record(
|
||||
))
|
||||
}
|
||||
|
||||
fn record_custom_world_profile_like_record(
|
||||
ctx: &ReducerContext,
|
||||
input: module_custom_world::CustomWorldProfileLikeRecordInput,
|
||||
) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> {
|
||||
let owner_user_id = input.owner_user_id.trim();
|
||||
let profile_id = input.profile_id.trim();
|
||||
let user_id = input.user_id.trim();
|
||||
if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() {
|
||||
return Err("custom_world like 参数不能为空".to_string());
|
||||
}
|
||||
let existing = ctx
|
||||
.db
|
||||
.custom_world_profile()
|
||||
.profile_id()
|
||||
.find(&profile_id.to_string())
|
||||
.filter(|row| row.owner_user_id == owner_user_id)
|
||||
.filter(|row| {
|
||||
row.publication_status == CustomWorldPublicationStatus::Published
|
||||
&& row.deleted_at.is_none()
|
||||
&& row.published_at.is_some()
|
||||
})
|
||||
.ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?;
|
||||
let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros);
|
||||
let inserted_like = record_public_work_like(
|
||||
ctx,
|
||||
crate::runtime::PublicWorkLikeRecordInput {
|
||||
source_type: "custom-world".to_string(),
|
||||
owner_user_id: existing.owner_user_id.clone(),
|
||||
profile_id: existing.profile_id.clone(),
|
||||
user_id: user_id.to_string(),
|
||||
liked_at_micros: input.liked_at_micros,
|
||||
},
|
||||
)?;
|
||||
|
||||
if !inserted_like {
|
||||
let gallery_entry = ctx
|
||||
.db
|
||||
.custom_world_gallery_entry()
|
||||
.profile_id()
|
||||
.find(&existing.profile_id)
|
||||
.filter(|row| row.owner_user_id == existing.owner_user_id)
|
||||
.map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row))
|
||||
.ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?;
|
||||
return Ok((build_custom_world_profile_snapshot(&existing), gallery_entry));
|
||||
}
|
||||
|
||||
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。
|
||||
let next_row = CustomWorldProfile {
|
||||
profile_id: existing.profile_id.clone(),
|
||||
owner_user_id: existing.owner_user_id.clone(),
|
||||
public_work_code: existing.public_work_code.clone(),
|
||||
author_public_user_code: existing.author_public_user_code.clone(),
|
||||
source_agent_session_id: existing.source_agent_session_id.clone(),
|
||||
publication_status: existing.publication_status,
|
||||
world_name: existing.world_name.clone(),
|
||||
subtitle: existing.subtitle.clone(),
|
||||
summary_text: existing.summary_text.clone(),
|
||||
theme_mode: existing.theme_mode,
|
||||
cover_image_src: existing.cover_image_src.clone(),
|
||||
profile_payload_json: existing.profile_payload_json.clone(),
|
||||
playable_npc_count: existing.playable_npc_count,
|
||||
landmark_count: existing.landmark_count,
|
||||
play_count: existing.play_count,
|
||||
remix_count: existing.remix_count,
|
||||
like_count: existing.like_count.saturating_add(1),
|
||||
author_display_name: existing.author_display_name.clone(),
|
||||
published_at: existing.published_at,
|
||||
deleted_at: existing.deleted_at,
|
||||
created_at: existing.created_at,
|
||||
updated_at: liked_at,
|
||||
};
|
||||
ctx.db
|
||||
.custom_world_profile()
|
||||
.profile_id()
|
||||
.delete(&existing.profile_id);
|
||||
let inserted = ctx.db.custom_world_profile().insert(next_row);
|
||||
let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?;
|
||||
Ok((
|
||||
build_custom_world_profile_snapshot(&inserted),
|
||||
gallery_entry,
|
||||
))
|
||||
}
|
||||
|
||||
fn list_custom_world_work_snapshots(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldWorksListInput,
|
||||
|
||||
@@ -123,6 +123,7 @@ macro_rules! migration_tables {
|
||||
profile_referral_relation,
|
||||
profile_played_world,
|
||||
public_work_play_daily_stat,
|
||||
public_work_like,
|
||||
profile_membership,
|
||||
profile_recharge_order,
|
||||
profile_save_archive,
|
||||
@@ -794,6 +795,25 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
|
||||
object
|
||||
.entry("like_count".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(0));
|
||||
// 中文注释:拼图多关卡字段晚于旧作品表加入,旧迁移包留空并由读取层补出首关。
|
||||
object
|
||||
.entry("levels_json".to_string())
|
||||
.or_insert_with(|| serde_json::Value::from(""));
|
||||
// 中文注释:作品名称/描述从旧关卡名/画面摘要拆出,旧行保留旧值做兼容回填。
|
||||
let fallback_title = object
|
||||
.get("level_name")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::Value::from(""));
|
||||
object
|
||||
.entry("work_title".to_string())
|
||||
.or_insert(fallback_title);
|
||||
let fallback_description = object
|
||||
.get("summary")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::Value::from(""));
|
||||
object
|
||||
.entry("work_description".to_string())
|
||||
.or_insert(fallback_description);
|
||||
}
|
||||
}
|
||||
next_value
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
use crate::runtime::{
|
||||
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
|
||||
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
|
||||
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
|
||||
upsert_profile_played_work, PublicWorkLikeRecordInput,
|
||||
};
|
||||
use module_puzzle::{
|
||||
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
|
||||
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
|
||||
PuzzleLeaderboardSubmitInput,
|
||||
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
|
||||
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput,
|
||||
PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput,
|
||||
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,
|
||||
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
|
||||
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, resolve_puzzle_grid_size, select_next_profile, selected_puzzle_level,
|
||||
};
|
||||
use serde_json::from_str as json_from_str;
|
||||
use serde_json::to_string as json_to_string;
|
||||
@@ -72,11 +76,14 @@ pub struct PuzzleWorkProfileRow {
|
||||
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,
|
||||
@@ -225,6 +232,27 @@ pub fn compile_puzzle_agent_draft(
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存拼图入口表单草稿。
|
||||
/// 中文注释:该 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,
|
||||
@@ -393,6 +421,25 @@ pub fn get_puzzle_gallery_detail(
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -572,6 +619,7 @@ fn create_puzzle_agent_session_tx(
|
||||
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(),
|
||||
@@ -581,7 +629,7 @@ fn create_puzzle_agent_session_tx(
|
||||
progress_percent: 0,
|
||||
stage: PuzzleAgentStage::CollectingAnchors,
|
||||
anchor_pack_json: serialize_json(&anchor_pack),
|
||||
draft_json: None,
|
||||
draft_json: Some(serialize_json(&initial_form_draft)),
|
||||
last_assistant_reply: Some(input.welcome_message_text.clone()),
|
||||
published_profile_id: None,
|
||||
created_at,
|
||||
@@ -595,6 +643,13 @@ fn create_puzzle_agent_session_tx(
|
||||
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 {
|
||||
@@ -730,9 +785,12 @@ fn compile_puzzle_agent_draft_tx(
|
||||
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)?;
|
||||
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(&anchor_pack, &messages);
|
||||
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(
|
||||
@@ -772,6 +830,59 @@ fn compile_puzzle_agent_draft_tx(
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -783,18 +894,22 @@ fn save_puzzle_generated_images_tx(
|
||||
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
|
||||
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()
|
||||
{
|
||||
draft.selected_candidate_id = Some(selected.candidate_id);
|
||||
draft.cover_image_src = Some(selected.image_src);
|
||||
draft.cover_asset_id = Some(selected.asset_id);
|
||||
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 {
|
||||
@@ -843,8 +958,38 @@ fn select_puzzle_cover_image_tx(
|
||||
) -> 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 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
|
||||
@@ -894,9 +1039,12 @@ fn publish_puzzle_work_tx(
|
||||
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);
|
||||
@@ -981,23 +1129,58 @@ fn update_puzzle_work_tx(
|
||||
if theme_tags.is_empty() || 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,
|
||||
anchor_pack_json: row.anchor_pack_json.clone(),
|
||||
publish_ready: row.publish_ready,
|
||||
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,
|
||||
@@ -1103,6 +1286,76 @@ fn get_puzzle_gallery_detail_tx(
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
@@ -1154,11 +1407,14 @@ fn remix_puzzle_work_tx(
|
||||
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),
|
||||
@@ -1172,6 +1428,8 @@ fn remix_puzzle_work_tx(
|
||||
);
|
||||
|
||||
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(),
|
||||
@@ -1183,6 +1441,8 @@ fn remix_puzzle_work_tx(
|
||||
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(),
|
||||
@@ -1212,11 +1472,14 @@ fn remix_puzzle_work_tx(
|
||||
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,
|
||||
@@ -1259,10 +1522,14 @@ fn start_puzzle_run_tx(
|
||||
if entry_profile_row.publication_status != PuzzlePublicationStatus::Published {
|
||||
return Err("入口拼图作品未发布".to_string());
|
||||
}
|
||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||
let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||
if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? {
|
||||
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, 0, started_at_ms)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut run =
|
||||
module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, 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(
|
||||
@@ -1502,13 +1769,11 @@ fn use_puzzle_runtime_prop_tx(
|
||||
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())?
|
||||
}
|
||||
"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())?,
|
||||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||||
¤t_run,
|
||||
false,
|
||||
@@ -1551,12 +1816,20 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
.current_level
|
||||
.as_ref()
|
||||
.ok_or_else(|| "拼图关卡不存在".to_string())?;
|
||||
if current_level.profile_id != input.profile_id {
|
||||
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
|
||||
if input.profile_id.trim().is_empty() {
|
||||
return Err("提交成绩的拼图作品不能为空".to_string());
|
||||
}
|
||||
if current_level.grid_size != input.grid_size {
|
||||
if input.grid_size != 3 && input.grid_size != 4 {
|
||||
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());
|
||||
}
|
||||
if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) {
|
||||
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
|
||||
}
|
||||
|
||||
let nickname = input.nickname.trim();
|
||||
if nickname.is_empty() {
|
||||
@@ -1588,20 +1861,42 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
&input.owner_user_id,
|
||||
10,
|
||||
);
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
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 {
|
||||
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
|
||||
// 因此提交榜单时不能要求 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();
|
||||
// 前端通过 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.cleared_level_count = run.cleared_level_count.max(run.current_level_index);
|
||||
run.leaderboard_entries = leaderboard_entries;
|
||||
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
|
||||
.played_profile_ids
|
||||
.iter()
|
||||
.any(|played_profile_id| played_profile_id == profile_id)
|
||||
}
|
||||
|
||||
fn build_puzzle_agent_session_snapshot(
|
||||
ctx: &TxContext,
|
||||
row: &PuzzleAgentSessionRow,
|
||||
@@ -1658,11 +1953,22 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
|
||||
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
|
||||
@@ -1677,6 +1983,64 @@ fn build_puzzle_work_profile_from_row_without_recent_count(
|
||||
})
|
||||
}
|
||||
|
||||
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-")
|
||||
@@ -1706,6 +2070,20 @@ fn upsert_puzzle_draft_work_profile(
|
||||
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;
|
||||
return upsert_puzzle_work_profile(ctx, profile);
|
||||
}
|
||||
let profile = create_work_profile(
|
||||
work_id,
|
||||
@@ -1869,11 +2247,14 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
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 时,作品内容可以覆盖,但历史游玩数属于
|
||||
// 广场消费数据,不能因为重新发布被清零。
|
||||
@@ -1898,11 +2279,14 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re
|
||||
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,
|
||||
@@ -1988,11 +2372,14 @@ fn increment_puzzle_profile_play_count(
|
||||
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,
|
||||
@@ -2029,11 +2416,11 @@ fn upsert_puzzle_profile_played_work(
|
||||
}
|
||||
|
||||
fn replace_generated_candidate(
|
||||
draft: &mut PuzzleResultDraft,
|
||||
candidates_slot: &mut Vec<PuzzleGeneratedImageCandidate>,
|
||||
candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||
) {
|
||||
// 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。
|
||||
draft.candidates = candidates
|
||||
*candidates_slot = candidates
|
||||
.into_iter()
|
||||
.take(1)
|
||||
.map(|mut candidate| {
|
||||
@@ -2197,7 +2584,11 @@ fn deserialize_anchor_pack(value: &str) -> Result<PuzzleAnchorPack, String> {
|
||||
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}")))
|
||||
.map(|raw| {
|
||||
json_from_str(raw)
|
||||
.map(normalize_puzzle_draft)
|
||||
.map_err(|error| format!("拼图 draft JSON 非法: {error}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -2209,6 +2600,22 @@ 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}"))
|
||||
}
|
||||
@@ -2217,8 +2624,8 @@ fn deserialize_run(value: &str) -> Result<PuzzleRunSnapshot, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use module_puzzle::{
|
||||
PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack,
|
||||
recommendation_score, tag_similarity_score,
|
||||
PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft,
|
||||
empty_anchor_pack, recommendation_score, tag_similarity_score,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -2248,6 +2655,20 @@ mod tests {
|
||||
.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
|
||||
},
|
||||
|
||||
@@ -139,6 +139,22 @@ pub struct PublicWorkPlayDailyStat {
|
||||
pub(crate) updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = public_work_like,
|
||||
index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])),
|
||||
index(accessor = by_public_work_like_user, btree(columns = [user_id]))
|
||||
)]
|
||||
pub struct PublicWorkLike {
|
||||
#[primary_key]
|
||||
pub(crate) like_id: String,
|
||||
// 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。
|
||||
pub(crate) source_type: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) liked_at: Timestamp,
|
||||
}
|
||||
|
||||
pub(crate) struct ProfilePlayedWorkUpsertInput {
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) world_key: String,
|
||||
@@ -157,6 +173,14 @@ pub(crate) struct PublicWorkPlayRecordInput {
|
||||
pub(crate) played_at_micros: i64,
|
||||
}
|
||||
|
||||
pub(crate) struct PublicWorkLikeRecordInput {
|
||||
pub(crate) source_type: String,
|
||||
pub(crate) owner_user_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
pub(crate) user_id: String,
|
||||
pub(crate) liked_at_micros: i64,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(accessor = profile_membership)]
|
||||
pub struct ProfileMembership {
|
||||
#[primary_key]
|
||||
@@ -778,6 +802,39 @@ pub(crate) fn record_public_work_play(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn record_public_work_like(
|
||||
ctx: &ReducerContext,
|
||||
input: PublicWorkLikeRecordInput,
|
||||
) -> Result<bool, String> {
|
||||
let source_type = input.source_type.trim();
|
||||
let owner_user_id = input.owner_user_id.trim();
|
||||
let profile_id = input.profile_id.trim();
|
||||
let user_id = input.user_id.trim();
|
||||
if source_type.is_empty()
|
||||
|| owner_user_id.is_empty()
|
||||
|| profile_id.is_empty()
|
||||
|| user_id.is_empty()
|
||||
{
|
||||
return Err("public_work_like 参数不能为空".to_string());
|
||||
}
|
||||
|
||||
let like_id = build_public_work_like_id(source_type, profile_id, user_id);
|
||||
if ctx.db.public_work_like().like_id().find(&like_id).is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
ctx.db.public_work_like().insert(PublicWorkLike {
|
||||
like_id,
|
||||
source_type: source_type.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
profile_id: profile_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros),
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn count_recent_public_work_plays(
|
||||
ctx: &ReducerContext,
|
||||
source_type: &str,
|
||||
@@ -817,6 +874,10 @@ fn build_public_work_play_daily_stat_id(
|
||||
format!("{source_type}:{profile_id}:{played_day}")
|
||||
}
|
||||
|
||||
fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String {
|
||||
format!("{source_type}:{profile_id}:{user_id}")
|
||||
}
|
||||
|
||||
fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) {
|
||||
if ctx
|
||||
.db
|
||||
|
||||
Reference in New Issue
Block a user