Multiple profiles on one device — so testers play their own game, and so seeded test users let you jump straight to late-stage UI.
The plan below is preserved as written. Jump to “As built” for the four things it got wrong or missed — two of them would have silently destroyed your existing data. Both were caught by running it on a simulator, not by reading the code.
| Question | Answer |
|---|---|
| Switch UX | Profile picker on launch |
| Isolation depth | Everything user-scoped — progress, settings, familiarity, narrative |
| Test users | Blank-slate + DEBUG seeded presets + reset/delete + export |
Two ways to scope SwiftData per user:
profileID to every model, filter every query.Three reasons, in order of weight:
Question.drills ↔ DrillResult.question is a cascade relationship. SwiftData does not permit relationships crossing ModelConfiguration boundaries — so I can't keep a shared question library in one configuration and per-profile progress in another. It's all one store, or all separate stores.@Query/FetchDescriptor sites across the game views. Every one needs a profile predicate, and one miss silently shows a tester another tester's data — the exact failure this feature exists to prevent. (B) makes cross-profile leakage structurally impossible.Each profile gets its own copy of the question corpus. StarterLibrary.json is 715 KB and re-imports from the bundle per profile, so a profile costs low single-digit MB — irrelevant on-device.
The real cost: questions you capture on your profile do not appear on a tester's profile. Handled by a "copy library from <profile>" option at creation time, reusing the existing SetExport/SetImporter path. But that's a point-in-time snapshot, not a live share.
Application Support/
profiles.json ← registry (outside every profile)
Profiles/
<uuid>/Peregrine.store ← SwiftData, all 8 @Model types
<uuid>/Peregrine.store-wal/-shm
+ per profile: UserDefaults(suiteName: "profile.<uuid>")
| Store | Scoping mechanism |
|---|---|
SwiftData — 8 @Model types | Separate store URL per profile |
UserDefaults — 7 keys + familiarity.* | UserDefaults(suiteName:) per profile |
| Keychain — Anthropic API key | Stays global. Deliberate — see below |
Two kinds of reader, two mechanisms:
@AppStorage readers (lineupStreamSpeed, lineup.answerMode, heckler.setting, contentMode) are redirected wholesale by .defaultAppStorage(session.defaults) on the root view. No call-site changes.UserDefaults.standard readers — GameFamiliarity (~30 call sites across every game view) and ShakedownGameView:197-208. Not @AppStorage, so defaultAppStorage doesn't reach them.Rather than thread a defaults: argument through 30+ call sites, one injection point in Core:
public enum ProfileScope {
/// The active profile's defaults. Set once at activation;
/// `.standard` only before a profile is chosen (and in unit tests).
public static var defaults: UserDefaults = .standard
}
GameFamiliarity's existing parameter defaults change from = .standard to = ProfileScope.defaults — its explicit-injection test seam is preserved and all 30 call sites keep working unchanged.
That's mutable global state, which I'd normally push back on. It's justified here because it's written exactly once per activation, before any view that reads it is constructed — and the alternative is a 30-site refactor of gameplay code for a testing feature.
kind (.owner/.tester/.test) and Capture is hidden for non-owners. Testers play the corpus; they don't build it.CaptureInbox (App Group share-extension drop-box). A transient queue between two processes, not durable state. It drains into whichever profile is active. Since Capture is owner-only, that's effectively always you. Accepted, not solved.PeregrineCore/…/Profiles/| File | Contents |
|---|---|
PlayerProfile.swift | Codable, Identifiable, Hashable — id, name, avatar (emoji), kind, createdAt, lastPlayedAt, note |
ProfileRegistry.swift | Foundation-only load/save of profiles.json. Atomic write, injectable root URL. Unit-testable with no SwiftData/SwiftUI. |
ProfileScope.swift | The defaults injection point (§2.1) |
ProfileArchive.swift | Codable snapshot of all 8 model types + the defaults dictionary → Data. Export only; no import path in v1. |
Peregrine/Profiles/ (app target)| File | Contents |
|---|---|
ProfileManager.swift | @Observable. Owns registry, profiles, and session: (profile, ModelContainer, UserDefaults)?. Methods: activate, leave, create, reset, delete, exportArchive. Owns the per-profile first-run work moved out of App.init(). |
ProfilePickerView.swift | The launch gate. Noir "personnel file" framing — profiles as ID cards, styled with existing PG tokens. Tap to enter; + to add; swipe/context menu → Reset / Export / Delete (destructive actions confirm). |
ProfileEditorView.swift | Name + emoji + kind. On create, choose starting library: Starter library (bundled JSON) or Copy from <profile>. |
ProfileSeeder.swift DEBUG | Presets writing synthetic VigilanceResult/NightTake/DrillResult/PersonalHistory + familiarity.*: Rookie (~1 night), Regular (~15 nights, mid mastery, one busted con), Veteran (~60 nights, several retired cons, late-act narrative state). Reuses DemoData patterns. |
| File | Change |
|---|---|
PeregrineApp.swift | Drop the eager let container. Hold @State ProfileManager. Body branches: no session → picker; else root view with .modelContainer / .defaultAppStorage / .id(profile.id). Move DemoData.seedIfRequested, backfillImageHashes, importStarterLibraryIfPresent out of init() into activate — they need the active container, and per-profile is where they belong. |
GameFamiliarity.swift | Parameter defaults .standard → ProfileScope.defaults (9 signatures) |
ShakedownGameView.swift | :197, :199, :208 — UserDefaults.standard → ProfileScope.defaults |
DemoData.swift | -resetDemoData currently deletes 5 of 8 model types and prefix-wipes familiarity.*. Replace with: delete the UI-test profile's store directory + defaults suite outright, before activation. Strictly more hermetic — and it stops missing GameSession/PersonalHistory/SuspectRecord. |
SettingsView.swift | Add a "Switch profile" row — see §5, this is an addition beyond the chosen option |
project.yml | No target edits needed (new folders fall inside the existing source glob) — but run xcodegen generate |
launch
├─ -demoData present? → activate fixed UI-test profile
│ (uuid 00000000-…-0001, created on demand;
│ -resetDemoData wipes it first). Picker skipped.
└─ otherwise → ProfilePickerView
last-played listed first as "Continue as <name>";
everything else below.
Existing tests pass ["-demoData","-resetDemoData"] and route via -route*. The picker is bypassed whenever -demoData is present, and the -route* args still reach PeregrineRootView.init unchanged.
You chose picker-on-launch only, not "both". I'm including a "Switch profile" row in the You tab anyway — because without it, every switch during your own test loop needs a force-quit, and hopping between seeded test profiles to inspect UI states is the second half of this request. It's ~10 lines (set manager.session = nil).
Say the word and I'll drop it.
PlayerProfile, ProfileRegistry, ProfileScope + registry unit tests (round-trip, corrupt-file recovery, atomic write).GameFamiliarity / ShakedownGameView defaults redirection. Build green, behaviour unchanged — ProfileScope.defaults is still .standard.ProfileManager + per-profile container/defaults construction.PeregrineApp restructure + UI-test bypass. Verify existing UI tests pass here — highest-risk step.ProfilePickerView + ProfileEditorView (create / blank / copy-library).ProfileArchive + export share sheet.ProfileSeeder DEBUG presets.xcodegen generate, build, run the UI test suite.UserDefaults seams.SetExport/SetImporter for library copying, DemoData patterns for seeding, PG tokens for the picker.Profiles/ folders, restore the eager container.ProfileScope.defaults). A real smell, accepted over a 30-site refactor. Written once per activation..id(profile.id) forces a full rebuild, but a view holding a stale ModelContext across a switch would crash. I believe there are none (the codebase uses @Environment(\.modelContext) throughout, no cached contexts) — but that's what to watch in step 4.CaptureInbox has no profile. Share-extension images drain into whoever is active. Mitigated by owner-only Capture, not solved. If a tester ever needs capture, this needs revisiting.ModelContainer before removing files, or SQLite holds open handles. Handled explicitly — but it's the kind of thing that fails only on device.fatalErrors on container failure; per-profile that means one bad profile bricks launch. I'll surface it as a broken row in the picker with a "Reset this profile" affordance instead of trapping.If corpus duplication becomes annoying, the alternative is profileID on the seven progress models with Question left global — but that requires severing the Question.drills relationship (store questionID: UUID on DrillResult instead). Cleaner data model, meaningfully more invasive. Not now.
ProfileScope is a compromise rather than the elegant answer. Everything else is mechanical.Four things the plan missed. The first two would have silently destroyed existing data; both were caught by running the thing on a simulator rather than by reading the code.
The plan restructured PeregrineApp to open a per-profile store and never said what happens to the store that already exists. Shipping it as written would have orphaned every night, dossier and captured question on your device — the app would have looked like a fresh install.
Fixed: ProfileManager.adoptLegacyStoreIfNeeded(). On the first launch with an empty registry, the pre-profiles store is moved into a new .owner profile named “You”, and the app's own UserDefaults.standard keys are copied into that profile's suite. Runs only when the registry is empty, so it cannot fire twice.
Because the app declares the App Group group.com.lochlustra.Peregrine (for CaptureInbox), SwiftData's implicit container writes to <AppGroup>/Library/Application Support/default.store — not the app's private container. FileManager.url(for: .applicationSupportDirectory, …) returns the private path and finds nothing.
Fixed: ProfileRegistry.defaultRoot() prefers the App Group's Application Support, and legacyStoreCandidates checks both locations (a device predating the share extension has it in the private container).
This is the failure mode worth remembering: looking in the wrong container doesn't error — it reads as “fresh install”.
Question.imageData is @Attribute(.externalStorage), so anything past SwiftData's inlining threshold — every real Bluebook screenshot — lives in a sidecar directory named after the store (default.store → .default_SUPPORT). Moving the store alone leaves the rows intact and every captured image empty.
Fixed: moveExternalStorage(from:to:) renames it alongside the store. Note this gap is invisible in the simulator — bundled starter and demo data are small enough to be inlined, so _EXTERNAL_DATA is empty there. It would only have shown up on a device with real captures.
PlayerProfile.uiTestProfile is .owner, not .test. As .test it lost Capture and the API-key settings, so testAPIKeySavesToKeychain could never pass. UI tests stand in for the real player and must exercise the shipping path.-demoData launch rather than reused — a stale entry on a surviving simulator container meant a changed fixture silently failed to take effect..task into PeregrineApp.init(), so tests never race a frame of the picker.One existing test changed: testAPIKeySavesToKeychain now launches with -demoData, because a bare launch legitimately opens the picker now. No other existing test was touched.
-resetProfiles (DEBUG): wipes the registry, every profile store and every profile defaults suite. Without it the picker itself was untestable — every other suite passes -demoData precisely to skip it.ProfileUITests — 9 tests: the gate, creation, name validation, switching, cross-profile isolation, re-entry, registry durability, seeded presets.ProfileRegistryTests — 17 Core tests: round-trip, tolerant decoding, corruption recovery, layout invariants, defaults isolation.The pre-profiles store held only throwaway testing, and adoption is non-destructive by construction: it moves the store and leaves it untouched if the move fails, so the failure mode is “no profiles appear”, never “data gone”.
To skip adoption entirely, delete the app first. You get an empty picker and can name your own profile deliberately instead of inheriting “You” — and it exercises the same first-run path a tester will hit. One caveat: iOS wipes Keychain items on app deletion, so the Anthropic API key has to be re-entered. The starter library re-imports itself per profile.