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