Peregrine · build report
Everything on the 2026-09-15 list that needed no narrative input. The playtest build is made and verified; the unlock is inventoried and untouched; the verb check is drafted for your ruling; the corpus rename is staged, not written.
The Lineup and Prime Suspect, and nothing else. Prime Suspect keeps b3fc666's sequential case unlock exactly as it stands. No game content changed.
A new Playtest build configuration (Release-type) defines a PLAYTEST compilation condition, and a new Peregrine-Playtest scheme runs, profiles and archives against it. A -playtest launch argument flips any build into the same roster, which is how UI tests and a local run reach it without switching schemes.
# make it xcodegen generate xcodebuild archive -scheme Peregrine-Playtest -configuration Playtest # see it without switching schemes xcrun simctl launch <device> com.lochlustra.Peregrine -playtest
| File | Change |
|---|---|
| Peregrine/PlaytestRoster.swift | new One roster gate both surfaces ask, so they cannot drift apart. |
| Peregrine/Views/LaunchDeckView.swift | Deck cards filtered at source, so the ordering, the hero slot and every accessibility card count read the same list. |
| Peregrine/Views/GamesView.swift | Each of the 12 game stations wrapped in its roster id. The two dossier rows are not games and stay. |
| project.yml | The Playtest config and the Peregrine-Playtest scheme. |
BUILD SUCCEEDED against the Playtest configuration; the app installs and launches on the simulator.BUILD SUCCEEDED, full roster intact.The Rematch is gated out. It calls itself a game, owns its own verb (turn), and appears in the launch deck's hero slot and the Training Ground marquee once the player has a nemesis. Playing The Lineup earns one, so an ungated playtest would grow a third game partway through the session in the most prominent slot on the screen. It is gated under the id rematch; delete one line in PlaytestRoster.underTest to put it back.
The bundle id is unchanged. A separate id would give the tester a separate app, but the SwiftData store lives in the app group group.com.lochlustra.Peregrine, and a new bundle id needs that group re-provisioned under automatic signing. The playtest build therefore replaces a normal build on the same device. Only the display name differs (Peregrine Playtest).
Who runs the session and what it records. The build is ready either way; nothing in it assumes an answer.
Every piece of unlock logic and every test that asserts unlock order. Read only; no file below was edited.
| File | Lines | What it holds |
|---|---|---|
| PeregrineCore/…/PrimeSuspect/ CaseLadder.swift |
66–93 102–105 108–110 |
The whole rule. rungs(cases:runs:) walks cases in caseID number order carrying a previousClosed flag: a case with no solved run and an unclosed predecessor is .locked. Status.isOpen is self != .locked. next(after:in:) is list order. number(of:) parses 3 from "CASE003"; a case without digits sorts last. Pure — cases and run summaries in, rungs out. |
| PeregrineCore/…/PrimeSuspect/ CaseLadder.swift |
96–99 | rungs(in: ModelContext?) — the bundled library read against a profile's store. The only place the rule meets persistence. |
| PeregrineCore/…/PrimeSuspect/ CaseRun.swift |
136–203 | No unlock logic. It supplies the four facts the ladder reads: moveCount (opened clues), minutesSpent, isResolved, and the accusation history. isOpened at line 131 is an evidence thread's state, unrelated to case unlock. |
| PeregrineCore/…/Models/ PrimeSuspectRun.swift |
19–63 | Not on your list, but the ladder's actual input. RunSummary.init(_ run:) reads caseID, solved, isLive (completedAt == nil), minutesSpent, moveCount. |
| Peregrine/Views/PrimeSuspect/ PrimeSuspectView.swift |
18–20 27, 41 64–70 |
Loads the rungs on appear and again whenever a case closes back to the list. Holds the doc comment naming the sequential unlock. |
| Peregrine/Views/PrimeSuspect/ CaseListView.swift |
48–51 75–77 87, 91 104–105 132–133 |
The only place the lock is rendered. locked = !rung.status.isOpen guards the tap, dims the row to 0.62, disables the button, swaps the footer to "Close case NN to open this file", shows the 🔒 locked tag, and speaks "Locked. Close case N to open it." A locked row still shows its title and premise. |
| Peregrine/Views/PrimeSuspect/ CaseRunView.swift |
33, 58–60 133–134 144–151 |
refreshNextRung() re-reads the ladder from the store once Case Closed is on screen, so the unlock the charge just earned is already counted, and offers the next case only when next.status.isOpen. |
| Peregrine/Views/PrimeSuspect/ CaseClosedView.swift |
23–24 47–52 |
Not on your list. Renders the handoff: the ps-next-case button appears only when CaseRunView passed a next rung. |
| Test | What it pins | Survives the replacement? |
|---|---|---|
| PrimeSuspectLadderTests .testOnlyTheFirstCaseIsOpenOnAFreshProfile | Three cases, no runs → [.new, .locked, .locked]. | rewrite Under the replacement this becomes [.new, .locked, .locked] pre-burn and all-open post-burn. |
| …testClosingACaseOpensTheNextOneOnly | Closing 1 opens 2 and leaves 3 locked; best score is (deadline − spent) × 10. | rewrite The "next one only" clause is exactly what goes. |
| …testInProgressNeedsAMoveAndKeepsTheNextCaseLocked | A live run with no moves is still .new; one pull makes it .inProgress; in progress does not open the next case. | split The .new / .inProgress half is about run state and should survive untouched. Only the trailing lock assertion is order. |
| …testBestScoreIsTheBestSolvedRunAndAReplayKeepsTheCaseClosed | Best across solved runs; a replay in progress does not reopen the case. Asserts rungs[1].status == .new. | split Scoring half survives; the one .new assertion is order. |
| …testAClosePastTheDeadlineScoresZeroAndStillOpensTheNextCase | Score floors at 0 and a late close still unlocks. | split The floor survives; "still opens the next" is order. |
| …testNextFollowsListOrder | next(after:) is list order; nil past the end. | keep Pure list navigation. Independent of what is open. |
| …testResolvingThroughTheSessionOpensTheNextBundledCase | Against a real store: resolving case 1 flips case 2 from .locked to .new. Skips when the bundle holds one case. | rewrite The only store-backed unlock test. Replacement needs the same shape against seasonState. |
| PrimeSuspectUITests .testClosingACaseOpensTheNext | On a fresh profile ps-case-CASE002 is not enabled; closing case 1 offers ps-next-case; back on the list case 2 is enabled and case 1 reads "Closed". | rewrite The two isEnabled assertions at lines 247 and 275 are the unlock. The Case Closed handoff is worth keeping. |
| …testLaterCasesUnlockAndShowTheirOnFileTables | Closes cases 1 and 2 in sequence, asserting each isEnabled on the way, then that case 3 is enabled, then that its first clue draws its on-file table. | split The on-file table is the real subject and has nothing to do with unlock. Post-replacement this test can open case 3 directly, which makes it shorter and faster. |
"Which db85da4 tests check unlock order, and which only check case content?" — Of the nine above, four are purely unlock order and must be rewritten, four mix an order assertion into a test whose real subject is something else (run state, scoring, the on-file table) and only need the order clause lifted out, and one — testNextFollowsListOrder — is untouched by the change.
The replacement needs a seasonState flag that does not exist yet: case 1 open before the burn, cases 2–10 after it. Nothing here was edited, and none of it was deleted.
The gate is "the concept names a one-word verb that no kept game owns." The binding clause behind it is docs/spec/verb-registry.md §2.1: no two games in the same section share a primary verb, and a game that cannot name a free verb is a reskin of one that already exists.
| Kept game | Verb | Section | What the hands do |
|---|---|---|---|
| Prime Suspect | pin | Math | Set a suspect's read on the board. Terminal: charge, press-and-hold. |
| The Lineup | call it | R&W + Math | Clean or con, at a line of your choosing. Terminal: the mugshot pick. |
| Target Practice | aim-fire | R&W | Press to aim, release to fire at one of two swaying targets. |
| The Wager | push a chip | R&W + Math | Drag-forward commit that prices confidence without resolving it. |
strikeIt already holds this row in the registry, and it is one word. Against the four kept games it collides with none of them on the letter of the gate.
Clear the Room and Target Practice are adjacent in feel and share a section. Both are reflex-tier, both decay a per-second meter, both reward speed on a wrong-thing-spotted. Target Practice drains 90 → 10 over the claim's reading budget with a ×3 streak multiplier; Clear the Room drains 80 → 20 over a nine-second window with a +120 completion bonus.
What separates them, and I think it holds: the object being judged is different. Target Practice judges a claim standing alone — is this reasoning sound. Clear the Room judges an answer against a stem — is this the wrong answer to that question. And the hands genuinely differ: a two-stage press-and-release at a moving target chosen from two, against a single tap on one of four in a fixed floor plan where spatial memory is part of the game.
What does not separate them: nothing in the scoring. If you want the gap widened rather than argued, the cheapest move is to take the streak out of one of them. Target Practice's ×3 is a true streak; Clear the Room's +120 is a completion bonus, which is closer to a streak than it should be. Your call.
The meeting named the skill: "the skill is knowing what to skip." A registry row names something narrower — what the hands do on a live call site that stops field 3. Those are not the same sentence, and the gap is where the draft has to be made.
| Verb | The case for it | The failure mode |
|---|---|---|
| open recommended |
Names the hand motion. Live on the first frame — the desk accepts it immediately, so field 3 stops honestly. Owned by no game. The skill lives in which file you open and how many you leave shut, which is opportunity cost stated as a hand. | It is Prime Suspect's priced clue-pull promoted to a primary verb. §2.1 could read two document games under a clock as one game. The counter-argument is that the registry already says pulling is a resource spend and not Prime Suspect's verb — so Before He's Back would be making a game out of the thing Prime Suspect prices and moves past. That is either elegant or a reskin, and it is the call only you can make. |
| skip | It is the sentence the meeting actually wrote, and it names the thing the game is about. | A negative act has no call site. If the verb is "do nothing to this file," there is no first frame that accepts it and field 3 never stops. Recommendation: keep skip in the reskin line, where it belongs, and off the verb row. |
| allocate | The verb the record already owns as Casing the Room — "crack or back out, one clock." That shape survives the rename intact, and it collides with no kept game, so it clears the gate with no work at all. | Choosing it is a statement that Before He's Back is the renamed game at order 7 rather than a new one. Which may be exactly true. It is worth deciding on purpose rather than by default. |
Any verb for Before He's Back that means rate how much this item is worth is push-a-chip with paper instead of chips, and §2.1 bars it. The Wager is already the triage game — "skim a run of questions, bet each fast."
The distinction that keeps Before He's Back clear of it: The Wager makes you price every item and then answer every item. Before He's Back's player never touches most of the desk. The Wager's currency is calibration; Before He's Back's is refusal. Keep the verb on the refusal and the collision does not open. Point the verb at valuation and it does.
I considered toss — sweeping a file off the desk — and dropped it. The meeting settled that "files never rearrange mid-round" and that the flags stay visible on files that do not move. A verb that removes a file from the desk contradicts that rule directly. Worth recording, since toss is the obvious first guess for a game about discarding.
Nothing here is decided. Both games stay exactly as they are until you rule.
Routed through /cr-corpus, which never writes to the corpus. Guide resolved as peregrine at tier 1 (project_path: ~/Developer/Peregrine).
Three items, all session notes against the guide-level mini_games record casing-the-room. A mini-game record is guide data rather than an entry, so no entry and no question was added:
label → "Before He's Back".reskin → "Ten files on his desk, three minutes. Read what you can and get out."consumes: "the-wager". It launches with real documents, its unit is a document rather than a question, and the host/consume pattern recorded on 2026-08-08 no longer describes it. bank_target stays 0; the content waits on the solved case and the season record, so this records the unhooking and nothing behind it.I renamed the label and deliberately left the id as casing-the-room. Re-keying it would move order: 7 in group_order and break the app's casingTheRoom game key, telemetry and familiarity counters. The meeting asked for a rename, not a re-keying. The note says so; decide the id separately.
Batch: PressRaven-Corpus/corpus-inbox/2026-09-16-014650-peregrine.md — open it with File → Import Session Return… in Press Raven. Nothing reaches the corpus until you commit it in the staging view.
Counted off the shipped banks, not off the spec. Words are whitespace-and-punctuation tokens; numerals and hyphenated forms count as one.
| Game | Smallest text unit | n | min | median | p90 | max |
|---|---|---|---|---|---|---|
| The Lineup | one reasoning line of the monologue | 564 | 1 | 12 | 20 | 30 |
| · whole round | passage + stem + choices + monologue | 113 | 58 | 116 | 243 | 285 |
| Target Practice | one claim | 45 | 5 | 10 | 16 | 16 |
| Go Undercover | one line (con line or honest line) | 75 | 7 | 12 | 16 | 18 |
| · whole round | claim + every line on the table | 25 | 34 | 50 | 60 | 64 |
| Red String | one sentence | 10 | 4 | 18 | 20 | 22 |
| · whole exhibit | the strung paragraph | 2 | 81 | 83 | 85 | 85 |
| Clear the Room | one answer choice (one strike) | 180 | 0 | 3 | 11 | 20 |
| · whole room | passage + stem + four hidden answers | 45 | 13 | 45 | 102 | 110 |
| The Wager | one question, as skimmed | 45 | 13 | 45 | 102 | 110 |
| Casing the Room | one question (crack or back out) | 45 | 13 | 45 | 102 | 110 |
| Prime Suspect | one clue card, as answered (account + prompt + four choices) | 65 | 48 | 87 | 110 | 129 |
| · account only | the evidence account | 65 | 31 | 51 | 69 | 77 |
| · whole case | premise + brief + every clue | 8 | 418 | 918 | 1037 | 1161 |
No game called The Blotter exists in the corpus roster. The closest record is fault-in-the-record (order 20, Fault in the Record) — the Writing container, whose reskin is "a record is being reconstructed from a corrupted transcript; fragments come up one at a time." Record, transcript, grammar, a stream of fragments: that matches the 2026-09-14 line "the stream is a transcript, and around line forty it stops being about grammar."
I am reporting that as a likely match and not a recorded equivalence — the corpus nowhere states it. And it is build_status: proposed with no bank_target and no bank, so there is no content to count. The Blotter line's word ceiling cannot be measured today under any id.
The nearest usable proxy is The Lineup's reasoning line — the only built line-by-line stream on the slate. It runs 1–30 words, median 12, p90 20. If The Blotter's unit is a line, that is the number a ceiling should be set against.
"Record the word ceiling of the smallest arc-carrier unit (Blotter line, Prime Suspect clue card)."
Prime Suspect clue card: 110 words at p90, 129 hard maximum, across all 65 clues in the eight shipped cases. Blotter line: unmeasurable; the proxy says 20 at p90, 30 hard maximum.
| Game | What one correct act pays | Range | Shape |
|---|---|---|---|
| The Lineup | Two separate awards per suspect. Your own straight read: +15 flat. The call: 80 × multiplier, ×4 on the tell, −1 per line late, floor ×1, 0 before the tell. A right con call with the wrong face pays a flat +40, and only on or after the tell. | 15 80–320 |
Speed-scaled |
| The Wager | The bet pays its locked win: Lock +100, Lean +50, Coin +20, each scaled at push by round(win × meter/100). A circled item answered later pays +10. Losses are flat: −100 / −25 / 0. |
10–100 | Wins scale, losses flat |
| Target Practice | Hit value 90 → 10 decaying over the claim's reading budget, × streak multiplier capped at 3. Sparing an honest line pays the same as downing a con. | 10–270 | Speed × streak |
| Clear the Room | Per strike: 80 → 20 over a nine-second window. All three wrong answers struck: +120. Waking the live one: −15. | 20–80 +120 |
Speed + completion |
| Casing the Room | Flat 100 per mark cracked, by design — "the easy ones are worth exactly as much as the hard ones." A wrong crack burns 90 seconds and costs no points. | 100 | Flat |
| Prime Suspect | Nothing per correct answer. The case pays 10 × minutes left when the charge sticks. A clue costs 20 minutes (= 200 points); a wrong charge costs 30 (= 300). Misreads and second looks cost 0. Case 001's 80-minute deadline caps a run at 800. | 0–800 per case |
Time is the only currency |
| Beat the Shakedown | Coin drains 100 → 5 over the question's reading budget and is awarded on a right answer. The pattern call adds +10; calling it before the choices deal adds +20. | 5–130 | Speed-scaled |
| The Make | Payout 150 → 40 across the stages, plus +40 flat for naming the move. Wrong man: −100. | 40–190 | Evidence-scaled |
| Forensics | Three parts per print: job +40, plant +40 (or −15), solve +20. | −15 to 100 | Flat, three-part |
| Red String | No award. Starts at 120 coins; a wrong slot costs 40, a wrong word 20. | 120 → 0 | Subtractive budget |
| Go Undercover | unscored nightPoints: 0. The cover meter runs 100 → 0 as pressure and banks nothing. |
— | Unscored |
| Kingpin Showdown | unscored nightPoints: 0. Counts kills and fights. |
— | Unscored |
The slate spans 0 to 320 points for one correct act, and "100" means six different things:
Three structural mismatches sit under that, and they matter more than the numbers:
One thing already agrees. Before He's Back's proposed 100 points per file matches Casing the Room's flat 100 per mark exactly — which follows, since it is the same record renamed. The rename carries the point value forward untouched, so that number needs no decision.