1
This commit is contained in:
@@ -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(¤t_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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user