1
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"anchorQuestions": [
|
||||
{
|
||||
"key": "themePromise",
|
||||
"label": "题材承诺",
|
||||
"label": "题材",
|
||||
"question": "这张拼图给玩家的题材和完成期待是什么?",
|
||||
"requiredEffect": "明确拼图主题、辨识度和完成后的满足感。"
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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