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