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}