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 }