updated Candidate System with Voice System
This commit is contained in:
@@ -0,0 +1,864 @@
|
||||
# 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 -
|
||||
```
|
||||
Reference in New Issue
Block a user