001package ball.game.crossword; 002/*- 003 * ########################################################################## 004 * Game Applications and Utilities 005 * $Id: Puzzle.java 6118 2020-06-04 19:31:45Z ball $ 006 * $HeadURL: svn+ssh://svn.hcf.dev/var/spool/scm/repository.svn/ball-game/trunk/src/main/java/ball/game/crossword/Puzzle.java $ 007 * %% 008 * Copyright (C) 2010 - 2020 Allen D. Ball 009 * %% 010 * Licensed under the Apache License, Version 2.0 (the "License"); 011 * you may not use this file except in compliance with the License. 012 * You may obtain a copy of the License at 013 * 014 * http://www.apache.org/licenses/LICENSE-2.0 015 * 016 * Unless required by applicable law or agreed to in writing, software 017 * distributed under the License is distributed on an "AS IS" BASIS, 018 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 019 * See the License for the specific language governing permissions and 020 * limitations under the License. 021 * ########################################################################## 022 */ 023import ball.util.Coordinate; 024import ball.util.CoordinateMap; 025import ball.util.DispatchSpliterator; 026import java.io.BufferedReader; 027import java.io.File; 028import java.io.FileInputStream; 029import java.io.IOException; 030import java.io.InputStream; 031import java.io.InputStreamReader; 032import java.io.OutputStream; 033import java.io.OutputStreamWriter; 034import java.io.PrintWriter; 035import java.io.UncheckedIOException; 036import java.io.Writer; 037import java.util.ArrayList; 038import java.util.Arrays; 039import java.util.Collection; 040import java.util.Comparator; 041import java.util.IdentityHashMap; 042import java.util.LinkedHashMap; 043import java.util.LinkedList; 044import java.util.List; 045import java.util.Map; 046import java.util.Objects; 047import java.util.Set; 048import java.util.Spliterator; 049import java.util.TreeMap; 050import java.util.TreeSet; 051import java.util.function.Supplier; 052import java.util.regex.Matcher; 053import java.util.regex.Pattern; 054import java.util.stream.Stream; 055import java.util.stream.StreamSupport; 056import lombok.ToString; 057 058import static java.nio.charset.StandardCharsets.UTF_8; 059import static java.util.Collections.copy; 060import static java.util.Collections.disjoint; 061import static java.util.Collections.indexOfSubList; 062import static java.util.Collections.unmodifiableList; 063import static java.util.Collections.unmodifiableMap; 064import static java.util.stream.Collectors.joining; 065import static java.util.stream.Collectors.toCollection; 066import static java.util.stream.Collectors.toList; 067import static java.util.stream.Collectors.toMap; 068import static org.apache.commons.lang3.StringUtils.EMPTY; 069import static org.apache.commons.lang3.StringUtils.SPACE; 070import static org.apache.commons.lang3.StringUtils.isBlank; 071import static org.apache.commons.lang3.StringUtils.isNotBlank; 072 073/** 074 * Crossword {@link Puzzle}. 075 * 076 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball} 077 * @version $Revision: 6118 $ 078 */ 079@ToString 080public class Puzzle extends CoordinateMap<Cell> implements Cloneable { 081 private static final long serialVersionUID = 5580701146811926874L; 082 083 private static final List<String> BOUNDARY = Arrays.asList(EMPTY, EMPTY); 084 085 private static final String COLON = ":"; 086 private static final String DOT = "."; 087 private static final String TILDE = "~"; 088 089 /** @serial */ 090 private final Puzzle parent; 091 /** @serial */ 092 private final Map<String,String> headers; 093 /** @serial */ 094 private final List<Coordinate> indices; 095 /** @serial */ 096 private final Map<Label,Solution> solutions; 097 /** @serial */ 098 private final IdentityHashMap<Solution,List<Solution>> xref; 099 /** @serial */ 100 private final Map<Label,String> clues; 101 /** @serial */ 102 private final List<String> notes; 103 104 /** 105 * Private constructor for {@link Spliterator} implmentation. 106 * 107 * @param parent The source {@link Puzzle}. 108 * @param solution The {@link Solution} to update. 109 * @param sequence The {@link CharSequence} for the 110 * {@link Solution}. 111 */ 112 private Puzzle(Puzzle parent, Solution solution, CharSequence sequence) { 113 this(parent); 114 115 try { 116 for (Coordinate coordinate : solution) { 117 put(coordinate, parent.get(coordinate).clone()); 118 } 119 120 solution.setSolution(this, sequence); 121 } catch (Exception exception) { 122 throw new ExceptionInInitializerError(exception); 123 } 124 } 125 126 /** 127 * Private constructor for {@link #clone()}. 128 * 129 * @param parent The source {@link Puzzle}. 130 */ 131 private Puzzle(Puzzle parent) { 132 this(parent, 133 parent.headers, 134 parent.indices, parent.solutions, parent.xref, 135 parent.clues, parent.notes); 136 137 this.putAll(parent); 138 } 139 140 /** 141 * Private constructor for {@link #load(String)}. 142 * 143 * @param headers The {@link List} of header {@link String}s. 144 * @param grid The {@link List} of grid row 145 * {@link String}s. 146 * @param clues The {@link List} of clue {@link String}s. 147 * @param notes The {@link List} of note {@link String}s. 148 */ 149 private Puzzle(List<String> headers, List<String> grid, 150 List<String> clues, List<String> notes) { 151 this(null, 152 new OrderedHeaders(headers), 153 new ArrayList<>(), new TreeMap<>(), new IdentityHashMap<>(), 154 new TreeMap<>(), (notes != null) ? notes : new LinkedList<>()); 155 /* 156 * Populate the grid. 157 */ 158 for (String line : grid) { 159 List<Cell> row = Cell.getRowFrom(line.trim()); 160 161 if ((getColumnCount() * getRowCount()) > 0) { 162 if (getColumnCount() != row.size()) { 163 throw new IllegalArgumentException(line + " does not have " 164 + getColumnCount() 165 + " columns"); 166 } 167 } 168 169 resize(getRowCount() + 1, row.size()); 170 copy(row(getRowCount() - 1).asList(), row); 171 } 172 /* 173 * Find the Coordinate groups that represent solutions. 174 */ 175 List<CoordinateMap<Cell>> groups = new LinkedList<>(); 176 Stream<CoordinateMap<Cell>> stream = 177 Stream.concat(rows().stream(), columns().stream()); 178 179 for (CoordinateMap<Cell> line : 180 (Iterable<CoordinateMap<Cell>>) stream::iterator) { 181 groups.add(new CoordinateMap<Cell>()); 182 183 for (Map.Entry<Coordinate,Cell> entry : line.entrySet()) { 184 if (! entry.getValue().isBlock()) { 185 groups.get(groups.size() - 1) 186 .put(entry.getKey(), entry.getValue()); 187 } else { 188 groups.add(new CoordinateMap<Cell>()); 189 } 190 } 191 } 192 193 groups.removeIf(t -> ! (t.size() > 1)); 194 /* 195 * Calculate the Label indices. 196 */ 197 TreeSet<Coordinate> indices = 198 groups.stream() 199 .map(t -> t.firstKey()) 200 .collect(toCollection(TreeSet::new)); 201 202 this.indices.addAll(indices); 203 /* 204 * Populate the solutions and xref maps. 205 */ 206 TreeMap<Label,Solution> solutions = 207 groups.stream() 208 .collect(toMap(k -> labelFor(k), 209 v -> new Solution(v.keySet()), 210 (v0, v1) -> v0, 211 TreeMap::new)); 212 213 this.solutions.putAll(solutions); 214 215 Collection<Solution> values = this.solutions.values(); 216 IdentityHashMap<Solution,List<Solution>> xref = 217 values.stream() 218 .collect(toMap(k -> k, 219 v -> (values.stream() 220 .filter(t -> t != v) 221 .filter(t -> (! disjoint(t, v))) 222 .collect(toList())), 223 (v0, v1) -> v0, IdentityHashMap::new)); 224 225 this.xref.putAll(xref); 226 /* 227 * Populate the clues map. 228 */ 229 if (clues != null) { 230 clues.stream() 231 .filter(t -> isNotBlank(t)) 232 .map(t -> t.split("[. ]+", 2)) 233 .forEach(t -> this.clues.put(Label.parse(t[0]), t[1])); 234 } 235 236 List<Label> labels = this.clues.keySet().stream().collect(toList());; 237 238 labels.removeAll(this.solutions.keySet()); 239 240 for (Label label : labels) { 241 throw new IllegalArgumentException("`" + label 242 + "' not found in grid'"); 243 } 244 245 for (Map.Entry<Label,String> entry : this.clues.entrySet()) { 246 String[] strings = entry.getValue().split("[~]", 2); 247 248 if (strings.length > 1) { 249 entry.setValue(strings[0].trim()); 250 251 if (isNotBlank(strings[1])) { 252 solutions 253 .get(entry.getKey()) 254 .setSolution(this, strings[1].trim()); 255 } 256 } 257 } 258 259 this.solutions.keySet() 260 .stream() 261 .forEach(t -> this.clues.computeIfAbsent(t, k -> "TBD")); 262 } 263 264 private Puzzle(Puzzle parent, 265 Map<String,String> headers, 266 List<Coordinate> indices, Map<Label,Solution> solutions, 267 IdentityHashMap<Solution,List<Solution>> xref, 268 Map<Label,String> clues, List<String> notes) { 269 super(); 270 271 this.parent = parent; 272 this.headers = headers; 273 this.indices = indices; 274 this.solutions = solutions; 275 this.xref = xref; 276 this.clues = clues; 277 this.notes = notes; 278 } 279 280 private Label labelFor(CoordinateMap<Cell> map) { 281 return new Label(Direction.of(map), 282 indices.indexOf(map.firstKey()) + 1); 283 } 284 285 /** 286 * Method to get the "seed" of this {@link Puzzle}. 287 * 288 * @return {@link.this} if {@link #parent()} is {@code null}; the 289 * ultimate (first) {@link #parent()} otherwise. 290 */ 291 public Puzzle seed() { 292 Puzzle seed = this; 293 294 while (seed.parent() != null) { 295 seed = seed.parent(); 296 } 297 298 return seed; 299 } 300 301 /** 302 * Method to get the parent of this {@link Puzzle}. 303 * 304 * @return The parent of {@link.this} {@link Puzzle} (may be 305 * {@code null}). 306 */ 307 public Puzzle parent() { return parent; } 308 309 public Map<String,String> headers() { return unmodifiableMap(headers); } 310 311 public Map<Label,Solution> solutions() { 312 return unmodifiableMap(solutions); 313 } 314 315 public Map<Label,String> clues() { return unmodifiableMap(clues); } 316 317 public List<String> notes() { return unmodifiableList(notes); } 318 319 /** 320 * Method to set the solution in a {@link Puzzle}. 321 * 322 * @param coordinate The {@link Coordinate} of the {@link Cell}.. 323 * @param character The solution {@link Character}. 324 * 325 * @throws IllegalArgumentException 326 * If any of the {@link Cell}s are already with 327 * a different {@link Character} than specified 328 * in the {@link String}. 329 */ 330 public void setSolution(Coordinate coordinate, Character character) { 331 Cell cell = get(coordinate); 332 333 if (cell == null) { 334 throw new IllegalStateException(); 335 } 336 337 if (! cell.isSolved()) { 338 cell.setSolution(character); 339 } else { 340 if (! character.equals(cell.getSolution())) { 341 throw new IllegalArgumentException(coordinate.toString() 342 + ": " 343 + cell.toString() 344 + " -> " 345 + character.toString()); 346 } 347 } 348 } 349 350 /** 351 * Method to determine if {@link.this} {@link Puzzle} is solved. 352 * 353 * @return {@code true} if the {@link Puzzle} is complete; 354 * {@code false} otherwise. 355 */ 356 public boolean isSolved() { 357 return values().stream().allMatch(Cell::isSolved); 358 } 359 360 /** 361 * Method to write {@link Puzzle} to an {@link OutputStream} in 362 * {@link.uri https://github.com/century-arcade/xd target=newtab .xd} 363 * format. 364 * 365 * @param out The {@link OutputStream}. 366 * 367 * @throws IOException If the {@link Puzzle} cannot be written. 368 */ 369 public void writeTo(OutputStream out) throws IOException { 370 writeTo(new OutputStreamWriter(out, UTF_8)); 371 } 372 373 /** 374 * Method to write {@link Puzzle} to a {@link Writer} in 375 * {@link.uri https://github.com/century-arcade/xd target=newtab .xd} 376 * format. 377 * 378 * @param out The {@link OutputStream}. 379 * 380 * @throws IOException If the {@link Puzzle} cannot be written. 381 */ 382 public void writeTo(Writer out) throws IOException { 383 writeTo((out instanceof PrintWriter) 384 ? ((PrintWriter) out) 385 : new PrintWriter(out)); 386 } 387 388 /** 389 * Method to write {@link Puzzle} to a {@link PrintWriter} in 390 * {@link.uri https://github.com/century-arcade/xd target=newtab .xd} 391 * format. 392 * 393 * @param out The {@link OutputStream}. 394 * 395 * @throws IOException If the {@link Puzzle} cannot be written. 396 */ 397 public void writeTo(PrintWriter out) throws IOException { 398 for (Map.Entry<String,String> entry : headers().entrySet()) { 399 if (isNotBlank(entry.getValue())) { 400 out.println(entry.getKey() + COLON + SPACE + entry.getValue()); 401 } 402 } 403 404 out.println(EMPTY); 405 out.println(EMPTY); 406 407 for (CoordinateMap<Cell> row : rows()) { 408 for (Cell cell : row.values()) { 409 out.print(cell); 410 } 411 412 out.println(); 413 } 414 415 out.println(EMPTY); 416 417 Direction last = null; 418 419 for (Map.Entry<Label,String> entry : clues.entrySet()) { 420 if (! entry.getKey().getDirection().equals(last)) { 421 out.println(EMPTY); 422 } 423 424 last = entry.getKey().getDirection(); 425 426 out.println(entry.getKey().toString() + DOT 427 + SPACE + entry.getValue() 428 + SPACE + TILDE + SPACE 429 + solutions.get(entry.getKey()).getSolution(this)); 430 } 431 432 if (! notes.isEmpty()) { 433 out.println(EMPTY); 434 out.println(EMPTY); 435 436 for (String note : notes) { 437 out.println(note); 438 } 439 } 440 } 441 442 /** 443 * Method to solve a {@link Puzzle}. 444 * 445 * @param dictionary The {@link Set} of possible solutions. 446 * 447 * @return A {@link Stream} of possible solutions. 448 */ 449 public Stream<Puzzle> solve(Set<CharSequence> dictionary) { 450 return StreamSupport.stream(new Solver(this, dictionary), false); 451 } 452 453 private boolean isChanged(Coordinate coordinate) { 454 return (parent == null || (get(coordinate) != parent.get(coordinate))); 455 } 456 457 private Map<Coordinate,Cell> changed() { 458 Map<Coordinate,Cell> map = 459 entrySet() 460 .stream() 461 .filter(t -> parent != null) 462 .filter(t -> t.getValue() != parent.get(t.getKey())) 463 .collect(toMap(Map.Entry::getKey, 464 Map.Entry::getValue, 465 (v0, v1) -> v0, 466 TreeMap::new)); 467 468 return map; 469 } 470 471 @Override 472 public Puzzle clone() throws CloneNotSupportedException { 473 return new Puzzle(this); 474 } 475 476 /** 477 * Static method to load an 478 * {@link.uri https://github.com/century-arcade/xd target=newtab .xd} 479 * resource or {@link File} into a {@link Puzzle}. 480 * 481 * @param path The resource path. 482 * 483 * @return The {@link Puzzle}. 484 * 485 * @throws IOException If the path cannot be parsed. 486 */ 487 public static Puzzle load(String path) throws IOException { 488 Puzzle puzzle = null; 489 InputStream in = null; 490 491 try { 492 in = Puzzle.class.getResourceAsStream(path); 493 494 if (in == null) { 495 in = new FileInputStream(path); 496 } 497 498 List<String> lines = 499 new BufferedReader(new InputStreamReader(in, UTF_8)) 500 .lines() 501 .map(t -> t.trim()) 502 .collect(toList()); 503 TreeMap<Integer,List<String>> sections = new TreeMap<>(); 504 505 while (! lines.isEmpty()) { 506 int index = indexOfSubList(lines, BOUNDARY); 507 508 if (! (index < 0)) { 509 sections.put(sections.size(), lines.subList(0, index)); 510 lines = lines.subList(index + BOUNDARY.size(), lines.size()); 511 } else { 512 sections.put(sections.size(), lines); 513 break; 514 } 515 } 516 517 puzzle = 518 new Puzzle(sections.get(0), sections.get(1), 519 sections.get(2), sections.get(3)); 520 } catch (UncheckedIOException exception) { 521 throw exception.getCause(); 522 } finally { 523 if (in != null) { 524 in.close(); 525 } 526 } 527 528 return puzzle; 529 } 530 531 private static class OrderedHeaders extends LinkedHashMap<String,String> { 532 private static final long serialVersionUID = -6640158167374093950L; 533 534 private static final List<String> HEADERS = 535 Arrays.asList("Title", "Author", "Editor", 536 "Special", "Rebus", "Date"); 537 538 public OrderedHeaders(Collection<String> lines) { 539 super(); 540 541 HEADERS.stream().forEach(t -> put(t, EMPTY)); 542 543 if (lines != null) { 544 lines.stream() 545 .filter(t -> isNotBlank(t)) 546 .map(t -> t.split(Pattern.quote(COLON), 2)) 547 .filter(t -> isNotBlank(t[0])) 548 .forEach(t -> put(t[0].trim(), 549 (t.length > 1) ? t[1].trim() : EMPTY)); 550 } 551 552 values().removeIf(t -> isBlank(t)); 553 } 554 } 555 556 /** 557 * {@link Puzzle} {@link Solution Solution}. 558 */ 559 public static class Solution extends ArrayList<Coordinate> { 560 private static final long serialVersionUID = -146821930642639986L; 561 562 private Solution(Collection<Coordinate> collection) { 563 super(collection); 564 } 565 566 /** 567 * Method to get the {@link CharSequence} of a {@link Puzzle} 568 * {@link Solution Solution}. 569 * 570 * @param puzzle The {@link Puzzle}. 571 * 572 * @return The {@link Solution} {@link CharSequence}. 573 */ 574 public CharSequence getSolution(Puzzle puzzle) { 575 return (stream() 576 .map(t -> puzzle.get(t)) 577 .map(Object::toString) 578 .collect(joining())); 579 } 580 581 private void setSolution(Puzzle puzzle, CharSequence solution) { 582 if (solution.length() != size()) { 583 throw new IllegalArgumentException(); 584 } 585 586 for (int i = 0; i < size(); i += 1) { 587 puzzle.setSolution(get(i), solution.charAt(i)); 588 } 589 } 590 591 /** 592 * Method to determine if {@link.this} {@link Solution Solution} is 593 * solved. 594 * 595 * @param puzzle The {@link Puzzle}. 596 * 597 * @return {@code true} if the {@link Solution} is complete; 598 * {@code false} otherwise. 599 */ 600 public boolean isSolved(Puzzle puzzle) { 601 return stream().map(t -> puzzle.get(t)).allMatch(Cell::isSolved); 602 } 603 604 private Pattern asPattern(Puzzle puzzle) { 605 String pattern = getSolution(puzzle).toString().toUpperCase(); 606 607 return Pattern.compile(pattern); 608 } 609 610 private List<Coordinate> unsolved(Puzzle puzzle) { 611 return (stream() 612 .filter(t -> (! puzzle.get(t).isSolved())) 613 .collect(toList())); 614 } 615 } 616 617 @ToString 618 private static class Solver extends DispatchSpliterator<Puzzle> { 619 private final Puzzle parent; 620 private final Set<CharSequence> dictionary; 621 622 public Solver(Puzzle parent, Set<CharSequence> dictionary) { 623 super(Integer.MAX_VALUE, Spliterator.NONNULL); 624 625 this.parent = Objects.requireNonNull(parent); 626 this.dictionary = Objects.requireNonNull(dictionary); 627 } 628 629 public Comparator<Solution> prioritization() { 630 Comparator<Solution> comparator = 631 Comparator 632 .<Solution>comparingInt(t -> (t.stream() 633 .map(c -> parent.get(c)) 634 .anyMatch(Cell::isSolved) ? +1 : -1)) 635 .thenComparingInt(t -> t.unsolved(parent).size()) 636 .thenComparingInt(Solution::size) 637 .reversed(); 638 639 return comparator; 640 } 641 642 @Override 643 protected Spliterator<Supplier<Spliterator<Puzzle>>> spliterators() { 644 List<Supplier<Spliterator<Puzzle>>> list = new LinkedList<>(); 645 Solution solution = 646 parent.solutions().values() 647 .stream() 648 .filter(t -> (! t.isSolved(parent))) 649 .sorted(prioritization()) 650 .findFirst().orElse(null); 651 652 if (solution != null) { 653 Pattern pattern = solution.asPattern(parent); 654 List<CharSequence> sequences = 655 dictionary.stream() 656 .filter(t -> pattern.matcher(t).matches()) 657 .collect(toList()); 658 List<CharSequence> used = 659 parent.solutions().values() 660 .stream() 661 .filter(t -> t.isSolved(parent)) 662 .map(t -> t.getSolution(parent)) 663 .collect(toList()); 664 665 sequences.removeAll(used); 666 667 for (CharSequence sequence : sequences) { 668 Puzzle child = new Puzzle(parent, solution, sequence); 669 Set<Coordinate> changed = child.changed().keySet(); 670 boolean verified = 671 child.xref.get(solution) 672 .stream() 673 .filter(t -> (! disjoint(t, changed))) 674 .filter(t -> t.isSolved(child)) 675 .allMatch(t -> dictionary.contains(t.getSolution(child))); 676 677 if (verified) { 678 list.add(() -> new Solver(child, dictionary)); 679 } 680 } 681 } 682 683 if (list.isEmpty()) { 684 list.add(() -> Stream.of(parent).spliterator()); 685 } 686 687 return list.spliterator(); 688 } 689 } 690}