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