This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -1,8 +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_like, record_public_work_play,
upsert_profile_played_work, PublicWorkLikeRecordInput,
ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput,
add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like,
record_public_work_play, upsert_profile_played_work,
};
use crate::*;

View File

@@ -3322,7 +3322,10 @@ fn record_custom_world_profile_like_record(
.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));
return Ok((
build_custom_world_profile_snapshot(&existing),
gallery_entry,
));
}
// 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。

View File

@@ -1,28 +1,31 @@
use crate::runtime::{
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_like, record_public_work_play,
upsert_profile_played_work, PublicWorkLikeRecordInput,
ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput,
ProfileSaveArchiveUpsertInput,
add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like,
record_public_work_play, upsert_profile_played_work, upsert_profile_save_archive,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK,
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry,
PuzzleLeaderboardSubmitInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput,
PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput,
PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput,
PuzzleWorksListInput, PuzzleWorksProcedureResult,
PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput,
PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, 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,
replace_puzzle_level, resolve_puzzle_grid_size, select_next_profiles,
selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score,
};
use serde_json::from_str as json_from_str;
use serde_json::json;
use serde_json::to_string as json_to_string;
use spacetimedb::{ProcedureContext, Table, Timestamp, TxContext};
@@ -889,6 +892,11 @@ fn save_puzzle_generated_images_tx(
) -> Result<PuzzleAgentSessionSnapshot, String> {
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
let mut draft = deserialize_draft_required(&row.draft_json)?;
if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? {
// 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。
draft.levels = levels;
module_puzzle::sync_primary_level_fields(&mut draft);
}
let candidates: Vec<PuzzleGeneratedImageCandidate> = json_from_str(&input.candidates_json)
.map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?;
if candidates.is_empty() {
@@ -1539,12 +1547,7 @@ fn start_puzzle_run_tx(
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());
refresh_next_level_handoff(ctx, &mut run)?;
record_public_work_play(
ctx,
@@ -1576,6 +1579,7 @@ fn get_puzzle_run_tx(
deserialize_run(&row.snapshot_json)?,
micros_to_millis(now_micros),
);
refresh_next_level_handoff(ctx, &mut run)?;
if serialize_json(&run) != row.snapshot_json {
replace_puzzle_runtime_run(ctx, &row, &run, now_micros);
}
@@ -1608,7 +1612,7 @@ fn swap_puzzle_pieces_tx(
micros_to_millis(input.swapped_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
@@ -1640,7 +1644,7 @@ fn drag_puzzle_piece_or_group_tx(
micros_to_millis(input.dragged_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
@@ -1671,21 +1675,28 @@ fn advance_puzzle_next_level_tx(
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 current_profile_row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&current_level.profile_id)
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
let current_profile = build_puzzle_work_profile_from_row(&current_profile_row)?;
let next_profile = selected_profile_level_after_runtime_level(&current_profile, current_level)
.map(|level| profile_for_single_level(&current_profile, &level))
.or_else(|| {
let candidates = list_published_puzzle_profiles(ctx).ok()?;
select_next_profiles(
&current_profile,
&current_run.played_profile_ids,
&candidates,
1,
)
.into_iter()
.next()
.cloned()
})
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
let mut next_run = module_puzzle::advance_next_level_at(
&current_run,
&next_profile,
@@ -1701,9 +1712,7 @@ fn advance_puzzle_next_level_tx(
&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());
refresh_next_level_handoff(ctx, &mut next_run)?;
if let Some(next_profile_row) = ctx
.db
@@ -1744,8 +1753,9 @@ fn update_puzzle_run_pause_tx(
micros_to_millis(input.updated_at_micros),
)
.map_err(|error| error.to_string())?;
replace_puzzle_runtime_run(ctx, &row, &next_run, input.updated_at_micros);
let mut hydrated_run = next_run;
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros);
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
@@ -1774,6 +1784,11 @@ fn use_puzzle_runtime_prop_tx(
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at(
&current_run,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"hint" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
false,
@@ -1788,8 +1803,9 @@ fn use_puzzle_runtime_prop_tx(
.map_err(|error| error.to_string())?,
_ => return Err("未知拼图道具".to_string()),
};
replace_puzzle_runtime_run(ctx, &row, &next_run, input.used_at_micros);
let mut hydrated_run = next_run;
refresh_next_level_handoff(ctx, &mut hydrated_run)?;
replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros);
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
@@ -1883,6 +1899,7 @@ fn submit_puzzle_leaderboard_entry_tx(
);
}
run.leaderboard_entries = leaderboard_entries;
refresh_next_level_handoff(ctx, &mut run)?;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
@@ -1891,6 +1908,14 @@ fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str)
run.recommended_next_profile_id
.as_ref()
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|| run
.next_level_profile_id
.as_ref()
.is_some_and(|candidate_profile_id| candidate_profile_id == profile_id)
|| run
.recommended_next_works
.iter()
.any(|candidate| candidate.profile_id == profile_id)
|| run
.played_profile_ids
.iter()
@@ -2328,6 +2353,7 @@ fn insert_puzzle_runtime_run(
created_at: timestamp,
updated_at: timestamp,
});
upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?;
Ok(())
}
@@ -2356,6 +2382,75 @@ fn replace_puzzle_runtime_run(
created_at: current.created_at,
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
});
if let Err(error) =
upsert_puzzle_profile_save_archive(ctx, run, &current.owner_user_id, updated_at_micros)
{
log::warn!("拼图存档投影同步失败: {}", error);
}
}
fn upsert_puzzle_profile_save_archive(
ctx: &TxContext,
run: &PuzzleRunSnapshot,
user_id: &str,
saved_at_micros: i64,
) -> Result<(), String> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Ok(());
}
let Some(current_level) = run.current_level.as_ref() else {
return Ok(());
};
let world_key = format!("puzzle:{}", run.entry_profile_id);
// 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。
let game_state_json = json_to_string(&json!({
"runtimeKind": "puzzle",
"runId": run.run_id,
"entryProfileId": run.entry_profile_id,
"currentProfileId": current_level.profile_id,
"currentLevelIndex": current_level.level_index,
"currentLevelId": current_level.level_id,
"status": current_level.status.as_str(),
}))
.unwrap_or_else(|_| "{}".to_string());
upsert_profile_save_archive(
ctx,
ProfileSaveArchiveUpsertInput {
user_id: user_id.to_string(),
world_key,
owner_user_id: resolve_puzzle_current_owner_user_id(ctx, &current_level.profile_id),
profile_id: Some(run.entry_profile_id.clone()),
world_type: Some("PUZZLE".to_string()),
world_name: current_level.level_name.clone(),
subtitle: format!("第 {} 关", current_level.level_index),
summary_text: puzzle_archive_summary_text(current_level.status),
cover_image_src: current_level.cover_image_src.clone(),
bottom_tab: "puzzle".to_string(),
game_state_json,
current_story_json: None,
saved_at_micros,
},
)
}
fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option<String> {
ctx.db
.puzzle_work_profile()
.profile_id()
.find(&profile_id.to_string())
.map(|row| row.owner_user_id)
}
fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String {
match status {
PuzzleRuntimeLevelStatus::Cleared => "关卡已完成",
PuzzleRuntimeLevelStatus::Failed => "关卡失败",
PuzzleRuntimeLevelStatus::Playing => "拼图进行中",
}
.to_string()
}
fn increment_puzzle_profile_play_count(
@@ -2439,14 +2534,33 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfi
.collect()
}
fn refresh_next_profile_recommendation(
ctx: &TxContext,
run: &mut PuzzleRunSnapshot,
) -> Result<(), String> {
fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) {
run.recommended_next_profile_id = None;
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string();
run.next_level_profile_id = None;
run.next_level_id = None;
run.recommended_next_works = Vec::new();
}
fn build_recommended_next_work(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
) -> PuzzleRecommendedNextWork {
PuzzleRecommendedNextWork {
profile_id: candidate.profile_id.clone(),
level_name: candidate.level_name.clone(),
author_display_name: candidate.author_display_name.clone(),
theme_tags: candidate.theme_tags.clone(),
cover_image_src: candidate.cover_image_src.clone(),
similarity_score: tag_similarity_score(&current_profile.theme_tags, &candidate.theme_tags),
}
}
fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> {
let current_level = match run.current_level.as_ref() {
Some(value) => value,
None => {
run.recommended_next_profile_id = None;
reset_next_level_handoff(run);
return Ok(());
}
};
@@ -2457,12 +2571,41 @@ fn refresh_next_profile_recommendation(
.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());
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
reset_next_level_handoff(run);
return Ok(());
}
if let Some(next_level) =
selected_profile_level_after_runtime_level(&current_profile, current_level)
{
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string();
run.next_level_profile_id = Some(current_profile.profile_id.clone());
run.next_level_id = Some(next_level.level_id);
run.recommended_next_profile_id = Some(current_profile.profile_id.clone());
run.recommended_next_works = Vec::new();
return Ok(());
}
let candidates = list_published_puzzle_profiles(ctx)?;
let recommended_next_works =
select_next_profiles(&current_profile, &run.played_profile_ids, &candidates, 3)
.into_iter()
.map(|candidate| build_recommended_next_work(&current_profile, candidate))
.collect::<Vec<_>>();
if recommended_next_works.is_empty() {
reset_next_level_handoff(run);
return Ok(());
}
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
run.next_level_profile_id = recommended_next_works
.first()
.map(|candidate| candidate.profile_id.clone());
run.next_level_id = None;
run.recommended_next_profile_id = run.next_level_profile_id.clone();
run.recommended_next_works = recommended_next_works;
Ok(())
}
@@ -2640,6 +2783,10 @@ mod tests {
previous_level_tags: vec!["蒸汽城市".to_string()],
current_level: None,
recommended_next_profile_id: None,
next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(),
next_level_profile_id: None,
next_level_id: None,
recommended_next_works: Vec::new(),
leaderboard_entries: Vec::new(),
};
let serialized = serialize_json(&snapshot);

