crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

PuzzleCatalog.swift (5673B)


      1 import Foundation
      2 
      3 /// Knows about packaged `.xd` puzzle resources. Used by the new-game picker
      4 /// to list available puzzles. The catalog is built from each bundle's
      5 /// `manifest.json` (written by the Crossmake `Bundlemake` tool); raw `.xd`
      6 /// source is read only when a specific puzzle is opened.
      7 struct PuzzleCatalog {
      8     struct Entry: Identifiable {
      9         let id: String          // resource name (e.g. "cm-starter-0001")
     10         let title: String       // display title from the manifest
     11         let publisher: String?  // publisher from the manifest, if any
     12         let gridWidth: Int      // grid columns, for the row thumbnail
     13         let gridHeight: Int     // grid rows, for the row thumbnail
     14         /// Row-major block mask (`true` == block). Drives `GridThumbnailView`
     15         /// so the puzzle list can show the same preview as the game list.
     16         let blockCells: [Bool]
     17         /// The puzzle's `.xd` resource. The catalog is built from manifests
     18         /// and never holds source text; callers read it on demand.
     19         let sourceURL: URL
     20 
     21         /// Reads the puzzle's raw `.xd` source from the app bundle.
     22         func loadSource() throws -> String {
     23             try String(contentsOf: sourceURL, encoding: .utf8)
     24         }
     25     }
     26 
     27     /// A bundle of puzzles under `Puzzles/`, e.g. "Crossmate Starter".
     28     struct PuzzleBundle: Identifiable {
     29         let id: String       // bundle id, e.g. "cm-starter"
     30         let name: String     // display name, e.g. "Crossmate Starter"
     31         let puzzles: [Entry]
     32     }
     33 
     34     /// Bundle id of the debug-only fixture set, kept out of `bundles()`.
     35     private static let debugBundleID = "debug"
     36 
     37     /// Manifests are immutable for the life of the process, so the catalog is
     38     /// read once, on first access. SwiftUI re-evaluates the picker's body
     39     /// freely without paying a re-read each time.
     40     private static let allBundles: [PuzzleBundle] = loadAllBundles()
     41 
     42     /// The puzzle bundles shown in the picker: every bundle under `Puzzles/`
     43     /// except the debug fixtures.
     44     static func bundles() -> [PuzzleBundle] {
     45         allBundles.filter { $0.id != debugBundleID }
     46     }
     47 
     48     /// The debug-only fixture puzzles, surfaced only in debug builds.
     49     static func debugPuzzles() -> [Entry] {
     50         allBundles.first { $0.id == debugBundleID }?.puzzles ?? []
     51     }
     52 
     53     static func source(
     54         matchingResourceID resourceID: String?,
     55         title: String?
     56     ) -> Entry? {
     57         let entries = allPuzzles()
     58         if let resourceID {
     59             return entries.first { $0.id == resourceID }
     60         } else if let title {
     61             return entries.first { $0.title == title }
     62         } else {
     63             return nil
     64         }
     65     }
     66 
     67     /// Finds the catalog id of a bundled puzzle whose `.xd` source matches
     68     /// `source` exactly. Pre-filtered by title so a non-bundled source (an
     69     /// NYT or imported puzzle) reads no `.xd` files at all.
     70     static func resourceID(matching source: String) -> String? {
     71         guard let title = try? XD.parse(source).title else { return nil }
     72         return allPuzzles()
     73             .filter { $0.title == title }
     74             .first { (try? $0.loadSource()) == source }?
     75             .id
     76     }
     77 
     78     /// Every cataloged puzzle — bundled and debug — flattened for lookup.
     79     private static func allPuzzles() -> [Entry] {
     80         allBundles.flatMap(\.puzzles)
     81     }
     82 
     83     // MARK: - Manifest loading
     84 
     85     private struct Manifest: Decodable {
     86         let version: Int
     87         let bundleID: String
     88         let name: String
     89         let puzzles: [ManifestPuzzle]
     90     }
     91 
     92     private struct ManifestPuzzle: Decodable {
     93         let id: String
     94         let title: String
     95         let publisher: String?
     96         let gridWidth: Int
     97         let gridHeight: Int
     98         let blockMask: String
     99     }
    100 
    101     private static func loadAllBundles() -> [PuzzleBundle] {
    102         guard let puzzlesURL = Bundle.main.resourceURL?
    103             .appendingPathComponent("Puzzles", isDirectory: true) else {
    104             return []
    105         }
    106         let directories = (try? FileManager.default.contentsOfDirectory(
    107             at: puzzlesURL,
    108             includingPropertiesForKeys: [.isDirectoryKey]
    109         )) ?? []
    110 
    111         return directories
    112             .compactMap { url -> PuzzleBundle? in
    113                 guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?
    114                     .isDirectory == true else {
    115                     return nil
    116                 }
    117                 return bundle(in: url)
    118             }
    119             .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
    120     }
    121 
    122     /// Builds a bundle from its `manifest.json`. Directories without a
    123     /// readable manifest are skipped — `Bundlemake` writes the manifest as
    124     /// part of authoring a bundle.
    125     private static func bundle(in directoryURL: URL) -> PuzzleBundle? {
    126         let manifestURL = directoryURL.appendingPathComponent("manifest.json")
    127         guard let data = try? Data(contentsOf: manifestURL),
    128               let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else {
    129             return nil
    130         }
    131         let entries = manifest.puzzles.map { puzzle in
    132             Entry(
    133                 id: puzzle.id,
    134                 title: puzzle.title,
    135                 publisher: puzzle.publisher,
    136                 gridWidth: puzzle.gridWidth,
    137                 gridHeight: puzzle.gridHeight,
    138                 blockCells: puzzle.blockMask.map { $0 == "#" },
    139                 sourceURL: directoryURL.appendingPathComponent("\(puzzle.id).xd")
    140             )
    141         }
    142         return PuzzleBundle(id: manifest.bundleID, name: manifest.name, puzzles: entries)
    143     }
    144 }