crossmate

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

commit 0d9f9f1730ae5597a4485d192ee65582b015ef4b
parent 12ced1516dcb353405aee0fbcffd030b233454ad
Author: Michael Camilleri <[email protected]>
Date:   Fri, 10 Apr 2026 06:22:44 +0900

Add persistence

The game state now persists between launches of the app. Persistence is
achieved with Core Data. The intention is to add support for syncing
using raw CloudKit calls (since this makes collaboration easier later
on).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 33+++++++++++++++++++++++++++++++++
MCrossmate/CrossmateApp.swift | 9++++++---
ACrossmate/Models/CrossmateModel.xcdatamodeld/.xccurrentversion | 8++++++++
ACrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 19+++++++++++++++++++
ACrossmate/Persistence/GameStore.swift | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Persistence/PersistenceController.swift | 36++++++++++++++++++++++++++++++++++++
6 files changed, 298 insertions(+), 3 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -12,11 +12,14 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; + 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; }; 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; }; + C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */; }; + D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; /* End PBXBuildFile section */ @@ -28,7 +31,10 @@ 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = "<group>"; }; + 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; + 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + ACC295195602B3DDF7BB3895 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; }; B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; }; B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -56,16 +62,27 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, B9031A1574C21866940F6A2C /* XD.swift */, + F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */, ); path = Models; sourceTree = "<group>"; }; + 565DBAFC8DB2589B3F0AF90E /* Persistence */ = { + isa = PBXGroup; + children = ( + 93EE5BA78566EDED68D846AB /* GameStore.swift */, + ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, + ); + path = Persistence; + sourceTree = "<group>"; + }; 5770CE69DB2B0B7462FACE53 /* Crossmate */ = { isa = PBXGroup; children = ( 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */, 9447F0FE34C63810C6F1D8BE /* Info.plist */, 41DB2417FF67A47FE6890256 /* Models */, + 565DBAFC8DB2589B3F0AF90E /* Persistence */, F53443E4827221C62DB7AA36 /* Resources */, 84445EA9CACB6AAAEDE6965F /* Views */, ); @@ -173,9 +190,12 @@ 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, + C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, 98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */, + D58980B92C99122C368D4216 /* GameStore.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, + 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, @@ -376,6 +396,19 @@ defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */, + ); + currentVersion = 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */; + path = CrossmateModel.xcdatamodeld; + sourceTree = "<group>"; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 9167165F088B7698D1319D3C /* Project object */; } diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -2,14 +2,18 @@ import SwiftUI @main struct CrossmateApp: App { + @State private var store = GameStore(persistence: PersistenceController()) + var body: some Scene { WindowGroup { - RootView() + RootView(store: store) } } } struct RootView: View { + let store: GameStore + @State private var session: PlayerSession? @State private var loadError: String? @@ -33,8 +37,7 @@ struct RootView: View { } .task { do { - let puzzle = try Puzzle.load(resource: "sample") - let game = Game(puzzle: puzzle) + let game = try store.loadOrCreateCurrentGame() session = PlayerSession(game: game) } catch { loadError = String(describing: error) diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/.xccurrentversion b/Crossmate/Models/CrossmateModel.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>_XCCurrentVersionName</key> + <string>CrossmateModel.xcdatamodel</string> +</dict> +</plist> diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="NO" userDefinedModelVersionIdentifier=""> + <entity name="GameEntity" representedClassName="GameEntity" syncable="YES" codeGenerationType="class"> + <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> + <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> + <attribute name="puzzleSource" attributeType="String"/> + <attribute name="title" attributeType="String"/> + <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> + <relationship name="cells" toMany="YES" deletionRule="Cascade" destinationEntity="CellEntity" inverseName="game" inverseEntity="CellEntity"/> + </entity> + <entity name="CellEntity" representedClassName="CellEntity" syncable="YES" codeGenerationType="class"> + <attribute name="checkedWrong" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="letter" attributeType="String" defaultValueString=""/> + <attribute name="markKind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <attribute name="row" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> + <relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/> + </entity> +</model> diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -0,0 +1,196 @@ +import CoreData +import Foundation +import Observation + +/// Repository over the local Core Data store. Owns the lifecycle of the +/// "current game" — loading it on launch (or seeding from `sample.xd` on +/// first run) and write-throughing every in-memory mutation back to the +/// persistent store. +/// +/// Persistence is whole-game write-through: any change to `Game.entries` +/// or `Game.marks` triggers a save of every cell. For puzzle-sized data +/// (a few hundred cells at most) the cost is negligible, and it lets us +/// skip per-cell dirty tracking entirely. If write throughput ever shows +/// up as a problem, debouncing or per-cell tracking can be layered on +/// without changing the call sites. +@MainActor +final class GameStore { + private let persistence: PersistenceController + private var context: NSManagedObjectContext { persistence.viewContext } + + private var currentGame: Game? + private var currentEntity: GameEntity? + + init(persistence: PersistenceController) { + self.persistence = persistence + } + + enum LoadError: Error { + case sampleResourceMissing + case persistedSourceMissing + } + + /// Returns the single current game, creating it from `sample.xd` on + /// first launch. Subsequent launches rehydrate the in-memory `Game` + /// from the stored `CellEntity` rows so any prior progress is restored. + func loadOrCreateCurrentGame() throws -> Game { + let entity: GameEntity + let puzzle: Puzzle + + if let existing = try fetchCurrentEntity() { + entity = existing + guard let source = existing.puzzleSource else { + throw LoadError.persistedSourceMissing + } + let xd = try XD.parse(source) + puzzle = Puzzle(xd: xd) + } else { + (entity, puzzle) = try seedFromSample() + } + + let game = Game(puzzle: puzzle) + restore(game: game, from: entity) + + currentGame = game + currentEntity = entity + startObserving(game) + + return game + } + + // MARK: - Loading + + private func fetchCurrentEntity() throws -> GameEntity? { + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] + request.fetchLimit = 1 + return try context.fetch(request).first + } + + private func seedFromSample() throws -> (GameEntity, Puzzle) { + guard let url = Bundle.main.url(forResource: "sample", withExtension: "xd") else { + throw LoadError.sampleResourceMissing + } + let source = try String(contentsOf: url, encoding: .utf8) + let xd = try XD.parse(source) + let puzzle = Puzzle(xd: xd) + + let now = Date() + let entity = GameEntity(context: context) + entity.id = UUID() + entity.title = puzzle.title + entity.puzzleSource = source + entity.createdAt = now + entity.updatedAt = now + + for row in puzzle.cells { + for cell in row where !cell.isBlock { + let cellEntity = CellEntity(context: context) + cellEntity.row = Int16(cell.row) + cellEntity.col = Int16(cell.col) + cellEntity.letter = "" + cellEntity.markKind = 0 + cellEntity.checkedWrong = false + cellEntity.game = entity + } + } + + try context.save() + return (entity, puzzle) + } + + private func restore(game: Game, from entity: GameEntity) { + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + for cellEntity in cellEntities { + let r = Int(cellEntity.row) + let c = Int(cellEntity.col) + guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } + game.entries[r][c] = cellEntity.letter ?? "" + game.marks[r][c] = decodeMark(kind: cellEntity.markKind, checkedWrong: cellEntity.checkedWrong) + } + } + + // MARK: - Observation + + /// Registers a one-shot observation on the game's mutable state. The + /// callback fires once on the next change, persists, and re-registers. + /// `withObservationTracking` is intentionally one-shot; the re-arm in + /// `persistCurrent` is what makes this run for the lifetime of the game. + private func startObserving(_ game: Game) { + withObservationTracking { + // Touch the properties we care about so the tracker knows to + // watch them. Mutating an element of `entries` or `marks` is a + // setter call on the array property under the hood, which + // @Observable picks up. + _ = game.entries + _ = game.marks + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.persistCurrent() + } + } + } + + private func persistCurrent() { + guard let game = currentGame, let entity = currentEntity else { return } + + let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] + for cellEntity in cellEntities { + let r = Int(cellEntity.row) + let c = Int(cellEntity.col) + guard r >= 0, r < game.puzzle.height, c >= 0, c < game.puzzle.width else { continue } + let newLetter = game.entries[r][c] + let (kind, wrong) = encodeMark(game.marks[r][c]) + if cellEntity.letter != newLetter { + cellEntity.letter = newLetter + } + if cellEntity.markKind != kind { + cellEntity.markKind = kind + } + if cellEntity.checkedWrong != wrong { + cellEntity.checkedWrong = wrong + } + } + entity.updatedAt = Date() + + if context.hasChanges { + do { + try context.save() + } catch { + // Surfacing this to the user properly is a v1 problem; for + // now log so we notice in development. + print("GameStore: failed to save context: \(error)") + } + } + + startObserving(game) + } + + // MARK: - CellMark coding + + /// Maps `CellMark` onto two storage columns: a small integer kind and a + /// boolean for the orthogonal `checkedWrong` dimension. `.revealed` + /// always stores `checkedWrong = false` since revealed cells can't be + /// wrong; the column is ignored on decode for that case. + private func encodeMark(_ mark: CellMark) -> (kind: Int16, checkedWrong: Bool) { + switch mark { + case .none: + return (0, false) + case .pen(let wrong): + return (1, wrong) + case .pencil(let wrong): + return (2, wrong) + case .revealed: + return (3, false) + } + } + + private func decodeMark(kind: Int16, checkedWrong: Bool) -> CellMark { + switch kind { + case 1: return .pen(checkedWrong: checkedWrong) + case 2: return .pencil(checkedWrong: checkedWrong) + case 3: return .revealed + default: return .none + } + } +} diff --git a/Crossmate/Persistence/PersistenceController.swift b/Crossmate/Persistence/PersistenceController.swift @@ -0,0 +1,36 @@ +import CoreData +import Foundation + +/// Wraps the app's `NSPersistentContainer`. Plain Core Data with no +/// CloudKit mirroring — sync (single-user iPhone↔iPad and CKShare +/// collaboration alike) is the job of a separate sync engine that will +/// drive CloudKit directly on top of this same store. See PLAN.md for the +/// layered design. +@MainActor +final class PersistenceController { + let container: NSPersistentContainer + + var viewContext: NSManagedObjectContext { container.viewContext } + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "CrossmateModel") + + if inMemory { + // Anonymous, throwaway store — useful for previews and tests. + let description = NSPersistentStoreDescription() + description.url = URL(fileURLWithPath: "/dev/null") + container.persistentStoreDescriptions = [description] + } + + container.loadPersistentStores { _, error in + if let error { + // Failing to open the store is unrecoverable for the app. + // Crash loudly so we notice in development rather than + // silently running with no persistence. + fatalError("Failed to load Core Data store: \(error)") + } + } + + container.viewContext.automaticallyMergesChangesFromParent = true + } +}