PuzzleCatalog.swift (5534B)
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. 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. Debug fixtures join the regular 43 /// bundles when the app's debug mode is enabled. 44 static func bundles(includeDebug: Bool = false) -> [PuzzleBundle] { 45 includeDebug ? allBundles : allBundles.filter { $0.id != debugBundleID } 46 } 47 48 static func source( 49 matchingResourceID resourceID: String?, 50 title: String? 51 ) -> Entry? { 52 let entries = allPuzzles() 53 if let resourceID { 54 return entries.first { $0.id == resourceID } 55 } else if let title { 56 return entries.first { $0.title == title } 57 } else { 58 return nil 59 } 60 } 61 62 /// Finds the catalog id of a bundled puzzle whose `.xd` source matches 63 /// `source` exactly. Pre-filtered by title so a non-bundled source (an 64 /// NYT or imported puzzle) reads no `.xd` files at all. 65 static func resourceID(matching source: String) -> String? { 66 guard let title = try? XD.parse(source).title else { return nil } 67 return allPuzzles() 68 .filter { $0.title == title } 69 .first { (try? $0.loadSource()) == source }? 70 .id 71 } 72 73 /// Every cataloged puzzle — bundled and debug — flattened for lookup. 74 private static func allPuzzles() -> [Entry] { 75 allBundles.flatMap(\.puzzles) 76 } 77 78 // MARK: - Manifest loading 79 80 private struct Manifest: Decodable { 81 let version: Int 82 let bundleID: String 83 let name: String 84 let puzzles: [ManifestPuzzle] 85 } 86 87 private struct ManifestPuzzle: Decodable { 88 let id: String 89 let title: String 90 let publisher: String? 91 let gridWidth: Int 92 let gridHeight: Int 93 let blockMask: String 94 } 95 96 private static func loadAllBundles() -> [PuzzleBundle] { 97 guard let puzzlesURL = Bundle.main.resourceURL? 98 .appendingPathComponent("Puzzles", isDirectory: true) else { 99 return [] 100 } 101 let directories = (try? FileManager.default.contentsOfDirectory( 102 at: puzzlesURL, 103 includingPropertiesForKeys: [.isDirectoryKey] 104 )) ?? [] 105 106 return directories 107 .compactMap { url -> PuzzleBundle? in 108 guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))? 109 .isDirectory == true else { 110 return nil 111 } 112 return bundle(in: url) 113 } 114 .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } 115 } 116 117 /// Builds a bundle from its `manifest.json`. Directories without a 118 /// readable manifest are skipped — `Bundlemake` writes the manifest as 119 /// part of authoring a bundle. 120 private static func bundle(in directoryURL: URL) -> PuzzleBundle? { 121 let manifestURL = directoryURL.appendingPathComponent("manifest.json") 122 guard let data = try? Data(contentsOf: manifestURL), 123 let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else { 124 return nil 125 } 126 let entries = manifest.puzzles.map { puzzle in 127 Entry( 128 id: puzzle.id, 129 title: puzzle.title, 130 publisher: puzzle.publisher, 131 gridWidth: puzzle.gridWidth, 132 gridHeight: puzzle.gridHeight, 133 blockCells: puzzle.blockMask.map { $0 == "#" }, 134 sourceURL: directoryURL.appendingPathComponent("\(puzzle.id).xd") 135 ) 136 } 137 return PuzzleBundle(id: manifest.bundleID, name: manifest.name, puzzles: entries) 138 } 139 }