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

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