commit 5ca5314401000e57dadd5534b93b62a8ba0d2db5 parent 1bf911eaa497b320e3e70d26816ee47088819e5f Author: Michael Camilleri <[email protected]> Date: Fri, 22 May 2026 12:13:56 +0900 Preview bundled puzzles in the puzzle picker The 'Bundles' tab listed each puzzle within its bundle as a title/publisher pair. This commit reworks that so that each row leads with a grid thumbnail — the same GridThumbnailView the game list uses — so a puzzle's shape is visible before it is opened. The problem with doing this with the previous code was that drawing a thumbnail needed every puzzle's grid, and parsing each bundled .xd to get it — on every render of the picker — was slow. Each bundle now ships a manifest.json file: a lightweight index of its puzzles' titles, publishers, and grid block masks. PuzzleCatalog builds its catalog by decoding these manifests rather than parsing .xd files, and an Entry's raw source is read lazily, only when its puzzle is opened. The manifest is generated by Bundlemake, a new tool in the Crossmake package that mirrors XD's section and grid parsing. bundle-puzzles.sh runs it after laying down a bundle's puzzles. The manifest.json file means that bundle directories under Puzzles/ can be named by their bundle id (Puzzles/cm-starter, Puzzles/debug) rather than by display name; the display name travels in the manifest. The rows also drop their button tint. In addition, the bundle listing itself shows how many puzzles it contains. The bundle rows drop the blue tint colour as action-producing buttons to be more consistent with the way puzzles are displayed in the Game List. Co-Authored-By: Claude Opus 4.7 <[email protected]> Diffstat:
25 files changed, 616 insertions(+), 136 deletions(-)
diff --git a/Crossmake/Package.swift b/Crossmake/Package.swift @@ -8,6 +8,7 @@ let package = Package( .macOS(.v13) ], products: [ + .executable(name: "Bundlemake", targets: ["Bundlemake"]), .executable(name: "Cluemake", targets: ["Cluemake"]), .executable(name: "Fillmake", targets: ["Fillmake"]), .executable(name: "Gridmake", targets: ["Gridmake"]), @@ -15,6 +16,7 @@ let package = Package( .executable(name: "Wordmake", targets: ["Wordmake"]) ], targets: [ + .executableTarget(name: "Bundlemake"), .executableTarget(name: "Cluemake"), .executableTarget( name: "Fillmake", diff --git a/Crossmake/Sources/Bundlemake/main.swift b/Crossmake/Sources/Bundlemake/main.swift @@ -0,0 +1,291 @@ +import Foundation + +/// Bumped whenever the manifest schema changes so the app can refuse or +/// migrate manifests it does not understand. +let manifestVersion = 1 + +struct BundlemakeError: Error, CustomStringConvertible { + let description: String +} + +struct Options { + var directory: String? + var outputPath: String? + var name: String? + var bundleID: String? +} + +/// The on-disk manifest: a lightweight index of a bundle's puzzles so the app +/// can draw grid thumbnails without parsing every `.xd` file. +struct Manifest: Encodable { + let version: Int + let bundleID: String + let name: String + let puzzles: [PuzzleEntry] +} + +struct PuzzleEntry: Encodable { + let id: String + let title: String + let publisher: String? + let gridWidth: Int + let gridHeight: Int + /// Row-major block mask: `#` is a block, `.` is an open cell. + let blockMask: String +} + +func parseOptions(_ arguments: [String]) throws -> Options { + var options = Options() + var index = 1 + + func requireValue(_ name: String) throws -> String { + guard index + 1 < arguments.count else { + throw BundlemakeError(description: "Missing value for \(name)") + } + index += 1 + return arguments[index] + } + + while index < arguments.count { + let arg = arguments[index] + switch arg { + case "-o", "--output": + options.outputPath = try requireValue(arg) + case "--name": + options.name = try requireValue(arg) + case "--bundle-id": + options.bundleID = try requireValue(arg) + case "-h", "--help": + printUsage() + exit(0) + default: + guard options.directory == nil else { + throw BundlemakeError(description: "Unexpected extra argument: \(arg)") + } + options.directory = arg + } + index += 1 + } + + guard options.directory != nil else { + throw BundlemakeError(description: "Missing <bundle-directory>. See --help.") + } + return options +} + +func printUsage() { + print(""" + Usage: Bundlemake [options] <bundle-directory> + + Writes manifest.json for a puzzle bundle: a lightweight index of every + .xd file's title, publisher, and grid block mask, so the app can render + puzzle thumbnails without parsing each puzzle. + + Options: + -o, --output PATH Manifest output path. Default: <directory>/manifest.json + --name NAME Bundle display name. Default: the directory's name. + --bundle-id ID Bundle id. Required when the puzzles carry no + Bundle: field (e.g. dev fixtures). + -h, --help Show this help. + """) +} + +/// Splits an `.xd` source into its sections. Mirrors the app's XD parser +/// (`XD.splitIntoSections`): a run of two or more blank lines, or a `## ` +/// header line, ends a section. Sections are metadata, grid, then clues. +func splitIntoSections(_ source: String) -> [[String]] { + let lines = source + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + + var sections: [[String]] = [] + var current: [String] = [] + var blankRun = 0 + + func flush() { + while current.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { + current.removeLast() + } + while current.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { + current.removeFirst() + } + if !current.isEmpty { + sections.append(current) + } + current = [] + } + + for rawLine in lines { + let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("## ") || trimmed == "##" { + flush() + blankRun = 0 + continue + } + if trimmed.isEmpty { + blankRun += 1 + if blankRun >= 2 { + flush() + } + continue + } + blankRun = 0 + current.append(line) + } + flush() + return sections +} + +/// Reads `Key: Value` metadata lines, keeping the first value seen per key. +func parseMetadata(_ lines: [String]) -> [String: String] { + var entries: [String: String] = [:] + for line in lines { + guard let colon = line.firstIndex(of: ":") else { continue } + let key = line[..<colon].trimmingCharacters(in: .whitespaces) + let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) + if !key.isEmpty, entries[key] == nil { + entries[key] = value + } + } + return entries +} + +struct Grid { + let width: Int + let height: Int + let blockMask: String +} + +/// Builds the block mask from a grid section. `#` and `_` are blocks (matching +/// the app's `XD.gridCell`); every other character is an open cell. +func parseGrid(_ lines: [String]) throws -> Grid { + var rows: [String] = [] + var width: Int? + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + if let width, trimmed.count != width { + throw BundlemakeError(description: "grid rows have inconsistent widths") + } + width = trimmed.count + rows.append(trimmed) + } + guard let width, !rows.isEmpty else { + throw BundlemakeError(description: "grid section is empty") + } + + var mask = "" + mask.reserveCapacity(width * rows.count) + for row in rows { + for character in row { + mask.append(character == "#" || character == "_" ? "#" : ".") + } + } + return Grid(width: width, height: rows.count, blockMask: mask) +} + +struct ParsedPuzzle { + let entry: PuzzleEntry + let bundleID: String? +} + +func parsePuzzle(at path: String) throws -> ParsedPuzzle { + let id = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + let source = try String(contentsOfFile: path, encoding: .utf8) + let sections = splitIntoSections(source) + guard sections.count >= 2 else { + throw BundlemakeError(description: "\(id): .xd source has no grid section") + } + + let metadata = parseMetadata(sections[0]) + let grid: Grid + do { + grid = try parseGrid(sections[1]) + } catch let error as BundlemakeError { + throw BundlemakeError(description: "\(id): \(error.description)") + } + + func nonEmpty(_ key: String) -> String? { + metadata[key].flatMap { $0.isEmpty ? nil : $0 } + } + + let entry = PuzzleEntry( + id: id, + title: nonEmpty("Title") ?? id, + publisher: nonEmpty("Publisher"), + gridWidth: grid.width, + gridHeight: grid.height, + blockMask: grid.blockMask + ) + return ParsedPuzzle(entry: entry, bundleID: nonEmpty("Bundle")) +} + +func run() throws { + let options = try parseOptions(CommandLine.arguments) + let directory = options.directory! + let fileManager = FileManager.default + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: directory, isDirectory: &isDirectory), + isDirectory.boolValue else { + throw BundlemakeError(description: "Not a directory: \(directory)") + } + + let directoryURL = URL(fileURLWithPath: directory) + let xdPaths = try fileManager + .contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil) + .filter { $0.pathExtension.lowercased() == "xd" } + .map(\.path) + .sorted() + guard !xdPaths.isEmpty else { + throw BundlemakeError(description: "No .xd puzzles found in \(directory)") + } + + let parsed = try xdPaths.map { try parsePuzzle(at: $0) } + + // Resolve the bundle id. Puzzles normally declare a `Bundle:` field, and + // every puzzle in a bundle must agree on it. Dirs whose puzzles carry no + // such field (dev fixtures) need an explicit --bundle-id instead. + let declaredIDs = Set(parsed.compactMap(\.bundleID)) + guard declaredIDs.count <= 1 else { + let listed = declaredIDs.sorted().joined(separator: ", ") + throw BundlemakeError(description: "Puzzles disagree on their Bundle: id (\(listed))") + } + if let declared = declaredIDs.first, + let override = options.bundleID, + declared != override { + throw BundlemakeError( + description: "--bundle-id \(override) contradicts the Bundle: \(declared) in the puzzles") + } + guard let bundleID = options.bundleID ?? declaredIDs.first else { + throw BundlemakeError( + description: "No Bundle: id in \(directory); pass --bundle-id to name the bundle") + } + + let manifest = Manifest( + version: manifestVersion, + bundleID: bundleID, + name: options.name ?? directoryURL.lastPathComponent, + puzzles: parsed.map(\.entry) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + var data = try encoder.encode(manifest) + data.append(0x0A) + + let outputURL = options.outputPath.map { URL(fileURLWithPath: $0) } + ?? directoryURL.appendingPathComponent("manifest.json") + try data.write(to: outputURL) + + fputs("Wrote \(outputURL.path): \(manifest.puzzles.count) puzzle(s) in bundle \"\(bundleID)\".\n", stderr) +} + +do { + try run() +} catch { + fputs("Bundlemake: \(error)\n", stderr) + exit(1) +} diff --git a/Crossmate/Models/PuzzleCatalog.swift b/Crossmate/Models/PuzzleCatalog.swift @@ -1,55 +1,53 @@ import Foundation /// Knows about packaged `.xd` puzzle resources. Used by the new-game picker -/// to list available puzzles. +/// to list available puzzles. The catalog is built from each bundle's +/// `manifest.json` (written by the Crossmake `Bundlemake` tool); raw `.xd` +/// source is read only when a specific puzzle is opened. struct PuzzleCatalog { struct Entry: Identifiable { - let id: String // resource name (e.g. "sample") - let title: String // parsed from the XD metadata - let publisher: String? // parsed from the XD metadata - let source: String // raw XD text - let cmVersion: Int // Crossmate version parser version + let id: String // resource name (e.g. "cm-starter-0001") + let title: String // display title from the manifest + let publisher: String? // publisher from the manifest, if any + let gridWidth: Int // grid columns, for the row thumbnail + let gridHeight: Int // grid rows, for the row thumbnail + /// Row-major block mask (`true` == block). Drives `GridThumbnailView` + /// so the puzzle list can show the same preview as the game list. + let blockCells: [Bool] + /// The puzzle's `.xd` resource. The catalog is built from manifests + /// and never holds source text; callers read it on demand. + let sourceURL: URL + + /// Reads the puzzle's raw `.xd` source from the app bundle. + func loadSource() throws -> String { + try String(contentsOf: sourceURL, encoding: .utf8) + } } - /// A directory of bundled puzzles under `Puzzles/`, e.g. "Crossmate Starter". + /// A bundle of puzzles under `Puzzles/`, e.g. "Crossmate Starter". struct PuzzleBundle: Identifiable { - let id: String // directory name, also used as the display name + let id: String // bundle id, e.g. "cm-starter" + let name: String // display name, e.g. "Crossmate Starter" let puzzles: [Entry] - - var name: String { id } } - /// Name of the `Puzzles/` subdirectory reserved for debug-only fixtures. - private static let debugDirectoryName = "Debug" + /// Bundle id of the debug-only fixture set, kept out of `bundles()`. + private static let debugBundleID = "debug" - /// The puzzle bundles shipped with the app: every subdirectory of `Puzzles/` - /// except `Debug`. Directories with no `.xd` files are skipped. - static func bundles() -> [PuzzleBundle] { - guard let puzzlesURL = Bundle.main.resourceURL? - .appendingPathComponent("Puzzles", isDirectory: true) else { - return [] - } - let contents = (try? FileManager.default.contentsOfDirectory( - at: puzzlesURL, - includingPropertiesForKeys: [.isDirectoryKey] - )) ?? [] + /// Manifests are immutable for the life of the process, so the catalog is + /// read once, on first access. SwiftUI re-evaluates the picker's body + /// freely without paying a re-read each time. + private static let allBundles: [PuzzleBundle] = loadAllBundles() - return contents.compactMap { url -> PuzzleBundle? in - guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true, - url.lastPathComponent != debugDirectoryName else { - return nil - } - let entries = puzzles(in: "Puzzles/\(url.lastPathComponent)") - guard !entries.isEmpty else { - return nil - } - return PuzzleBundle(id: url.lastPathComponent, puzzles: entries) - } - .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + /// The puzzle bundles shown in the picker: every bundle under `Puzzles/` + /// except the debug fixtures. + static func bundles() -> [PuzzleBundle] { + allBundles.filter { $0.id != debugBundleID } } + /// The debug-only fixture puzzles, surfaced only in debug builds. static func debugPuzzles() -> [Entry] { - puzzles(in: "Puzzles/\(debugDirectoryName)") + allBundles.first { $0.id == debugBundleID }?.puzzles ?? [] } static func source( @@ -57,7 +55,6 @@ struct PuzzleCatalog { title: String? ) -> Entry? { let entries = allPuzzles() - if let resourceID { return entries.first { $0.id == resourceID } } else if let title { @@ -67,39 +64,81 @@ struct PuzzleCatalog { } } + /// Finds the catalog id of a bundled puzzle whose `.xd` source matches + /// `source` exactly. Pre-filtered by title so a non-bundled source (an + /// NYT or imported puzzle) reads no `.xd` files at all. static func resourceID(matching source: String) -> String? { - allPuzzles().first { $0.source == source }?.id + guard let title = try? XD.parse(source).title else { return nil } + return allPuzzles() + .filter { $0.title == title } + .first { (try? $0.loadSource()) == source }? + .id } /// Every cataloged puzzle — bundled and debug — flattened for lookup. private static func allPuzzles() -> [Entry] { - bundles().flatMap(\.puzzles) + debugPuzzles() + allBundles.flatMap(\.puzzles) } - private static func puzzles(in subdirectory: String) -> [Entry] { - guard let directoryURL = Bundle.main.resourceURL? - .appendingPathComponent(subdirectory, isDirectory: true) else { + // MARK: - Manifest loading + + private struct Manifest: Decodable { + let version: Int + let bundleID: String + let name: String + let puzzles: [ManifestPuzzle] + } + + private struct ManifestPuzzle: Decodable { + let id: String + let title: String + let publisher: String? + let gridWidth: Int + let gridHeight: Int + let blockMask: String + } + + private static func loadAllBundles() -> [PuzzleBundle] { + guard let puzzlesURL = Bundle.main.resourceURL? + .appendingPathComponent("Puzzles", isDirectory: true) else { return [] } - let urls = (try? FileManager.default.contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil)) ?? [] + let directories = (try? FileManager.default.contentsOfDirectory( + at: puzzlesURL, + includingPropertiesForKeys: [.isDirectoryKey] + )) ?? [] - return urls.compactMap { url in - guard url.pathExtension == "xd" else { - return nil + return directories + .compactMap { url -> PuzzleBundle? in + guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))? + .isDirectory == true else { + return nil + } + return bundle(in: url) } - let name = url.deletingPathExtension().lastPathComponent - guard let source = try? String(contentsOf: url, encoding: .utf8), - let xd = try? XD.parse(source) else { - return nil - } - return Entry( - id: name, - title: xd.title ?? name, - publisher: xd.publisher, - source: source, - cmVersion: xd.cmVersion + .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + } + + /// Builds a bundle from its `manifest.json`. Directories without a + /// readable manifest are skipped — `Bundlemake` writes the manifest as + /// part of authoring a bundle. + private static func bundle(in directoryURL: URL) -> PuzzleBundle? { + let manifestURL = directoryURL.appendingPathComponent("manifest.json") + guard let data = try? Data(contentsOf: manifestURL), + let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else { + return nil + } + let entries = manifest.puzzles.map { puzzle in + Entry( + id: puzzle.id, + title: puzzle.title, + publisher: puzzle.publisher, + gridWidth: puzzle.gridWidth, + gridHeight: puzzle.gridHeight, + blockCells: puzzle.blockMask.map { $0 == "#" }, + sourceURL: directoryURL.appendingPathComponent("\(puzzle.id).xd") ) } - .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + return PuzzleBundle(id: manifest.bundleID, name: manifest.name, puzzles: entries) } } diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -649,7 +649,7 @@ final class GameStore { private func seedFromSample() throws -> (GameEntity, Puzzle) { guard let url = Bundle.main.resourceURL? - .appendingPathComponent("Puzzles/Debug/sample.xd") else { + .appendingPathComponent("Puzzles/debug/sample.xd") else { throw LoadError.sampleResourceMissing } let source = try String(contentsOf: url, encoding: .utf8) @@ -687,7 +687,7 @@ final class GameStore { matchingResourceID: entity.puzzleResourceID, title: try? XD.parse(source).title ) - let nextSource = catalogSource?.source ?? source + let nextSource = (catalogSource.flatMap { try? $0.loadSource() }) ?? source let nextXD = try XD.parse(nextSource) let puzzle = Puzzle(xd: nextXD) entity.title = puzzle.title diff --git a/Crossmate/Views/BundledBrowseView.swift b/Crossmate/Views/BundledBrowseView.swift @@ -9,10 +9,17 @@ struct BundledBrowseView: View { var body: some View { List(bundles) { bundle in - NavigationLink(bundle.name) { + NavigationLink { PuzzleListView(puzzles: bundle.puzzles, onSelected: onSelected) .navigationTitle(bundle.name) .navigationBarTitleDisplayMode(.inline) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(bundle.name) + Text("^[\(bundle.puzzles.count) puzzle](inflect: true)") + .font(.subheadline) + .foregroundStyle(.secondary) + } } } } @@ -30,7 +37,8 @@ struct DebugBrowseView: View { } } -/// A list of individual puzzles, each showing its title above its publisher. +/// A list of individual puzzles, each showing a grid preview alongside its +/// title and publisher. private struct PuzzleListView: View { let puzzles: [PuzzleCatalog.Entry] let onSelected: (String) -> Void @@ -38,18 +46,27 @@ private struct PuzzleListView: View { var body: some View { List(puzzles) { entry in Button { - onSelected(entry.source) + if let source = try? entry.loadSource() { + onSelected(source) + } } label: { - VStack(alignment: .leading, spacing: 2) { - Text(entry.title) - .foregroundStyle(.primary) - if let publisher = entry.publisher, !publisher.isEmpty { - Text(publisher) - .font(.subheadline) - .foregroundStyle(.secondary) + HStack(spacing: 12) { + GridThumbnailView( + width: entry.gridWidth, + height: entry.gridHeight, + cells: entry.blockCells.map { $0 ? .block : .empty } + ) + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + if let publisher = entry.publisher, !publisher.isEmpty { + Text(publisher) + .font(.subheadline) + .foregroundStyle(.secondary) + } } } } + .buttonStyle(.plain) } } } diff --git a/Puzzles/Debug/garden.xd b/Puzzles/Debug/garden.xd @@ -1,20 +0,0 @@ -Title: Garden Party -CmVer: 3 -Author: Crossmate -Copyright: Public domain test puzzle - - -##B## -#ALE# -GROVE -#TOE# -##M## - - -A2. Beer or lager ~ ALE -A4. Cluster of trees ~ GROVE -A5. Foot digit ~ TOE - -D1. Full flower ~ BLOOM -D2. Painting or sculpture ~ ART -D3. Christmas ___ ~ EVE diff --git a/Puzzles/Debug/morning.xd b/Puzzles/Debug/morning.xd @@ -1,20 +0,0 @@ -Title: Morning Routine -CmVer: 3 -Author: Crossmate -Copyright: Public domain test puzzle - - -##S## -#ATE# -BREAK -#TAR# -##M## - - -A2. Had breakfast ~ ATE -A4. Fracture ~ BREAK -A5. Road surface substance ~ TAR - -D1. Water vapor ~ STEAM -D2. Gallery exhibit ~ ART -D3. Body part for hearing ~ EAR diff --git a/Puzzles/Debug/sample.xd b/Puzzles/Debug/sample.xd @@ -1,20 +0,0 @@ -Title: Crossmate Demo -CmVer: 3 -Author: Crossmate -Copyright: Public domain test puzzle - - -##L## -#BOY# -RIVER -#SEW# -##R## - - -A2. Young lad ~ BOY -A4. Flowing waterway ~ RIVER -A5. Stitch with needle and thread ~ SEW - -D1. Romantic partner ~ LOVER -D2. Encore, in music ~ BIS -D3. Evergreen tree with red berries ~ YEW diff --git a/Puzzles/Crossmate Starter/cm-starter-0001.xd b/Puzzles/cm-starter/cm-starter-0001.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0002.xd b/Puzzles/cm-starter/cm-starter-0002.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0003.xd b/Puzzles/cm-starter/cm-starter-0003.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0004.xd b/Puzzles/cm-starter/cm-starter-0004.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0005.xd b/Puzzles/cm-starter/cm-starter-0005.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0006.xd b/Puzzles/cm-starter/cm-starter-0006.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0007.xd b/Puzzles/cm-starter/cm-starter-0007.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0008.xd b/Puzzles/cm-starter/cm-starter-0008.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0009.xd b/Puzzles/cm-starter/cm-starter-0009.xd diff --git a/Puzzles/Crossmate Starter/cm-starter-0010.xd b/Puzzles/cm-starter/cm-starter-0010.xd diff --git a/Puzzles/cm-starter/manifest.json b/Puzzles/cm-starter/manifest.json @@ -0,0 +1,87 @@ +{ + "bundleID" : "cm-starter", + "name" : "Crossmate Starter", + "puzzles" : [ + { + "blockMask" : "....#....#.........#....#.........#....#..................##...#....#......###...##...#.............#.........#...#.........#.............#...##...###......#....#...##..................#....#.........#....#.........#....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0001", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #1" + }, + { + "blockMask" : "....#....##........#....#.........#..................#...........####..............#...###...#.....#....#....#.....#....#....#.....#...###...#..............####...........#..................#.........#....#........##....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0002", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #2" + }, + { + "blockMask" : ".....#.....#........#.....#..............#............##....#####................###...........#......###...............###......#...........###................#####....##............#..............#.....#........#.....#.....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0003", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #3" + }, + { + "blockMask" : ".....#....#.........#....#.........#.....................###...#....#......###...##...#.............#.........#...#.........#.............#...##...###......#....#...###.....................#.........#....#.........#....#.....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0004", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #4" + }, + { + "blockMask" : "....#.....#........#.....#..............#.........#.........###...###..................###...#.....#.........#.....#.........#.....#...###..................###...###.........#.........#..............#.....#........#.....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0005", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #5" + }, + { + "blockMask" : ".....#....#.........#....#.........#...............##..........#....#.....####.......##........#...#..........#...#..........#...#........##.......####.....#....#..........##...............#.........#....#.........#....#.....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0006", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #6" + }, + { + "blockMask" : "......#....#.........#....#......................#...##.....####.................##....###.....#....#.......................#....#.....###....##.................####.....##...#......................#....#.........#....#......", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0007", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #7" + }, + { + "blockMask" : "....#....#.........#....#.........#....#..................##.......#...#...####....#...........#....#.......................#....#...........#....####...#...#.......##..................#....#.........#....#.........#....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0008", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #8" + }, + { + "blockMask" : "....#.....#........#.....#........#.....#.................##.....###...#...###.....##........#...............#.....#...............#........##.....###...#...###.....##.................#.....#........#.....#........#.....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0009", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #9" + }, + { + "blockMask" : "....#.....#........#.....#........#.............#...###..................#####...#....#........#....#.......................#....#........#....#...#####..................###...#.............#........#.....#........#.....#....", + "gridHeight" : 15, + "gridWidth" : 15, + "id" : "cm-starter-0010", + "publisher" : "Michael Camilleri", + "title" : "Crossmate Starter #10" + } + ], + "version" : 1 +} diff --git a/Puzzles/debug/garden.xd b/Puzzles/debug/garden.xd @@ -0,0 +1,21 @@ +Title: Garden Party +CmVer: 3 +Bundle: debug +Author: Crossmate +Copyright: Public domain test puzzle + + +##B## +#ALE# +GROVE +#TOE# +##M## + + +A2. Beer or lager ~ ALE +A4. Cluster of trees ~ GROVE +A5. Foot digit ~ TOE + +D1. Full flower ~ BLOOM +D2. Painting or sculpture ~ ART +D3. Christmas ___ ~ EVE diff --git a/Puzzles/debug/manifest.json b/Puzzles/debug/manifest.json @@ -0,0 +1,28 @@ +{ + "bundleID" : "debug", + "name" : "Debug", + "puzzles" : [ + { + "blockMask" : "##.###...#.....#...###.##", + "gridHeight" : 5, + "gridWidth" : 5, + "id" : "garden", + "title" : "Garden Party" + }, + { + "blockMask" : "##.###...#.....#...###.##", + "gridHeight" : 5, + "gridWidth" : 5, + "id" : "morning", + "title" : "Morning Routine" + }, + { + "blockMask" : "##.###...#.....#...###.##", + "gridHeight" : 5, + "gridWidth" : 5, + "id" : "sample", + "title" : "Crossmate Demo" + } + ], + "version" : 1 +} diff --git a/Puzzles/debug/morning.xd b/Puzzles/debug/morning.xd @@ -0,0 +1,21 @@ +Title: Morning Routine +CmVer: 3 +Bundle: debug +Author: Crossmate +Copyright: Public domain test puzzle + + +##S## +#ATE# +BREAK +#TAR# +##M## + + +A2. Had breakfast ~ ATE +A4. Fracture ~ BREAK +A5. Road surface substance ~ TAR + +D1. Water vapor ~ STEAM +D2. Gallery exhibit ~ ART +D3. Body part for hearing ~ EAR diff --git a/Puzzles/debug/sample.xd b/Puzzles/debug/sample.xd @@ -0,0 +1,21 @@ +Title: Crossmate Demo +CmVer: 3 +Bundle: debug +Author: Crossmate +Copyright: Public domain test puzzle + + +##L## +#BOY# +RIVER +#SEW# +##R## + + +A2. Young lad ~ BOY +A4. Flowing waterway ~ RIVER +A5. Stitch with needle and thread ~ SEW + +D1. Romantic partner ~ LOVER +D2. Encore, in music ~ BIS +D3. Evergreen tree with red berries ~ YEW diff --git a/Scripts/bundle-puzzles.sh b/Scripts/bundle-puzzles.sh @@ -11,7 +11,9 @@ fi BUNDLE_ID="$1" TITLE_PREFIX="$2" -DEST_DIR="${3:-./Puzzles}/${TITLE_PREFIX}" +# The directory is named by bundle id; the title prefix is the display name, +# carried into the manifest (and into each puzzle's Title) below. +DEST_DIR="${3:-./Puzzles}/${BUNDLE_ID}" SOURCE_DIR="${REPO_DIR}/Crossmake/Picked" @@ -51,3 +53,9 @@ for src in "${source_files[@]}"; do done echo "Bundled $((next - first)) puzzle(s) into ${DEST_DIR}; numbers ${first}-$((next - 1))." >&2 + +# Write manifest.json — the puzzle index the app reads to draw grid +# thumbnails without parsing every .xd file. Built by the Bundlemake tool in +# the Crossmake package so it always reflects the bundle's final contents. +echo "Generating manifest for ${DEST_DIR}..." >&2 +swift run --package-path "${REPO_DIR}/Crossmake" Bundlemake --name "${TITLE_PREFIX}" "${DEST_DIR}" diff --git a/Tests/Unit/PuzzleCatalogTests.swift b/Tests/Unit/PuzzleCatalogTests.swift @@ -17,8 +17,8 @@ struct PuzzleCatalogTests { #expect(puzzles.count == 10) #expect(puzzles.map(\.title).contains("Crossmate Starter #1")) #expect(puzzles.map(\.title).contains("Crossmate Starter #10")) - #expect(puzzles.allSatisfy { $0.cmVersion == XD.currentCmVersion }) #expect(puzzles.allSatisfy { $0.publisher == "Michael Camilleri" }) + #expect(puzzles.allSatisfy { $0.blockCells.count == $0.gridWidth * $0.gridHeight }) #expect(puzzles.map(\.id) == [ "cm-starter-0001", "cm-starter-0002", @@ -31,11 +31,16 @@ struct PuzzleCatalogTests { "cm-starter-0009", "cm-starter-0010" ]) + + // The catalog holds no source text; it is read from the `.xd` + // resource on demand. + let firstSource = try? puzzles.first?.loadSource() + #expect(firstSource?.isEmpty == false) } - @Test("The Debug directory is excluded from the bundles") - func debugDirectoryIsExcludedFromBundles() { - #expect(PuzzleCatalog.bundles().allSatisfy { $0.name != "Debug" }) + @Test("The debug bundle is excluded from the bundles") + func debugBundleIsExcludedFromBundles() { + #expect(PuzzleCatalog.bundles().allSatisfy { $0.id != "debug" }) } @Test("Debug puzzle resources are discoverable") @@ -43,6 +48,6 @@ struct PuzzleCatalogTests { let puzzles = PuzzleCatalog.debugPuzzles() #expect(puzzles.count == 3) - #expect(puzzles.allSatisfy { $0.cmVersion == XD.currentCmVersion }) + #expect(puzzles.map(\.id) == ["garden", "morning", "sample"]) } }