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