diff --git a/.github/workflows/board_generator.yaml b/.github/workflows/board_generator.yaml index 3677a5edb..44663e825 100644 --- a/.github/workflows/board_generator.yaml +++ b/.github/workflows/board_generator.yaml @@ -19,3 +19,4 @@ jobs: dart_sdk: stable working_directory: packages/board_generator coverage_excludes: "**/*.g.dart" + min_coverage: 90 diff --git a/packages/board_generator/.gitignore b/packages/board_generator/.gitignore index 526da1584..4dd1ed845 100644 --- a/packages/board_generator/.gitignore +++ b/packages/board_generator/.gitignore @@ -4,4 +4,7 @@ .dart_tool/ .packages build/ -pubspec.lock \ No newline at end of file +pubspec.lock + +allWords.json +board.txt \ No newline at end of file diff --git a/packages/board_generator/README.md b/packages/board_generator/README.md index 6e0c32707..1ed71ba5f 100644 --- a/packages/board_generator/README.md +++ b/packages/board_generator/README.md @@ -8,16 +8,50 @@ Crossword board generator. ## Usage ๐Ÿ’ป -**โ— In order to start using Board Generator you must have the [Dart SDK][dart_install_link] installed on your machine.** +There are two steps in the process to generate a board and have it ready in Firestore: -Run it as a dart program: +- Board generation +- Divide board into sections and upload it to Firestore + +### Generate the crossword board + +Add an `allWords.json` file inside the `assets` folder with all the words that will be used to generate the board with the following structure: + +```json +{ + "words":[ + { + "answer": "alpine", + "definition": "Relative to high mountains" + } + ] +} +``` + +Then, from the package folder, run: + +```sh +dart lib/generate_board.dart +``` + +The board will be created in `assets/board.txt` and updated until all words are placed in it. + +### Dividing the board in sections + +Once the board is generated, create the sections and upload them to Firestore by running: ```sh -dart main.dart +dart lib/create_sections.dart ``` +In case you want to update the sections size, do so in the `create_sections.dart` file before creating the sections. + +>Note: When creating the sections you will need a service account to upload them to Firestore. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your service account. + ## Running Tests ๐Ÿงช +The coverage in this package is not enforced at a 100% due to the nature of testing the algorithm with only a few words. + To run all unit tests: ```sh @@ -36,7 +70,6 @@ genhtml coverage/lcov.info -o coverage/ open coverage/index.html ``` -[dart_install_link]: https://dart.dev/get-dart [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg [license_link]: https://opensource.org/licenses/MIT [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg diff --git a/packages/board_generator/lib/board_generator.dart b/packages/board_generator/lib/board_generator.dart deleted file mode 100644 index dd55a7866..000000000 --- a/packages/board_generator/lib/board_generator.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:collection'; - -import 'package:board_generator/models/data_model.dart'; - -/// Generates a crossword. -Iterable generateCrosswords(List wordList) sync* { - final queue = Queue>() - ..addFirst( - Crossword( - (b) => b..candidates.addAll(wordList), - ).generate().iterator, - ); - - while (queue.isNotEmpty) { - final iterator = queue.removeFirst(); - if (iterator.moveNext()) { - final crossword = iterator.current; - yield crossword; - queue - ..addFirst(iterator) - ..addFirst(crossword.generate().iterator); - } - } -} diff --git a/packages/board_generator/lib/create_sections.dart b/packages/board_generator/lib/create_sections.dart new file mode 100644 index 000000000..c1ab0afaf --- /dev/null +++ b/packages/board_generator/lib/create_sections.dart @@ -0,0 +1,119 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:board_generator/src/crossword_repository.dart'; +import 'package:csv/csv.dart'; +import 'package:dart_firebase_admin/dart_firebase_admin.dart'; +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:game_domain/game_domain.dart'; + +void main(List args) async { + final serviceAccountPath = + Platform.environment['GOOGLE_APPLICATION_CREDENTIALS']; + if (serviceAccountPath == null) { + throw Exception('Service account path not found'); + } + + final admin = FirebaseAdminApp.initializeApp( + 'io-crossword-dev', + Credential.fromServiceAccount(File(serviceAccountPath)), + ); + final firestore = Firestore(admin); + final crosswordRepository = CrosswordRepository(firestore: firestore); + + // Read the file + final fileString = File('assets/board.txt').readAsStringSync(); + final rows = const CsvToListConverter(eol: '\n').convert(fileString); + + // Convert to custom object + final words = rows.map((row) { + return Word( + position: Point(row[0] as int, row[1] as int), + answer: row[2] as String, + clue: 'The answer is: ${row[2]}', + hints: const [], + visible: false, + axis: row[3] == 'horizontal' ? Axis.horizontal : Axis.vertical, + solvedTimestamp: null, + ); + }).toList(); + + // Get crossword size + final maxX = words + .map((e) => e.position.x) + .reduce((value, element) => value > element ? value : element); + final maxY = words + .map((e) => e.position.y) + .reduce((value, element) => value > element ? value : element); + final minX = words + .map((e) => e.position.x) + .reduce((value, element) => value < element ? value : element); + final minY = words + .map((e) => e.position.y) + .reduce((value, element) => value < element ? value : element); + + final boardHeight = maxY - minY; + final boardWidth = maxX - minX; + + print('Crossword size: $boardWidth x $boardHeight.'); + + final sections = []; + const sectionSize = 300; + + var sectionX = minX; + while (sectionX < maxX) { + var sectionY = minY; + while (sectionY < maxY) { + final sectionWords = words.where((word) { + return word.isStartInSection(sectionX, sectionY, sectionSize); + }).toList(); + + final borderWords = words.where((word) { + final isStartInSection = + word.isStartInSection(sectionX, sectionY, sectionSize); + final isEndInSection = + word.isEndInSection(sectionX, sectionY, sectionSize); + return !isStartInSection && isEndInSection; + }).toList(); + + final section = BoardSection( + id: '', + position: Point(sectionX, sectionY), + size: sectionSize, + words: sectionWords, + borderWords: borderWords, + ); + sections.add(section); + + sectionY += sectionSize; + } + sectionX += sectionSize; + } + + await crosswordRepository.addSections(sections); + + print('Added all ${sections.length} section to the database.'); +} + +/// An extension on [Word] to check if it is in a section. +extension SectionBelonging on Word { + /// Returns true if the word starting letter is in the section. + bool isStartInSection(int sectionX, int sectionY, int sectionSize) { + return position.x >= sectionX && + position.x < sectionX + sectionSize && + position.y >= sectionY && + position.y < sectionY + sectionSize; + } + + /// Returns true if the word ending letter is in the section. + bool isEndInSection(int sectionX, int sectionY, int sectionSize) { + final (endX, endY) = axis == Axis.horizontal + ? (position.x + answer.length - 1, position.y) + : (position.x, position.y + answer.length - 1); + return endX >= sectionX && + endX < sectionX + sectionSize && + endY >= sectionY && + endY < sectionY + sectionSize; + } +} diff --git a/packages/board_generator/lib/generate_board.dart b/packages/board_generator/lib/generate_board.dart new file mode 100644 index 000000000..37f06f0f6 --- /dev/null +++ b/packages/board_generator/lib/generate_board.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:board_generator/src/board_generator.dart'; + +void main(List args) async { + final file = File('assets/allWords.json'); + + final string = await file.readAsString(); + final map = jsonDecode(string) as Map; + final words = + (map['words'] as List).map((e) => e as Map); + final parsedWords = words.map((word) { + return word['answer'] as String; + }).toList(); + + final regex = RegExp(r'^[a-z]+$'); + final filteredWords = [...parsedWords]..removeWhere( + (element) => !regex.hasMatch(element), + ); + + generateCrossword(filteredWords, 'assets/board.txt'); +} diff --git a/packages/board_generator/lib/main.dart b/packages/board_generator/lib/main.dart deleted file mode 100644 index caa759806..000000000 --- a/packages/board_generator/lib/main.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:board_generator/board_generator.dart'; - -/// The list of all words that can be used to generate a crossword. -final allWords = [ - 'winter', - 'spring', - 'summer', -]; - -void main(List args) { - final iter = generateCrosswords(allWords); - final completeCrossword = iter.last; - - // ignore: avoid_print - print(completeCrossword); -} diff --git a/packages/board_generator/lib/models/data_model.dart b/packages/board_generator/lib/models/data_model.dart deleted file mode 100644 index 0a192f3b2..000000000 --- a/packages/board_generator/lib/models/data_model.dart +++ /dev/null @@ -1,260 +0,0 @@ -library data_model; - -import 'package:built_collection/built_collection.dart'; -import 'package:built_value/built_value.dart'; -import 'package:characters/characters.dart'; - -part 'data_model.g.dart'; - -///{@template crossword} -/// A crossword puzzle data model. -/// {@endtemplate} -abstract class Crossword implements Built { - ///{@macro crossword} - factory Crossword([void Function(CrosswordBuilder) updates]) = _$Crossword; - - Crossword._(); - - /// The list of unused candidate words that can be added to this crossword. - BuiltList get candidates; - - /// The list of down words, by their starting point location - BuiltMap get downWords; - - /// The list of across words, by their starting point location - BuiltMap get acrossWords; - - /// The characters by location. Useful for displaying the crossword, - /// or checking the proposed solution. - BuiltMap get characters; - - /// Checks if this crossword is valid. - bool get valid { - for (final entry in characters.entries) { - final location = entry.key; - final character = entry.value; - - // All characters must be a part of an across or down word. - if (character.acrossWord == null && character.downWord == null) { - return false; - } - - // Characters above and below this character must be related - // by a vertical word - final characterNorth = - characters[location.rebuild((b) => b..down = location.down - 1)]; - if (characterNorth != null) { - if (character.downWord == null) return false; - if (characterNorth.downWord != character.downWord) return false; - } - - final characterSouth = - characters[location.rebuild((b) => b..down = location.down + 1)]; - if (characterSouth != null) { - if (character.downWord == null) return false; - if (characterSouth.downWord != character.downWord) return false; - } - - // Characters to the left and right of this character must be - // related by a horizontal word - final characterEast = - characters[location.rebuild((b) => b..across = location.across - 1)]; - if (characterEast != null) { - if (character.acrossWord == null) return false; - if (characterEast.acrossWord != character.acrossWord) return false; - } - - final characterWest = - characters[location.rebuild((b) => b..across = location.across + 1)]; - if (characterWest != null) { - if (character.acrossWord == null) return false; - if (characterWest.acrossWord != character.acrossWord) return false; - } - } - - return true; - } - - /// Adds a word across to the crossword puzzle, if it fits. - Crossword? addAcrossWord({required Location location, required String word}) { - final wordCharacters = word.characters; - - for (final (index, character) in wordCharacters.indexed) { - final characterLocation = - location.rebuild((b) => b.across = b.across! + index); - final target = characters[characterLocation]; - if (target != null) { - if (target.character != character) return null; - if (target.acrossWord != null) return null; - } - } - - final updated = rebuild((b) => b.acrossWords.addAll({location: word})); - if (updated.valid) return updated; - return null; - } - - /// Adds a word down to the crossword puzzle, if it fits. - Crossword? addDownWord({required Location location, required String word}) { - final wordCharacters = word.characters; - - for (final (index, character) in wordCharacters.indexed) { - final characterLocation = - location.rebuild((b) => b.down = b.down! + index); - final target = characters[characterLocation]; - if (target != null) { - if (target.character != character) return null; - if (target.downWord != null) return null; - } - } - - final updated = rebuild((b) => b.downWords.addAll({location: word})); - if (updated.valid) return updated; - return null; - } - - /// Generates a crossword puzzle by adding words to the board. - Iterable generate() sync* { - // Nothing left to do if there are no candidate words - if (candidates.isEmpty) return; - - // Split out the first word case - if (downWords.isEmpty && acrossWords.isEmpty) { - for (final word in candidates) { - final location = Location( - (b) => b - ..across = 0 - ..down = 0, - ); - - yield rebuild((b) => b..candidates.remove(word)) - .addAcrossWord(location: location, word: word)!; - } - return; - } - - // All subsequent words are joined to previous words - for (final entry in characters.entries.toList()..shuffle()) { - // ignore locations that already join two words - if (entry.value.acrossWord != null && entry.value.downWord != null) { - continue; - } - - // Filter down the candidate word list to those that contain the letter - // at the current location - final filteredCandidates = candidates.rebuild( - (b) => b.removeWhere( - (innerWord) => !innerWord.characters.contains(entry.value.character), - ), - ); - - // Attempt to place the filtered candidate words over the current - // location as a join point. - for (final word in filteredCandidates) { - for (final (index, character) in word.characters.indexed) { - if (character != entry.value.character) continue; - - if (entry.value.acrossWord != null) { - // Adding a downWord - final candidate = - rebuild((b) => b..candidates.remove(word)).addDownWord( - location: - entry.key.rebuild((b) => b.down = entry.key.down - index), - word: word, - ); - - if (candidate != null) yield candidate; - } else { - // Adding an acrossWord - final candidate = - rebuild((b) => b..candidates.remove(word)).addAcrossWord( - location: - entry.key.rebuild((b) => b.across = entry.key.across - index), - word: word, - ); - - if (candidate != null) yield candidate; - } - } - } - } - } - - @BuiltValueHook(finalizeBuilder: true) - static void _fillCharacters(CrosswordBuilder b) { - b.characters.clear(); - - for (final entry in b.acrossWords.build().entries) { - final location = entry.key; - final word = entry.value; - for (final (idx, character) in word.characters.indexed) { - final characterLocation = - location.rebuild((b) => b..across = location.across + idx); - b.characters.putIfAbsent( - characterLocation, - () => CrosswordCharacter( - (b) => b - ..acrossWord = word - ..character = character, - ), - ); - } - } - - for (final entry in b.downWords.build().entries) { - final location = entry.key; - final word = entry.value; - for (final (idx, character) in word.characters.indexed) { - final characterLocation = - location.rebuild((b) => b..down = location.down + idx); - b.characters.updateValue( - characterLocation, - (b) => b.rebuild((b) => b.downWord = word), - ifAbsent: () => CrosswordCharacter( - (b) => b - ..downWord = word - ..character = character, - ), - ); - } - } - } -} - -///{@template location} -/// A location in the crossword puzzle. -/// {@endtemplate} -abstract class Location implements Built { - ///{@macro location} - factory Location([void Function(LocationBuilder) updates]) = _$Location; - - Location._(); - - /// The horizontal part of the location. - int get across; - - /// The vertical part of the location. - int get down; -} - -///{@template crossword_character} -/// A character in the crossword puzzle. -/// {@endtemplate} -abstract class CrosswordCharacter - implements Built { - ///{@macro crossword_character} - factory CrosswordCharacter([ - void Function(CrosswordCharacterBuilder) updates, - ]) = _$CrosswordCharacter; - - CrosswordCharacter._(); - - /// The character at this location. - String get character; - - /// The across word that this character is a part of. - String? get acrossWord; - - /// The down word that this character is a part of. - String? get downWord; -} diff --git a/packages/board_generator/lib/src/board_generator.dart b/packages/board_generator/lib/src/board_generator.dart new file mode 100644 index 000000000..5070694bd --- /dev/null +++ b/packages/board_generator/lib/src/board_generator.dart @@ -0,0 +1,60 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:board_generator/src/models/data_model.dart'; +import 'package:game_domain/game_domain.dart'; + +/// Generates a crossword. +Crossword generateCrossword(List wordList, String filePath) { + var crossword = Crossword((b) => b..candidates.addAll(wordList)).generate(); + + final before = DateTime.now(); + + final file = File(filePath); + final savedDownWords = {}; + final savedAcrossWords = {}; + + while (crossword.candidates.isNotEmpty) { + crossword = crossword.generate(); + + // Save the words to a file. Only save every 100 words to avoid + // slowing down the process. + final wordCount = crossword.downWords.length + crossword.acrossWords.length; + if ((wordCount % 100 == 0) || (wordCount == wordList.length)) { + final now = DateTime.now(); + + print('Generated $wordCount words in ${now.difference(before)}'); + print('Candidate locations: ${crossword.candidateLocations.length}'); + + final newAcrossWords = crossword.acrossWords.rebuild( + (b) => + b..removeWhere((key, value) => savedAcrossWords.containsKey(key)), + ); + final newDownWords = crossword.downWords.rebuild( + (b) => b..removeWhere((key, value) => savedDownWords.containsKey(key)), + ); + + final buffer = StringBuffer(); + for (final entry in newAcrossWords.entries) { + buffer.writeln( + '${entry.key.across},${entry.key.down},${entry.value},' + '${Axis.horizontal}', + ); + } + for (final entry in newDownWords.entries) { + buffer.writeln( + '${entry.key.across},${entry.key.down},${entry.value},' + '${Axis.vertical}', + ); + } + + file.writeAsStringSync(buffer.toString(), mode: FileMode.append); + + savedAcrossWords.addAll(newAcrossWords.asMap()); + savedDownWords.addAll(newDownWords.asMap()); + } + } + + return crossword; +} diff --git a/packages/board_generator/lib/src/crossword_repository.dart b/packages/board_generator/lib/src/crossword_repository.dart new file mode 100644 index 000000000..672311a92 --- /dev/null +++ b/packages/board_generator/lib/src/crossword_repository.dart @@ -0,0 +1,33 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:game_domain/game_domain.dart'; + +/// {@template crossword_repository} +/// Repository for interacting with the crossword database in a dart only env. +/// {@endtemplate} +class CrosswordRepository { + /// {@macro crossword_repository} + CrosswordRepository({required this.firestore}); + + /// The firestore instance. + final Firestore firestore; + + /// Adds a list of sections to the database. + Future addSections(List sections) async { + for (final section in sections) { + await firestore.collection('boardSections').add(section.toJson()); + } + } + + /// Deletes all sections from the database. + Future deleteSections() async { + var docs = await firestore.collection('boardSections').listDocuments(); + var length = docs.length; + while (length > 0) { + for (final doc in docs) { + await doc.delete(); + } + docs = await firestore.collection('boardSections').listDocuments(); + length = docs.length; + } + } +} diff --git a/packages/board_generator/lib/src/models/data_model.dart b/packages/board_generator/lib/src/models/data_model.dart new file mode 100644 index 000000000..d5d7d29af --- /dev/null +++ b/packages/board_generator/lib/src/models/data_model.dart @@ -0,0 +1,557 @@ +library data_model; + +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:characters/characters.dart'; + +part 'data_model.g.dart'; + +///{@template crossword} +/// A crossword puzzle data model. +/// {@endtemplate} +abstract class Crossword implements Built { + ///{@macro crossword} + factory Crossword([void Function(CrosswordBuilder) updates]) = _$Crossword; + + Crossword._(); + + /// The list of unused candidate words that can be added to this crossword. + BuiltList get candidates; + + /// The list of candidate locations to process. + BuiltList get candidateLocations; + + /// The list of down words, by their starting point location + BuiltMap get downWords; + + /// The list of across words, by their starting point location + BuiltMap get acrossWords; + + /// The characters by location. Useful for displaying the crossword, + /// or checking the proposed solution. + BuiltMap get characters; + + /// Checks if this crossword is valid. + bool get valid { + for (final entry in characters.entries) { + final location = entry.key; + final character = entry.value; + + // All characters must be a part of an across or down word. + if (character.acrossWord == null && character.downWord == null) { + return false; + } + + // Characters above and below this character must be related + // by a vertical word + final characterNorth = + characters[location.rebuild((b) => b..down = location.down - 1)]; + if (characterNorth != null) { + if (character.downWord == null) return false; + if (characterNorth.downWord != character.downWord) return false; + } + + final characterSouth = + characters[location.rebuild((b) => b..down = location.down + 1)]; + if (characterSouth != null) { + if (character.downWord == null) return false; + if (characterSouth.downWord != character.downWord) return false; + } + + // Characters to the left and right of this character must be + // related by a horizontal word + final characterEast = + characters[location.rebuild((b) => b..across = location.across - 1)]; + if (characterEast != null) { + if (character.acrossWord == null) return false; + if (characterEast.acrossWord != character.acrossWord) return false; + } + + final characterWest = + characters[location.rebuild((b) => b..across = location.across + 1)]; + if (characterWest != null) { + if (character.acrossWord == null) return false; + if (characterWest.acrossWord != character.acrossWord) return false; + } + } + + return true; + } + + /// Adds a word across to the crossword puzzle, if it fits. + Crossword? addAcrossWord({required Location location, required String word}) { + final wordCharacters = word.characters; + final characterLocations = []; + + for (final (index, character) in wordCharacters.indexed) { + final characterLocation = + location.rebuild((b) => b.across = b.across! + index); + characterLocations.add(characterLocation); + final target = characters[characterLocation]; + if (target != null) { + if (target.character != character) return null; + if (target.acrossWord != null) return null; + } + } + + final updated = rebuild( + (b) => b + ..acrossWords.addAll({location: word}) + ..candidateLocations.addAll(characterLocations), + ); + if (updated.valid) return updated; + return null; + } + + /// Adds a word down to the crossword puzzle, if it fits. + Crossword? addDownWord({required Location location, required String word}) { + final wordCharacters = word.characters; + final characterLocations = []; + + for (final (index, character) in wordCharacters.indexed) { + final characterLocation = + location.rebuild((b) => b.down = b.down! + index); + characterLocations.add(characterLocation); + final target = characters[characterLocation]; + if (target != null) { + if (target.character != character) return null; + if (target.downWord != null) return null; + } + } + + final updated = rebuild( + (b) => b + ..downWords.addAll({location: word}) + ..candidateLocations.addAll(characterLocations), + ); + if (updated.valid) return updated; + return null; + } + + /// Generates a crossword puzzle by adding words to the board. + Crossword generate() { + // Nothing left to do if there are no candidate words + if (candidates.isEmpty) return this; + + // Split out the first word case + if (downWords.isEmpty && acrossWords.isEmpty) { + for (final word in candidates) { + final location = Location( + (b) => b + ..across = 0 + ..down = 0, + ); + + return rebuild((b) => b..candidates.remove(word)) + .addAcrossWord(location: location, word: word)!; + } + return this; + } + + const radius = 5; + const maxNeighbors = 20; + // Use the locations that have a higher probability of fitting a word. + // Keep the locations that have less characters around them. + final filteredLocations = candidateLocations.rebuild( + (b) => b.removeWhere((e) { + final neighbors = []; + for (var i = e.across - radius; i <= e.across + radius; i++) { + for (var j = e.down - radius; j <= e.down + radius; j++) { + final location = Location( + (b) => b + ..across = i + ..down = j, + ); + final character = characters[location]; + if (character != null) { + neighbors.add(character); + } + } + } + return neighbors.length > maxNeighbors; + }), + ); + + // All subsequent words are joined to previous words + var locations = filteredLocations.rebuild((b) => b.shuffle()); + while (locations.isNotEmpty) { + final location = locations.first; + locations = locations.rebuild((b) => b.remove(location)); + final target = characters[location]!; + + // ignore locations that already join two words + if (target.acrossWord != null && target.downWord != null) { + continue; + } + + // Skip locations that have neighbors that join two words + final characterEast = + characters[location.rebuild((b) => b..across = location.across + 1)]; + if (characterEast != null && characterEast.downWord != null) { + continue; + } + final characterWest = + characters[location.rebuild((b) => b..across = location.across - 1)]; + if (characterWest != null && characterWest.downWord != null) { + continue; + } + final characterNorth = + characters[location.rebuild((b) => b..down = location.down - 1)]; + if (characterNorth != null && characterNorth.acrossWord != null) { + continue; + } + final characterSouth = + characters[location.rebuild((b) => b..down = location.down + 1)]; + if (characterSouth != null && characterSouth.acrossWord != null) { + continue; + } + + // Get the maximum word length that can be placed at this location + final (wordEnd, wordStart, letters) = _getMaxWordLength(location); + final maxWordLength = wordEnd - wordStart; + + // Filter down the candidate word list to those that contain the letter + // at the current location + final filteredCandidates = candidates.rebuild( + (b) => b.where( + (innerWord) => + innerWord.characters.contains(target.character) && + innerWord.length <= maxWordLength && + innerWord.characters.containsAll(letters.join().characters), + ), + ); + + // Attempt to place the filtered candidate words over the current + // location as a join point. + for (final word in filteredCandidates) { + for (final (index, character) in word.characters.indexed) { + if (character != target.character) continue; + + if (target.acrossWord != null) { + // Adding a downWord + final candidate = rebuild( + (b) => b + ..candidates.remove(word) + ..candidateLocations.replace(locations), + ).addDownWord( + location: location.rebuild((b) => b.down = location.down - index), + word: word, + ); + + if (candidate != null) return candidate; + } else { + // Adding an acrossWord + final candidate = rebuild( + (b) => b + ..candidates.remove(word) + ..candidateLocations.replace(locations), + ).addAcrossWord( + location: + location.rebuild((b) => b.across = location.across - index), + word: word, + ); + + if (candidate != null) return candidate; + } + } + } + } + return this; + } + + @BuiltValueHook(finalizeBuilder: true) + static void _fillCharacters(CrosswordBuilder b) { + b.characters.clear(); + + for (final entry in b.acrossWords.build().entries) { + final location = entry.key; + final word = entry.value; + for (final (idx, character) in word.characters.indexed) { + final characterLocation = + location.rebuild((b) => b..across = location.across + idx); + b.characters.putIfAbsent( + characterLocation, + () => CrosswordCharacter( + (b) => b + ..acrossWord = word + ..character = character, + ), + ); + } + } + + for (final entry in b.downWords.build().entries) { + final location = entry.key; + final word = entry.value; + for (final (idx, character) in word.characters.indexed) { + final characterLocation = + location.rebuild((b) => b..down = location.down + idx); + b.characters.updateValue( + characterLocation, + (b) => b.rebuild((b) => b.downWord = word), + ifAbsent: () => CrosswordCharacter( + (b) => b + ..downWord = word + ..character = character, + ), + ); + } + } + } + + (int, int, Set) _getMaxWordLength(Location location) { + final target = characters[location]!; + if (target.acrossWord != null) { + final (startBoundary, startLetters) = _getDownStartBoundary(location); + final (endBoundary, endLetters) = _getDownEndBoundary(location); + final letters = startLetters.union(endLetters); + return (endBoundary, startBoundary, letters); + } else { + final (startBoundary, startLetters) = _getAcrossStartBoundary(location); + final (endBoundary, endLetters) = _getAcrossEndBoundary(location); + final letters = startLetters.union(endLetters); + return (endBoundary, startBoundary, letters); + } + } + + (int, Set) _getDownStartBoundary(Location location) { + final letters = {}; + var startBoundary = location.down; + final down = location.down; + for (var i = down; i >= down - 20; i--) { + final westTarget = characters[location.rebuild( + (b) => b + ..down = i + ..across = location.across - 1, + )]; + final target = characters[location.rebuild((b) => b..down = i - 1)]; + final eastTarget = characters[location.rebuild( + (b) => b + ..down = i + ..across = location.across + 1, + )]; + + // Avoid adding a word parallel to another or that extends another word + if ((westTarget?.downWord != null) || + (target?.downWord != null) || + (eastTarget?.downWord != null)) break; + + final northWestTarget = characters[location.rebuild( + (b) => b + ..down = i - 1 + ..across = location.across - 1, + )]; + final northEastTarget = characters[location.rebuild( + (b) => b + ..down = i - 1 + ..across = location.across + 1, + )]; + + // Avoid adding a word that extends other word by one character + final hasDiagonalNeighbor = (northWestTarget?.acrossWord != null) || + (northEastTarget?.acrossWord != null); + if (hasDiagonalNeighbor && (target?.acrossWord == null)) { + break; + } + + // If the target contains a character, add it to the list of letters + // that can be used to filter the candidate words + final distance = (down - i).abs(); + if ((target != null) && (distance < 4)) { + letters.add(target.character); + } + startBoundary = i; + } + return (startBoundary, letters); + } + + (int, Set) _getDownEndBoundary(Location location) { + final letters = {}; + var endBoundary = location.down; + final down = location.down; + for (var i = down; i <= down + 20; i++) { + final westTarget = characters[location.rebuild( + (b) => b + ..down = i + ..across = location.across - 1, + )]; + final target = characters[location.rebuild((b) => b..down = i + 1)]; + final eastTarget = characters[location.rebuild( + (b) => b + ..down = i + ..across = location.across + 1, + )]; + + // Avoid adding a word parallel to another or that extends another word + if ((westTarget?.downWord != null) || + (target?.downWord != null) || + (eastTarget?.downWord != null)) break; + + final southEastTarget = characters[location.rebuild( + (b) => b + ..down = i + 1 + ..across = location.across + 1, + )]; + final southWestTarget = characters[location.rebuild( + (b) => b + ..down = i + 1 + ..across = location.across - 1, + )]; + + // Avoid adding a word that extends other word by one character + final hasDiagonalNeighbor = (southEastTarget?.acrossWord != null) || + (southWestTarget?.acrossWord != null); + if (hasDiagonalNeighbor && (target?.acrossWord == null)) { + break; + } + + // If the target contains a character, add it to the list of letters + // that can be used to filter the candidate words + final distance = (down - i).abs(); + if ((target != null) && (distance < 4)) { + letters.add(target.character); + } + endBoundary = i; + } + return (endBoundary, letters); + } + + (int, Set) _getAcrossStartBoundary(Location location) { + final letters = {}; + var startBoundary = location.across; + final across = location.across; + for (var i = across; i >= across - 20; i--) { + final northTarget = characters[location.rebuild( + (b) => b + ..across = i + ..down = location.down - 1, + )]; + final target = characters[location.rebuild((b) => b..across = i - 1)]; + final southTarget = characters[location.rebuild( + (b) => b + ..across = i + ..down = location.down + 1, + )]; + + // Avoid adding a word parallel to another or that extends another word + if ((northTarget?.acrossWord != null) || + (target?.acrossWord != null) || + (southTarget?.acrossWord != null)) break; + + final northWestTarget = characters[location.rebuild( + (b) => b + ..across = i - 1 + ..down = location.down - 1, + )]; + final southWestTarget = characters[location.rebuild( + (b) => b + ..across = i - 1 + ..down = location.down + 1, + )]; + + // Avoid adding a word that extends other word by one character + final hasDiagonalNeighbor = (northWestTarget?.downWord != null) || + (southWestTarget?.downWord != null); + if (hasDiagonalNeighbor && (target?.downWord == null)) { + break; + } + + // If the target contains a character, add it to the list of letters + // that can be used to filter the candidate words + final distance = (across - i).abs(); + if ((target != null) && (distance < 4)) { + letters.add(target.character); + } + startBoundary = i; + } + return (startBoundary, letters); + } + + (int, Set) _getAcrossEndBoundary(Location location) { + final letters = {}; + var endBoundary = location.across; + final across = location.across; + for (var i = across; i <= across + 20; i++) { + final northTarget = characters[location.rebuild( + (b) => b + ..across = i + ..down = location.down - 1, + )]; + final target = characters[location.rebuild((b) => b..across = i + 1)]; + final southTarget = characters[location.rebuild( + (b) => b + ..across = i + ..down = location.down + 1, + )]; + + // Avoid adding a word parallel to another or that extends another word + if ((northTarget?.acrossWord != null) || + (target?.acrossWord != null) || + (southTarget?.acrossWord != null)) break; + final northEastTarget = characters[location.rebuild( + (b) => b + ..across = i + 1 + ..down = location.down - 1, + )]; + final southEastTarget = characters[location.rebuild( + (b) => b + ..across = i + 1 + ..down = location.down + 1, + )]; + + // Avoid adding a word that extends other word by one character + final hasDiagonalNeighbor = (northEastTarget?.downWord != null) || + (southEastTarget?.downWord != null); + if (hasDiagonalNeighbor && (target?.downWord == null)) { + break; + } + + // If the target contains a character, add it to the list of letters + // that can be used to filter the candidate words + final distance = (across - i).abs(); + if ((target != null) && (distance < 4)) { + letters.add(target.character); + } + endBoundary = i; + } + return (endBoundary, letters); + } +} + +///{@template location} +/// A location in the crossword puzzle. +/// {@endtemplate} +abstract class Location implements Built { + ///{@macro location} + factory Location([void Function(LocationBuilder) updates]) = _$Location; + + Location._(); + + /// The horizontal part of the location. + int get across; + + /// The vertical part of the location. + int get down; +} + +///{@template crossword_character} +/// A character in the crossword puzzle. +/// {@endtemplate} +abstract class CrosswordCharacter + implements Built { + ///{@macro crossword_character} + factory CrosswordCharacter([ + void Function(CrosswordCharacterBuilder) updates, + ]) = _$CrosswordCharacter; + + CrosswordCharacter._(); + + /// The character at this location. + String get character; + + /// The across word that this character is a part of. + String? get acrossWord; + + /// The down word that this character is a part of. + String? get downWord; +} diff --git a/packages/board_generator/lib/models/data_model.g.dart b/packages/board_generator/lib/src/models/data_model.g.dart similarity index 92% rename from packages/board_generator/lib/models/data_model.g.dart rename to packages/board_generator/lib/src/models/data_model.g.dart index 042e13457..37581e23f 100644 --- a/packages/board_generator/lib/models/data_model.g.dart +++ b/packages/board_generator/lib/src/models/data_model.g.dart @@ -10,6 +10,8 @@ class _$Crossword extends Crossword { @override final BuiltList candidates; @override + final BuiltList candidateLocations; + @override final BuiltMap downWords; @override final BuiltMap acrossWords; @@ -21,12 +23,15 @@ class _$Crossword extends Crossword { _$Crossword._( {required this.candidates, + required this.candidateLocations, required this.downWords, required this.acrossWords, required this.characters}) : super._() { BuiltValueNullFieldError.checkNotNull( candidates, r'Crossword', 'candidates'); + BuiltValueNullFieldError.checkNotNull( + candidateLocations, r'Crossword', 'candidateLocations'); BuiltValueNullFieldError.checkNotNull(downWords, r'Crossword', 'downWords'); BuiltValueNullFieldError.checkNotNull( acrossWords, r'Crossword', 'acrossWords'); @@ -46,6 +51,7 @@ class _$Crossword extends Crossword { if (identical(other, this)) return true; return other is Crossword && candidates == other.candidates && + candidateLocations == other.candidateLocations && downWords == other.downWords && acrossWords == other.acrossWords && characters == other.characters; @@ -55,6 +61,7 @@ class _$Crossword extends Crossword { int get hashCode { var _$hash = 0; _$hash = $jc(_$hash, candidates.hashCode); + _$hash = $jc(_$hash, candidateLocations.hashCode); _$hash = $jc(_$hash, downWords.hashCode); _$hash = $jc(_$hash, acrossWords.hashCode); _$hash = $jc(_$hash, characters.hashCode); @@ -66,6 +73,7 @@ class _$Crossword extends Crossword { String toString() { return (newBuiltValueToStringHelper(r'Crossword') ..add('candidates', candidates) + ..add('candidateLocations', candidateLocations) ..add('downWords', downWords) ..add('acrossWords', acrossWords) ..add('characters', characters)) @@ -82,6 +90,12 @@ class CrosswordBuilder implements Builder { set candidates(ListBuilder? candidates) => _$this._candidates = candidates; + ListBuilder? _candidateLocations; + ListBuilder get candidateLocations => + _$this._candidateLocations ??= new ListBuilder(); + set candidateLocations(ListBuilder? candidateLocations) => + _$this._candidateLocations = candidateLocations; + MapBuilder? _downWords; MapBuilder get downWords => _$this._downWords ??= new MapBuilder(); @@ -106,6 +120,7 @@ class CrosswordBuilder implements Builder { final $v = _$v; if ($v != null) { _candidates = $v.candidates.toBuilder(); + _candidateLocations = $v.candidateLocations.toBuilder(); _downWords = $v.downWords.toBuilder(); _acrossWords = $v.acrossWords.toBuilder(); _characters = $v.characters.toBuilder(); @@ -135,6 +150,7 @@ class CrosswordBuilder implements Builder { _$result = _$v ?? new _$Crossword._( candidates: candidates.build(), + candidateLocations: candidateLocations.build(), downWords: downWords.build(), acrossWords: acrossWords.build(), characters: characters.build()); @@ -143,6 +159,8 @@ class CrosswordBuilder implements Builder { try { _$failedField = 'candidates'; candidates.build(); + _$failedField = 'candidateLocations'; + candidateLocations.build(); _$failedField = 'downWords'; downWords.build(); _$failedField = 'acrossWords'; diff --git a/packages/board_generator/pubspec.yaml b/packages/board_generator/pubspec.yaml index 4564e899f..0c00a1b08 100644 --- a/packages/board_generator/pubspec.yaml +++ b/packages/board_generator/pubspec.yaml @@ -10,6 +10,10 @@ dependencies: built_collection: ^5.1.1 built_value: ^8.9.1 characters: ^1.3.0 + csv: ^6.0.0 + dart_firebase_admin: ^0.3.0 + game_domain: + path: ../../api/packages/game_domain dev_dependencies: build_runner: ^2.4.8 diff --git a/packages/board_generator/test/board_generator_test.dart b/packages/board_generator/test/src/board_generator_test.dart similarity index 55% rename from packages/board_generator/test/board_generator_test.dart rename to packages/board_generator/test/src/board_generator_test.dart index 745c1246d..29300aa54 100644 --- a/packages/board_generator/test/board_generator_test.dart +++ b/packages/board_generator/test/src/board_generator_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: prefer_const_constructors -import 'package:board_generator/board_generator.dart'; +import 'dart:io'; + +import 'package:board_generator/src/board_generator.dart'; import 'package:test/test.dart'; void main() { @@ -23,16 +25,12 @@ void main() { group('BoardGenerator', () { test('generate crosswords', () { - var depth = 0; - for (final crossword in generateCrosswords(wordList)) { - depth++; - expect(crossword.valid, true); - expect( - crossword.acrossWords.length + crossword.downWords.length, - depth, - ); - if (depth >= wordList.length) break; - } + final crossword = generateCrossword(wordList, 'test-board.txt'); + + final size = crossword.acrossWords.length + crossword.downWords.length; + expect(size, wordList.length); + + File('test-board.txt').deleteSync(); }); }); } diff --git a/packages/board_generator/test/models/data_model_test.dart b/packages/board_generator/test/src/models/data_model_test.dart similarity index 85% rename from packages/board_generator/test/models/data_model_test.dart rename to packages/board_generator/test/src/models/data_model_test.dart index 68b02715d..2dd5d8ca3 100644 --- a/packages/board_generator/test/models/data_model_test.dart +++ b/packages/board_generator/test/src/models/data_model_test.dart @@ -1,4 +1,4 @@ -import 'package:board_generator/models/data_model.dart'; +import 'package:board_generator/src/models/data_model.dart'; import 'package:test/test.dart'; void main() { @@ -177,59 +177,45 @@ void main() { (b) => b..candidates.addAll(wordList), ); - final crossword1 = crossword0.generate().first; + final crossword1 = crossword0.generate(); expect(crossword1.valid, true); expect(crossword1.acrossWords.length, 1); expect(crossword1.candidates.length, crossword0.candidates.length - 1); - final crossword2 = crossword1.generate().first; + final crossword2 = crossword1.generate(); expect(crossword2.valid, true); expect(crossword2.acrossWords.length, 1); expect(crossword2.downWords.length, 1); expect(crossword2.candidates.length, crossword1.candidates.length - 1); - final crossword3 = crossword2.generate().first; + final crossword3 = crossword2.generate(); expect(crossword3.valid, true); expect(crossword3.acrossWords.length + crossword3.downWords.length, 3); expect(crossword3.candidates.length, crossword2.candidates.length - 1); - final crossword4 = crossword3.generate().first; + final crossword4 = crossword3.generate(); expect(crossword4.valid, true); expect(crossword4.acrossWords.length + crossword4.downWords.length, 4); expect(crossword4.candidates.length, crossword3.candidates.length - 1); - final crossword5 = crossword4.generate().first; + final crossword5 = crossword4.generate(); expect(crossword5.valid, true); expect(crossword5.acrossWords.length + crossword5.downWords.length, 5); expect(crossword5.candidates.length, crossword4.candidates.length - 1); - final crossword6 = crossword5.generate().first; + final crossword6 = crossword5.generate(); expect(crossword6.valid, true); expect(crossword6.acrossWords.length + crossword6.downWords.length, 6); expect(crossword6.candidates.length, crossword5.candidates.length - 1); - final crossword7 = crossword6.generate().first; + final crossword7 = crossword6.generate(); expect(crossword7.valid, true); expect(crossword7.acrossWords.length + crossword7.downWords.length, 7); expect(crossword7.candidates.length, crossword6.candidates.length - 1); - final iterable = crossword7.generate(); - expect(iterable.isEmpty, false); - expect(iterable.isNotEmpty, true); - - final iterator = iterable.iterator; - expect(iterator.moveNext(), true); - expect(iterator.current, isNotNull); - final crossword8 = iterator.current; + final crossword8 = crossword7.generate(); expect(crossword8.valid, true); expect(crossword8.acrossWords.length + crossword8.downWords.length, 8); expect(crossword8.candidates.length, crossword7.candidates.length - 1); - - expect(iterator.moveNext(), true); - expect(iterator.current, isNotNull); - final crossword9 = iterator.current; - expect(crossword9.valid, true); - expect(crossword9.acrossWords.length + crossword9.downWords.length, 8); - expect(crossword9.candidates.length, crossword7.candidates.length - 1); }); }