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

416 lines
34 KiB
Markdown
Raw Permalink 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 — identity-lean tracking
**Audience:** the developer implementing this, assumed to have no prior context on this project or its design documents. Everything you need is in this file.
**Repo root:** `/Users/lennart/Dev/nightlcub-arcadia`
**Unity project:** `/Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia`
**Unity editor binary:** `/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity`
**Status:** ⚠️ **Superseded.** Steps 1, 2 and 4 landed in commit `22b8c7a`; Step 3 (persistence
verification) has no committed evidence and Step 5 was deferred. The design in §1 and the traps in §8
remain accurate and worth reading. For the remaining and wider work, the authoritative document is
now [`docs/candidate-system-implementation-plan.md`](./candidate-system-implementation-plan.md).
**Related:** [`docs/skill-system-refactor-plan.md`](./skill-system-refactor-plan.md) — the completed plan for the skill-voice system this one builds on.
> The design below is **decided intent, not a proposal**. Do not redesign it. Where it leaves something open, §7 says so explicitly — surface those as questions rather than inventing answers.
---
## 0. Glossary
| Term | Meaning |
|---|---|
| **PC identity fugue** | The player character starts the game with no memory of who they are. This is not a framing device resolved by a cutscene — the PC's identity is the central mystery of the opening act, and the player *builds* it rather than uncovering it. |
| **Candidate** | One of three permanently-live readings of who the PC might be. Not mutually exclusive; a player's belief can blend more than one. **There is no ground-truth identity value anywhere in the data.** |
| **Lean** | A per-candidate integer accumulator that grows as the player investigates, believes, and acts. Never chosen by the player, never resolved, never reduced to a winner. |
| **Evidence types** | The four kinds of support a candidate can have: **object**, **testimony** (NPC dialogue), **document**, and **intuition** (a skill voice arguing for a reading). Each candidate gets exactly one of each. |
| **Skill voice** | One of eleven in-head narrator characters (Disco Elysium-style skill-as-character narration). Implemented in this repo as `SkillDefinition` ScriptableObjects. A voice **advocates**; it does not report verified fact. |
| **Skill-voice pair** | The design's word for what this repo calls `SkillAxis`: Reason, Body, Social, Self (two voices each) plus Specialist (three). See `Assets/Scripts/Skills/Data/SkillAxis.cs`. |
| **SC-101** | The opening scene, "The Card at the Desk". Carries the first four candidate hooks. |
| **Yarn Spinner** | The dialogue scripting system this project uses (v3.2.8, vendored). `.yarn` files under `Assets/Dialogue/`. |
---
## 1. Design context
This section is copied forward from the design document *"Candidate System — Design Explanation for Lead Dev"*, which is **not in this repo**. Treat it as the complete statement of intent.
### 1.1 The problem it solves
The PC wakes with no memory of who they are. The system must genuinely support **more than one reading being true at once, indefinitely**, without ever collapsing to a single canonical state — unless a future, explicit design decision changes that.
### 1.2 The three candidates
Not mutually exclusive. None of these is "the twist"; they are three permanently live readings of the same character.
| Id | Name | Reading |
|---|---|---|
| `made_asset` | **Candidate A — The Made Asset** | The PC was trained / conditioned / activated by one of the factions orbiting the "Vesalian Concordat" for a role in Basel. |
| `journalist` | **Candidate B — The Journalist** | The PC's journalist framing is a cover; they're really here for a *person*, not a story. |
| `inheritor` | **Candidate C — The Inheritor** | The PC didn't choose to be here. A parent or elder relative had standing (or a debt) with one of the societies, and it passed to the PC without their consent. |
### 1.3 Evidence structure
Each candidate is supported by **exactly four evidence pieces, one of each type**: object, testimony, document, intuition.
This 1-of-each-type pattern **must be preserved for all future candidate evidence**, so that trust in a candidate is never an artifact of evidence-type imbalance. It is an invariant to enforce, not a coincidence of the current content.
### 1.4 Skill voices advocate, they don't report
A skill voice reacting to candidate-relevant material is **arguing for a reading**, not stating verified fact. This is already a general property of the skill-voice system in this project (see `Assets/Skills/skill_bible.json` — every voice has an explicit "what it's wrong about" and a FAILURE VOICE that is indistinguishable in tone from its SUCCESS VOICE). Nothing new needs building for it.
Each candidate's intuition clue in the opening scene comes from a **different voice-pair**, so that trust in "the smart voice" or "the gut voice" doesn't systematically favour one candidate:
| Candidate | Voice-pair (`SkillAxis`) | Voice | Roster id |
|---|---|---|---|
| A — Made Asset | Reason | CONFLUENCE | `confluence` |
| B — Journalist | Social | FACEWORK | `facework` |
| C — Inheritor | Self | STANDING ORDER | `standing_order` |
A **fourth voice may appear as pure neutral texture with no lean at all**, so that voice interactions don't universally read as votes. The Body pair (`house_pour` / `long_shift`) is the natural home for it — it is the one axis with no candidate assigned. Choosing which of the two is a writing decision.
### 1.5 Discovery order is not fixed
Which candidate a player meets first must depend on **where they choose to go and what they choose to investigate** — errand- or location-gated, never skill-gated and never a fixed script order.
This mirrors an existing decision recorded in `Assets/Dialogue/Common.yarn`'s header and `docs/skill-system-refactor-plan.md` §6: *documentary routes are gated on Act and errand state, never on a voice.* **See §7.1 — the errand/location gating system does not exist yet.**
### 1.6 Lean accumulates, it isn't chosen
The player is never asked to pick an identity. Moments throughout the game quietly add weight toward one or more candidates. This is a running, per-candidate accumulator — not a single decision point.
Endings and epilogue material, if ever built, read off accumulated lean rather than a "declare your identity" choice. This also means **there must be no single flag anywhere a player could datamine to learn "the real answer," because there isn't one.**
### 1.7 The SC-101 flag set
From the opening scene SC-101 ("The Card at the Desk"), four Yarn-facing flags are the intended pattern:
- `seen_sc_101` — scene witnessed
- `self_lean_madeAsset +1` — increments on the Candidate A hook (fires with skill voice CONFLUENCE)
- `self_lean_journalist +1` — increments on the Candidate B hook (fires with skill voice FACEWORK)
- `self_lean_inheritor +1` — increments on the Candidate C hook (fires with skill voice STANDING ORDER)
The general shape `self_lean_<candidateId>`, incrementing by integer amounts, is the intended design: a small, growing, per-candidate numeric accumulator, readable by Yarn conditions, with **no single derived "winner" value computed anywhere**.
The design document explicitly marks the exact spellings as *proposed naming, not a locked registry*. §3 of this plan resolves them against the repo's existing conventions.
### 1.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 boss-fight-style 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 to pick one clean outcome, **that is a new, separate design decision.** The underlying lean-tracking system must not silently assume it.
---
## 2. What already exists in the repo
Survey findings. Read this before writing anything — most of what this feature needs is already here.
### 2.1 Yarn Spinner wiring
- **Yarn Spinner 3.2.8**, vendored as an embedded package at `NightclubArcadia/Packages/dev.yarnspinner.unity/`. It is *not* listed in `Packages/manifest.json`.
- `Assets/Dialogue/NightclubArcadia.yarnproject` compiles `**/*.yarn` under `Assets/Dialogue/`. **New `.yarn` files anywhere under that folder are picked up automatically** — no importer or project-file change is needed to add a scene.
- `Assets/Scenes/DialogueTest.unity` is the working scene. It contains a `Dialogue System` prefab instance (`DialogueRunner`, Line Presenter, Options Presenter, Line Advancer, Canvas, and an **`InMemoryVariableStorage`**), with `autoStart: 1` and `startNode: "Start"`.
- `Assets/Scenes/SampleScene.unity` is the URP template leftover and is the *only* scene in `EditorBuildSettings`. `DialogueTest` is opened manually.
Existing `.yarn` files:
| File | Contents |
|---|---|
| `Assets/Dialogue/Common.yarn` | The `Declarations` node. Never played. Every variable in the project is declared here. |
| `Assets/Dialogue/Scenes/Entrance.yarn` | `Start`, `Entrance_Inside`, `Entrance_Outside`. Scene files stay thin: set the stage, `<<detour>>` into character nodes, branch on the outcome. |
| `Assets/Dialogue/Characters/Bouncer.yarn` | Four nodes all titled `Bouncer_Talk` — a Yarn 3 node group with `when:` saliency variations. |
| `Assets/Dialogue/Scenes/Debug.yarn` | `Debug_SkillCheck`. Not part of the story; exists to exercise mechanics in isolation. **This is the precedent to follow for verifying each step below.** |
| `Assets/Dialogue/Commentary.yarn` | Passive skill barks. Node names follow `Commentary_{contextId}_{skillId}`. |
Every one of these files opens with a `//` header comment explaining the file's role and its writing conventions. **Match that.**
### 2.2 Variable storage and persistence
There is **no custom variable storage subclass, no save system, and no game-state singleton beyond `SkillRuntime`**. There is also nothing that needs one:
- `VariableStorageBehaviour` (`Packages/dev.yarnspinner.unity/Runtime/Storage/VariableStorageBehaviour.cs`) exposes `SetValue(string, float/bool/string)`, `TryGetValue<T>`, `GetAllVariables()`, `SetAllVariables(...)`, `Contains(...)`, and `AddChangeListener(...)`.
- `DialogueRunner.SaveStateToPersistentStorage(string fileName)` and `LoadStateFromPersistentStorage(string fileName)` (`Runtime/DialogueRunner/DialogueRunner.Utility.cs`) serialise the entire variable storage to JSON under `Application.persistentDataPath`. **This is the persistence mechanism. Do not build another one.**
- The yarnproject importer's generated-variables feature (`generateVariablesSourceFile`) is **switched off**. Leave it off; turning it on is a project-wide change out of scope here.
There is one existing precedent for engine-written Yarn variables: the `$check_*` family in `Common.yarn`, written by `YarnSkillCommands.WriteResult(...)` and marked *"Never `<<set>>` these by hand."* Lean variables are the opposite case — they are **writer-set, never engine-set** — but the declaration discipline is identical.
### 2.3 The skill-voice system
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. |
| `SkillDefinition` | `Scripts/Skills/Data/SkillDefinition.cs` | ScriptableObject per voice. **Generated** from JSON; hand-edits are overwritten on script reload. |
| `SkillDatabase` | `Scripts/Skills/Data/SkillDatabase.cs` | ScriptableObject holding the roster. Its `OnValidate()` runs a full audit (id regex, duplicates, pair symmetry, axis counts). **This is the pattern to copy if a candidate registry is ever built — see Step 5.** |
| `skill_bible.json` | `Assets/Skills/skill_bible.json` | The roster source of truth. 11 voices, each with id, displayName, axis, domain, full prose notes, channels, accent colour. |
| `SkillSystemSetup` | `Assets/Editor/SkillSystemSetup.cs` | `[DidReloadScripts]` generator: JSON → `.asset` files → database → scene wiring. |
| `CommentarySystem` | `Scripts/Skills/Commentary/CommentarySystem.cs` | Fires **one** passive bark per request from node `Commentary_{contextId}_{skillId}`, weighted by rank, gated by per-skill firing budget and recency. **See §8.2 — do not route candidate intuition clues through this.** |
| `YarnSkillCommands` | `Scripts/Skills/Yarn/YarnSkillCommands.cs` | The entire Yarn↔C# surface: `<<check>>`, `<<skill_mod>>`, `<<clear_skill_mods>>`, `skill_rank()`. |
The three candidate voices already exist in the roster with these exact ids: **`confluence`** (axis Reason), **`facework`** (axis Social), **`standing_order`** (axis Self). Nothing needs to be added to the roster for this feature.
### 2.4 Naming conventions already in force
Defer to these. They are enforced by `SkillDatabase.OnValidate()` and by `Assets/Tests/EditMode/SkillRosterTests.cs`.
- **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): `^[a-z][a-z0-9_]*$`. Enforced because ids become Yarn node-title fragments, and node titles must be valid identifiers.
- **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]`.
- **There is no `seen_*` convention yet.** Once-only behaviour is currently handled by Yarn's `when: once` saliency and by `CommentarySystem._firedContexts`, not by flags. §3 establishes `$seen_*` deliberately.
- **Docs:** lowercase-kebab-case `.md` in `docs/`.
### 2.5 What does not exist
Say so plainly, because these are the starting conditions:
- No errand, quest, Act, location, or scene-progression system of any kind. `docs/skill-system-refactor-plan.md` §6 lists "Act / errand state, or any gating for documentary routes" as an explicit non-goal of that pass.
- No clue, evidence, or deduction objects. Same doc, same section: *"That system does not exist in this repo and is out of scope. Do not invent a schema for it."*
- No save/load call sites (the Yarn API in §2.2 is present but never called).
- No `CLAUDE.md`, no project `README.md`.
- No SC-101 scene file, and no candidate content of any kind.
Note: `Assets/Dialogue/Commentary.yarn` currently has an uncommitted working-tree edit (a `TODO: ambient fallback` block). It is unrelated to this feature. **Leave it alone.**
---
## 3. Naming decisions
The design document (§1.7) marks its flag spellings as proposed. Resolved against §2.4 and confirmed by a human:
| Concept | Locked name | Rationale |
|---|---|---|
| Candidate ids | `made_asset`, `journalist`, `inheritor` | snake_case, satisfies the `^[a-z][a-z0-9_]*$` id regex, so a candidate id can safely become a Yarn node-title fragment later — same reasoning as skill ids (§8.4). |
| Lean accumulators | `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` | The design's `self_lean_madeAsset` camelCase suffix is the only camelCase identifier that would exist in the project. Converted to snake_case; the `self_lean_` prefix is kept verbatim. |
| Scene-witnessed flag | `$seen_sc_101` | Establishes a new `$seen_<scene_id>` convention. Chosen over Yarn's `when: once` / node-visit tracking because it is readable from any node and from C#, and keys on a stable scene id rather than a node title. |
| Scene id / node prefix | `SC101` | e.g. `SC101_Desk`, `SC101_Card_Object`. Matches the existing PascalCase-with-underscores context-id style (`Entrance_Queue`). |
**These names are extensible by construction.** Adding a fourth candidate means adding one `<<declare $self_lean_<new_id> = 0 as number>>` line and authoring content. It requires no code change, because there is no code (§4, Step 1).
If a human wants to revisit the `self_lean_` prefix, now is the moment — after Step 1 lands it becomes a rename across content.
---
## 4. Implementation steps
Each step is independently verifiable in-engine before the next begins. Steps 1–3 involve **zero C#**.
### Step 1 — Declare the accumulators
**Adds:** three lean variables and one scene flag.
**Builds on:** `Assets/Dialogue/Common.yarn`, the `Declarations` node.
**Does NOT touch:** any C#, any `.asset`, `SkillRuntime`, `CommentarySystem`, the scene, the yarnproject.
Append to `Common.yarn` with a comment block explaining what lean is and the guardrail that no winner is ever computed:
```yarn
// Candidate lean. Three permanently-live readings of who the PC is; they are
// not mutually exclusive and never resolve. Writers <<set>> these; no code
// reads them. There is deliberately NO derived "winning candidate" variable —
// see docs/candidate-system-plan.md §5.
<<declare $self_lean_made_asset = 0 as number>>
<<declare $self_lean_journalist = 0 as number>>
<<declare $self_lean_inheritor = 0 as number>>
// Scene witnessed. New convention: $seen_<scene_id>.
<<declare $seen_sc_101 = false as bool>>
```
**Why plain `<<set>>` and no C# command:** Yarn's own variable storage holds these directly as numbers. A `<<add_lean made_asset 1>>` command would take the candidate id as a *string*, which throws away the compile-time typo checking that declaring everything in `Common.yarn` buys. Adding an abstraction here would be strictly worse than the built-in. See §5 for when to revisit.
**Verify:** the yarnproject reimports with no compiler diagnostics.
### Step 2 — Prove the accumulator round-trips, in isolation
**Adds:** a debug node.
**Builds on:** `Assets/Dialogue/Scenes/Debug.yarn` — the existing precedent for exercising a mechanic outside the story.
**Does NOT touch:** story content, C#, the scene hierarchy.
Add `Debug_CandidateLean` to `Debug.yarn`: increment each accumulator, print the values with inline interpolation, and branch on a threshold. Something equivalent to:
```yarn
title: Debug_CandidateLean
---
<<set $self_lean_made_asset += 1>>
<<set $self_lean_made_asset += 1>>
<<set $self_lean_journalist += 1>>
Narrator: made_asset {$self_lean_made_asset} / journalist {$self_lean_journalist} / inheritor {$self_lean_inheritor}
<<if $self_lean_made_asset >= 2>>
Narrator: threshold branch reached.
<<endif>>
===
```
**Verify in-engine:** 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.
This is the step the design brief calls out as the gate: prove increment-and-branch works before layering anything on top.
### Step 3 — Prove persistence, without building a save system
**Adds:** a temporary verification only. Ideally **no committed file at all**.
**Builds on:** `DialogueRunner.SaveStateToPersistentStorage` / `LoadStateFromPersistentStorage` (§2.2).
**Does NOT touch:** variable storage — the stock `InMemoryVariableStorage` on the prefab stays exactly as it is.
Lean must persist across play sessions "the same way other game state does". In this project *all* game state is Yarn variables, so this is already true the moment a save call site exists. Confirm it rather than assume it: from a temporary editor script or a `[ContextMenu]` on a throwaway component, run Step 2's node, call `SaveStateToPersistentStorage("candidate-lean-smoketest.json")`, exit Play, re-enter, call `LoadStateFromPersistentStorage(...)`, and confirm the values come back.
**Verify:** inspect the JSON at `Application.persistentDataPath` and confirm `$self_lean_*` appear in `floatKeys`/`floatValues` and `$seen_sc_101` in `boolKeys`/`boolValues`.
**Also confirm while you're here:** whether Yarn 3's `when: once` saliency state is stored in the same variable storage (it is generated as an internal visited-variable per node). If it is, saliency survives save/load for free; if not, that is a project-wide finding worth recording — but it is **not** this feature's problem to fix, because §3 chose an explicit `$seen_sc_101` bool precisely to avoid depending on it.
Then delete the temporary script. Choosing when the game actually saves is a separate ticket (§7.5).
### Step 4 — Author the SC-101 hook structure
**Adds:** `Assets/Dialogue/Scenes/SC101.yarn` — node structure, flag writes, and voice attribution. **Prose is a writer's job and is left as TODO placeholders.**
**Builds on:** the thin-scene pattern from `Entrance.yarn`; the `<<detour>>` idiom; the speaker-name convention from `Commentary.yarn` (uppercase displayName as speaker: `CONFLUENCE:`, `FACEWORK:`, `STANDING ORDER:`).
**Does NOT touch:** `CommentarySystem` (see §8.2), the skill roster, `Entrance.yarn`.
Structure, following the design in §1.3–§1.7:
- One thin entry node `SC101_Desk` that sets `<<set $seen_sc_101 = true>>` and offers routes into the hooks.
- **Four separate hook nodes**, one per candidate plus one neutral. Each hook node carries exactly one `<<set $self_lean_<id> += 1>>` and speaks in exactly one voice:
| Node | Voice | Lean write |
|---|---|---|
| `SC101_Hook_MadeAsset` | `CONFLUENCE:` | `<<set $self_lean_made_asset += 1>>` |
| `SC101_Hook_Journalist` | `FACEWORK:` | `<<set $self_lean_journalist += 1>>` |
| `SC101_Hook_Inheritor` | `STANDING ORDER:` | `<<set $self_lean_inheritor += 1>>` |
| `SC101_Hook_Neutral` | Body-pair voice (writer picks `HOUSE POUR` or `THE LONG SHIFT`) | **none — deliberately** |
Each hook node must be reachable **independently of the others and in any order**. Concretely:
- No hook may be `<<if>>`-guarded on another hook having fired, on any `$self_lean_*` value, or on `skill_rank(...)`.
- No hook may be reachable only as a fall-through from another hook.
- Use `<<detour>>` from `SC101_Desk` so each hook returns to the desk and the others stay available.
Put the header comment in the file stating this rule, in the style of the other `.yarn` headers — this is the file where the discovery-order-independence guardrail (§1.5) is actually enforced.
**Verify in-engine:** point `startNode` at `SC101_Desk`, play through in three different orders (A→B→C, C→A→B, B only), and confirm each order produces the same accumulator totals for the hooks visited, and that visiting one hook never removes or unlocks another.
### Step 5 — *(Deferred until there is content to validate)* The evidence registry
**Do not build this yet.** It is written down here so the shape is agreed before it is needed.
The 1-of-each-evidence-type invariant (§1.3) is an authoring rule that will rot silently as content grows. When the project has enough candidate evidence authored to make that a real risk — realistically, when the second candidate's full four pieces exist beyond SC-101 — mirror the pattern the skill roster already uses, rather than inventing a new one:
- `Assets/Candidates/candidates.json` as the writer-facing source of truth (mirroring `Assets/Skills/skill_bible.json`), holding candidate id, display name, and an evidence list of `{ id, type, sceneId, notes }` where `type` ∈ `{ Object, Testimony, Document, Intuition }` and intuition entries carry the `skillId` of the advocating voice.
- A generator following `Assets/Editor/SkillSystemSetup.cs` (JSON → `.asset` → database).
- A `CandidateDatabase.OnValidate()` audit following `SkillDatabase.OnValidate()`: id regex, no duplicates, **exactly one evidence piece of each of the four types per candidate**, intuition `skillId` resolves against `SkillDatabase`, and — for as long as the design in §1.4 holds — no two candidates' intuition clues share a `SkillAxis`.
- An EditMode test following `Assets/Tests/EditMode/SkillRosterTests.cs`, which loads the real asset via `AssetDatabase` and asserts the invariants.
**This registry must remain descriptive.** It records what evidence exists and its type; it must not hold a weight, a truth value, or anything from which a winner could be derived. Adding a fourth candidate must mean adding a JSON entry and authoring content — no code change.
**Until then:** the invariant is a writing-discipline rule stated in `SC101.yarn`'s header comment. That is honest and sufficient for one scene.
---
## 5. Guardrails — things to actively avoid
These are implementation constraints, not flavour text. Each one is something a reasonable engineer would otherwise do.
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 value against a *constant* threshold (`<<if $self_lean_journalist >= 3>>`) is fine and intended. Comparing two lean values *against each other* is the thing that must not exist.
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 value guardrail 1 forbids, and 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 evidence record, in JSON, ScriptableObject, or save data. If the registry in Step 5 is ever built, a reviewer should be able to read the whole JSON and be unable to tell which candidate is "right" — because none is.
4. **Save data must not become the leak.** Persistence is a straight dump of Yarn variable storage (§2.2). As long as guardrails 1–3 hold, the save file contains three integers and no answer. Any future custom save format must preserve that property.
5. **Discovery order lives in the wiring, not the prose.** Enforced in Step 4's constraints. Restated because it is easy to satisfy in writing and violate in structure.
6. **Do not route candidate intuition clues through `CommentarySystem`.** See §8.2.
7. **Extensibility is load-bearing.** Three candidates is the current count, not necessarily the final one. Every mechanism here must accept a fourth by adding a declaration and content, never by editing a `switch`, an enum, or a hardcoded list of three.
---
## 6. Explicit non-goals
Do not build these as a side effect of this feature:
- A clue / deduction / evidence-object system (`CL-0xx`, `DED-0xx`, criticality, routes). Out of scope per `docs/skill-system-refactor-plan.md` §6, and still out of scope here.
- An errand, quest, Act, or location-gating system. This feature *depends* on one (§1.5) but must not invent one — see §7.1.
- A save/load call-site policy, autosave, or save slots. Step 3 verifies the mechanism; deciding when the game saves is a separate ticket.
- Any UI that surfaces lean to the player. Whether lean is ever shown during play is undecided — §7.3.
- A custom `VariableStorageBehaviour` subclass. The stock `InMemoryVariableStorage` is sufficient and the yarnproject's generated-variables feature stays off.
- Any narrative content. Node structure and flag placement are engineering; the lines are a writing pass.
---
## 7. Open questions and deferred decisions
These are genuinely undecided. Do not invent answers — raise them.
**7.1 — Discovery-order gating has no host system. (Blocking for anything past SC-101.)**
The design requires candidate evidence to be errand- or location-gated. No errand, quest, Act, or location system exists in this repo, and the previous plan explicitly declined to build one. Within SC-101 this is handled structurally (Step 4: four independently reachable `<<detour>>` hooks). **The moment candidate evidence spans more than one scene, this becomes a hard dependency on a system somebody has to design.** Flagging it as an open dependency; do not build a bespoke candidate-only gating mechanism to work around it — that would be exactly the parallel system the design warns against.
**7.2 — How lean is measured, and whether thresholds mean anything.** Does `+1` per hook stay the only increment size? Do larger commitments weigh more? Are there thresholds at which anything changes, and does lean decay over time? Undecided. Step 1 supports any of these without change (they are integers in a Yarn variable), so nothing is being foreclosed.
**7.3 — Is lean ever surfaced to the player during play?** Or only reflected at the end? Undecided. Affects whether any UI work is ever needed. Nothing here assumes either.
**7.4 — Total number of candidate touchpoints across the game.** SC-101 has the first four hooks (one lean signal per candidate, plus one neutral voice). The full count is unknown. This is the input to the §4 Step 5 question of when a registry earns its keep.
**7.5 — When does the game save?** Step 3 proves the mechanism works; no call site exists. Somebody has to decide scene-exit vs. autosave vs. manual.
**7.6 — Candidate C and "Ferran Doss."** The design document flags a possible narrative link between Candidate C (The Inheritor) and an existing NPC hook named Ferran Doss as **undecided**. Do not treat it as canon and do not wire anything to it.
**7.7 — The neutral fourth voice.** §1.4 says a neutral, lean-free voice may exist so that voice interactions don't universally read as votes. The Body pair (`house_pour` / `long_shift`) is the only axis without a candidate, making it the natural home. Which of the two speaks in SC-101 is a writing decision, not an engineering one.
**7.8 — The `self_lean_` prefix.** Kept verbatim from the design document. If it should instead be `lean_`, `identity_lean_`, or namespaced differently, decide before Step 1 lands — afterwards it is a rename across content.
---
## 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 $self_lean_inheritor += 1>>` without the matching `<<declare>>` will not silently create a variable — it will fail. This is the feature, not the bug: it is what makes plain `<<set>>` safer than a string-keyed C# command.
**8.2 — `CommentarySystem` is the wrong vehicle for intuition clues.**
It looks like an exact fit — it fires a named skill voice from a node — but it is a *probabilistic* channel: it picks **one** voice per request, weighted by rank, gated by a per-skill firing budget (`3600 / targetFiringsPerHour` seconds), a global `minSecondsBetween` floor, a `minRankToSpeak` threshold, and a recency penalty. It also refuses to fire while dialogue is running. Candidate intuition clues must be **deterministic and each independently discoverable**. Route them through the scene's own nodes via `<<detour>>` (Step 4). Leave ambient commentary in SC-101, if any, as a separate and additive concern.
**8.3 — Node lookups are case-sensitive; skill lookups are not.**
`SkillDatabase` and `PlayerSkills` use `OrdinalIgnoreCase`, but `CommentarySystem.NodeExists` uses `Ordinal`. Keep every authored id lowercase. Inherited verbatim from `docs/skill-system-refactor-plan.md` §7.3.
**8.4 — Ids become node-title fragments.**
This is why candidate ids follow the `^[a-z][a-z0-9_]*$` regex even though nothing enforces it for candidates *yet*. An id with a space or a hyphen produces a node title 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`. If Step 5 is ever built on the same pattern, its JSON is the source of truth and its `.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.
**8.7 — Declared Yarn numbers are floats.**
`<<declare $x = 0 as number>>` stores a float and serialises into `floatKeys`/`floatValues`. Integer comparisons (`>= 1`) behave correctly; do not be surprised by `0.0` in the save JSON.
---
## 9. Definition of done
For the scope covered by Steps 1–4. Step 5 is deliberately deferred (§4).
- [ ] `Common.yarn` declares `$self_lean_made_asset`, `$self_lean_journalist`, `$self_lean_inheritor` (number) and `$seen_sc_101` (bool), with a comment block stating the no-winner guardrail.
- [ ] `Debug.yarn` has a `Debug_CandidateLean` node; playing it prints the expected totals and takes the threshold branch.
- [ ] Lean values and `$seen_sc_101` survive a `SaveStateToPersistentStorage` → exit Play → `LoadStateFromPersistentStorage` round trip, and the temporary verification script is deleted.
- [ ] `Assets/Dialogue/Scenes/SC101.yarn` exists with `SC101_Desk` plus four hook nodes; each candidate hook carries exactly one `<<set $self_lean_* += 1>>`; the neutral hook carries none.
- [ ] Each hook is reachable in any order, with no `<<if>>` guard on another hook, on any `$self_lean_*`, or on `skill_rank(...)`. Verified by playing three different orders.
- [ ] `SC101.yarn` has a header comment in the house style stating the one-of-each-evidence-type rule and the discovery-order-independence rule.
- [ ] Zero new C# files. Zero changes to `SkillRuntime`, `CommentarySystem`, `YarnSkillCommands`, the skill roster, or the `Dialogue System` prefab.
- [ ] `grep -rniE "leading_?candidate|dominant_?candidate|winning_?candidate|actual_?identity|is_?canonical" NightclubArcadia/Assets` returns nothing (§5.1–5.3).
- [ ] The yarnproject reimports with no compiler diagnostics.
- [ ] Existing EditMode tests still 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 -
```
- [ ] The uncommitted `Commentary.yarn` working-tree edit is untouched.