From fc9d420951e0da972d03e482c851d402b8a0994c Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 12 Jul 2026 12:00:27 -0700 Subject: [PATCH] add hint quality probe tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plays 500 seeded deals per variant two ways — following every hint, and forward-only random moves as the calibration floor — and reports win rates and loss taxonomy against the baselines recorded in the readme. deterministic (no search deadlines) and parallel across cores, so a full pass takes about seven minutes and reproduces exactly. exits nonzero if any hint follower ever loops or yukon records a position revisit. the readme records acceptance gates for adding new variants and the findings from two retired tap-policy controls. compiled by run.sh against the ui-free game sources; not part of the app, tests, or ci. --- tools/hint-probe/README.md | 129 +++++++++ tools/hint-probe/main.swift | 542 ++++++++++++++++++++++++++++++++++++ tools/hint-probe/run.sh | 44 +++ 3 files changed, 715 insertions(+) create mode 100644 tools/hint-probe/README.md create mode 100644 tools/hint-probe/main.swift create mode 100755 tools/hint-probe/run.sh diff --git a/tools/hint-probe/README.md b/tools/hint-probe/README.md new file mode 100644 index 0000000..5cc530e --- /dev/null +++ b/tools/hint-probe/README.md @@ -0,0 +1,129 @@ +# Hint Quality Probe + +The acceptance and regression instrument for the hint engines. It answers one +question — *is this variant's hint system actually good?* — at the three +moments that matter: when a new variant lands, when someone tunes a planner, +and when someone refactors shared code. + +It exists because unit tests cannot catch a planner that plays at random-level +strength; only measured full games can. + +It is a tool, not a test: statistical, takes minutes, compiled by `run.sh` +with `swiftc -O` directly against the UI-free Game sources. It is not part of +the app target, the test suite, or CI. + +## Usage + +From the repo root: + +```bash +tools/hint-probe/run.sh all # full study, 500 deals per run (~7 min) +tools/hint-probe/run.sh yukon 500 +tools/hint-probe/run.sh klondike 500 1 # third arg is the draw count +tools/hint-probe/run.sh klondike 500 3 +tools/hint-probe/run.sh freecell 500 +``` + +The number is how many seeded deals the run plays (seeds 1 through N; default +500). Deals are deterministic (SplitMix64 + Fisher–Yates, matching +`GameStateFixtures`) and the players call the planners' deterministic entry +points — no wall-clock deadlines — so every figure below is exact: the same +run produces the same numbers on any machine, every time. Deals run in +parallel across all cores (each game is self-contained, so parallelism changes +only wall-clock time); a full `all 500` pass takes about 7 minutes. Progress +streams to stderr every 100 deals. + +## Players + +Each run reports two players over the same deals: + +- **Following every hint** — requests a hint each turn and plays exactly what + it says. This measures the hint system itself. +- **Random legal moves (control)** — plays a uniformly random move drawn from + the legal *forward* moves (foundation rollbacks are legal but deliberately + excluded: a uniform player would spend the endgame yanking banked cards back + down, and the column would measure self-sabotage instead of luck). The floor + that calibrates each variant's deal universe: it tells you what wins are + worth in that variant before crediting the planner with anything. + +## Recorded baselines (July 2026, 500 deals per run) + +Every figure below comes from a single run of the committed tool; the +hint-following column has additionally reproduced identically across five +consecutive runs, serial and parallel. + +| Run | Following every hint | Random (control) | +|---|---|---| +| `yukon` | **62.0%** | 13.6% | +| `klondike` draw-1 | **44.4%** | 39.4% | +| `klondike` draw-3 | **24.0%** | 6.0% | +| `freecell` | **99.8%** | 0.2% | + +Reading the table honestly: + +- **Yukon (62.0% vs 13.6%)**: theoretical winnability is ~80%. Yukon's fully + reversible moves let even aimless play grind out wins given 600 moves, so + the hint value is winning 4.6x as often in half the moves (median 95 vs + 184). Tuning directions already measured flat or negative: empty-pile + weight 8, burial weight 3, inversion weight 5, depth 96, unconditional + foundation rollbacks (53.2%). +- **Klondike draw-1 random winning 39.4%** is not a bug: with unlimited stock + passes, even random play eventually stumbles into wins. The hint value at + draw-1 shows up in efficiency as much as win rate — hints win in a median of + 133 moves versus random's 358. At draw-3 the win-rate gap is the story + (24.0% vs 6.0%). +- **FreeCell (99.8%)**: the single loss is a deal the solver cannot prove + within its node budget; the follower classifies it as a deadlock because the + nudge fallback only circles there (a solved line is finite and cannot loop, + so any plan-line revisit would be a real bug and trips the gate). +- These figures use the planners' full node budgets. The app additionally + clips each interactive search at a fraction of a second so the UI never + hitches; that clip rarely binds, so in-app quality is at most a hair below + these numbers on the slowest positions. + +## Adding a new variant + +Wire its deal into `seededDeal`, add a hint-following player for its planner, +add its sources to `run.sh`, then run 500 deals. Acceptance gates: + +- The hint column must **decisively beat the random control**. +- **Zero stalemate-loops and zero revisit events** for the hint player. Both + are machine-enforced: the probe exits nonzero if any hint follower loops in + any variant, or if Yukon records a single position revisit. (Revisits are + reported without reclassifying the game, so win rates stay honestly + measured; the exit code is what enforces the gate.) +- **Watch the over-banking detector** (`losses with >=40 banked`): it should + be zero for stockless variants (Yukon and FreeCell measure zero). The + Klondike draw-1 baseline records a single such loss; treat any increase as + a regression. +- Record the measured numbers in the table above; they become the variant's + regression baseline. Mechanical refactors must reproduce every figure + exactly; deliberate quality changes must move the hint column up, never + down. These comparisons are a human step against this ledger by design — + the tool does not duplicate the baselines in code. + +## Retired control players + +Two tap-policy control players were evaluated and removed because their +columns could not vary, and a control that cannot vary carries no information: + +- **Deterministic tap heuristic** (`TapMovePolicy.bestMove` every turn): lost + 500/500 in every variant — a deterministic policy with no lookahead enters a + cycle before it can win, regardless of move quality. This is why the hint + system's loop-freedom guarantee exists: hints are equally deterministic and + win only because the search's strict-improvement ratchet makes revisiting + impossible. +- **ε-greedy tap** (10% random jitter): still 0/500 everywhere (stockless + figures measured under the earlier stop-on-first-revisit rule) — most + tellingly at Klondike draw-1, where pure random wins 39.4%. Breaking cycles didn't + help because the tap policy's preferences are actively bad for whole-game + play: its eager foundation banking (correct for single-tap ergonomics) + strands the landing cards games need. Good tap ergonomics is not strategy; + whole-game strength requires search. + +## Maintenance + +`run.sh` compiles an explicit source list and fails loudly when a file is +missing. When Game-layer files are added or renamed, update the list. Session, +persistence, and view sources cannot be included — they import SwiftData, +Observation, or SwiftUI. diff --git a/tools/hint-probe/main.swift b/tools/hint-probe/main.swift new file mode 100644 index 0000000..fa5dbc2 --- /dev/null +++ b/tools/hint-probe/main.swift @@ -0,0 +1,542 @@ +import Foundation + +// Statistical quality probe for the hint engines. Every variant gets the same +// treatment: seeded deals played to completion by a bot that follows every hint, +// alongside a forward-only random control. Hints come from the planners' +// deterministic entry points — no wall-clock deadlines — so every figure is +// exactly reproducible. Exits nonzero if any hint follower ever loops. Compiled +// by run.sh against the UI-free Game sources; never part of the app or test +// targets. See README.md for the recorded baselines and acceptance gates. + +// MARK: - Seeded dealing (mirrors GameStateFixtures in ComputerSolitaireTests) + +struct SeededRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var mixed = state + mixed = (mixed ^ (mixed >> 30)) &* 0xBF58476D1CE4E5B9 + mixed = (mixed ^ (mixed >> 27)) &* 0x94D049BB133111EB + return mixed ^ (mixed >> 31) + } +} + +func seededDeck(seed: UInt64, faceUp: Bool) -> [Card] { + var generator = SeededRandomNumberGenerator(seed: seed) + var deck = Suit.allCases.flatMap { suit in + Rank.allCases.map { rank in Card(suit: suit, rank: rank, isFaceUp: faceUp) } + } + for index in stride(from: deck.count - 1, through: 1, by: -1) { + let swapIndex = Int(generator.next() % UInt64(index + 1)) + deck.swapAt(index, swapIndex) + } + return deck +} + +func seededDeal(variant: GameVariant, seed: UInt64) -> GameState { + switch variant { + case .klondike: + var deck = seededDeck(seed: seed, faceUp: false) + var tableau: [[Card]] = Array(repeating: [], count: 7) + for pileIndex in 0..<7 { + for cardIndex in 0...pileIndex { + var card = deck.removeLast() + card.isFaceUp = cardIndex == pileIndex + tableau[pileIndex].append(card) + } + } + return GameState( + stock: deck, + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + + case .freecell: + let deck = seededDeck(seed: seed, faceUp: true) + var tableau = Array(repeating: [Card](), count: 8) + for index in 0..= faceDownCount + tableau[pileIndex].append(card) + } + } + return GameState( + variant: .yukon, + stock: [], + waste: [], + wasteDrawCount: 0, + foundations: Array(repeating: [], count: 4), + tableau: tableau + ) + } +} + +// MARK: - Shared driver plumbing + +func fingerprint(_ state: GameState) -> UInt64 { + var hash: UInt64 = 0xcbf29ce484222325 + func mix(_ value: UInt8) { + hash = (hash ^ UInt64(value)) &* 0x100000001b3 + } + func mix(card: Card) { + let suitValue = Suit.allCases.firstIndex(of: card.suit) ?? 0 + mix(UInt8(suitValue << 5 | card.rank.rawValue << 1 | (card.isFaceUp ? 1 : 0))) + } + for card in state.stock { mix(card: card) } + mix(0xFF) + for card in state.waste { mix(card: card) } + mix(UInt8(min(255, max(0, state.wasteDrawCount)))) + for cell in state.freeCells { + mix(0xFC) + if let card = cell { mix(card: card) } + } + for pile in state.foundations { + mix(0xFE) + for card in pile { mix(card: card) } + } + for pile in state.tableau { + mix(0xFD) + for card in pile { mix(card: card) } + } + return hash +} + +func apply( + _ selection: Selection, + _ destination: Destination, + to state: GameState, + stockDrawCount: Int +) -> GameState? { + AutoMoveAdvisor.simulatedState( + afterMoving: selection, + to: destination, + in: state, + stockDrawCount: stockDrawCount + ) +} + +/// Mirrors drawFromStock / recycleWaste in the session. +func stockTap(_ state: GameState, drawCount: Int) -> GameState? { + var next = state + if !next.stock.isEmpty { + let n = min(drawCount, next.stock.count) + for _ in 0.. Int { + variant == .klondike ? 1_200 : 600 +} + +func foundationCount(_ state: GameState) -> Int { + state.foundations.reduce(0) { $0 + $1.count } +} + +// MARK: - Hint-following players (deterministic planner entry points) + +func playKlondikeFollowingHints(seed: UInt64, drawCount: Int) -> Outcome { + var state = seededDeal(variant: .klondike, seed: seed) + // Stock recycling makes exact-state revisits legal in Klondike, but this + // follower is deterministic: the same position always produces the same hint, + // so any revisit means the hints are looping forever. (The random control + // rightly skips this check — a stochastic player diverges after a revisit.) + var seen: Set = [fingerprint(state)] + var actions = 0 + while actions < actionCap(for: .klondike) { + if state.isWon { return .win(moves: actions) } + guard let hint = KlondikePlanner.bestHint(in: state, stockDrawCount: drawCount) else { + return .deadlock(foundation: foundationCount(state)) + } + switch hint { + case .move(let move): + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: drawCount) else { + fatalError("Seed \(seed): illegal Klondike hint") + } + state = next + case .stockTap: + guard let next = stockTap(state, drawCount: drawCount) else { + fatalError("Seed \(seed): stock tap hinted with nothing to tap") + } + state = next + } + actions += 1 + if !seen.insert(fingerprint(state)).inserted { + return .stalemateLoop(foundation: foundationCount(state)) + } + } + return .actionCap(foundation: foundationCount(state)) +} + +func playFreeCellFollowingHints(seed: UInt64) -> Outcome { + // Replicates HintPlanner's FreeCell path without its wall-clock deadline: + // solve once, follow the cached line; fall back to the tap heuristic when the + // solver proves nothing (with a strict revisit check so fallback shuffling + // registers as the loss it is). + var state = seededDeal(variant: .freecell, seed: seed) + var plan: [String: FreeCellSolver.Move] = [:] + var seen: Set = [fingerprint(state)] + var moves = 0 + while moves < actionCap(for: .freecell) { + if state.isWon { return .win(moves: moves) } + + let key = FreeCellSolver.stateKey(for: state) + var move = plan[key].flatMap { FreeCellSolver.materialize($0, in: state) } + var cameFromFallback = false + if move == nil { + plan.removeAll() + if let solution = FreeCellSolver.solve(state) { + plan = FreeCellSolver.keyedMoves(along: solution, from: state) + } + move = plan[key].flatMap { FreeCellSolver.materialize($0, in: state) } + if move == nil { + move = TapMovePolicy.bestMove(in: state) + cameFromFallback = true + } + } + guard let move else { + return .deadlock(foundation: foundationCount(state)) + } + + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: 3) else { + fatalError("Seed \(seed): illegal FreeCell hint") + } + state = next + moves += 1 + if !seen.insert(fingerprint(state)).inserted { + // A solved line is finite and ends in a win, so it can never revisit. + // A revisit is therefore only reachable in fallback, on a deal the + // solver cannot prove: the nudge is circling, the hint stack is out of + // constructive moves, and that is a deadlock — FreeCell's analogue of + // Yukon's nil. A revisit on a plan line would be a genuinely broken + // solver and stays a gate-tripping loop. + return cameFromFallback + ? .deadlock(foundation: foundationCount(state)) + : .stalemateLoop(foundation: foundationCount(state)) + } + } + return .actionCap(foundation: foundationCount(state)) +} + +func playYukonFollowingHints(seed: UInt64) -> (outcome: Outcome, revisitEvents: Int) { + // Replicates HintPlanner's Yukon path without its wall-clock deadline: follow + // each improving line to its end, then replan; nil means no progress exists. + var state = seededDeal(variant: .yukon, seed: seed) + var visitCounts: [UInt64: Int] = [fingerprint(state): 1] + var revisitEvents = 0 + var moves = 0 + while moves < actionCap(for: .yukon) { + if state.isWon { return (.win(moves: moves), revisitEvents) } + guard case .line(let line) = YukonPlanner.bestLine(in: state) else { + return (.deadlock(foundation: foundationCount(state)), revisitEvents) + } + for move in line { + guard let next = apply(move.selection, move.destination, to: state, stockDrawCount: 3) else { + fatalError("Seed \(seed): illegal Yukon hint") + } + state = next + moves += 1 + let key = fingerprint(state) + let count = (visitCounts[key] ?? 0) + 1 + visitCounts[key] = count + if count > 1 { revisitEvents += 1 } + // A transient cross-line revisit is survivable (the next plan differs); + // a third visit to the same exact layout means the hints are looping. + if count >= 3 { + return (.stalemateLoop(foundation: foundationCount(state)), revisitEvents) + } + // Cap before win, matching the other players: their win check only + // runs on the next loop iteration, so a win landed on the final + // permitted action classifies as .actionCap everywhere. + if moves >= actionCap(for: .yukon) { + return (.actionCap(foundation: foundationCount(state)), revisitEvents) + } + if state.isWon { return (.win(moves: moves), revisitEvents) } + } + } + return (.actionCap(foundation: foundationCount(state)), revisitEvents) +} + +// MARK: - Control player + +// The random-moves floor calibrates each variant's deal universe. Deliberately +// forward-only: foundation rollbacks are legal but excluded, so the floor +// measures aimless progress rather than self-sabotage (a uniform player would +// spend much of the endgame yanking banked cards back down). Two tap-policy +// control players were evaluated and retired as uninformative; those findings +// are recorded in README.md. +func playRandom(variant: GameVariant, seed: UInt64, drawCount: Int) -> Outcome { + // No revisit check: a stochastic player legitimately revisits positions and + // diverges by luck afterward, so it runs to a true deadlock or the action cap. + // (The hint followers keep their revisit checks — they are deterministic, so + // for them a revisit is a proven infinite loop.) + var state = seededDeal(variant: variant, seed: seed) + var generator = SeededRandomNumberGenerator(seed: seed ^ 0xDEADBEEF) + var actions = 0 + while actions < actionCap(for: variant) { + if state.isWon { return .win(moves: actions) } + + var legal: [(Selection, Destination)] = [] + for selection in AutoMoveAdvisor.candidateSelections(in: state) { + if case .foundation = selection.source { continue } + for destination in AutoMoveAdvisor.legalDestinations(for: selection, in: state) { + legal.append((selection, destination)) + } + } + let canTapStock = variant == .klondike && (!state.stock.isEmpty || !state.waste.isEmpty) + let choices = legal.count + (canTapStock ? 1 : 0) + guard choices > 0 else { return .deadlock(foundation: foundationCount(state)) } + + let pick = Int(generator.next() % UInt64(choices)) + if pick == legal.count { + guard let next = stockTap(state, drawCount: drawCount) else { + fatalError("Seed \(seed): random stock tap with nothing to tap") + } + state = next + } else { + guard let next = apply(legal[pick].0, legal[pick].1, to: state, stockDrawCount: drawCount) else { + fatalError("Seed \(seed): illegal random move") + } + state = next + } + actions += 1 + } + return .actionCap(foundation: foundationCount(state)) +} + +// MARK: - Reporting + +func summarize(_ name: String, outcomes: [(UInt64, Outcome)]) { + var wins = 0 + var deadlocks = 0 + var loops = 0 + var caps = 0 + var winMoves: [Int] = [] + var lossFoundations: [Int] = [] + var highBankLosses = 0 + for (_, outcome) in outcomes { + switch outcome { + case .win(let moves): + wins += 1 + winMoves.append(moves) + case .deadlock(let foundation): + deadlocks += 1 + lossFoundations.append(foundation) + if foundation >= 40 { highBankLosses += 1 } + case .stalemateLoop(let foundation): + loops += 1 + lossFoundations.append(foundation) + if foundation >= 40 { highBankLosses += 1 } + case .actionCap(let foundation): + caps += 1 + lossFoundations.append(foundation) + if foundation >= 40 { highBankLosses += 1 } + } + } + let total = outcomes.count + print("\n=== \(name) (n=\(total)) ===") + print(String(format: "win rate: %.1f%% (%d)", 100.0 * Double(wins) / Double(total), wins)) + print("losses: deadlock=\(deadlocks) stalemate-loop=\(loops) action-cap=\(caps)") + if !winMoves.isEmpty { + let sorted = winMoves.sorted() + print("moves/win: median=\(sorted[sorted.count / 2]) mean=\(winMoves.reduce(0, +) / winMoves.count)") + } + if !lossFoundations.isEmpty { + let sorted = lossFoundations.sorted() + print("foundation-at-loss: median=\(sorted[sorted.count / 2]), losses with >=40 banked: \(highBankLosses)") + } +} + +// MARK: - Modes + +/// Progress goes to stderr, unbuffered, so `tail -f` on a redirected log shows +/// where a long run is while stdout results are still being buffered. +func reportProgress(_ message: String) { + FileHandle.standardError.write(Data((" " + message + "\n").utf8)) +} + +/// Plays seeds 1...count across all cores and returns results in seed order. +/// Parallelism changes only wall-clock time: every game is self-contained and +/// deterministic (the engines are pure functions over value types), and results +/// are collected by seed index, so the output is byte-identical to a serial run. +func mapInParallel( + seeds: UInt64, + progressLabel: String, + _ play: @escaping (UInt64) -> Result +) -> [Result] { + let count = Int(seeds) + var results = [Result?](repeating: nil, count: count) + let progressLock = NSLock() + var completed = 0 + results.withUnsafeMutableBufferPointer { buffer in + DispatchQueue.concurrentPerform(iterations: count) { index in + let result = play(UInt64(index + 1)) + buffer[index] = result + progressLock.lock() + completed += 1 + let done = completed + progressLock.unlock() + if done % 100 == 0 { + reportProgress("\(progressLabel): \(done)/\(count) deals") + } + } + } + return results.map { $0! } +} + +func run(variant: GameVariant, seeds: UInt64, drawCount: Int) { + let label: String + switch variant { + case .klondike: + label = "klondike draw-\(drawCount)" + case .freecell: + label = "freecell" + case .yukon: + label = "yukon" + } + + let start = DispatchTime.now() + let followerResults = mapInParallel( + seeds: seeds, + progressLabel: "\(label) — following every hint" + ) { seed -> (outcome: Outcome, revisitEvents: Int) in + switch variant { + case .klondike: + return (playKlondikeFollowingHints(seed: seed, drawCount: drawCount), 0) + case .freecell: + return (playFreeCellFollowingHints(seed: seed), 0) + case .yukon: + return playYukonFollowingHints(seed: seed) + } + } + let seconds = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1e9 + + let followerOutcomes = zip(1...seeds, followerResults.map(\.outcome)).map { ($0, $1) } + let revisitEvents = followerResults.reduce(0) { $0 + $1.revisitEvents } + let followerLoops = followerResults.count { result in + if case .stalemateLoop = result.outcome { return true } + return false + } + + summarize("\(label) — following every hint", outcomes: followerOutcomes) + print(String(format: "elapsed: %.1fs", seconds)) + if variant == .yukon { + print("hint revisit events: \(revisitEvents)") + } + if followerLoops > 0 { + print("GATE VIOLATION: \(label) hint follower looped in \(followerLoops) game(s)") + gateViolations += followerLoops + } + if variant == .yukon, revisitEvents > 0 { + print("GATE VIOLATION: yukon hint follower revisited positions \(revisitEvents) time(s)") + gateViolations += revisitEvents + } + + let randomResults = mapInParallel( + seeds: seeds, + progressLabel: "\(label) — random control" + ) { seed in + playRandom(variant: variant, seed: seed, drawCount: drawCount) + } + summarize( + "\(label) — random legal moves (control)", + outcomes: zip(1...seeds, randomResults).map { ($0, $1) } + ) +} + +// MARK: - Entry + +// The machine-enforceable acceptance gates: a hint follower that stalemate-loops +// is a broken hint system, and any Yukon revisit event is a regression signal +// (the shipped planner measures zero across 500 deals). Violations fail the exit +// code without altering the measured outcomes. Win-rate comparisons stay a human +// step against the README ledger. +var gateViolations = 0 + +// Line-buffer stdout so results stream into redirected logs as each section +// prints instead of arriving all at once on exit. +setvbuf(stdout, nil, _IOLBF, 0) + +func exitWithUsage() -> Never { + print("usage: run.sh [deals >= 1] [klondike draw count: 1 or 3]") + exit(1) +} + +let arguments = CommandLine.arguments +let mode = arguments.count > 1 ? arguments[1] : "all" +guard let seeds = arguments.count > 2 ? UInt64(arguments[2]) : 500, seeds >= 1 else { + exitWithUsage() +} +guard let draw = arguments.count > 3 ? Int(arguments[3]) : 1, + DrawMode(rawValue: draw) != nil else { + exitWithUsage() +} + +switch mode { +case "yukon": + run(variant: .yukon, seeds: seeds, drawCount: 3) +case "klondike": + run(variant: .klondike, seeds: seeds, drawCount: draw) +case "freecell": + run(variant: .freecell, seeds: seeds, drawCount: 3) +case "all": + run(variant: .yukon, seeds: seeds, drawCount: 3) + run(variant: .klondike, seeds: seeds, drawCount: 1) + run(variant: .klondike, seeds: seeds, drawCount: 3) + run(variant: .freecell, seeds: seeds, drawCount: 3) +default: + exitWithUsage() +} + +if gateViolations > 0 { + print("\nFAILED: \(gateViolations) acceptance-gate violation(s) — see GATE VIOLATION lines above") + exit(2) +} diff --git a/tools/hint-probe/run.sh b/tools/hint-probe/run.sh new file mode 100755 index 0000000..3142b9a --- /dev/null +++ b/tools/hint-probe/run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Compiles the hint-quality probe against the UI-free Game sources and runs it. +# Usage: tools/hint-probe/run.sh [seeds] [klondike draw count] +set -euo pipefail + +cd "$(dirname "$0")/../.." + +SOURCES=( + ComputerSolitaire/Game/Shared/Card.swift + ComputerSolitaire/Game/Shared/GameState.swift + ComputerSolitaire/Game/Shared/GameVariant.swift + ComputerSolitaire/Game/Shared/MoveTypes.swift + ComputerSolitaire/Game/Shared/GameRulesShared.swift + ComputerSolitaire/Game/Shared/AutoMoveAdvisor.swift + ComputerSolitaire/Game/Shared/TapMovePolicy.swift + ComputerSolitaire/Game/Shared/HintAdvisor.swift + ComputerSolitaire/Game/Shared/BinaryHeap.swift + ComputerSolitaire/Game/Klondike/GameStateKlondike.swift + ComputerSolitaire/Game/Klondike/GameRulesKlondike.swift + ComputerSolitaire/Game/Klondike/AutoMoveAdvisorKlondike.swift + ComputerSolitaire/Game/Klondike/KlondikePlanner.swift + ComputerSolitaire/Game/FreeCell/GameStateFreeCell.swift + ComputerSolitaire/Game/FreeCell/GameRulesFreeCell.swift + ComputerSolitaire/Game/FreeCell/AutoMoveAdvisorFreeCell.swift + ComputerSolitaire/Game/FreeCell/FreeCellSolver.swift + ComputerSolitaire/Game/Yukon/GameStateYukon.swift + ComputerSolitaire/Game/Yukon/GameRulesYukon.swift + ComputerSolitaire/Game/Yukon/AutoMoveAdvisorYukon.swift + ComputerSolitaire/Game/Yukon/YukonPlanner.swift +) + +for source in "${SOURCES[@]}"; do + if [[ ! -f "$source" ]]; then + echo "error: missing source $source — update SOURCES in tools/hint-probe/run.sh" >&2 + exit 1 + fi +done + +BUILD_DIR="$(mktemp -d)" +trap 'rm -rf "$BUILD_DIR"' EXIT + +swiftc -O -o "$BUILD_DIR/hint-probe" "${SOURCES[@]}" tools/hint-probe/main.swift + +"$BUILD_DIR/hint-probe" "$@"