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

52 KiB
Raw Permalink Blame History

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, 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:

<<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:

{
  "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:

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.

<<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:
/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 -