crossmate

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

commit 7497b0e087480865b11c3393fb38bb4eae22e690
parent dc110fd819b95cf132b9abb24d953ff5b9654a63
Author: Michael Camilleri <[email protected]>
Date:   Tue, 30 Jun 2026 13:23:56 +0900

Add partial fill hints

This commit adds 'Fill Quarter' and 'Fill Half' to the Hints menu so the
user can ask Crossmate to solve a random slice of the remaining puzzle
without revealing the whole grid.

The new commands compute their target count from the still-empty
fillable cells, round up so a non-empty remainder always fills at least
one square, and route the selected cells through the existing reveal
pipeline.

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

Diffstat:
MCrossmate/Models/PlayerSession.swift | 20++++++++++++++++++++
MCrossmate/Views/Puzzle/PuzzleCommands.swift | 7+++++++
MCrossmate/Views/Puzzle/PuzzleModifiers.swift | 4++++
MTests/Unit/GameMutatorTests.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 76 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -326,6 +326,26 @@ final class PlayerSession { publishLocalCompletion() } + func fillQuarter() { + fillRemainingPuzzle(fraction: 0.25) + } + + func fillHalf() { + fillRemainingPuzzle(fraction: 0.5) + } + + private func fillRemainingPuzzle(fraction: Double) { + let remaining = puzzle.cells.flatMap { $0 }.filter { cell in + guard !cell.isBlock, cell.solution != nil, !cell.expectsBlank else { return false } + return game.squares[cell.row][cell.col].entry.isEmpty + } + guard !remaining.isEmpty else { return } + + let fillCount = max(1, Int((Double(remaining.count) * fraction).rounded(.up))) + mutator.revealCells(Array(remaining.shuffled().prefix(fillCount))) + publishLocalCompletion() + } + func clearCurrentWord() { mutator.clearCells(currentWordCells()) } diff --git a/Crossmate/Views/Puzzle/PuzzleCommands.swift b/Crossmate/Views/Puzzle/PuzzleCommands.swift @@ -94,6 +94,13 @@ struct PuzzleCommands: Commands { Divider() + Button("Fill Quarter") { target?.session.fillQuarter() } + .disabled(!isEnabled) + Button("Fill Half") { target?.session.fillHalf() } + .disabled(!isEnabled) + + Divider() + Button("Reveal Square") { target?.requestReveal(.square) } .keyboardShortcut("r", modifiers: .command) .disabled(!isEnabled) diff --git a/Crossmate/Views/Puzzle/PuzzleModifiers.swift b/Crossmate/Views/Puzzle/PuzzleModifiers.swift @@ -129,6 +129,10 @@ struct PuzzleToolbarModifier: ViewModifier { Button("Check Puzzle") { session.checkPuzzle() } } Section { + Button("Fill Quarter") { session.fillQuarter() } + Button("Fill Half") { session.fillHalf() } + } + Section { Button("Reveal Square") { confirmReveal(.square) } Button("Reveal Word") { confirmReveal(.word) } Button("Reveal Puzzle") { confirmReveal(.puzzle) } diff --git a/Tests/Unit/GameMutatorTests.swift b/Tests/Unit/GameMutatorTests.swift @@ -172,6 +172,41 @@ struct GameMutatorTests { #expect(game.squares[0][0].mark == .revealed) } + @Test("fill quarter reveals a quarter of empty fillable cells") + func fillQuarterRevealsQuarterOfRemainingCells() throws { + let (game, mutator, _, _) = try makeTestGame() + let session = PlayerSession(game: game, mutator: mutator) + + session.fillQuarter() + + #expect(revealedCellCount(in: game) == 2) + #expect(revealedCells(in: game).allSatisfy { cell in + game.squares[cell.row][cell.col].entry == cell.solution?.uppercased() + }) + } + + @Test("fill half counts only still-empty cells") + func fillHalfCountsOnlyEmptyRemainingCells() throws { + let (game, mutator, _, _) = try makeTestGame() + let session = PlayerSession(game: game, mutator: mutator) + + mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) + mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) + mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false) + mutator.setLetter("D", atRow: 1, atCol: 0, pencil: false) + + session.fillHalf() + + #expect(revealedCellCount(in: game) == 2) + #expect(revealedCells(in: game).allSatisfy { cell in + game.squares[cell.row][cell.col].entry == cell.solution?.uppercased() + }) + #expect(!game.squares[0][0].mark.isRevealed) + #expect(!game.squares[0][1].mark.isRevealed) + #expect(!game.squares[0][2].mark.isRevealed) + #expect(!game.squares[1][0].mark.isRevealed) + } + @Test("clearCells clears non-revealed cells") func clearCellsClearsNonRevealed() throws { let (game, mutator, _, _) = try makeTestGame() @@ -402,6 +437,16 @@ struct GameMutatorTests { ) } + private func revealedCellCount(in game: Game) -> Int { + game.squares.flatMap { $0 }.filter { $0.mark.isRevealed }.count + } + + private func revealedCells(in game: Game) -> [Puzzle.Cell] { + game.puzzle.cells.flatMap { $0 }.filter { cell in + game.squares[cell.row][cell.col].mark.isRevealed + } + } + /// Reads back the per-cell entry at `(row, col)` from the local-device /// MovesEntity for `gameID`. Used by emission tests to verify what got /// persisted into the cells blob.