Files
nightclub-arcadia/docs/candidate-system-implementation-plan.md
T

865 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Candidate System — Implementation Plan
**Audience:** the developer or agent implementing this. Assume no prior context on this project and no
access to any design document. Everything needed is in this file. Nothing here says "see the design
doc," because you do not have one.
**Repo root:** `/Users/lennart/Dev/nightlcub-arcadia`
**Unity project:** `NightclubArcadia/` — Unity `6000.5.8f1`, URP, Yarn Spinner 3.2.8 (vendored).
**Unity editor binary:** `/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity`
**Status:** implemented (Phases 0–5). A predecessor plan,
[`docs/candidate-system-plan.md`](./candidate-system-plan.md), covered the first slice of this
feature, and its Steps 1, 2 and 4 landed in commit `22b8c7a`. That document has been marked
superseded and points here. Its §1 design summary and §8 traps remain accurate. §1 below was the
survey at plan-writing time; the code now also contains the Phase 1–5 deliverables
(`Assets/Candidates/`, lint/registry tests, Common.yarn flags, SC-101 prose, `Debug_CandidateCheck`).
---
## 0. How to read this
- §1 is a survey of the codebase as it stands, with paths. Read it first; most of the machinery this
feature needs already exists and must be extended, not re-invented.
- §2 restates the whole design — the Voice-System it rides on, and the Candidate-System itself.
It is decided intent, not a proposal. Do not redesign it.
- §3–§4 fix names and architecture decisions.
- §5 is the phased build. Each phase is independently buildable and testable.
- §6 turns the design's guardrails into enforceable implementation constraints.
- §7 lists decisions that are **genuinely open**. Do not resolve them; raise them with the lead
developer. Where a placeholder was needed to keep a phase buildable, it is marked *provisional*.
- §8 lists traps that have already bitten this codebase.
---
## 1. Survey — what exists in the repo today
### 1.1 Dialogue and variable storage
| Thing | Path | Notes |
|---|---|---|
| Yarn Spinner 3.2.8 | `NightclubArcadia/Packages/dev.yarnspinner.unity/` | Vendored as an embedded package; **not** listed in `Packages/manifest.json`. |
| Yarn project | `Assets/Dialogue/NightclubArcadia.yarnproject` | Compiles `**/*.yarn` under `Assets/Dialogue/`. **New `.yarn` files are picked up automatically** — no importer change needed to add a scene. |
| Working scene | `Assets/Scenes/DialogueTest.unity` | Contains the `Dialogue System` objects: `DialogueRunner`, Line Presenter, Options Presenter, Line Advancer, Canvas, and a stock **`InMemoryVariableStorage`**. `autoStart: 1`, `startNode: "Start"`. |
| Template leftover | `Assets/Scenes/SampleScene.unity` | The only scene in `EditorBuildSettings`. `DialogueTest` is opened by hand. |
Existing `.yarn` files, all under `Assets/Dialogue/`:
| File | Contents |
|---|---|
| `Common.yarn` | The `Declarations` node. Never played. **Every variable in the project is declared here.** |
| `Scenes/Entrance.yarn` | `Start`, `Entrance_Inside`, `Entrance_Outside`. Scene files stay thin: set the stage, `<<detour>>` into character nodes, branch on the outcome. |
| `Scenes/SC101.yarn` | The opening candidate scene. Structure and flag writes are in; prose is `TODO`. See §1.4. |
| `Scenes/Debug.yarn` | `Debug_SkillCheck`, `Debug_CandidateLean`. Not story content; exists to exercise mechanics in isolation. **This is the precedent for verifying each phase below.** |
| `Characters/Bouncer.yarn` | Four nodes all titled `Bouncer_Talk` — a Yarn 3 node group with `when:` saliency variations. |
| `Commentary.yarn` | Passive skill barks. Node names follow `Commentary_{contextId}_{skillId}`. |
Every one of these opens with a `//` header comment explaining the file's role and its writing
conventions. **Match that.**
**Persistence.** There is no custom variable-storage subclass, no save system, and no game-state
singleton beyond `SkillRuntime`. There does not need to be:
- `VariableStorageBehaviour` (`Packages/dev.yarnspinner.unity/Runtime/Storage/VariableStorageBehaviour.cs`)
exposes `SetValue(string, float/bool/string)`, `TryGetValue<T>`, `GetAllVariables()`,
`SetAllVariables(...)`, `Contains(...)`, `AddChangeListener(...)`.
- `DialogueRunner.SaveStateToPersistentStorage(fileName)` / `LoadStateFromPersistentStorage(fileName)`
(`Runtime/DialogueRunner/DialogueRunner.Utility.cs`) serialise the whole variable storage to JSON
under `Application.persistentDataPath`. **This is the persistence mechanism. Do not build another.**
No call site exists yet (§7.6).
- The yarnproject importer's generated-variables feature (`generateVariablesSourceFile`) is **off**.
Leave it off.
### 1.2 The skill-voice system (the Voice-System, in code)
Assembly `NightclubArcadia.Skills` — `Assets/Scripts/Skills/NightclubArcadia.Skills.asmdef`,
references `YarnSpinner.Unity` and `Unity.TextMeshPro`.
| Piece | Path | Relevance here |
|---|---|---|
| `SkillAxis` enum | `Scripts/Skills/Data/SkillAxis.cs` | `{ Reason, Body, Social, Self, Specialist }`. **This is the design's "voice pair" concept.** Do not duplicate it. |
| `TraceChannel` enum | `Scripts/Skills/Data/TraceChannel.cs` | `{ None, Documentary, Behavioural, Testimonial, Physical }`. |
| `ClueYield` enum | `Scripts/Skills/Data/ClueYield.cs` | `{ Low, Medium, High }` — a per-voice authoring hint, not a clue object. |
| `DcBand` enum | `Scripts/Skills/Data/DcBand.cs` | `Trivial=8, Routine=11, Hard=14, Specialist=17, BuildDefining=20`. `DcBands.TryParse` accepts band names or raw ints (raw ints log a warning). |
| `SkillCheckDegree` | `Scripts/Skills/Data/SkillCheckDegree.cs` | `{ CriticalFailure, Failure, Success, CriticalSuccess }`. |
| `SkillDefinition` | `Scripts/Skills/Data/SkillDefinition.cs` | ScriptableObject per voice. **Generated** from JSON; hand-edits to `.asset` files are overwritten on script reload. |
| `SkillDatabase` | `Scripts/Skills/Data/SkillDatabase.cs` | Roster ScriptableObject. Its `OnValidate()` runs a full audit: id regex `^[a-z][a-z0-9_]*$`, duplicates, `opposedTo` symmetry, per-axis counts. **This is the pattern to copy in Phase 1.** |
| `skill_bible.json` | `Assets/Skills/skill_bible.json` | Writer-facing source of truth for the roster. 11 voices. |
| `SkillSystemSetup` | `Assets/Editor/SkillSystemSetup.cs` | `[DidReloadScripts]` generator: JSON → `.asset` files → database → scene wiring. |
| `PlayerSkills` | `Scripts/Skills/Runtime/PlayerSkills.cs` | Plain C#. Ranks only, seeded from each definition's `startingRank`. **In-memory; not persisted.** |
| `SkillCheckSystem` | `Scripts/Skills/Runtime/SkillCheckSystem.cs` | Plain C#. `Resolve(skillId, dc, situationalMod)` = d20 + rank + situational + state; nat 1 crit-fail, nat 20 crit-success, else `total >= dc`. |
| `SkillStateModifiers` | `Scripts/Skills/Runtime/SkillStateModifiers.cs` | Nested dict `sourceKey → (skillId → delta)`; `"*"` wildcard supported. |
| `SkillRuntime` | `Scripts/Skills/Runtime/SkillRuntime.cs` | Composition root + singleton the static Yarn commands route through. |
| `CommentarySystem` | `Scripts/Skills/Commentary/CommentarySystem.cs` | Fires **one** passive bark per request from `Commentary_{contextId}_{skillId}`, weighted by rank, gated by per-skill budget, recency, and a global floor. See §8.2. |
| `YarnSkillCommands` | `Scripts/Skills/Yarn/YarnSkillCommands.cs` | The entire Yarn↔C# surface: `<<check>>`, `<<skill_mod>>`, `<<clear_skill_mods>>`, `skill_rank()`. |
| Tests | `Assets/Tests/EditMode/` | `SkillRosterTests`, `SkillCheckSystemTests`, `CommentaryPickerTests`, `DcBandTests`. `SkillRosterTests` loads the real `SkillDatabase.asset` via `AssetDatabase` and asserts roster invariants — **the pattern to copy in Phase 1.** |
The roster in `skill_bible.json` matches the design cast exactly:
| id | display | axis | opposedTo | primary / secondary channel |
|---|---|---|---|---|
| `provenance` | PROVENANCE | Reason | `confluence` | Documentary / Physical |
| `confluence` | CONFLUENCE | Reason | `provenance` | Behavioural / Testimonial |
| `house_pour` | HOUSE POUR | Body | `long_shift` | Behavioural / Physical |
| `long_shift` | THE LONG SHIFT | Body | `house_pour` | Physical / Behavioural |
| `facework` | FACEWORK | Social | `placement` | Testimonial / Behavioural |
| `placement` | PLACEMENT | Social | `facework` | Testimonial / None |
| `amnesty` | AMNESTY | Self | `standing_order` | Behavioural / None |
| `standing_order` | STANDING ORDER | Self | `amnesty` | Testimonial / None |
| `undisclosed` | UNDISCLOSED | Specialist | `provenance` | Physical / Behavioural |
| `the_float` | THE FLOAT | Specialist | `amnesty` | Documentary / Physical |
| `room_tone` | ROOM TONE | Specialist | `placement` | Physical / Testimonial |
**Nothing needs to be added to the roster for this feature.**
### 1.3 Candidate state that already exists
`Assets/Dialogue/Common.yarn` already declares, with a comment block stating the no-winner guardrail:
```yarn
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
<<declare $seen_sc_101 = false as bool>>
```
`Assets/Dialogue/Scenes/Debug.yarn` has `Debug_CandidateLean`, which increments the accumulators and
branches on a constant threshold — the round-trip proof.
### 1.4 SC-101 as it stands
`Assets/Dialogue/Scenes/SC101.yarn` exists with a house-style header and five nodes: an entry node
`SC101_Desk` that sets `$seen_sc_101` and offers four `<<detour>>` routes, plus four hook nodes —
`SC101_Hook_MadeAsset` (`CONFLUENCE:`), `SC101_Hook_Journalist` (`FACEWORK:`),
`SC101_Hook_Inheritor` (`STANDING ORDER:`) and `SC101_Hook_Neutral` (`HOUSE POUR:`, deliberately
carrying no lean write). Every line of prose is a `TODO` placeholder.
### 1.5 What does **not** exist
State these plainly, because they are the starting conditions and two of them are dependencies:
- **No clue, evidence, or deduction objects of any kind.** No registry, no ids, no discovery flags.
`docs/skill-system-refactor-plan.md` §6 explicitly lists this as out of scope for that pass:
*"That system does not exist in this repo and is out of scope. Do not invent a schema for it."*
This plan is where it gets invented, deliberately and minimally (Phase 1).
- **No errand, quest, Act, location, or scene-progression system.** Same doc, same section, lists
"Act / errand state, or any gating for documentary routes" as an explicit non-goal. The
Candidate-System *requires* exploration gating (§2.6), so Phase 4 proposes the minimal new
structure — see §7.2 for who owns the larger system.
- **No skill-rank progression.** `PlayerSkills.SetRank` has no caller anywhere outside its own file
and the tests. Ranks come from `startingRank` and never change, and are not persisted. The design
premise "a player who has invested in a voice pair hears more advocacy" therefore has no mechanism
behind it yet. This does not block anything here — see §7.5.
- **No save/load call site.** The Yarn API in §1.1 is present but never called.
- **No `CLAUDE.md`, no project `README.md`.**
- **No representation of the two permanently unresolved lore threads** referred to internally as
"Thread 2" and "Thread 9". A repo-wide grep for thread identifiers returns nothing. See §6.8 —
they are off-limits for this feature regardless.
- **No NPC content beyond `Bouncer.yarn`.** In particular, the NPC named in
`docs/candidate-system-plan.md` §7.6 as a possible Inheritor link does not exist in code. See §7.3.
### 1.6 Working tree
`git status` is clean at the time of writing. The `TODO: ambient fallback` note at the top of
`Commentary.yarn` is committed, not a stray edit; it is unrelated to this feature. Leave it alone.
---
## 2. The design, restated in full
This section is the complete statement of intent. It is **decided design, not a proposal.** Where
something is genuinely open, §7 says so.
### 2.1 The game
NightclubArcadia is a mystery-driven, dialogue-heavy narrative game. Combat is a lightweight
secondary system; the core is investigation and writing. One developer, roughly a one-year horizon.
### 2.2 The Voice-System — what a "skill" is here
A skill in this game is **not** a stat that unlocks a door. It is one of eleven internal character
voices living in the protagonist's head, each with its own domain, agenda, blind spot, register and
verbal tics. Skill text is written as that character's *opinion*, never as neutral system text.
The cast is four opposed pairs plus three specialists:
- **Reason** — PROVENANCE (*things have an author; a paper trail is a confession*) vs.
CONFLUENCE (*things have a shape across time; connection is authorship*).
- **Body** — HOUSE POUR (*appetite is honesty*) vs. THE LONG SHIFT (*endurance is honesty*).
- **Social** — FACEWORK (*know a person to understand them*) vs. PLACEMENT (*know a person to
position them*).
- **Self** — AMNESTY (*letting go is mercy*) vs. STANDING ORDER (*holding the line is identity*).
- **Specialists**, with no opposite, covering trace channels the pairs miss — UNDISCLOSED
(leverage and reserves), THE FLOAT (money in motion), ROOM TONE (overheard sound).
There are two kinds of skill text:
- **Passives** — fire unprompted, cost nothing, and are the large majority of all skill text.
- **Active checks** — dice rolls against a visible difficulty number, used sparingly (on the order of
once every few minutes of play), and only where *both* success and failure are worth writing.
**Failure is never "nothing happens."** A failed check does one of five things:
1. hands the player a **wrong-but-confident conclusion** (the most common case);
2. **costs something** instead of information;
3. **closes one route while opening an uglier one**;
4. **teaches the player something about themselves** rather than about the case;
5. is **logged silently** and resurfaces later.
Wrong conclusions are never flagged as wrong in the prose. Only the roll's pass/fail state appears in
the UI banner; what is contaminated downstream is not marked. This is already the codebase's
convention — `Common.yarn` and `Commentary.yarn` both carry the header note *"Wrong Truth failures
carry no failure signal in the prose — the UI banner is the only tell."*
Every piece of evidence in the game is reachable through at least one of four **trace channels**:
documentary, physical, testimonial, behavioural. Each voice has a primary and a secondary channel
(table in §1.2).
### 2.3 The problem the Candidate-System solves
The protagonist has amnesia — a **dissociative fugue**. They have lost their own identity, not an
external memory. This is designed to be **genuinely open-ended**: there is no secret canonical answer
the writers are withholding, and no hidden ground-truth value anywhere in the data or the save file.
### 2.4 The three candidates
They are allowed to overlap and coexist. They are not mutually exclusive, and none of them is "the
twist."
| Id | Name | Reading |
|---|---|---|
| `made_asset` | **The Made Asset** | The protagonist was trained, conditioned, or activated by one of the story's factions, for a role near its central conference. |
| `journalist` | **The Journalist** | The protagonist came for a specific *person*, not a story. "Journalist" is a cover identity. |
| `inheritor` | **The Inheritor** | This was never the protagonist's story. A parent or older relative had standing, or a debt, with a faction; it passed to the protagonist without full knowledge or consent. |
### 2.5 Symmetric evidence structure
Each candidate has **exactly one object clue, one testimony clue, one document clue, and one
intuition clue.** The symmetry is deliberate: no evidence type should feel more trustworthy than
another *across* candidates, so a player's growing belief comes from which clues they happen to find
and choose to weight, never from the system tipping its hand through an imbalance.
This is an invariant to enforce mechanically as content grows (Phase 1), not a coincidence of the
current content.
Each candidate's intuition clue is attached to a **different voice pair**, so that trusting "the
smart voice" or "the gut voice" doesn't systematically favour one candidate:
| Candidate | Voice pair (`SkillAxis`) | Shape of the intuition |
|---|---|---|
| `made_asset` | **Reason** | Parsing something too fluently, before consciously trying to. |
| `journalist` | **Social** | A physical or emotional reaction that lands wrong, which the protagonist can't yet explain. |
| `inheritor` | **Self** | Correcting an old etiquette rule they never consciously learned. |
| — | **Body** | **Attached to no candidate, on purpose.** It stays neutral texture, so that not every voice pulls the player toward a theory of their identity. |
Because each pair is two characters with opposite blind spots, a line leaning toward a candidate is
**that character arguing for it in its own biased register** — never a flat statement of fact. Both
voices of an attached pair may argue for the same candidate from opposite directions; which of the
two speaks in a given beat is a writing decision.
### 2.6 How belief accumulates
- There is **no "reveal the truth" moment.** Belief in a candidate accumulates across the whole game
from which clues the player finds and which clues and voices they choose to trust.
- **Clue-surfacing order must be steerable through ordinary player choices** — which errands they
run, which locations they visit. It is **exploration-gated, never skill-gated.** This is a hard
constraint, and it exists specifically so that no candidate is reliably surfaced first across
playthroughs.
- A player who has invested in a given voice pair will naturally hear more advocacy for that pair's
attached candidate — but that is an **emergent consequence of play, not a system-imposed lock.**
Lean is a running per-candidate accumulator. The player is never asked to pick an identity, and
nothing ever resolves the three into a winner.
### 2.7 How it appears on the page
The Candidate-System is **not** a separate scene type and **not** separate UI. It rides inside the
ordinary Voice-System, using the same passive/active split and the same *voices argue, they don't
report* rule as everything else.
The opening scene, **SC-101 "The Card at the Desk,"** is the reference example: four voices — one per
pair — introduce themselves, three planting one candidate's intuition each, the fourth (Body) staying
neutral. None of these lines is proof; each is in-character noticing.
> **Flagged exception.** SC-101 uses **four** voice introductions, against a general
> *"cap introductions at three voices"* rule in the project's writing handbook (the handbook is not
> in this repo). This is a **deliberate, signed-off exception for the opening scene only.** Do not
> silently "correct" SC-101 down to three voices, and do not treat it as a new precedent that licenses
> four-voice beats elsewhere. See §7.4.
### 2.8 What this system is explicitly NOT
- Not a personality quiz with three outcomes.
- Not a hard branch that locks the player into one identity path early.
- Not resolved by a single reveal or one late dialogue choice.
- Must never produce a discoverable "correct answer" anywhere in game files or save data.
If a future feature (endings, epilogue text, achievements) needs one clean outcome, **that is a new,
separate design decision.** Nothing built here may silently assume it.
---
## 3. Names and conventions
These are already in force in the repo, enforced by `SkillDatabase.OnValidate()` and
`Assets/Tests/EditMode/SkillRosterTests.cs`. Defer to them.
- **Yarn variables:** `$snake_case`, every one declared in `Common.yarn`'s `Declarations` node, so a
typo is a compile error rather than a silently-created second variable.
- **Ids** (skills, and by extension candidates and clues): `^[a-z][a-z0-9_]*$`. Enforced because ids
become Yarn node-title and variable-name fragments (§8.4).
- **Yarn node titles:** `Start`, `{Scene}_{Beat}`, `{Character}_Talk`,
`Commentary_{ContextId}_{skillId}`, `Debug_*`. Context ids are PascalCase-with-underscores; skill
ids are lowercase.
- **Yarn commands:** lowercase snake_case.
- **C#:** namespace `NightclubArcadia.Skills`, classes `sealed` by default, `[SerializeField]` fields
bare `camelCase`, log prefixes `[TypeName]`.
- **Docs:** lowercase-kebab-case `.md`, flat in `docs/`.
Names locked by the predecessor plan and already in the repo — **do not rename them**, they are now
content-wide:
| Concept | Name |
|---|---|
| Candidate ids | `made_asset`, `journalist`, `inheritor` |
| Lean accumulators | `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` |
| Scene-witnessed flag | `$seen_<scene_id>`, e.g. `$seen_sc_101` |
| Scene id / node prefix | `SC101`, e.g. `SC101_Desk`, `SC101_Hook_MadeAsset` |
New names introduced by this plan:
| Concept | Name | Where |
|---|---|---|
| Clue ids | `<candidate_id>_<type>`, e.g. `journalist_document` | Phase 1 |
| Clue-found flags | `$found_<clue_id>`, e.g. `$found_journalist_document` (bool) | Phase 2 |
| Location-visited flags | `$visited_<location_id>` (bool) | Phase 4 |
| Errand-state flags | `$errand_<errand_id>` (bool) | Phase 4 |
---
## 4. Architecture decisions
**4.1 — Lean stays a plain Yarn number, written by plain `<<set>>`.**
Already the case, and it stays that way. A `<<add_lean made_asset 1>>` command would take the
candidate id as a *string*, throwing away the compile-time typo checking that declaring everything in
`Common.yarn` buys. Registry consistency is instead enforced at editor time by a lint test (Phase 2),
which gives the same safety without runtime coupling.
**4.2 — The candidate registry is descriptive, not authoritative over runtime.**
The registry (Phase 1) records *what evidence exists, of what type, gated behind what*. It holds no
weight, no truth value, no lean amount, and nothing from which a winner could be derived. No runtime
code reads lean out of it. Its job is to make the symmetry invariant enforceable and the gating
auditable.
**4.3 — Candidate clue text is deterministic and scene-authored; `CommentarySystem` is not the
vehicle.** `CommentarySystem` looks like an exact fit — it fires a named voice from a node — but it
is a *probabilistic, rank-weighted* channel (§8.2). Routing clue-bearing lines through it would make
discovery depend on skill rank, which is exactly the skill-gating that §2.6 forbids. Clue-bearing
lines are authored in scene nodes and reached by `<<detour>>`.
**Where the "invested players hear more advocacy" property comes from, then:**
`CommentarySystem`'s rank-weighted pick is precisely that mechanism — a higher-ranked voice speaks
more often. Candidate-*flavoured* commentary that carries **no clue and no lean** may ride
`CommentarySystem` freely. The split is: *advocacy is ambient and rank-weighted; evidence is
deterministic and exploration-gated.*
**4.4 — Lean is written on failed checks too.**
This follows from §2.3 and §2.2 rather than being a free choice, and it is easy to get wrong. A
failed candidate check most often hands the player a **wrong-but-confident conclusion**, which the
player believes. Lean tracks *belief*, not truth. If lean were only written on successes, the
accumulator would encode which readings were "really" supported, and a player reading their save file
could infer a ground truth that is not supposed to exist. So: a candidate-relevant check writes lean
on both branches, with the failure branch writing its own (possibly different) candidate's lean.
The *amount* is a separate open question (§7.1).
**4.5 — Exploration gating is expressed as data, not as prose discipline.**
Each clue entry names an unlock flag. A lint test forbids that flag from being a skill-derived
expression, and forbids clue-bearing nodes from sitting behind `skill_rank(...)` guards (Phase 4).
This is the one guardrail most likely to be violated by accident, because gating on rank is *easier*
to write than gating on exploration.
---
## 5. Phases
Each phase is independently buildable and independently testable. Phases 1–2 are engineering with no
prose; Phase 5 is where writing lands.
### Phase 0 — Confirm the landed slice, and prove persistence
**Hooks into:** `Common.yarn`, `Debug.yarn`, `DialogueRunner`'s save API.
**Adds:** nothing permanent.
Steps 1, 2 and 4 of the predecessor plan are in the repo (§1.3, §1.4). Step 3 — persistence — has no
committed evidence that it was ever run. Do it now, because everything later assumes it:
1. Open `Assets/Scenes/DialogueTest.unity`, temporarily point the `DialogueRunner`'s `startNode` at
`Debug_CandidateLean`, press Play. Expect `made_asset 2 / journalist 1 / inheritor 0` and the
threshold branch to fire. Restore `startNode` to `Start` afterwards.
2. From a throwaway `[ContextMenu]` component or editor script, call
`SaveStateToPersistentStorage("candidate-lean-smoketest.json")`, exit Play, re-enter, call
`LoadStateFromPersistentStorage(...)`, and confirm the values come back.
3. Inspect the JSON under `Application.persistentDataPath`: `$self_lean_*` should appear in
`floatKeys`/`floatValues`, `$seen_sc_101` in `boolKeys`/`boolValues`.
4. While there, record whether Yarn 3's `when: once` saliency state lives in the same variable
storage. If it does, saliency survives save/load for free. Either way it is **not this feature's
problem** — the explicit `$seen_*` bool convention exists precisely so nothing here depends on it.
5. **Delete the throwaway script.** When the game actually saves is a separate ticket (§7.6).
**Done when:** the round trip is confirmed and no temporary script remains.
---
### Phase 1 — The candidate registry and the symmetry invariant
**Hooks into:** the `skill_bible.json` → generator → `SkillDatabase` → `OnValidate` → EditMode-test
pattern. Mirror it exactly; do not invent a second idiom.
**Adds:** new files only. Touches no existing runtime code.
**This is genuinely new structure** — no clue or evidence system exists in this repo (§1.5). It is
kept as small as it can be while still making §2.5's invariant enforceable.
**1a. Writer-facing source of truth:** `Assets/Candidates/candidates.json`, mirroring
`Assets/Skills/skill_bible.json`. Shape:
```jsonc
{
"candidates": [
{
"id": "journalist",
"displayName": "The Journalist",
"reading": "The PC came for a specific person, not a story; 'journalist' is a cover.",
"intuitionAxis": "Social",
"clues": [
{
"id": "journalist_object",
"type": "Object",
"sceneId": "SC101",
"unlockFlag": "$visited_desk", // Phase 4; empty string until then
"status": "Planned", // Planned | Authored
"notes": "writer-facing one-liner"
}
// ... exactly one each of Object, Testimony, Document, Intuition
]
}
]
}
```
Intuition entries additionally carry `"skillId"` — the voice that speaks the line.
**1b. New enum** `CandidateEvidenceType { Object, Testimony, Document, Intuition }` in
`Assets/Scripts/Skills/Data/`. Keep it separate from `TraceChannel`: the two vocabularies nearly but
not exactly align (document↔Documentary, object↔Physical, testimony↔Testimonial,
intuition↔Behavioural), and collapsing them would quietly redefine one of them. Document the
correspondence in a comment.
**1c. New ScriptableObjects** `CandidateDefinition` and `CandidateDatabase` in the same folder,
following `SkillDefinition` / `SkillDatabase` in style: `[SerializeField]` private fields with
public getters, `#if UNITY_EDITOR EditorSet(...)`, `sealed`.
**1d. Generator** `Assets/Editor/CandidateSystemSetup.cs`, following `SkillSystemSetup.cs`: JSON →
`Assets/Candidates/Definitions/<id>.asset` → `Assets/Candidates/CandidateDatabase.asset`. Same
`[DidReloadScripts]` + `SessionState` guard. Put the *"the JSON is the source of truth; `.asset`
files are build output and hand-edits are lost"* comment at the top, as `SkillSystemSetup` does.
**1e. `CandidateDatabase.OnValidate()` audit**, following `SkillDatabase.OnValidate()`:
- Candidate ids match `^[a-z][a-z0-9_]*$`; no duplicates. Same for clue ids.
- **Exactly one clue of each of the four `CandidateEvidenceType` values per candidate.** This is the
§2.5 invariant. Assert it over the full *planned* set, so the design is symmetric from day one even
while most clues are unwritten; report `Authored` coverage separately as an informational
`Debug.Log`, the way `SkillDatabase` logs its channel tally.
- Each candidate's `intuitionAxis` resolves to a `SkillAxis`; **no two candidates share one**; and
**no candidate uses `SkillAxis.Body`** (§2.5 — Body is deliberately unattached).
- Every intuition clue's `skillId` resolves against `SkillDatabase`, and that skill's `Axis` equals
the candidate's `intuitionAxis`.
- **No field anywhere may express weight, truth, or ranking** — see §6.3. Enforce by keeping the
schema free of such fields; a reviewer reading the whole JSON must be unable to tell which
candidate is "right," because none is.
**1f. EditMode test** `Assets/Tests/EditMode/CandidateRegistryTests.cs`, following
`SkillRosterTests.cs`: load the real `CandidateDatabase.asset` via `AssetDatabase`, assert every
invariant in 1e as hard test failures rather than only editor logs.
**Extensibility is load-bearing.** Adding a fourth candidate must mean adding a JSON entry, one
`<<declare>>`, and content — never editing a `switch`, an enum, or a hardcoded list of three. No code
in this phase may enumerate the three by name.
**Done when:** the three candidates and their twelve clue entries exist in JSON, the database asset
generates, `CandidateRegistryTests` passes, and deliberately breaking symmetry in the JSON (delete
one clue, duplicate a type, point two candidates at the same axis, point one at `Body`) makes the
test fail with a legible message.
---
### Phase 2 — Clue-discovery state, and the lint that keeps lean honest
**Hooks into:** `Common.yarn`'s `Declarations` node; the Phase 1 registry; `Assets/Tests/EditMode/`.
**Adds:** one declaration per clue, and one editor-time lint test. No C# runtime code.
**2a. Declare a `$found_<clue_id>` bool per clue** in `Common.yarn`, grouped under a comment block
that says what they are and that no code reads them. Twelve declarations at three candidates × four
types. Writers set them at the moment the clue is discovered, alongside the lean write.
Why a flag per clue rather than reading the count off lean: lean is *belief*, which can be moved by a
wrong-but-confident failure (§4.4). `$found_*` is *coverage*, which is what the symmetry audit and
the Phase 4 gating actually need. Conflating them would make one of the two useless.
**2b. The lint test** `Assets/Tests/EditMode/CandidateYarnLintTests.cs`. It reads the `.yarn` files
under `Assets/Dialogue/` as text — this is a source lint, not a Yarn-runtime test — and asserts:
1. Every `$self_lean_*` and `$found_*` variable written anywhere in `.yarn` corresponds to a
candidate or clue id in the Phase 1 registry. Catches typos and orphaned content.
2. Every registry clue whose `status` is `Authored` has at least one `<<set $found_<clue_id> = true>>`
somewhere in `.yarn`. Catches content marked done that was never wired.
3. **No `.yarn` file contains a comparison of two `$self_lean_*` variables against each other**, and
no `<<declare>>` derives a variable from such a comparison. Comparing one lean against a *constant*
(`<<if $self_lean_journalist >= 3>>`) is fine and intended; comparing two leans is the
derived-winner the design forbids (§6.1, §6.2).
4. **No node that writes `$self_lean_*` or `$found_*` sits behind a `skill_rank(` guard** in the same
node, and no `<<if>>` guarding a `<<detour>>`/`<<jump>>` to such a node mentions `skill_rank(`.
This is the exploration-gating guardrail (§6.5) made mechanical.
Be honest in the file's header comment about the limits of a regex lint: it catches the easy-and-wrong
wirings, not every possible one. It is a tripwire, not a proof.
**Done when:** the lint passes on current content, and each of the four rules can be shown to fail by
deliberately introducing the violation it targets.
---
### Phase 3 — The authoring surface for candidate passives and active checks
**Hooks into:** existing Yarn commands (`<<check>>`, `<<detour>>`), `Commentary.yarn`'s node-naming
convention, `SkillCheckResultView`.
**Adds:** documented patterns and one debug node. **No new C# commands** — the existing surface is
sufficient, and adding one would trade compile-time safety for a string argument (§4.1).
**3a. The passive pattern (the large majority of candidate text).** A clue-bearing passive is a node
in the owning scene file, spoken by one voice, reached by `<<detour>>` from an exploration-gated beat:
```yarn
title: SC101_Hook_Journalist
---
FACEWORK: <line — FACEWORK arguing for this reading in its own biased register>
<<set $found_journalist_intuition = true>>
<<set $self_lean_journalist += 1>>
===
```
Rules, to be restated in each scene file's header comment:
- One voice per hook node. The voice must be from the candidate's attached pair (§2.5).
- The line is that character's **argument**, never a report. It must be legible as a biased reading
even when it happens to be right (§6.6).
- Exactly one `$self_lean_*` write per hook.
- No `<<if>>` guard on another hook having fired, on any `$self_lean_*`, or on `skill_rank(...)`.
**3b. The active-check pattern.** Reuse `<<check skillId band [situationalMod]>>`, which writes
`$check_result`, `$check_roll`, `$check_total`, `$check_dc`, `$check_modifier`, `$check_degree`,
`$check_skill` and shows the roll banner. Difficulty uses the named bands (`trivial`, `routine`,
`hard`, `specialist`, `build_defining` → 8/11/14/17/20); raw integers work but log a warning.
Candidate checks are **sparse** — active checks across the whole game run at roughly one every few
minutes, and only where both outcomes are worth writing. The failure branch must do one of the five
things in §2.2, and **carry no failure signal in the prose** — the banner is the only tell.
```yarn
<<check confluence hard>>
<<if $check_result>>
CONFLUENCE: <what it concludes>
<<set $found_made_asset_document = true>>
<<set $self_lean_made_asset += 1>>
<<else>>
CONFLUENCE: <a different, equally confident, wrong conclusion — unmarked>
<<set $self_lean_inheritor += 1>>
<<endif>>
```
Note the failure branch writing a *different* candidate's lean: that is the point of §4.4. A failure
that writes nothing is a failure that "does nothing," which the design forbids. Which candidate a
given failure feeds is a writing decision per check.
**3c. Ambient candidate advocacy** — lines that argue for a reading but carry **no clue and no lean**
— may use `CommentarySystem` via `Commentary_{contextId}_{skillId}` nodes. This is where the
"invested players hear more advocacy" property comes from (§4.3). Never put a `$found_*` or
`$self_lean_*` write in a `Commentary_*` node; the Phase 2 lint should be extended to assert that.
**3d. Debug node** `Debug_CandidateCheck` in `Debug.yarn`, exercising 3b with a seeded roll source
(`SkillRuntime.rollSeed` is non-zero → `SeededRollSource`) so both branches are reachable
deterministically.
**Done when:** the patterns are documented in the scene-file headers, `Debug_CandidateCheck` runs
both branches under a fixed seed, and the Phase 2 lint still passes.
---
### Phase 4 — Exploration-gated clue availability
**Hooks into:** `Common.yarn`; the Phase 1 registry's `unlockFlag`; the Phase 2 lint.
**Adds:** the minimal gating vocabulary. **This is new** — no errand, quest, Act, or location system
exists (§1.5), and this phase deliberately does not build one (§7.2).
The constraint, restated: **which candidate a player meets first must depend on where they choose to
go and what they choose to do — never on which voices they have invested in, and never on a fixed
script order.**
**4a. Two flag families**, declared in `Common.yarn` under a comment block explaining the rule:
- `$visited_<location_id>` (bool) — set once on first arrival at a location.
- `$errand_<errand_id>` (bool) — set when an errand reaches the state that matters.
Both are plain Yarn bools set by scene content. That is the whole mechanism for now. When a real
errand/quest system arrives, it can set the same flags, and nothing authored against them breaks.
**4b. Registry wiring.** Each clue's `unlockFlag` names exactly one of those variables. Add to the
Phase 1 `OnValidate` and to `CandidateRegistryTests`:
- Every non-empty `unlockFlag` matches `^\$(visited|errand)_[a-z][a-z0-9_]*$`. This is the
data-level enforcement: **a skill threshold cannot be expressed in this field at all.** It is not a
free-form condition string, precisely so nobody can put `skill_rank(...)` in it.
- Every `unlockFlag` used is declared in `Common.yarn` (grep the declarations file from the test).
- **Not** every clue needs a distinct flag, and clues from different candidates may share one. What
is forbidden is the pattern where all of one candidate's clues sit behind flags that are
themselves only reachable in a fixed order relative to another candidate's — see 4c.
**4c. The order-independence check.** Add to `CandidateRegistryTests`: collect the set of unlock
flags per candidate and assert that no candidate's flag set is a strict superset of another's. That
is a cheap structural proxy for "no candidate is reliably surfaced after another." It is a proxy, not
a proof — say so in the test's comment. Genuine order coverage is a playtest question, and belongs in
the checklist for Phase 5.
**4d. Playable verification.** For SC-101, gating is already handled structurally: four independently
reachable `<<detour>>` hooks from one desk node. Play it in three different orders (A→B→C, C→A→B, B
alone) and confirm the accumulator totals depend only on which hooks were visited, never on the order,
and that visiting one hook never removes or unlocks another.
**Done when:** the flag families are declared, every clue's `unlockFlag` validates, the superset
check passes, and the three-order playthrough gives order-independent totals.
---
### Phase 5 — SC-101 as the reference implementation
**Hooks into:** the existing `Assets/Dialogue/Scenes/SC101.yarn` skeleton (§1.4).
**Adds:** prose, the remaining clue types, and registry entries marked `Authored`.
SC-101 is *"The Card at the Desk."* Four voices — one per pair — introduce themselves. Three plant one
candidate's intuition each; the fourth, from the Body pair, stays neutral. None of the four lines is
proof. Each is in-character noticing.
1. **Write the four hook lines.** `CONFLUENCE:` for `made_asset` (parsing something too fluently,
before consciously trying to), `FACEWORK:` for `journalist` (a reaction that lands wrong and can't
yet be explained), `STANDING ORDER:` for `inheritor` (correcting an etiquette rule never
consciously learned), and a Body voice for the neutral beat. Which Body voice speaks — `HOUSE POUR`
or `THE LONG SHIFT` — is a writing decision; the skeleton currently has `HOUSE POUR`.
2. **Write `SC101_Desk`'s stage-setting and exit beats.**
3. **Restate the four-voice exception** in `SC101.yarn`'s header comment: this scene runs four voice
introductions against the handbook's three-voice cap, deliberately and for this scene only (§2.7,
§7.4). Writing it down in the file is what stops the next writer from either "fixing" it or
copying it.
4. **Author SC-101's non-intuition clues.** The scene currently carries only the three intuition
hooks. The object / testimony / document clues for each candidate do not exist yet anywhere. They
need not all live in SC-101 — most should not — but the registry must show the full symmetric
twelve as `Planned` from Phase 1 onward, and each becomes `Authored` as it is written.
5. **Flip `status` to `Authored`** in `candidates.json` for what was written, and confirm the Phase 2
lint's rule 2 passes.
**Done when:** SC-101 plays end to end with no `TODO` in the prose, the header states the four-voice
exception, all lint and registry tests pass, and the three-order playthrough from Phase 4d still gives
order-independent totals.
---
## 6. Guardrails as implementation constraints
Each of these is something a competent engineer would otherwise do by default. They are constraints,
not flavour text. Phase 1's `OnValidate`, Phase 2's lint, and Phase 4's registry rules are where most
of them are actually enforced.
1. **Never compute a winning candidate.** No `GetLeadingCandidate()`, no `$dominant_candidate`, no
sort, no max, no ranking, no "which is highest" comparison — not in C#, not in Yarn, not in a debug
UI, not in a log line. Comparing one lean against a *constant* threshold is fine and intended.
Comparing two leans against each other is what must not exist. (Phase 2 lint, rule 3.)
2. **Do not add a smart variable for lean.** `Common.yarn` already contains the idiom
(`<<declare $is_vip = $reputation > 10>>`) and it will be tempting to write
`<<declare $leans_journalist = $self_lean_journalist > $self_lean_made_asset>>`. That is exactly the
derived winner guardrail 1 forbids; being a smart variable does not make it less discoverable.
3. **No ground-truth field anywhere.** No `isTrue`, `isCanonical`, `actualIdentity`, or `weight` on any
candidate or clue record — not in JSON, not in a ScriptableObject, not in save data.
4. **Save data must not become the leak.** Persistence is a straight dump of Yarn variable storage.
As long as 1–3 hold, the save file contains three integers, some bools, and no answer. Any future
custom save format must preserve that property.
5. **Clue availability is exploration-gated, never skill-gated.** Enforced at the data level: an
`unlockFlag` is a `$visited_*`/`$errand_*` variable name and syntactically cannot express a skill
threshold (Phase 4b), and clue-bearing nodes may not sit behind `skill_rank(` guards (Phase 2 lint,
rule 4).
6. **No voice ever states a candidate as settled fact.** Every candidate-relevant line is that voice's
own biased reading, even when it happens to be right. This one is unenforceable by code and lives
in the scene-file header comments and in review.
7. **Extensibility is load-bearing.** Three candidates is the current count, not necessarily the final
one. Adding a fourth must mean a JSON entry, one `<<declare>>`, and content — never editing a
`switch`, an enum, or a hardcoded list of three.
8. **The two permanently unresolved lore threads are off-limits.** The project has two threads,
referred to internally as **"Thread 2"** and **"Thread 9"**, that are designed never to resolve.
The Candidate-System must stay **structurally separate** from them: do not resolve them, do not
reference their contents, and do not map any candidate clue onto them. A repo-wide grep finds **no
existing representation of either thread in the codebase** (§1.5), so there is currently nothing to
accidentally couple to — but if such a representation ever appears (a flag, an id, a data file),
candidate clue entries must not reference it, and the Phase 2 lint should grow a rule that fails if
they do. This plan deliberately does not state what the two threads contain, because it does not
need to know.
---
## 7. Open decisions — flag, do not resolve
Each needs the lead developer's sign-off. Where a placeholder was required to keep a phase buildable,
it is marked **provisional** and is safe to change later.
**7.1 — How lean is surfaced, and whether it decays or caps.**
Settled: lean accumulates from play, not from one choice. **Open:** whether the player's accumulated
lean is ever surfaced back to them during play, only reflected at the end, or stays invisible
entirely; and whether it decays over time, caps at a ceiling, or grows without bound. Also open:
whether `+1` per clue stays the only increment size, or larger commitments weigh more, and whether
any threshold means anything mechanically.
**Provisional default, to keep Phases 2–5 buildable:** `+1` per clue moment, no cap, no decay, never
surfaced in UI. Nothing in this plan forecloses the alternatives — lean is a plain number in Yarn
storage, so changing the arithmetic is a content edit, and "surface it" is additive UI work that has
been given no hooks in either direction.
**7.2 — The exploration-gating host system has no owner.**
The design requires clue availability to be errand- or location-gated. No errand, quest, Act, or
location system exists, and `docs/skill-system-refactor-plan.md` §6 explicitly declined to build one.
Phase 4 introduces two flag families as the minimal vocabulary, which is enough for SC-101 and for
one or two more scenes. **The moment candidate evidence spans several scenes, this becomes a hard
dependency on a system somebody has to design.** Flagging it as an open dependency — and specifically
warning against the tempting workaround of building a bespoke candidate-only gating mechanism, which
would be exactly the parallel system this feature is supposed to avoid.
**7.3 — Whether the Inheritor ties into the journalist-hook NPC.**
There is a possible narrative link between the Inheritor candidate and an existing NPC hook. **This is
a lore call, not a mechanical one, and this plan must not decide it.** Do not treat any link as canon
and do not wire anything to it. For reference: `docs/candidate-system-plan.md` §7.6 names a specific
NPC as the candidate for this link and also marks it undecided; no NPC by that name exists in the
codebase, which contains only `Bouncer.yarn`.
**7.4 — The four-voice opening against the three-voice cap.**
SC-101 introduces four voices; the writing handbook (not in this repo) caps introductions at three.
This is a **deliberate, flagged exception for the opening scene**, not an oversight to correct and not
a new precedent to propagate. Recorded here and in `SC101.yarn`'s header so that neither happens by
accident. If the lead developer wants the cap revisited generally, that is a separate handbook
decision.
**7.5 — Voice investment has no mechanism.**
"A player who has invested in a voice pair hears more advocacy for that pair's candidate" presumes
skill ranks change during play. They do not: `PlayerSkills.SetRank` has no caller, ranks come from
`startingRank`, and they are not persisted (§1.5). Nothing in this plan is blocked by that — the
rank-weighted `CommentarySystem` path (§4.3) will simply deliver a flat distribution until a
progression mechanism exists. Worth deciding whether progression is on the roadmap, since it changes
how much the ambient-advocacy channel is worth investing in.
**7.6 — When does the game save?**
Phase 0 proves the mechanism works. No call site exists. Somebody has to choose scene-exit vs.
autosave vs. manual. Separate ticket.
**7.7 — Total candidate touchpoints across the game.**
SC-101 carries the first three intuition hooks plus one neutral beat. The full count of candidate
moments across the game is unknown. It is the input to how much the Phase 1 registry earns its keep,
and to §7.1's question about increment sizes.
---
## 8. Traps
**8.1 — Yarn variables must be declared or they are compile errors.**
Every variable in this project is declared in `Common.yarn`. A `<<set $found_journalist_object = true>>`
without the matching `<<declare>>` fails to compile rather than silently creating a variable. This is
the feature, not the bug — it is what makes plain `<<set>>` safer than a string-keyed C# command
(§4.1).
**8.2 — `CommentarySystem` is the wrong vehicle for clue-bearing lines.**
It picks **one** voice per request, weighted by rank, gated by a per-skill budget
(`3600 / targetFiringsPerHour` seconds), a global `minSecondsBetween` floor, a `minRankToSpeak`
threshold, and a recency penalty — and it refuses to fire while dialogue is running. Clue lines must be
deterministic and each independently discoverable. Route them through scene nodes via `<<detour>>`
(§4.3, Phase 3a).
**8.3 — Node lookups are case-sensitive; skill lookups are not.**
`SkillDatabase` and `PlayerSkills` use `OrdinalIgnoreCase`, but `CommentarySystem.NodeExists` uses
`Ordinal`. So `<<check PLACEMENT routine>>` works while `Commentary_Entrance_Queue_PLACEMENT` silently
never matches. Keep every authored id lowercase.
**8.4 — Ids become node-title and variable-name fragments.**
This is why candidate and clue ids follow `^[a-z][a-z0-9_]*$`. An id with a space or a hyphen produces
a node title or variable name that either fails to compile or silently never matches.
**8.5 — Generated assets are overwritten on script reload.**
`SkillSystemSetup.AutoSetupAfterReload` regenerates every skill definition and the database once per
editor session from `skill_bible.json`. Phase 1's generator follows the same pattern, so the same rule
applies to candidates: **the JSON is the source of truth; `.asset` files are build output.** Hand-edits
to `.asset` files are lost.
**8.6 — Do not delete or recreate `SkillDatabase.asset`.**
`Assets/Scenes/DialogueTest.unity` wires `SkillRuntime.database` and `CommentarySystem.database` to it
by GUID. Nothing in this plan should touch it, but it is the standing landmine in this project. Phase 1
creates a *separate* `CandidateDatabase.asset`; wiring it into the scene (if it ever needs to be) is
additive and must not disturb the skill database's GUID.
**8.7 — Declared Yarn numbers are floats.**
`<<declare $x = 0 as number>>` stores a float and serialises into `floatKeys`/`floatValues`. Integer
comparisons behave correctly; do not be surprised by `0.0` in the save JSON.
**8.8 — `float` is a keyword.**
Restated from `docs/skill-system-refactor-plan.md` §7.5 because it will come up: the id for THE FLOAT
is `the_float`.
---
## 9. Definition of done
Per phase. Phases can land in separate commits; each should leave the project green.
**Phase 0**
- [ ] `Debug_CandidateLean` prints `made_asset 2 / journalist 1 / inheritor 0` and takes the threshold branch.
- [ ] Lean values and `$seen_sc_101` survive a save → exit Play → load round trip; verified in the JSON under `Application.persistentDataPath`.
- [ ] The temporary verification script is deleted.
**Phase 1**
- [ ] `Assets/Candidates/candidates.json` holds three candidates × four clue entries, one of each type.
- [ ] `CandidateEvidenceType`, `CandidateDefinition`, `CandidateDatabase`, and `CandidateSystemSetup` exist, following the skill-system idiom.
- [ ] `CandidateDatabase.OnValidate()` audits ids, per-candidate type symmetry, unique non-Body intuition axes, and intuition `skillId` → `SkillDatabase` resolution.
- [ ] `CandidateRegistryTests` passes, and fails legibly when symmetry, axis uniqueness, or the Body exclusion is deliberately broken.
- [ ] No code enumerates the three candidates by name.
**Phase 2**
- [ ] `Common.yarn` declares one `$found_<clue_id>` bool per registry clue, under an explanatory comment block.
- [ ] `CandidateYarnLintTests` implements all four rules and each can be shown to fail on a deliberate violation.
**Phase 3**
- [ ] Passive and active-check patterns are documented in the scene-file header comments.
- [ ] `Debug_CandidateCheck` exercises both branches deterministically under a fixed roll seed.
- [ ] The failure branch writes lean (§4.4) and carries no failure signal in its prose.
- [ ] No `Commentary_*` node contains a `$found_*` or `$self_lean_*` write.
- [ ] Zero new Yarn commands; zero changes to `SkillRuntime`, `SkillCheckSystem`, `YarnSkillCommands`, or the skill roster.
**Phase 4**
- [ ] `$visited_*` / `$errand_*` families are declared in `Common.yarn` with the gating rule stated.
- [ ] Every clue `unlockFlag` matches `^\$(visited|errand)_[a-z][a-z0-9_]*$` and is declared.
- [ ] The no-superset order-independence check passes.
- [ ] SC-101 played in three different hook orders gives order-independent totals.
**Phase 5**
- [ ] SC-101 has no `TODO` prose; four voices, three planting intuitions, the Body voice neutral.
- [ ] `SC101.yarn`'s header states the four-voice exception, the one-of-each-evidence-type rule, and the discovery-order-independence rule.
- [ ] Authored clues are flipped to `Authored` in `candidates.json` and pass lint rule 2.
**Every phase**
- [ ] The yarnproject reimports with no compiler diagnostics.
- [ ] `grep -rniE "leading_?candidate|dominant_?candidate|winning_?candidate|actual_?identity|is_?canonical" NightclubArcadia/Assets` returns nothing.
- [ ] EditMode tests pass:
```bash
/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity -batchmode -quit -nographics -projectPath /Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia -runTests -testPlatform EditMode -testFilter "NightclubArcadia.Skills.Tests" -testResults /tmp/candidate-tests.xml -logFile -
```