fix: 完善拼消消模板运行规则

This commit is contained in:
2026-06-11 00:50:18 +08:00
parent c98c3de96d
commit 21ac5642e8
19 changed files with 1952 additions and 317 deletions

View File

@@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
use shared_kernel::normalize_required_string;
use crate::{
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell,
PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig,
PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot,
PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota,
PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination,
PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation,
PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind,
PuzzleClearShapeQuota,
};
pub fn puzzle_clear_level_configs() -> Vec<PuzzleClearLevelConfig> {
vec![PuzzleClearLevelConfig {
level_index: 1,
board_size: 6,
target_clears: 35,
duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
PuzzleClearShapeKind::TwoByThree,
],
}]
vec![
PuzzleClearLevelConfig {
level_index: 1,
board_size: 6,
target_clears: 15,
duration_seconds: 300,
unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo],
},
PuzzleClearLevelConfig {
level_index: 2,
board_size: 6,
target_clears: 20,
duration_seconds: 300,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
],
},
PuzzleClearLevelConfig {
level_index: 3,
board_size: 6,
target_clears: 30,
duration_seconds: 420,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
],
},
PuzzleClearLevelConfig {
level_index: 4,
board_size: 6,
target_clears: 35,
duration_seconds: 600,
unlocked_shapes: vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
PuzzleClearShapeKind::TwoByThree,
],
},
]
}
pub fn puzzle_clear_shape_quotas() -> Vec<PuzzleClearShapeQuota> {
@@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board(
return Err(PuzzleClearError::InvalidLevel);
}
let total = (level.board_size * level.board_size) as usize;
if cards.len() < total {
if cards.is_empty() {
return Err(PuzzleClearError::EmptyDeck);
}
let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index));
@@ -125,10 +155,12 @@ pub fn create_puzzle_clear_board(
for row in 0..level.board_size {
for col in 0..level.board_size {
let index = (row * level.board_size + col) as usize;
let empty_slots = total.saturating_sub(selected.len());
let card_index = index.checked_sub(empty_slots);
cells.push(PuzzleClearCell {
row,
col,
card: selected.get(index).cloned(),
card: card_index.and_then(|card_index| selected.get(card_index).cloned()),
locked_group_id: None,
});
}
@@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap(
.into_iter()
.find(|config| config.level_index == next.level_index)
.ok_or(PuzzleClearError::InvalidLevel)?;
if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) {
if next.clears_done >= level.target_clears && !has_remaining_cards_in_run(&next) {
next.status = if next.level_index >= max_puzzle_clear_level_index() {
PuzzleClearRunStatus::Finished
} else {
@@ -401,8 +433,13 @@ fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), P
}
fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool {
let duration_seconds = puzzle_clear_level_configs()
.into_iter()
.find(|config| config.level_index == run.level_index)
.map(|config| config.duration_seconds)
.unwrap_or(600);
now_ms.saturating_sub(run.level_started_at_ms)
> u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000
> u64::from(duration_seconds) * 1000
}
fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> {
@@ -421,6 +458,15 @@ fn has_remaining_cards(board: &PuzzleClearBoard) -> bool {
board.cells.iter().any(|cell| cell.card.is_some())
}
fn has_remaining_cards_in_run(run: &PuzzleClearRunSnapshot) -> bool {
has_remaining_cards(&run.board)
|| run
.deck
.ready_columns
.iter()
.any(|column| !column.is_empty())
}
fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> {
if find_eliminations(board).is_empty() && has_playable_move(board) {
return Ok(());
@@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo {
return None;
}
let mut ordered = entries.clone();
ordered.sort_by_key(|(_, _, card)| (card.part_y, card.part_x));
let adjacent = ordered.windows(2).all(|pair| {
let a = &pair[0].2;
let b = &pair[1].2;
manhattan_part_distance(a, b) == 1
&& are_neighbors(pair[0].0, pair[0].1, pair[1].0, pair[1].1)
});
adjacent.then(|| PuzzleClearElimination {
is_connected_partial_group(&entries).then(|| PuzzleClearElimination {
group_id,
positions: entries
.into_iter()
@@ -509,6 +547,32 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
.collect()
}
fn is_connected_partial_group(entries: &[(u32, u32, PuzzleClearCard)]) -> bool {
if entries.len() < 2 {
return false;
}
let mut visited = vec![false; entries.len()];
let mut stack = vec![0usize];
visited[0] = true;
while let Some(index) = stack.pop() {
let current = &entries[index];
for (candidate_index, candidate) in entries.iter().enumerate() {
if visited[candidate_index] {
continue;
}
if manhattan_part_distance(&current.2, &candidate.2) == 1
&& are_neighbors(current.0, current.1, candidate.0, candidate.1)
{
visited[candidate_index] = true;
stack.push(candidate_index);
}
}
}
visited.into_iter().all(|is_visited| is_visited)
}
fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) {
for cell in &mut board.cells {
if cell.locked_group_id.as_deref() == Some(group_id) {
@@ -1177,14 +1241,40 @@ mod tests {
use super::*;
#[test]
fn fixed_level_config_uses_single_six_by_six_level() {
fn fixed_level_config_uses_four_six_by_six_levels() {
let configs = puzzle_clear_level_configs();
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].board_size, 6);
assert_eq!(configs[0].target_clears, 35);
assert_eq!(configs.len(), 4);
assert!(configs.iter().all(|config| config.board_size == 6));
assert_eq!(
configs[0].unlocked_shapes,
configs
.iter()
.map(|config| (
config.level_index,
config.target_clears,
config.duration_seconds
))
.collect::<Vec<_>>(),
vec![(1, 15, 300), (2, 20, 300), (3, 30, 420), (4, 35, 600)]
);
assert_eq!(configs[0].unlocked_shapes, vec![PuzzleClearShapeKind::OneByTwo]);
assert_eq!(
configs[1].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
]
);
assert_eq!(
configs[2].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
PuzzleClearShapeKind::TwoByTwo,
]
);
assert_eq!(
configs[3].unlocked_shapes,
vec![
PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree,
@@ -1192,7 +1282,6 @@ mod tests {
PuzzleClearShapeKind::TwoByThree,
]
);
assert!(configs.iter().all(|config| config.duration_seconds == 600));
}
#[test]
@@ -1250,6 +1339,23 @@ mod tests {
assert!(has_playable_move(&board));
}
#[test]
fn first_level_board_uses_exact_target_cards_and_leaves_empty_cells() {
let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan");
let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear")
.into_iter()
.filter(|card| card.shape == PuzzleClearShapeKind::OneByTwo)
.take(30)
.collect::<Vec<_>>();
let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards)
.expect("board should create with empty cells");
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_some()).count(), 30);
assert_eq!(board.cells.iter().filter(|cell| cell.card.is_none()).count(), 6);
assert!(find_eliminations(&board).is_empty());
assert!(has_playable_move(&board));
}
#[test]
fn one_by_two_neighbors_are_not_half_locked() {
let board = board_from_cards(
@@ -1350,7 +1456,7 @@ mod tests {
}
#[test]
fn reaching_target_clears_without_empty_board_keeps_playing() {
fn reaching_target_clears_does_not_complete_level_with_remaining_cards() {
let board = board_from_cards(
3,
vec![
@@ -1376,7 +1482,7 @@ mod tests {
100,
)
.expect("run should start");
run.clears_done = 4;
run.clears_done = 14;
let next = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
@@ -1389,11 +1495,57 @@ mod tests {
)
.expect("swap should resolve");
assert_eq!(next.clears_done, 5);
assert_eq!(next.clears_done, 15);
assert_eq!(next.status, PuzzleClearRunStatus::Playing);
assert!(next.finished_at_ms.is_none());
assert!(next.board.cells.iter().any(|cell| cell.card.is_some()));
}
#[test]
fn reaching_target_clears_completes_level_after_all_cards_are_removed() {
let board = board_from_cards(
3,
vec![
Some(card("play", 0, 0)),
None,
None,
None,
Some(card("play", 1, 0)),
None,
None,
None,
None,
],
);
let mut run = start_puzzle_clear_run(
"run-target-empty".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
board,
PuzzleClearDeck {
ready_columns: vec![vec![], vec![], vec![]],
},
100,
)
.expect("run should start");
run.clears_done = 14;
let next = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
from_row: 1,
from_col: 1,
to_row: 0,
to_col: 1,
},
200,
)
.expect("swap should resolve");
assert_eq!(next.clears_done, 15);
assert_eq!(next.status, PuzzleClearRunStatus::LevelCleared);
assert!(next.board.cells.iter().all(|cell| cell.card.is_none()));
}
#[test]
fn refill_keeps_locked_partial_group_in_place() {
let mut board = board_from_cards(
@@ -1737,6 +1889,57 @@ mod tests {
);
}
#[test]
fn two_by_two_l_shaped_partial_group_locks_as_one_group() {
let board = board_from_cards(
3,
vec![
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 0)),
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 1, 0)),
Some(card("noise-a", 0, 0)),
Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 1)),
Some(card("noise-b", 0, 0)),
Some(card("play", 1, 0)),
Some(card("noise-d", 0, 0)),
Some(card("play", 0, 0)),
Some(card("noise-c", 0, 0)),
],
);
let run = start_puzzle_clear_run(
"run-2x2-partial".to_string(),
"user-1".to_string(),
"profile-1".to_string(),
board,
PuzzleClearDeck {
ready_columns: vec![vec![], vec![], vec![]],
},
100,
)
.expect("run should start");
let locked = apply_puzzle_clear_swap(
&run,
PuzzleClearMove {
from_row: 1,
from_col: 1,
to_row: 2,
to_col: 0,
},
200,
)
.expect("non-clear swap should lock partial group");
let block_locks = locked
.board
.cells
.iter()
.filter(|cell| cell.card.as_ref().is_some_and(|card| card.group_id == "block"))
.map(|cell| cell.locked_group_id.as_deref())
.collect::<Vec<_>>();
assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]);
assert_eq!(locked.clears_done, 0);
}
#[test]
fn timeout_fails_only_current_level_and_retry_restarts_it() {
let board = board_from_cards(