View File

@@ -181,6 +181,22 @@ pub(crate) struct PublicWorkLikeRecordInput {
pub(crate) liked_at_micros: i64,
}
pub(crate) struct ProfileSaveArchiveUpsertInput {
pub(crate) user_id: String,
pub(crate) world_key: String,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) world_type: Option<String>,
pub(crate) world_name: String,
pub(crate) subtitle: String,
pub(crate) summary_text: String,
pub(crate) cover_image_src: Option<String>,
pub(crate) bottom_tab: String,
pub(crate) game_state_json: String,
pub(crate) current_story_json: Option<String>,
pub(crate) saved_at_micros: i64,
}
#[spacetimedb::table(accessor = profile_membership)]
pub struct ProfileMembership {
#[primary_key]
@@ -759,6 +775,53 @@ pub(crate) fn add_profile_observed_play_time(
Ok(())
}
pub(crate) fn upsert_profile_save_archive(
ctx: &ReducerContext,
input: ProfileSaveArchiveUpsertInput,
) -> Result<(), String> {
let user_id = input.user_id.trim();
let world_key = input.world_key.trim();
if user_id.is_empty() || world_key.is_empty() {
return Err("profile_save_archive 参数不能为空".to_string());
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let archive_id = format!("{user_id}:{world_key}");
let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id);
let created_at = existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(saved_at);
if let Some(existing) = existing {
ctx.db
.profile_save_archive()
.archive_id()
.delete(&existing.archive_id);
}
ctx.db.profile_save_archive().insert(ProfileSaveArchive {
archive_id,
user_id: user_id.to_string(),
world_key: world_key.to_string(),
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
world_type: input.world_type,
world_name: input.world_name,
subtitle: input.subtitle,
summary_text: input.summary_text,
cover_image_src: input.cover_image_src,
saved_at,
bottom_tab: input.bottom_tab,
game_state_json: input.game_state_json,
current_story_json: input.current_story_json,
created_at,
updated_at: saved_at,
});
Ok(())
}
pub(crate) fn record_public_work_play(
ctx: &ReducerContext,
input: PublicWorkPlayRecordInput,