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

@@ -83,6 +83,7 @@ use crate::{
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message,
submit_puzzle_leaderboard,
swap_puzzle_pieces,
},
refresh_session::refresh_session,
@@ -694,6 +695,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/puzzle/runs/{run_id}/leaderboard",
post(submit_puzzle_leaderboard).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/custom-world/entity",
post(generate_custom_world_entity).route_layer(middleware::from_fn_with_state(

View File

@@ -28,11 +28,8 @@ use crate::{
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
];
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
["character_visual", "scene_image", "puzzle_cover_image"];
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
@@ -480,7 +477,9 @@ mod tests {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(!super::is_supported_asset_history_kind("puzzle_preview_image"));
assert!(!super::is_supported_asset_history_kind(
"puzzle_preview_image"
));
}
#[test]

View File

@@ -62,7 +62,7 @@
"anchorQuestions": [
{
"key": "themePromise",
"label": "题材承诺",
"label": "题材",
"question": "这张拼图给玩家的题材和完成期待是什么?",
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
},

View File

@@ -17,7 +17,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use module_puzzle::PuzzleGeneratedImageCandidate;
use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
@@ -37,9 +37,10 @@ use shared_contracts::{
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
puzzle_runtime::{
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
PuzzleCellPositionResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
StartPuzzleRunRequest, SwapPuzzlePiecesRequest,
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse,
PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse, PuzzleRunResponse,
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
},
puzzle_works::{
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
@@ -53,7 +54,8 @@ use spacetime_client::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunRecord,
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
@@ -1104,6 +1106,54 @@ pub async fn advance_local_puzzle_next_level(
))
}
pub async fn submit_puzzle_leaderboard(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<SubmitPuzzleLeaderboardRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_RUNTIME_PROVIDER,
"message": error.body_text(),
})),
)
})?;
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
let run = state
.spacetime_client()
.submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput {
run_id,
owner_user_id: authenticated.claims().user_id().to_string(),
profile_id: payload.profile_id,
grid_size: payload.grid_size,
elapsed_ms: payload.elapsed_ms.max(1_000),
nickname: payload.nickname.trim().to_string(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_RUNTIME_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleRunResponse {
run: map_puzzle_run_response(run),
},
))
}
fn map_puzzle_agent_session_response(
session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionSnapshotResponse {
@@ -1303,7 +1353,11 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: Vec::new(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
@@ -1318,6 +1372,11 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_level_request_record),
recommended_next_profile_id: run.recommended_next_profile_id,
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
@@ -1335,6 +1394,25 @@ fn map_puzzle_level_request_record(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_request_record(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_request_record)
.collect(),
}
}
fn map_puzzle_leaderboard_request_record(
entry: PuzzleLeaderboardEntryResponse,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
@@ -1389,10 +1467,25 @@ fn map_puzzle_runtime_level_response(
cover_image_src: level.cover_image_src,
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: 0,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
PuzzleLeaderboardEntryResponse {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
is_current_player: entry.is_current_player,
}
}
@@ -1922,6 +2015,7 @@ fn build_next_run_from_parts(
if !played_profile_ids.contains(&profile_id) {
played_profile_ids.push(profile_id.clone());
}
let board = build_local_puzzle_board(grid_size, &run.run_id, &profile_id, next_level_index);
PuzzleRunRecord {
run_id: run.run_id.clone(),
entry_profile_id: run.entry_profile_id,
@@ -1939,52 +2033,125 @@ fn build_next_run_from_parts(
author_display_name,
theme_tags,
cover_image_src,
board: build_local_puzzle_board(grid_size),
board,
status: "playing".to_string(),
started_at_ms: (current_utc_micros().max(0) as u64) / 1_000,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
}
}
fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
let total = grid_size * grid_size;
let mut positions = (0..total)
.map(|index| PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
})
.collect::<Vec<_>>();
if !positions.is_empty() {
let first = positions.remove(0);
positions.push(first);
fn build_local_puzzle_board(
grid_size: u32,
run_id: &str,
profile_id: &str,
level_index: u32,
) -> PuzzleBoardRecord {
let board = module_puzzle::build_initial_board_with_seed(
grid_size,
build_local_puzzle_shuffle_seed(run_id, profile_id, level_index, grid_size),
)
.unwrap_or_else(|_| {
module_puzzle::build_initial_board_with_seed(3, 1)
.expect("fallback puzzle board should use supported grid size")
});
map_puzzle_board_snapshot_record(board)
}
fn build_local_puzzle_shuffle_seed(
run_id: &str,
profile_id: &str,
level_index: u32,
grid_size: u32,
) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in run_id
.bytes()
.chain(profile_id.bytes())
.chain(level_index.to_le_bytes())
.chain(grid_size.to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
let pieces = (0..total)
.map(|index| {
let current =
positions
.get(index as usize)
.cloned()
.unwrap_or(PuzzleCellPositionRecord {
row: index / grid_size,
col: index % grid_size,
});
PuzzlePieceStateRecord {
piece_id: format!("piece-{index}"),
correct_row: index / grid_size,
correct_col: index % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
}
})
.collect();
hash
}
fn map_puzzle_board_snapshot_record(board: PuzzleBoardSnapshot) -> PuzzleBoardRecord {
PuzzleBoardRecord {
rows: grid_size,
cols: grid_size,
pieces,
merged_groups: Vec::new(),
selected_piece_id: None,
all_tiles_resolved: false,
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateRecord {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupRecord {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionRecord {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board_positions(board: &PuzzleBoardRecord) -> Vec<(u32, u32)> {
board
.pieces
.iter()
.map(|piece| (piece.current_row, piece.current_col))
.collect()
}
fn has_original_neighbor_pair(board: &PuzzleBoardRecord) -> bool {
board.pieces.iter().any(|piece| {
board.pieces.iter().any(|candidate| {
piece.piece_id != candidate.piece_id
&& piece.current_row.abs_diff(candidate.current_row)
+ piece.current_col.abs_diff(candidate.current_col)
== 1
&& piece.correct_row.abs_diff(candidate.correct_row)
+ piece.correct_col.abs_diff(candidate.correct_col)
== 1
})
})
}
#[test]
fn local_next_level_board_shuffle_changes_by_level() {
let second = build_local_puzzle_board(3, "run-a", "profile-level-2", 2);
let third = build_local_puzzle_board(3, "run-a", "profile-level-3", 3);
assert_ne!(board_positions(&second), board_positions(&third));
assert!(!has_original_neighbor_pair(&second));
assert!(!has_original_neighbor_pair(&third));
}
}

View File

@@ -239,6 +239,15 @@ pub struct PuzzleMergedGroupState {
pub occupied_cells: Vec<PuzzleCellPosition>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardEntry {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub is_current_player: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleBoardSnapshot {
@@ -263,6 +272,10 @@ pub struct PuzzleRuntimeLevelSnapshot {
pub cover_image_src: Option<String>,
pub board: PuzzleBoardSnapshot,
pub status: PuzzleRuntimeLevelStatus,
pub started_at_ms: u64,
pub cleared_at_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -277,6 +290,7 @@ pub struct PuzzleRunSnapshot {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelSnapshot>,
pub recommended_next_profile_id: Option<String>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntry>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -438,6 +452,18 @@ pub struct PuzzleRunNextLevelInput {
pub advanced_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleLeaderboardSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentSessionProcedureResult {
@@ -924,6 +950,7 @@ pub fn start_run_with_shuffle_seed(
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let grid_size = resolve_puzzle_grid_size(cleared_level_count);
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
let started_at_ms = current_unix_ms();
Ok(PuzzleRunSnapshot {
run_id: run_id.clone(),
entry_profile_id: entry_profile.profile_id.clone(),
@@ -943,8 +970,13 @@ pub fn start_run_with_shuffle_seed(
cover_image_src: entry_profile.cover_image_src.clone(),
board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms,
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
})
}
@@ -1163,8 +1195,13 @@ pub fn advance_next_level(
cover_image_src: next_profile.cover_image_src.clone(),
board: next_board,
status: PuzzleRuntimeLevelStatus::Playing,
started_at_ms: current_unix_ms(),
cleared_at_ms: None,
elapsed_ms: None,
leaderboard_entries: Vec::new(),
}),
recommended_next_profile_id: None,
leaderboard_entries: Vec::new(),
})
}
@@ -1389,7 +1426,6 @@ fn build_initial_pieces_without_correct_neighbors(
grid_size: u32,
shuffle_seed: u64,
) -> Vec<PuzzlePieceState> {
let total = grid_size * grid_size;
let base_positions = build_correct_positions(grid_size);
for attempt in 0..PUZZLE_INITIAL_SHUFFLE_ATTEMPTS {
let mut positions = base_positions.clone();
@@ -1399,16 +1435,15 @@ fn build_initial_pieces_without_correct_neighbors(
);
ensure_board_is_not_solved(&mut positions, grid_size);
let pieces = build_pieces_from_positions(grid_size, &positions);
if !has_any_correct_neighbor_pair(&pieces) {
if !has_any_original_neighbor_pair(&pieces) {
return pieces;
}
}
// 反序布局等价于把完整棋盘旋转 180 度;任意原图相邻块在当前棋盘中的方向都会反向,
// 因此可作为“开局没有正确相邻块”的确定性兜底。
let fallback_pieces =
build_pieces_from_positions(grid_size, &build_reverse_positions(total, grid_size));
debug_assert!(!has_any_correct_neighbor_pair(&fallback_pieces));
// 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。
let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed)
.unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions));
debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces));
fallback_pieces
}
@@ -1422,16 +1457,6 @@ fn build_correct_positions(grid_size: u32) -> Vec<PuzzleCellPosition> {
.collect()
}
fn build_reverse_positions(total: u32, grid_size: u32) -> Vec<PuzzleCellPosition> {
(0..total)
.rev()
.map(|index| PuzzleCellPosition {
row: index / grid_size,
col: index % grid_size,
})
.collect()
}
fn build_pieces_from_positions(
grid_size: u32,
positions: &[PuzzleCellPosition],
@@ -1466,7 +1491,7 @@ fn ensure_board_is_not_solved(positions: &mut [PuzzleCellPosition], grid_size: u
}
}
fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
fn has_any_original_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
let pieces_by_cell = pieces
.iter()
.map(|piece| ((piece.current_row, piece.current_col), piece))
@@ -1476,10 +1501,138 @@ fn has_any_correct_neighbor_pair(pieces: &[PuzzlePieceState]) -> bool {
neighbor_cells(piece.current_row, piece.current_col)
.into_iter()
.filter_map(|cell| pieces_by_cell.get(&cell))
.any(|neighbor| are_correct_neighbors(piece, neighbor))
.any(|neighbor| are_original_neighbors(piece, neighbor))
})
}
fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> bool {
left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1
}
fn build_original_neighbor_free_pieces(
grid_size: u32,
shuffle_seed: u64,
) -> Option<Vec<PuzzlePieceState>> {
let total = (grid_size * grid_size) as usize;
let mut piece_order = (0..total as u32).collect::<Vec<_>>();
sort_indices_by_seed(&mut piece_order, shuffle_seed ^ 0xa076_1d64_78bd_642f);
let mut cell_order = build_correct_positions(grid_size);
sort_cells_by_seed(&mut cell_order, shuffle_seed ^ 0xe703_7ed1_a0b4_28db);
let mut placements = vec![None; total];
let mut used_cells = BTreeSet::new();
if place_neighbor_free_piece(
grid_size,
&piece_order,
&cell_order,
0,
&mut placements,
&mut used_cells,
) {
Some(
placements
.into_iter()
.enumerate()
.filter_map(|(index, current)| {
current.map(|current| PuzzlePieceState {
piece_id: format!("piece-{index}"),
correct_row: index as u32 / grid_size,
correct_col: index as u32 % grid_size,
current_row: current.row,
current_col: current.col,
merged_group_id: None,
})
})
.collect(),
)
} else {
None
}
}
fn place_neighbor_free_piece(
grid_size: u32,
piece_order: &[u32],
cell_order: &[PuzzleCellPosition],
depth: usize,
placements: &mut [Option<PuzzleCellPosition>],
used_cells: &mut BTreeSet<(u32, u32)>,
) -> bool {
let Some(piece_index) = piece_order.get(depth).copied() else {
return true;
};
for cell in cell_order {
if used_cells.contains(&(cell.row, cell.col)) {
continue;
}
if cell.row == piece_index / grid_size && cell.col == piece_index % grid_size {
continue;
}
if violates_original_neighbor_free_rule(grid_size, piece_index, cell.clone(), placements) {
continue;
}
placements[piece_index as usize] = Some(cell.clone());
used_cells.insert((cell.row, cell.col));
if place_neighbor_free_piece(
grid_size,
piece_order,
cell_order,
depth + 1,
placements,
used_cells,
) {
return true;
}
used_cells.remove(&(cell.row, cell.col));
placements[piece_index as usize] = None;
}
false
}
fn violates_original_neighbor_free_rule(
grid_size: u32,
piece_index: u32,
cell: PuzzleCellPosition,
placements: &[Option<PuzzleCellPosition>],
) -> bool {
placements
.iter()
.enumerate()
.filter_map(|(placed_index, placed_cell)| {
placed_cell
.as_ref()
.map(|placed_cell| (placed_index as u32, placed_cell))
})
.any(|(placed_index, placed_cell)| {
let original_neighbors = (piece_index / grid_size).abs_diff(placed_index / grid_size)
+ (piece_index % grid_size).abs_diff(placed_index % grid_size)
== 1;
let current_neighbors =
cell.row.abs_diff(placed_cell.row) + cell.col.abs_diff(placed_cell.col) == 1;
original_neighbors && current_neighbors
})
}
fn sort_indices_by_seed(indices: &mut [u32], seed: u64) {
indices.sort_by_key(|index| seeded_order_key(seed, u64::from(*index)));
}
fn sort_cells_by_seed(cells: &mut [PuzzleCellPosition], seed: u64) {
cells.sort_by_key(|cell| seeded_order_key(seed, u64::from(cell.row * 16 + cell.col)));
}
fn seeded_order_key(seed: u64, value: u64) -> u64 {
let mut state = seed ^ value.wrapping_mul(0x9e37_79b9_7f4a_7c15);
state ^= state >> 30;
state = state.wrapping_mul(0xbf58_476d_1ce4_e5b9);
state ^= state >> 27;
state = state.wrapping_mul(0x94d0_49bb_1331_11eb);
state ^ (state >> 31)
}
fn rebuild_board_snapshot(
grid_size: u32,
pieces: Vec<PuzzlePieceState>,
@@ -1808,15 +1961,32 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
if let Some(current_level) = next_run.current_level.as_mut() {
current_level.board = next_board;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared && is_cleared {
let cleared_at_ms = current_unix_ms();
current_level.cleared_at_ms = Some(cleared_at_ms);
current_level.elapsed_ms =
Some(cleared_at_ms.saturating_sub(current_level.started_at_ms).max(1_000));
}
current_level.status = next_level_status;
}
if is_cleared {
if is_cleared && run.current_level.as_ref().map(|level| level.status)
!= Some(PuzzleRuntimeLevelStatus::Cleared)
{
next_run.cleared_level_count += 1;
}
next_run
}
fn current_unix_ms() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1951,14 +2121,14 @@ mod tests {
}
#[test]
fn initial_board_has_no_correct_neighbor_pairs() {
fn initial_board_has_no_original_neighbor_pairs() {
for grid_size in [3, 4] {
for shuffle_seed in 0..128 {
let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board");
assert!(board.merged_groups.is_empty());
assert!(
!has_any_correct_neighbor_pair(&board.pieces),
!has_any_original_neighbor_pair(&board.pieces),
"grid_size={grid_size}, shuffle_seed={shuffle_seed}"
);
}

View File

@@ -29,6 +29,15 @@ pub struct DragPuzzlePieceRequest {
pub target_col: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubmitPuzzleLeaderboardRequest {
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCellPositionResponse {

View File

@@ -31,7 +31,8 @@ pub use mapper::{
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,

View File

@@ -2272,6 +2272,11 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz
.current_level
.map(map_puzzle_runtime_level_snapshot),
recommended_next_profile_id: snapshot.recommended_next_profile_id,
leaderboard_entries: snapshot
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry)
.collect(),
}
}
@@ -2289,6 +2294,25 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
cover_image_src: snapshot.cover_image_src,
board: map_puzzle_board_snapshot(snapshot.board),
status: snapshot.status.as_str().to_string(),
started_at_ms: snapshot.started_at_ms,
cleared_at_ms: snapshot.cleared_at_ms,
elapsed_ms: snapshot.elapsed_ms,
leaderboard_entries: snapshot
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry)
.collect(),
}
}
pub(crate) fn map_puzzle_leaderboard_entry(
snapshot: module_puzzle::PuzzleLeaderboardEntry,
) -> PuzzleLeaderboardEntryRecord {
PuzzleLeaderboardEntryRecord {
rank: snapshot.rank,
nickname: snapshot.nickname,
elapsed_ms: snapshot.elapsed_ms,
is_current_player: snapshot.is_current_player,
}
}
@@ -4374,6 +4398,14 @@ pub struct PuzzleMergedGroupRecord {
pub occupied_cells: Vec<PuzzleCellPositionRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleLeaderboardEntryRecord {
pub rank: u32,
pub nickname: String,
pub elapsed_ms: u64,
pub is_current_player: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleBoardRecord {
pub rows: u32,
@@ -4396,6 +4428,10 @@ pub struct PuzzleRuntimeLevelRecord {
pub cover_image_src: Option<String>,
pub board: PuzzleBoardRecord,
pub status: String,
pub started_at_ms: u64,
pub cleared_at_ms: Option<u64>,
pub elapsed_ms: Option<u64>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -4409,6 +4445,18 @@ pub struct PuzzleRunRecord {
pub previous_level_tags: Vec<String>,
pub current_level: Option<PuzzleRuntimeLevelRecord>,
pub recommended_next_profile_id: Option<String>,
pub leaderboard_entries: Vec<PuzzleLeaderboardEntryRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleLeaderboardSubmitRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]

View File

@@ -294,6 +294,8 @@ pub mod puzzle_agent_session_row_type;
pub mod puzzle_agent_stage_type;
pub mod puzzle_draft_compile_input_type;
pub mod puzzle_generated_images_save_input_type;
pub mod puzzle_leaderboard_entry_row_type;
pub mod puzzle_leaderboard_submit_input_type;
pub mod puzzle_publication_status_type;
pub mod puzzle_publish_input_type;
pub mod puzzle_run_drag_input_type;
@@ -442,6 +444,7 @@ pub mod submit_big_fish_input_procedure;
pub mod submit_big_fish_message_procedure;
pub mod submit_custom_world_agent_message_procedure;
pub mod submit_puzzle_agent_message_procedure;
pub mod submit_puzzle_leaderboard_entry_procedure;
pub mod swap_puzzle_pieces_procedure;
pub mod treasure_interaction_action_type;
pub mod treasure_record_procedure_result_type;
@@ -755,6 +758,8 @@ pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow;
pub use puzzle_agent_stage_type::PuzzleAgentStage;
pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput;
pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput;
pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow;
pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
pub use puzzle_publication_status_type::PuzzlePublicationStatus;
pub use puzzle_publish_input_type::PuzzlePublishInput;
pub use puzzle_run_drag_input_type::PuzzleRunDragInput;
@@ -903,6 +908,7 @@ pub use submit_big_fish_input_procedure::submit_big_fish_input;
pub use submit_big_fish_message_procedure::submit_big_fish_message;
pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message;
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces;
pub use treasure_interaction_action_type::TreasureInteractionAction;
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;

View File

@@ -0,0 +1,70 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PuzzleLeaderboardEntryRow {
pub entry_id: String,
pub profile_id: String,
pub grid_size: u32,
pub user_id: String,
pub nickname: String,
pub best_elapsed_ms: u64,
pub last_run_id: String,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for PuzzleLeaderboardEntryRow {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `PuzzleLeaderboardEntryRow`.
///
/// Provides typed access to columns for query building.
pub struct PuzzleLeaderboardEntryRowCols {
pub entry_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
pub profile_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
pub grid_size: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, u32>,
pub user_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
pub nickname: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
pub best_elapsed_ms: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, u64>,
pub last_run_id: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, String>,
pub updated_at: __sdk::__query_builder::Col<PuzzleLeaderboardEntryRow, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for PuzzleLeaderboardEntryRow {
type Cols = PuzzleLeaderboardEntryRowCols;
fn cols(table_name: &'static str) -> Self::Cols {
PuzzleLeaderboardEntryRowCols {
entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
grid_size: __sdk::__query_builder::Col::new(table_name, "grid_size"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
nickname: __sdk::__query_builder::Col::new(table_name, "nickname"),
best_elapsed_ms: __sdk::__query_builder::Col::new(table_name, "best_elapsed_ms"),
last_run_id: __sdk::__query_builder::Col::new(table_name, "last_run_id"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `PuzzleLeaderboardEntryRow`.
///
/// Provides typed access to indexed columns for query building.
pub struct PuzzleLeaderboardEntryRowIxCols {
pub entry_id: __sdk::__query_builder::IxCol<PuzzleLeaderboardEntryRow, String>,
}
impl __sdk::__query_builder::HasIxCols for PuzzleLeaderboardEntryRow {
type IxCols = PuzzleLeaderboardEntryRowIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
PuzzleLeaderboardEntryRowIxCols {
entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for PuzzleLeaderboardEntryRow {}

View File

@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PuzzleLeaderboardSubmitInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub grid_size: u32,
pub elapsed_ms: u64,
pub nickname: String,
pub submitted_at_micros: i64,
}
impl __sdk::InModule for PuzzleLeaderboardSubmitInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput;
use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct SubmitPuzzleLeaderboardEntryArgs {
pub input: PuzzleLeaderboardSubmitInput,
}
impl __sdk::InModule for SubmitPuzzleLeaderboardEntryArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `submit_puzzle_leaderboard_entry`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait submit_puzzle_leaderboard_entry {
fn submit_puzzle_leaderboard_entry(&self, input: PuzzleLeaderboardSubmitInput) {
self.submit_puzzle_leaderboard_entry_then(input, |_, _| {});
}
fn submit_puzzle_leaderboard_entry_then(
&self,
input: PuzzleLeaderboardSubmitInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl submit_puzzle_leaderboard_entry for super::RemoteProcedures {
fn submit_puzzle_leaderboard_entry_then(
&self,
input: PuzzleLeaderboardSubmitInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
"submit_puzzle_leaderboard_entry",
SubmitPuzzleLeaderboardEntryArgs { input },
__callback,
);
}
}

View File

@@ -462,4 +462,32 @@ impl SpacetimeClient {
})
.await
}
pub async fn submit_puzzle_leaderboard_entry(
&self,
input: PuzzleLeaderboardSubmitRecordInput,
) -> Result<PuzzleRunRecord, SpacetimeClientError> {
let procedure_input = PuzzleLeaderboardSubmitInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
grid_size: input.grid_size,
elapsed_ms: input.elapsed_ms,
nickname: input.nickname,
submitted_at_micros: input.submitted_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().submit_puzzle_leaderboard_entry_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_run_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
}

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);
}
}