865 lines
52 KiB
Markdown
865 lines
52 KiB
Markdown
# 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 -
|
||
```
|