crossmate

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

commit 6b3879dec3ed689cba4b6126b2ab88cb7652d662
parent 18f3f9d50ef9cd390764218683507abc544ea08e
Author: Michael Camilleri <[email protected]>
Date:   Thu, 18 Jun 2026 09:56:18 +0900

Increase player limit to three

This commit updates the player limit to three. To support this, public
share links so a puzzle can admit two invitees instead of stopping after
the first crossmate. Tickets now carry a remaining-seat count and a list
of CloudKit users that have already claimed a seat, so the same user can
follow a link from another device without spending the final place.

Finished games now close their outstanding public-link ticket when
completion is recorded locally or arrives through sync. The sync engine
surfaces completion transitions to the app layer so that cleanup runs
alongside the existing journal and archive work, while deleted games
continue to rely on their CloudKit zone deletion to remove ticket
records.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 5+++++
MCrossmate/Sync/RecordApplier.swift | 5+++++
MCrossmate/Sync/ShareController.swift | 198++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MCrossmate/Sync/SyncEngine.swift | 13+++++++++++++
4 files changed, 184 insertions(+), 37 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -671,6 +671,10 @@ final class AppServices { } } + await syncEngine.setOnGameCompleted { [weak self] gameID in + await self?.shareController.closeTicketForCompletedGame(gameID: gameID) + } + await syncEngine.setOnGameJoined { [weak self] gameID in guard let self else { return } // A shared zone just synced in for this game — joined here or on @@ -990,6 +994,7 @@ final class AppServices { // steps below are gated on it. await self.store.flushCompletionWrites() guard self.preferences.isICloudSyncEnabled else { return } + await self.shareController.closeTicketForCompletedGame(gameID: gameID) await self.syncEngine.enqueueJournalUpload(gameID: gameID, authorID: authorID) // Snapshot finished participant games to this user's private DB for // cross-device durability. A no-op for owned games (already durable) diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -164,6 +164,11 @@ extension SyncEngine { enqueueJournalUpload(gameID: id, authorID: localAuthorID) } } + if let onGameCompleted { + for id in effects.completedTransitions { + await onGameCompleted(id) + } + } let deletedPings = deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in let recordName = deletion.0.recordName guard recordName.hasPrefix("ping-"), diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -8,16 +8,20 @@ import Foundation @MainActor final class ShareController { private static let zoneWideShareRecordName = "cloudkit.zoneshare" - static let maximumPeoplePerPuzzle = 2 + static let maximumPeoplePerPuzzle = 3 private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 } + private static let ticketVersionField = "version" + private static let ticketRemainingSeatsField = "remainingSeats" + private static let ticketClaimedAuthorIDsField = "claimedAuthorIDs" + private static let countedTicketVersion: Int64 = 2 /// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner - /// mints into the game zone alongside the link, and the first link joiner - /// consumes by deleting it. Deletion is atomic — CloudKit rejects the - /// second deleter with `.unknownItem` — so simultaneous joiners settle on - /// exactly one winner even before they can see each other in the share's - /// participant list. The `ticket` kind is unknown to `PingKind`, so - /// `Ping.parseRecord` drops the record everywhere Pings are surfaced. + /// mints into the game zone alongside the link. Current tickets carry a + /// remaining-seat count and joiners consume a seat by saving a decremented + /// record under CloudKit's optimistic lock; legacy one-seat tickets had no + /// count and are still consumed by deletion. The `ticket` kind is unknown + /// to `PingKind`, so `Ping.parseRecord` drops the record everywhere Pings + /// are surfaced. private static let ticketPingKind = "ticket" private static func ticketRecordName(for gameID: UUID) -> String { "ticket-\(gameID.uuidString)" @@ -99,7 +103,12 @@ final class ShareController { } catch let error as CKError where error.code == .serverRecordChanged { savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) } - try await mintTicketIfAbsent(for: gameID, in: savedShare.recordID.zoneID) + try await setTicketSeats( + max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: savedShare)), + claimedAuthorIDs: Self.inviteeAuthorIDs(in: savedShare), + for: gameID, + in: savedShare.recordID.zoneID + ) let url = try shareURL(from: savedShare) syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") syncMonitor?.recordSuccess("create share link") @@ -181,10 +190,20 @@ final class ShareController { } private static func inviteeCount(in share: CKShare) -> Int { + inviteeParticipants(in: share).count + } + + private static func inviteeAuthorIDs(in share: CKShare) -> [String] { + inviteeParticipants(in: share).compactMap { + $0.userIdentity.userRecordID?.recordName + } + } + + private static func inviteeParticipants(in share: CKShare) -> [CKShare.Participant] { share.participants.filter { participant in participant.role != .owner && participant.acceptanceStatus != .removed - }.count + } } private func fetchParticipant( @@ -464,6 +483,39 @@ final class ShareController { try ctx.save() } + /// Best-effort cleanup for terminal games. Deleting a game deletes its + /// CloudKit zone and therefore the ticket; completion keeps the zone around + /// for replay/archive, so close the public-link seat explicitly. + func closeTicketForCompletedGame(gameID: UUID) async { + let ctx = persistence.viewContext + let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") + request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + request.fetchLimit = 1 + guard let entity = try? ctx.fetch(request).first, + entity.completedAt != nil else { return } + + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + let ownerName = entity.databaseScope == 0 + ? CKCurrentUserDefaultName + : (entity.ckZoneOwnerName ?? CKCurrentUserDefaultName) + let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + let database = entity.databaseScope == 1 + ? container.sharedCloudDatabase + : container.privateCloudDatabase + let ticketID = CKRecord.ID(recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID) + + do { + try await database.deleteRecord(withID: ticketID) + syncMonitor?.note("ticket closed for completed game \(gameID.uuidString)") + } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound { + // Already gone, or the zone was deleted; either way the link seat is closed. + } catch { + syncMonitor?.note( + "ticket close skipped for \(gameID.uuidString): \(error.localizedDescription)" + ) + } + } + /// Joiner-side seat check, run right after a share acceptance has synced /// the new zone. Cooperative by design: CloudKit cannot enforce a /// participant cap, so an over-cap joiner leaves voluntarily and a client @@ -473,9 +525,10 @@ final class ShareController { /// by identity, so the participant list itself is the gate. Link joiners /// are admitted while the share is under the cap; simultaneous joiners /// that cannot see each other in the participant list yet are settled by - /// consuming the zone's ticket Ping. A missing ticket without a prior - /// Player-record footprint means the seat went to someone else (or the - /// link predates tickets and is considered dead), so the joiner leaves. + /// consuming one slot from the zone's ticket Ping. A missing or exhausted + /// ticket without a prior Player-record footprint means the seat went to + /// someone else (or the link predates tickets and is considered dead), so + /// the joiner leaves. /// /// Only `ShareError.collaborationLimitReached` escapes. A transient /// CloudKit failure is traced and the join stands — a failed check must @@ -518,26 +571,17 @@ final class ShareController { } if share.currentUserParticipant?.role == .privateUser { return false } if Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle { return true } + if try await hasNoPlayerFootprint(gameID: gameID, zoneID: zoneID, in: database) == false { + return false + } // Under the cap. A simultaneous link joiner may not be visible in the - // participant list yet; consuming the ticket settles it atomically. + // participant list yet; consuming a ticket seat settles it atomically. let ticketID = CKRecord.ID( recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID ) - do { - try await database.deleteRecord(withID: ticketID) - return false - } catch let error as CKError where error.code == .unknownItem { - // Ticket already consumed. Rejoining a game this account - // previously won leaves a Player-record footprint; without one, - // the seat belongs to someone else. - return try await hasNoPlayerFootprint( - gameID: gameID, - zoneID: zoneID, - in: database - ) - } + return try await consumeTicketSeat(ticketID: ticketID, in: database) == false } private func hasNoPlayerFootprint( @@ -607,30 +651,110 @@ final class ShareController { return share } - /// Saves the zone's seat ticket if it doesn't exist yet. Minted only when - /// a public link is created, and only while the puzzle has no invitee - /// (`createShareLink` guards capacity first), so a consumed ticket is - /// reissued exactly when its seat frees up again. Never overwrites an - /// existing ticket: an unconsumed one still guards the open seat. - private func mintTicketIfAbsent(for gameID: UUID, in zoneID: CKRecordZone.ID) async throws { + /// Saves the zone's counted seat ticket. Recreating a public link resets the + /// count to the share's currently available invitee seats, which reopens a + /// freed seat after a participant is removed while keeping a full game closed. + private func setTicketSeats( + _ seats: Int, + claimedAuthorIDs: [String], + for gameID: UUID, + in zoneID: CKRecordZone.ID + ) async throws { let ticketID = CKRecord.ID( recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID ) + let ticket: CKRecord do { - _ = try await container.privateCloudDatabase.record(for: ticketID) - return + ticket = try await container.privateCloudDatabase.record(for: ticketID) } catch let error as CKError where error.code == .unknownItem { - // No ticket — mint one below. + ticket = CKRecord(recordType: "Ping", recordID: ticketID) } - let ticket = CKRecord(recordType: "Ping", recordID: ticketID) ticket["kind"] = Self.ticketPingKind as CKRecordValue ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue + ticket[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue + ticket[Self.ticketRemainingSeatsField] = Int64(seats) as CKRecordValue + ticket[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue do { _ = try await container.privateCloudDatabase.save(ticket) } catch let error as CKError where error.code == .serverRecordChanged { - // A sibling device minted it concurrently — the ticket exists. + guard let serverTicket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord else { + throw error + } + serverTicket["kind"] = Self.ticketPingKind as CKRecordValue + serverTicket["authorID"] = try await container.userRecordID().recordName as CKRecordValue + serverTicket[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue + serverTicket[Self.ticketRemainingSeatsField] = Int64(seats) as CKRecordValue + serverTicket[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue + _ = try await container.privateCloudDatabase.save(serverTicket) + } + } + + /// Returns true when this joiner successfully consumed a public-link seat. + /// Legacy tickets have no seat count and are consumed by deleting the record. + private func consumeTicketSeat(ticketID: CKRecord.ID, in database: CKDatabase) async throws -> Bool { + let myRecordName = try await container.userRecordID().recordName + var attempts = 0 + var ticket: CKRecord? + while attempts < 4 { + attempts += 1 + let record: CKRecord + if let ticket { + record = ticket + } else { + do { + record = try await database.record(for: ticketID) + } catch let error as CKError where error.code == .unknownItem { + return false + } + } + + var claimedAuthorIDs = Self.claimedAuthorIDs(in: record) + if claimedAuthorIDs.contains(myRecordName) { + return true + } + + guard let remaining = Self.remainingSeats(in: record) else { + do { + try await database.deleteRecord(withID: ticketID) + return true + } catch let error as CKError where error.code == .unknownItem { + return false + } + } + guard remaining > 0 else { return false } + claimedAuthorIDs.insert(myRecordName) + record[Self.ticketRemainingSeatsField] = Int64(remaining - 1) as CKRecordValue + record[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue + record[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue + do { + _ = try await database.save(record) + return true + } catch let error as CKError where error.code == .serverRecordChanged { + ticket = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord + } + } + return false + } + + private static func remainingSeats(in ticket: CKRecord) -> Int? { + if let value = ticket[ticketRemainingSeatsField] as? Int64 { + return Int(value) + } + if let value = ticket[ticketRemainingSeatsField] as? Int { + return value + } + return nil + } + + private static func claimedAuthorIDs(in ticket: CKRecord) -> Set<String> { + if let values = ticket[ticketClaimedAuthorIDsField] as? [String] { + return Set(values) + } + if let values = ticket[ticketClaimedAuthorIDsField] as? NSArray { + return Set(values.compactMap { $0 as? String }) } + return [] } private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -149,6 +149,10 @@ actor SyncEngine { private var onAccountChange: (@MainActor @Sendable () async -> Void)? private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)? private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)? + /// Fires when an inbound Game record transitions a local row to completed. + /// App-level side effects that are not sync-engine state (for example + /// closing public share tickets) hang off this edge. + var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)? /// Fires with the game ID of a shared zone that just appeared locally — /// the user joined the game here or on a sibling device. Drives cleanup /// of the now-redundant pending invite row. @@ -235,6 +239,10 @@ actor SyncEngine { onGameRemoved = cb } + func setOnGameCompleted(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { + onGameCompleted = cb + } + func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) { onGameJoined = cb } @@ -1660,6 +1668,11 @@ actor SyncEngine { enqueueJournalUpload(gameID: id, authorID: localAuthorID) } } + if let onGameCompleted { + for id in effects.completedTransitions { + await onGameCompleted(id) + } + } let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in let recordName = deletion.recordID.recordName guard recordName.hasPrefix("ping-"),