fix: 完善拼消消模板运行规则
This commit is contained in:
@@ -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(¤t.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(
|
||||
|
||||
Reference in New Issue
Block a user