Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ public enum UITestLaunchArg {
/// per `LiveAdMobBridge`) still loads and renders for real. Absent from
/// Release builds via the `#if DEBUG` guard.
public static let openAdGate = "-uitest-open-ad-gate"

/// #1054: shared `-uitest-route` screen key both apps resolve to their
/// own FIXED, pre-built mid-game board — a deterministic fixture for App
/// Store marketing captures (03-board slot). Distinct from Minesweeper's
/// `board:<difficulty>` key (a FRESH, empty board at a fixed seed): this
/// key always resolves to the SAME one pre-populated scenario per app
/// (Minesweeper: a revealed region + 3 flags on real mines; Sudoku:
/// pencil notes + one conflicting entry). Held here (not duplicated per
/// app) so both `uitestRoute(for:)` resolvers compare against the same
/// string. Absent from Release builds via the `#if DEBUG` guard.
public static let showcaseBoardRouteKey = "board:showcase"
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public struct MinesweeperAppComposition {
/// #1020: `daily` / `practice` used to be push routes; they are tab
/// identities now, so both just select their tab — `settings` still
/// pushes onto the Today tab's stack. Unknown keys stay at the root. Keys:
/// `daily` / `practice` / `settings` / `resumeFail` /
/// `daily` / `practice` / `settings` / `resumeFail` / `board:showcase` /
/// `board:<beginner|intermediate|expert>` (#1026 B-1: zero-click straight
/// into a practice board at that difficulty, fixed seed above, pushed on
/// the Practice tab so the runner sees it where a Start tap would land it).
Expand All @@ -115,6 +115,19 @@ public struct MinesweeperAppComposition {
// read when `-uitest-loader-fail` is also passed — the hook short-
// circuits `load()` before `store.loadInProgress` is called.
case "resumeFail": return .push(.resumeBoard(recordName: "uitest-loader-fail", mode: .practice))
// #1054: checked BEFORE the `board:` prefix fallback below so it can
// never be shadowed by (or accidentally fall through into) the
// `board:<difficulty>` parsing — `Difficulty(rawValue: "showcase")`
// is nil regardless, but the explicit case makes that non-shadowing
// a compile-time-adjacent guarantee, not a coincidence of the
// fallback's own rejection logic. Fixed seed lives on
// `MinesweeperShowcaseSession`, not `uitestBoardSeed` — this route
// always lands on the ONE pre-built showcase board, never a fresh one.
case UITestLaunchArg.showcaseBoardRouteKey:
return .push(
.board(difficulty: .beginner, seed: MinesweeperShowcaseSession.seed, mode: .practice),
tab: .practice
)
default:
// #1026 B-1: `board:<difficulty>` — macOS has no synthetic tap
// path to the practice hub's Start button, so this is the only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,17 @@ public struct MinesweeperFreshBoardLoaderView: View {
recordName: String? = nil,
personalRecordStore: MinesweeperPersonalRecordStore? = nil
) -> MinesweeperGameViewModel {
MinesweeperGameViewModel(
#if DEBUG
// #1054: the showcase route's fixed (difficulty, seed) pair mounts
// the pre-built deterministic fixture instead of a fresh
// actor-backed board. See `MinesweeperShowcaseSession`'s header doc
// for why this must go through the `(seeded:)` init, not a session
// restore. Absent from Release builds via the `#if DEBUG` guard.
if difficulty == .beginner, seed == MinesweeperShowcaseSession.seed {
return MinesweeperGameViewModel(seeded: MinesweeperShowcaseSession.snapshot)
}
#endif
return MinesweeperGameViewModel(
difficulty: difficulty,
seed: seed,
mode: mode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ public final class MinesweeperGameViewModel {
}

public func reveal(row: Int, col: Int) async {
// #1054 CR round 1 (blocker): a seeded VM's `session` is a throwaway
// actor at the snapshot's difficulty (see `init(seeded:)`) — routing
// a real tap through it would silently replace the fixed showcase
// board with a fresh seed-0 game on first touch. Every mutator that
// would touch `session` must no-op when seeded; `refresh()` already
// does (see its doc), this one didn't.
guard !isSeeded else { return }
// #330 P2: snapshot the prior status + revealed-count so we can classify
// the outcome of this reveal into the right audio event.
let previousStatus = snapshot.status
Expand Down Expand Up @@ -322,6 +329,8 @@ public final class MinesweeperGameViewModel {
// file under the 400-line lint ceiling).

public func toggleFlag(row: Int, col: Int) async {
// #1054 CR round 1 (blocker): see `reveal(row:col:)` — same reasoning.
guard !isSeeded else { return }
let flagsBefore = snapshot.flagCount
do {
snapshot = try await session.toggleFlag(row: row, col: col)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// MinesweeperShowcaseSession — DEBUG-only fixed showcase board (#1054).
//
// Produces a deterministic, byte-stable mid-game board for the App Store
// 03-board marketing capture: a revealed region (flood-filled from a fixed
// first click) plus 3 flags planted on real mines.
//
// Deliberate shape deviation from the sibling `MinesweeperNearWinSession`:
// that builder restores a `MinesweeperSession` ACTOR (`.restore(from:)`,
// always normalized to `.paused`) so the player's one remaining tap can win
// the game. This builder instead mounts via
// `MinesweeperGameViewModel(seeded:)` — the #297 snapshot/preview seam — for
// two reasons specific to a marketing capture:
// 1. `.paused` mounts `PauseOverlayView`, a full-screen mask that hides the
// board entirely (`MinesweeperBoardView.swift`) — the opposite of what a
// store screenshot needs.
// 2. A LIVE `.playing` session's `elapsedSeconds` advances off a real
// `MonotonicClock`; two captures taken seconds apart would show
// different times and fail the required byte-identical hash check
// (#1054 acceptance item 6). `(seeded:)` sets `isSeeded = true`, which
// makes `MinesweeperBoardView`'s `.task { refresh() }` ticker a no-op —
// the cached snapshot (and its `elapsedSeconds`) never changes, no
// matter how long the board stays on screen.
//
// Accepted asymmetry vs. `SudokuShowcaseBoard` (CR round 2, PM ruling): Sudoku's
// builder goes through the LIVE `GameSession` actor with an injected frozen
// clock instead. `MinesweeperSession.restore(from:clock:)` does expose a
// `clock:` parameter, but `applySnapshot` unconditionally normalizes a
// restored `.playing` to `.paused`, and the only way back to a genuinely
// `.playing` state is an async `resume()` call on the actor.
// `MinesweeperFreshBoardLoaderView.makeViewModel(...)` — the seam this
// builder is injected into — is a synchronous function (`.task(id:) { state =
// .loaded(makeViewModel()) }`), so wiring in that async restore+resume dance
// would mean restructuring the loader's synchronous seam. Sudoku's
// `BoardLoaderView.mountLoaded` is already `async` (it awaits
// `GameSession.restore` + `viewModel.startOrResume()`), so the same
// frozen-clock + resume approach is cheap there instead — see that file's
// header doc for the full reasoning.
//
// Availability: `#if DEBUG` only — stripped from Release builds entirely.

#if DEBUG

internal import MinesweeperEngine
public import MinesweeperGameState

/// A fixed, deterministic mid-game Minesweeper board. See the header doc
/// above for why this is a plain snapshot builder, not a session restore.
public enum MinesweeperShowcaseSession {

/// Fixed seed for the showcase board. Distinct from
/// `MinesweeperAppComposition.uitestBoardSeed` (`0x5EED_B1`, a FRESH
/// board for the `board:<difficulty>` key) and
/// `MinesweeperNearWinSession.nearWinSeed` (`0xAABB_CCDD_EEFF_0011`).
/// #1054 CR round 2 (PM ruling): chosen, together with
/// `firstClickRow`/`firstClickCol` below, by a brute-force search over
/// nearby seeds × first-click cells for a combination whose flood-reveal
/// opens a region with at least 3 mines FORCED by the basic
/// single-constraint count rule (see `flagsQualifyingAsForced(engine:)`)
/// — this exact seed + cell yields 7 such forced mines (comfortably over
/// the 3 flagged) with a 20-cell revealed region.
/// `MinesweeperShowcaseSessionTests` pins the result.
public static let seed: UInt64 = 0xC0FF_EE15_5C0B_0008

/// First-click cell — bottom-center of the beginner grid. Fixed
/// alongside `seed` (see its doc): the combination is what produces the
/// 20-cell revealed region / 7 forced mines.
private static let firstClickRow = 8
private static let firstClickCol = 4

/// Frozen elapsed-time value baked into the snapshot. Never advances —
/// see the header doc's determinism rationale.
private static let elapsedSeconds = 42

/// Number of real mines flagged in the fixed scenario.
private static let flaggedMineCount = 3

/// The fixed showcase snapshot. A `static let` initializer runs lazily,
/// exactly once per process, on first access — so every caller
/// (the loader, tests) observes the identical value.
public static let snapshot: MinesweeperSessionSnapshot = {
let difficulty = Difficulty.beginner
var engine = MinesweeperEngine(difficulty: difficulty, seed: seed)
// First-click-safe placement, then a real flood-reveal of the
// connected zero region from `firstClickRow`/`firstClickCol` — never
// hand-set state.
_ = try? engine.reveal(row: firstClickRow, col: firstClickCol)

// Flag up to `flaggedMineCount` real, still-hidden mines via the
// engine's own `toggleFlag` — never by hand-writing `Cell.state`.
// #1054 CR round 2 (PM ruling, strengthens round 1's adjacency-only
// check): a flag must be FORCED by the basic single-constraint count
// rule — there must exist a revealed neighbor cell whose displayed
// number equals ITS OWN count of non-revealed (hidden or flagged)
// neighbors, which makes every one of that revealed cell's
// non-revealed neighbors (this candidate included) a certain mine.
// Mere adjacency to a revealed cell is not enough (a "2" with 3
// non-revealed neighbors doesn't pin any single one of them).
// Row-major among the qualifying set for determinism; if fewer than
// `flaggedMineCount` qualify, only those are flagged — never falls
// back to an unforced mine.
var flaggedCount = 0
for index in engine.cells.indices where flaggedCount < flaggedMineCount {
let cell = engine.cells[index]
guard cell.isMine, cell.state == .hidden else { continue }
guard hasForcingWitness(atIndex: index, engine: engine) else { continue }
let row = index / engine.columns
let col = index % engine.columns
_ = try? engine.toggleFlag(row: row, col: col)
flaggedCount += 1
}

return MinesweeperSessionSnapshot(
difficulty: difficulty,
seed: seed,
cells: engine.cells,
status: .playing,
elapsedSeconds: elapsedSeconds,
mineCount: engine.mineCount,
flagCount: flaggedCount,
everFlagged: flaggedCount > 0
)
}()

/// True iff some revealed neighbor of `index` "forces" it as a mine by
/// the basic single-constraint count rule: that neighbor's displayed
/// `neighborMineCount` equals its OWN count of non-revealed (hidden or
/// flagged) neighboring cells — meaning every one of those non-revealed
/// neighbors, `index` included, must be a mine. `internal` (not
/// `private`) so `MinesweeperShowcaseSessionTests` can independently
/// recompute the witness for each flag from the built board, rather than
/// hard-coding coordinates.
static func hasForcingWitness(atIndex index: Int, engine: MinesweeperEngine) -> Bool {
forcingWitness(atIndex: index, engine: engine) != nil
}

/// The board index of a revealed cell that forces `index` as a mine (see
/// `hasForcingWitness`), or `nil` if none exists.
static func forcingWitness(atIndex index: Int, engine: MinesweeperEngine) -> Int? {
let row = index / engine.columns
let col = index % engine.columns
for neighborIndex in engine.neighborIndices(row: row, col: col) {
let neighbor = engine.cells[neighborIndex]
guard neighbor.state == .revealed else { continue }
let neighborRow = neighborIndex / engine.columns
let neighborCol = neighborIndex % engine.columns
let nonRevealedCount = engine.neighborIndices(row: neighborRow, col: neighborCol)
.filter { engine.cells[$0].state != .revealed }
.count
if neighbor.neighborMineCount == nonRevealedCount {
return neighborIndex
}
}
return nil
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -146,5 +146,37 @@ import Telemetry
// pre-existing exact-match keys.
#expect(MinesweeperAppComposition.uitestRoute(for: "practice") == .tab(.practice))
}

// #1054: `board:showcase` — the fixed pre-built board for App Store
// marketing captures. Distinct from the `board:<difficulty>` branch
// above (a fresh board at `uitestBoardSeed`).

@Test func uitestRouteBoardShowcaseResolvesToFixedShowcaseBoard() {
#expect(
MinesweeperAppComposition.uitestRoute(for: "board:showcase")
== .push(
.board(difficulty: .beginner, seed: MinesweeperShowcaseSession.seed, mode: .practice),
tab: .practice
)
)
}

@Test func uitestRouteBoardShowcaseDoesNotShadowDifficultyPrefixBranch() {
// Regression: `board:showcase` must be handled by its own explicit
// case, never by falling into the `board:<difficulty>` prefix
// parsing — and that branch must still resolve `board:beginner` to
// the FRESH-board seed, not the showcase seed.
#expect(
MinesweeperAppComposition.uitestRoute(for: "board:beginner")
== .push(
.board(difficulty: .beginner, seed: MinesweeperAppComposition.uitestBoardSeed, mode: .practice),
tab: .practice
)
)
}

@Test func uitestRouteBoardShowcaseRejectsTrailingWhitespace() {
#expect(MinesweeperAppComposition.uitestRoute(for: "board:showcase ") == nil)
}
#endif
}
Loading
Loading