diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abb7ef..c45e739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `cli` and `image` Cargo features, enabled by default, so library users can exclude the CLI or image support and their dependencies. - Add Windows support by replacing `termion` with `crossterm`. +- Add a heuristic for two adjacent rows or columns that must contain a queen of + a particular color, allowing puzzles such as + [#15](https://github.com/dschafer/qsolve/issues/15) to be solved without + forcing chains. ## [1.0.0] - 2025-04-23 diff --git a/games/README.md b/games/README.md index 4f2605d..0371b69 100644 --- a/games/README.md +++ b/games/README.md @@ -5,3 +5,4 @@ These are sample games taken from various Queens sites online: - [LinkedIn](https://www.linkedin.com/games/queens). Since there's no archive of these games avaiable, the screenshot of Game #1 is taken from [MojoDojo](https://mojodojo.io/blog/you-can-now-play-games-on-linkedin/). - [QueensGame](https://queensgame.vercel.app/level/1). This is an archive of LinkedIn Queens. - [Game of Crowns](https://gameofcrowns.sanish.me/challenges/1). This is a dedicated site with original puzzles. +- [GitHub issue #15](https://github.com/dschafer/qsolve/issues/15). This puzzle is a solver regression test. diff --git a/games/github-issue-15.txt b/games/github-issue-15.txt new file mode 100644 index 0000000..ef6b545 --- /dev/null +++ b/games/github-issue-15.txt @@ -0,0 +1,7 @@ +BmYYYrr +BmYYYYr +BmmYYYr +BmmMkYr +BBBMkMb +BBBMMMb +BBBMbbb diff --git a/src/heuristic.rs b/src/heuristic.rs index b07fce9..d859e95 100644 --- a/src/heuristic.rs +++ b/src/heuristic.rs @@ -5,6 +5,7 @@ use crate::{ board::Board, datastructure::{Coord, CoordSet, LineSet, SquareColorSet}, solvestate::{SolveState, SolveStrategy, SquareVal}, + squarecolor::SquareColor, }; #[derive(Debug, Eq, PartialEq)] @@ -158,6 +159,38 @@ pub fn all_heuristics(board: &Board) -> Vec> { }) as _ })); + for (first, second) in (0..board.size()).tuple_windows() { + let rows = [board.row_coords(first), board.row_coords(second)]; + v.extend( + rows.iter() + .flatten() + .map(|coord| board.color(&coord)) + .unique() + .map(|color| { + Box::new(TwoLinesRequireColor { + lines: rows, + color, + desc: format!("Rows {} and {}", first + 1, second + 1), + }) as _ + }), + ); + + let cols = [board.col_coords(first), board.col_coords(second)]; + v.extend( + cols.iter() + .flatten() + .map(|coord| board.color(&coord)) + .unique() + .map(|color| { + Box::new(TwoLinesRequireColor { + lines: cols, + color, + desc: format!("Cols {} and {}", first + 1, second + 1), + }) as _ + }), + ); + } + v.extend( (0..board.size()) .powerset() @@ -288,6 +321,83 @@ impl Heuristic for AllPossibilitiesEliminateSquare { } } +#[derive(Debug)] +struct TwoLinesRequireColor { + lines: [CoordSet; 2], + color: SquareColor, + desc: String, +} + +impl Heuristic for TwoLinesRequireColor { + fn seen_coords(&self, solve_state: &SolveState) -> CoordSet { + self.lines + .iter() + .flatten() + .filter(|&coord| solve_state.square(&coord).is_none()) + .collect() + } + + fn changes(&self, solve_state: &SolveState) -> Option { + trace!("Heuristic Start: TwoLinesRequireColor {self:?}"); + if self + .lines + .iter() + .flatten() + .any(|coord| solve_state.square(&coord) == Some(SquareVal::Queen)) + { + trace!("Heuristic Invalid: TwoLinesRequireColor {self:?}"); + return None; + } + + let candidates = self.lines.map(|line| { + line.iter() + .filter(|&coord| solve_state.square(&coord).is_none()) + .collect::>() + }); + let legal_pairs = candidates[0] + .iter() + .copied() + .cartesian_product(candidates[1].iter().copied()) + .filter(|(first, second)| !solve_state.board.queen_borders(first).contains(second)) + .collect::>(); + if legal_pairs.is_empty() { + trace!("Heuristic Invalid: TwoLinesRequireColor {self:?}"); + return None; + } + if legal_pairs.iter().any(|(first, second)| { + solve_state.board.color(first) != self.color + && solve_state.board.color(second) != self.color + }) { + trace!("Heuristic Invalid: TwoLinesRequireColor {self:?}"); + return None; + } + + trace!("Heuristic Success: TwoLinesRequireColor {self:?}"); + let line_coords = CoordSet::from_iter(self.lines.iter().flatten()); + let x = solve_state + .board + .coords_for_color(&self.color) + .iter() + .filter(|coord| !line_coords.contains(coord)) + .filter(|coord| solve_state.square(coord).is_none()) + .collect::(); + if x.is_empty() { + trace!("Heuristic No-op: TwoLinesRequireColor {self:?}"); + None + } else { + trace!("Heuristic Return: TwoLinesRequireColor {self:?}"); + Some(Changes::AddX { x }) + } + } + + fn description(&self) -> String { + format!( + "At most one non-'{:?}' queen can be placed in {}.\nOne queen there must be '{:?}'; x out that color outside {}.", + self.color, self.desc, self.color, self.desc + ) + } +} + #[derive(Debug)] struct NLinesContainOnlyNColors { lines: Vec, @@ -583,6 +693,123 @@ mod tests { assert!(heuristic.description().contains("desc")); } + #[test] + fn two_rows_require_color() -> Result<()> { + let input_str = "BmYYYrr\nBmYYYYr\nBmmYYYr\nBmmMkYr\nBBBMkMb\nBBBMMMb\nBBBMbbb\n\n.x.xx..\n.x.xxx.\nx.xxxx.\nx.xx.x.\n.xxx.x.\n.xx.xx.\n.xx.x.."; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.row_coords(1), ss.board.row_coords(2)], + color: SquareColor::Red, + desc: String::new(), + }; + assert_eq!( + heuristic.changes(&ss), + Some(Changes::AddX { + x: CoordSet::from_iter([(0, 5), (0, 6), (3, 6)]) + }) + ); + assert_eq!( + heuristic.seen_coords(&ss), + CoordSet::from_iter([(1, 0), (1, 2), (1, 6), (2, 1), (2, 6)]) + ); + Ok(()) + } + + #[test] + fn two_columns_require_color() -> Result<()> { + let input_str = "BBBBBBB\nmmmmBBB\nYYmmBBB\nYYYMMMM\nYYYkkMb\nrYYYMMb\nrrrrbbb\n\n..xx...\nxx..xxx\n..xxxxx\nxxxxx..\nxxx..xx\n.xxxxx.\n......."; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.col_coords(1), ss.board.col_coords(2)], + color: SquareColor::Red, + desc: String::new(), + }; + assert_eq!( + heuristic.changes(&ss), + Some(Changes::AddX { + x: CoordSet::from_iter([(5, 0), (6, 0), (6, 3)]) + }) + ); + Ok(()) + } + + #[test] + fn two_lines_require_color_needs_legal_pair() -> Result<()> { + let input_str = "rrrr\nkkkk\nbbbb\ncccc\n\n.xxx\nx.xx\nxx.x\nxxxx"; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.row_coords(0), ss.board.row_coords(1)], + color: SquareColor::Blue, + desc: String::new(), + }; + assert_eq!(heuristic.changes(&ss), None); + Ok(()) + } + + #[test] + fn two_lines_require_color_ignores_solved_lines() -> Result<()> { + let input_str = "rrrr\nkkkk\nbbbb\ncccc\n\nQ...\n....\n....\n...."; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.row_coords(0), ss.board.row_coords(1)], + color: SquareColor::Blue, + desc: String::new(), + }; + assert_eq!(heuristic.changes(&ss), None); + Ok(()) + } + + #[test] + fn two_lines_require_color_ignores_non_required_color() -> Result<()> { + let input_str = "BmYYYrr\nBmYYYYr\nBmmYYYr\nBmmMkYr\nBBBMkMb\nBBBMMMb\nBBBMbbb\n\n.x.xx..\n.x.xxx.\nx.xxxx.\nx.xx.x.\n.xxx.x.\n.xx.xx.\n.xx.x.."; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.row_coords(1), ss.board.row_coords(2)], + color: SquareColor::BrightBlue, + desc: String::new(), + }; + assert_eq!(heuristic.changes(&ss), None); + Ok(()) + } + + #[test] + fn two_lines_require_color_ignores_noop() -> Result<()> { + let input_str = "BmYYYrr\nBmYYYYr\nBmmYYYr\nBmmMkYr\nBBBMkMb\nBBBMMMb\nBBBMbbb\n\n.x.xxxx\n.x.xxx.\nx.xxxx.\nx.xx.xx\n.xxx.x.\n.xx.xx.\n.xx.x.."; + let queens_file = QueensFile::from_str(input_str)?; + let ss = SolveState::from(&queens_file); + assert!(ss.is_valid()); + let heuristic = TwoLinesRequireColor { + lines: [ss.board.row_coords(1), ss.board.row_coords(2)], + color: SquareColor::Red, + desc: String::new(), + }; + assert_eq!(heuristic.changes(&ss), None); + Ok(()) + } + + #[test] + fn two_lines_require_color_description() { + let heuristic = TwoLinesRequireColor { + lines: [CoordSet::default(); 2], + color: SquareColor::Red, + desc: "Rows 2 and 3".to_string(), + }; + let description = heuristic.description(); + assert!(description.contains("Rows 2 and 3")); + assert!(description.contains("Red")); + assert_eq!(description.lines().count(), 2); + } + #[test] fn nlines_contain_only_ncolors() -> Result<()> { let input_str = "rrrr\nrkkk\nbbbb\ncccc\n\n....\n....\n....\n....";