This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -1,5 +1,6 @@
use crate::runtime::{
ProfilePlayedWorkUpsertInput, add_profile_observed_play_time, upsert_profile_played_work,
ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time,
count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work,
};
use module_puzzle::{
PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
@@ -8,13 +9,14 @@ use module_puzzle::{
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput,
PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot,
PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
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, start_run, swap_pieces,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -77,13 +79,15 @@ pub struct PuzzleWorkProfileRow {
cover_asset_id: Option<String>,
publication_status: PuzzlePublicationStatus,
play_count: u32,
remix_count: u32,
like_count: u32,
anchor_pack_json: String,
publish_ready: bool,
created_at: Timestamp,
updated_at: Timestamp,
published_at: Option<Timestamp>,
#[default(0)]
remix_count: u32,
#[default(0)]
like_count: u32,
}
/// 运行态 run 快照表。
@@ -503,6 +507,44 @@ pub fn advance_puzzle_next_level(
}
}
#[spacetimedb::procedure]
pub fn update_puzzle_run_pause(
ctx: &mut ProcedureContext,
input: PuzzleRunPauseInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn use_puzzle_runtime_prop(
ctx: &mut ProcedureContext,
input: PuzzleRunPropInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_leaderboard_entry(
ctx: &mut ProcedureContext,
@@ -755,7 +797,7 @@ fn save_puzzle_generated_images_tx(
}
let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros);
let next_stage = if build_result_preview(&draft, Some("创作者")).publish_ready {
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -804,7 +846,7 @@ fn select_puzzle_cover_image_tx(
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 {
let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready {
PuzzleAgentStage::ReadyToPublish
} else {
PuzzleAgentStage::ImageRefining
@@ -1029,12 +1071,13 @@ fn delete_puzzle_work_tx(
}
fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut items = ctx
.db
.puzzle_work_profile()
.iter()
.filter(|row| row.publication_status == PuzzlePublicationStatus::Published)
.map(|row| build_puzzle_work_profile_from_row(&row))
.map(|row| build_puzzle_work_profile_from_row_with_recent_count(ctx, &row, now_micros))
.collect::<Result<Vec<_>, _>>()?;
items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros));
Ok(items)
@@ -1053,7 +1096,11 @@ fn get_puzzle_gallery_detail_tx(
if row.publication_status != PuzzlePublicationStatus::Published {
return Err("拼图作品尚未发布".to_string());
}
build_puzzle_work_profile_from_row(&row)
build_puzzle_work_profile_from_row_with_recent_count(
ctx,
&row,
ctx.timestamp.to_micros_since_unix_epoch(),
)
}
fn remix_puzzle_work_tx(
@@ -1213,8 +1260,9 @@ fn start_puzzle_run_tx(
return Err("入口拼图作品未发布".to_string());
}
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
let mut run =
start_run(input.run_id.clone(), &entry_profile, 0).map_err(|error| error.to_string())?;
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 current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1231,6 +1279,15 @@ fn start_puzzle_run_tx(
)
.map(|value| value.profile_id.clone());
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: entry_profile_row.owner_user_id.clone(),
profile_id: entry_profile_row.profile_id.clone(),
played_at_micros: input.started_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &entry_profile_row, input.started_at_micros);
upsert_puzzle_profile_played_work(
ctx,
@@ -1247,7 +1304,14 @@ fn get_puzzle_run_tx(
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)?;
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
let mut run = module_puzzle::resolve_puzzle_run_timer_at(
deserialize_run(&row.snapshot_json)?,
micros_to_millis(now_micros),
);
if serialize_json(&run) != row.snapshot_json {
replace_puzzle_runtime_run(ctx, &row, &run, now_micros);
}
if let Some((profile_id, grid_size)) = run
.current_level
.as_ref()
@@ -1270,9 +1334,27 @@ fn swap_puzzle_pieces_tx(
) -> 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())?;
let mut next_run = module_puzzle::swap_pieces_at(
&current_run,
&input.first_piece_id,
&input.second_piece_id,
micros_to_millis(input.swapped_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.swapped_at_micros);
Ok(next_run)
}
@@ -1283,14 +1365,28 @@ fn drag_puzzle_piece_or_group_tx(
) -> 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(
let mut next_run = module_puzzle::drag_piece_or_group_at(
&current_run,
&input.piece_id,
input.target_row,
input.target_col,
micros_to_millis(input.dragged_at_micros),
)
.map_err(|error| error.to_string())?;
refresh_next_profile_recommendation(ctx, &mut next_run)?;
if let Some((profile_id, grid_size)) = next_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
replace_puzzle_runtime_run(ctx, &row, &next_run, input.dragged_at_micros);
Ok(next_run)
}
@@ -1323,8 +1419,12 @@ fn advance_puzzle_next_level_tx(
)
.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 mut next_run = module_puzzle::advance_next_level_at(
&current_run,
&next_profile,
micros_to_millis(input.advanced_at_micros),
)
.map_err(|error| error.to_string())?;
let next_grid_size = next_run.current_grid_size;
let next_profile_id = next_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
@@ -1344,6 +1444,15 @@ fn advance_puzzle_next_level_tx(
.profile_id()
.find(&next_profile.profile_id)
{
record_public_work_play(
ctx,
PublicWorkPlayRecordInput {
source_type: "puzzle".to_string(),
owner_user_id: next_profile_row.owner_user_id.clone(),
profile_id: next_profile_row.profile_id.clone(),
played_at_micros: input.advanced_at_micros,
},
)?;
increment_puzzle_profile_play_count(ctx, &next_profile_row, input.advanced_at_micros);
upsert_puzzle_profile_played_work(
ctx,
@@ -1356,6 +1465,82 @@ fn advance_puzzle_next_level_tx(
Ok(next_run)
}
fn update_puzzle_run_pause_tx(
ctx: &TxContext,
input: PuzzleRunPauseInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = module_puzzle::set_puzzle_run_paused_at(
&current_run,
input.paused,
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;
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn use_puzzle_runtime_prop_tx(
ctx: &TxContext,
input: PuzzleRunPropInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let current_run = deserialize_run(&row.snapshot_json)?;
let next_run = match input.prop_kind.as_str() {
"freezeTime" | "freeze_time" => {
module_puzzle::apply_puzzle_freeze_time_at(
&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,
micros_to_millis(input.used_at_micros),
)
.map_err(|error| error.to_string())?,
"reference" => module_puzzle::set_puzzle_run_paused_at(
&current_run,
true,
micros_to_millis(input.used_at_micros),
)
.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;
if let Some((profile_id, grid_size)) = hydrated_run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut hydrated_run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(hydrated_run)
}
fn submit_puzzle_leaderboard_entry_tx(
ctx: &TxContext,
input: PuzzleLeaderboardSubmitInput,
@@ -1424,7 +1609,7 @@ fn build_puzzle_agent_session_snapshot(
let messages = list_session_messages(ctx, &row.session_id);
let result_preview = draft
.as_ref()
.map(|value| build_result_preview(value, Some("创作者")));
.map(|value| build_result_preview(value, Some("陶泥主")));
Ok(PuzzleAgentSessionSnapshot {
session_id: row.session_id.clone(),
@@ -1447,6 +1632,23 @@ fn build_puzzle_agent_session_snapshot(
fn build_puzzle_work_profile_from_row(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
build_puzzle_work_profile_from_row_without_recent_count(row)
}
fn build_puzzle_work_profile_from_row_with_recent_count(
ctx: &TxContext,
row: &PuzzleWorkProfileRow,
now_micros: i64,
) -> Result<PuzzleWorkProfile, String> {
let mut profile = build_puzzle_work_profile_from_row_without_recent_count(row)?;
profile.recent_play_count_7d =
count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros);
Ok(profile)
}
fn build_puzzle_work_profile_from_row_without_recent_count(
row: &PuzzleWorkProfileRow,
) -> Result<PuzzleWorkProfile, String> {
Ok(PuzzleWorkProfile {
work_id: row.work_id.clone(),
@@ -1467,6 +1669,7 @@ fn build_puzzle_work_profile_from_row(
play_count: row.play_count,
remix_count: row.remix_count,
like_count: row.like_count,
recent_play_count_7d: 0,
publish_ready: row.publish_ready,
anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?,
})
@@ -1482,6 +1685,13 @@ fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) {
)
}
fn micros_to_millis(value: i64) -> u64 {
if value <= 0 {
return 0;
}
(value as u64).saturating_div(1_000)
}
fn upsert_puzzle_draft_work_profile(
ctx: &TxContext,
session_id: &str,
@@ -1500,7 +1710,7 @@ fn upsert_puzzle_draft_work_profile(
profile_id,
owner_user_id.to_string(),
Some(session_id.to_string()),
"创作者".to_string(),
"陶泥主".to_string(),
draft,
updated_at_micros,
)
@@ -2095,6 +2305,9 @@ mod tests {
updated_at_micros: 1,
published_at_micros: Some(1),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
};
@@ -2110,6 +2323,9 @@ mod tests {
updated_at_micros: 2,
published_at_micros: Some(2),
play_count: 0,
recent_play_count_7d: 0,
remix_count: 0,
like_count: 0,
publish_ready: true,
anchor_pack: empty_anchor_pack(),
source_session_id: None,