This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -4,14 +4,14 @@ use module_puzzle::{
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate,
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput,
PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate,
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
start_run, swap_pieces,
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzleRunDragInput, PuzzleRunGetInput,
PuzzleRunNextLevelInput, PuzzleRunProcedureResult, PuzzleRunSnapshot, PuzzleRunStartInput,
PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput,
PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -102,6 +102,25 @@ pub struct PuzzleRuntimeRunRow {
updated_at: Timestamp,
}
/// 拼图关卡真实成绩表。
/// 每个用户在同一作品同一网格规格下只保留一条最佳成绩,用于结算弹窗排行榜。
#[spacetimedb::table(
accessor = puzzle_leaderboard_entry,
index(accessor = by_puzzle_leaderboard_profile_grid, btree(columns = [profile_id, grid_size])),
index(accessor = by_puzzle_leaderboard_user_profile_grid, btree(columns = [user_id, profile_id, grid_size]))
)]
pub struct PuzzleLeaderboardEntryRow {
#[primary_key]
entry_id: String,
profile_id: String,
grid_size: u32,
user_id: String,
nickname: String,
best_elapsed_ms: u64,
last_run_id: String,
updated_at: Timestamp,
}
#[spacetimedb::procedure]
pub fn create_puzzle_agent_session(
ctx: &mut ProcedureContext,
@@ -460,6 +479,25 @@ pub fn advance_puzzle_next_level(
}
}
#[spacetimedb::procedure]
pub fn submit_puzzle_leaderboard_entry(
ctx: &mut ProcedureContext,
input: PuzzleLeaderboardSubmitInput,
) -> PuzzleRunProcedureResult {
match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) {
Ok(run) => PuzzleRunProcedureResult {
ok: true,
run_json: Some(serialize_json(&run)),
error_message: None,
},
Err(message) => PuzzleRunProcedureResult {
ok: false,
run_json: None,
error_message: Some(message),
},
}
}
fn create_puzzle_agent_session_tx(
ctx: &TxContext,
input: PuzzleAgentSessionCreateInput,
@@ -1017,6 +1055,15 @@ fn start_puzzle_run_tx(
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 current_grid_size = run.current_grid_size;
let current_profile_id = entry_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
current_profile_id.as_str(),
current_grid_size,
);
run.recommended_next_profile_id = select_next_profile(
&entry_profile,
&run.played_profile_ids,
@@ -1034,7 +1081,21 @@ fn get_puzzle_run_tx(
input: PuzzleRunGetInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
deserialize_run(&row.snapshot_json)
let mut run = deserialize_run(&row.snapshot_json)?;
if let Some((profile_id, grid_size)) = run
.current_level
.as_ref()
.map(|level| (level.profile_id.clone(), level.grid_size))
{
hydrate_puzzle_leaderboard_entries(
ctx,
&mut run,
&input.owner_user_id,
&profile_id,
grid_size,
);
}
Ok(run)
}
fn swap_puzzle_pieces_tx(
@@ -1098,6 +1159,15 @@ fn advance_puzzle_next_level_tx(
.clone();
let mut next_run = module_puzzle::advance_next_level(&current_run, &next_profile)
.map_err(|error| error.to_string())?;
let next_grid_size = next_run.current_grid_size;
let next_profile_id = next_profile.profile_id.clone();
hydrate_puzzle_leaderboard_entries(
ctx,
&mut next_run,
&input.owner_user_id,
&next_profile_id,
next_grid_size,
);
next_run.recommended_next_profile_id =
select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates)
.map(|value| value.profile_id.clone());
@@ -1114,6 +1184,58 @@ fn advance_puzzle_next_level_tx(
Ok(next_run)
}
fn submit_puzzle_leaderboard_entry_tx(
ctx: &TxContext,
input: PuzzleLeaderboardSubmitInput,
) -> Result<PuzzleRunSnapshot, String> {
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
let mut run = deserialize_run(&row.snapshot_json)?;
let current_level = run
.current_level
.as_ref()
.ok_or_else(|| "拼图关卡不存在".to_string())?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err("当前关卡尚未通关".to_string());
}
if current_level.profile_id != input.profile_id {
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
}
if current_level.grid_size != input.grid_size {
return Err("提交成绩的网格规格与当前关卡不匹配".to_string());
}
let nickname = input.nickname.trim();
if nickname.is_empty() {
return Err("排行榜昵称不能为空".to_string());
}
upsert_puzzle_leaderboard_entry(
ctx,
&input.owner_user_id,
&input.profile_id,
input.grid_size,
nickname,
input.elapsed_ms.max(1_000),
&input.run_id,
input.submitted_at_micros,
);
let leaderboard_entries = list_puzzle_leaderboard_entries(
ctx,
&input.profile_id,
input.grid_size,
&input.owner_user_id,
10,
);
if let Some(level) = run.current_level.as_mut() {
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
level.leaderboard_entries = leaderboard_entries.clone();
}
run.leaderboard_entries = leaderboard_entries;
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
Ok(run)
}
fn build_puzzle_agent_session_snapshot(
ctx: &TxContext,
row: &PuzzleAgentSessionRow,
@@ -1536,6 +1658,116 @@ fn refresh_next_profile_recommendation(
Ok(())
}
fn hydrate_puzzle_leaderboard_entries(
ctx: &TxContext,
run: &mut PuzzleRunSnapshot,
current_user_id: &str,
profile_id: &str,
grid_size: u32,
) {
let leaderboard_entries =
list_puzzle_leaderboard_entries(ctx, profile_id, grid_size, current_user_id, 10);
run.leaderboard_entries = leaderboard_entries.clone();
if let Some(level) = run.current_level.as_mut() {
level.leaderboard_entries = leaderboard_entries;
}
}
fn build_puzzle_leaderboard_entry_id(user_id: &str, profile_id: &str, grid_size: u32) -> String {
format!("puzzle-leaderboard-{user_id}-{profile_id}-{grid_size}")
}
fn upsert_puzzle_leaderboard_entry(
ctx: &TxContext,
user_id: &str,
profile_id: &str,
grid_size: u32,
nickname: &str,
elapsed_ms: u64,
run_id: &str,
updated_at_micros: i64,
) {
let entry_id = build_puzzle_leaderboard_entry_id(user_id, profile_id, grid_size);
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
if let Some(existing) = ctx
.db
.puzzle_leaderboard_entry()
.entry_id()
.find(&entry_id)
{
let should_replace = elapsed_ms < existing.best_elapsed_ms
|| (elapsed_ms == existing.best_elapsed_ms
&& updated_at.to_micros_since_unix_epoch()
< existing.updated_at.to_micros_since_unix_epoch());
let next_row = PuzzleLeaderboardEntryRow {
entry_id: existing.entry_id.clone(),
profile_id: existing.profile_id.clone(),
grid_size: existing.grid_size,
user_id: existing.user_id.clone(),
nickname: nickname.to_string(),
best_elapsed_ms: if should_replace {
elapsed_ms
} else {
existing.best_elapsed_ms
},
last_run_id: if should_replace {
run_id.to_string()
} else {
existing.last_run_id.clone()
},
updated_at,
};
ctx.db
.puzzle_leaderboard_entry()
.entry_id()
.delete(&existing.entry_id);
ctx.db.puzzle_leaderboard_entry().insert(next_row);
return;
}
ctx.db.puzzle_leaderboard_entry().insert(PuzzleLeaderboardEntryRow {
entry_id,
profile_id: profile_id.to_string(),
grid_size,
user_id: user_id.to_string(),
nickname: nickname.to_string(),
best_elapsed_ms: elapsed_ms,
last_run_id: run_id.to_string(),
updated_at,
});
}
fn list_puzzle_leaderboard_entries(
ctx: &TxContext,
profile_id: &str,
grid_size: u32,
current_user_id: &str,
limit: usize,
) -> Vec<PuzzleLeaderboardEntry> {
let mut rows = ctx
.db
.puzzle_leaderboard_entry()
.iter()
.filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.best_elapsed_ms
.cmp(&right.best_elapsed_ms)
.then_with(|| left.updated_at.cmp(&right.updated_at))
.then_with(|| left.user_id.cmp(&right.user_id))
});
rows.into_iter()
.take(limit)
.enumerate()
.map(|(index, row)| PuzzleLeaderboardEntry {
rank: index as u32 + 1,
nickname: row.nickname,
elapsed_ms: row.best_elapsed_ms,
is_current_player: row.user_id == current_user_id,
})
.collect()
}
fn serialize_json<T: ::serde::Serialize>(value: &T) -> String {
json_to_string(value).unwrap_or_else(|_| "{}".to_string())
}
@@ -1568,6 +1800,7 @@ mod tests {
use super::*;
use module_puzzle::{
build_generated_candidates, empty_anchor_pack, recommendation_score, tag_similarity_score,
PuzzleLeaderboardEntry,
};
#[test]
@@ -1582,6 +1815,7 @@ mod tests {
previous_level_tags: vec!["蒸汽城市".to_string()],
current_level: None,
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
};
let serialized = serialize_json(&snapshot);
let parsed = deserialize_run(&serialized).expect("run json should parse");
@@ -1681,4 +1915,31 @@ mod tests {
> tag_similarity_score(&left.theme_tags, &right.theme_tags)
);
}
#[test]
fn puzzle_leaderboard_entries_sort_by_elapsed_time() {
let mut entries = vec![
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 B".to_string(),
elapsed_ms: 5200,
is_current_player: false,
},
PuzzleLeaderboardEntry {
rank: 0,
nickname: "玩家 A".to_string(),
elapsed_ms: 3100,
is_current_player: true,
},
];
entries.sort_by(|left, right| left.elapsed_ms.cmp(&right.elapsed_ms));
for (index, entry) in entries.iter_mut().enumerate() {
entry.rank = index as u32 + 1;
}
assert_eq!(entries[0].nickname, "玩家 A");
assert_eq!(entries[0].rank, 1);
assert_eq!(entries[1].nickname, "玩家 B");
assert_eq!(entries[1].rank, 2);
}
}