1
This commit is contained in:
@@ -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(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
|
||||
)?;
|
||||
let candidates = list_published_puzzle_profiles(ctx)?;
|
||||
let next_profile = select_next_profile(
|
||||
¤t_profile,
|
||||
¤t_run.played_profile_ids,
|
||||
&candidates,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?
|
||||
.clone();
|
||||
let current_profile_row = ctx
|
||||
.db
|
||||
.puzzle_work_profile()
|
||||
.profile_id()
|
||||
.find(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?;
|
||||
let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?;
|
||||
let next_profile = selected_profile_level_after_runtime_level(¤t_profile, current_level)
|
||||
.map(|level| profile_for_single_level(¤t_profile, &level))
|
||||
.or_else(|| {
|
||||
let candidates = list_published_puzzle_profiles(ctx).ok()?;
|
||||
select_next_profiles(
|
||||
¤t_profile,
|
||||
¤t_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(
|
||||
¤t_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(
|
||||
¤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,
|
||||
@@ -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, ¤t.owner_user_id, updated_at_micros)
|
||||
{
|
||||
log::warn!("拼图存档投影同步失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_puzzle_profile_save_archive(
|
||||
ctx: &TxContext,
|
||||
run: &PuzzleRunSnapshot,
|
||||
user_id: &str,
|
||||
saved_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(current_level) = run.current_level.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let world_key = format!("puzzle:{}", run.entry_profile_id);
|
||||
|
||||
// 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 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, ¤t_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(¤t_profile.theme_tags, &candidate.theme_tags),
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> {
|
||||
let current_level = match run.current_level.as_ref() {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
run.recommended_next_profile_id = None;
|
||||
reset_next_level_handoff(run);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
@@ -2457,12 +2571,41 @@ fn refresh_next_profile_recommendation(
|
||||
.find(¤t_level.profile_id)
|
||||
.ok_or_else(|| "当前拼图作品不存在".to_string())?,
|
||||
)?;
|
||||
run.recommended_next_profile_id = select_next_profile(
|
||||
¤t_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(¤t_profile, current_level)
|
||||
{
|
||||
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string();
|
||||
run.next_level_profile_id = Some(current_profile.profile_id.clone());
|
||||
run.next_level_id = Some(next_level.level_id);
|
||||
run.recommended_next_profile_id = Some(current_profile.profile_id.clone());
|
||||
run.recommended_next_works = Vec::new();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let candidates = list_published_puzzle_profiles(ctx)?;
|
||||
let recommended_next_works =
|
||||
select_next_profiles(¤t_profile, &run.played_profile_ids, &candidates, 3)
|
||||
.into_iter()
|
||||
.map(|candidate| build_recommended_next_work(¤t_profile, candidate))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if recommended_next_works.is_empty() {
|
||||
reset_next_level_handoff(run);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string();
|
||||
run.next_level_profile_id = recommended_next_works
|
||||
.first()
|
||||
.map(|candidate| candidate.profile_id.clone());
|
||||
run.next_level_id = None;
|
||||
run.recommended_next_profile_id = run.next_level_profile_id.clone();
|
||||
run.recommended_next_works = recommended_next_works;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user