# Skill System Refactor Plan — placeholder roster → Skill Bible roster **Audience:** an implementing agent with no prior context on this repo. **Repo root:** `/Users/lennart/Dev/nightlcub-arcadia` **Unity project:** `NightclubArcadia/` — Unity `6000.5.8f1`, Yarn Spinner 3 (`dev.yarnspinner.unity`), TextMeshPro. **Unity editor binary:** `/Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity` Read this whole document before touching a file. The section that matters most is [§2 The reconciliation principle](#2-the-reconciliation-principle) — it tells you what *not* to build. --- ## 1. What exists today A working, tested skill system built to a *placeholder* roster. The architecture is sound; only the content is wrong. ### 1.1 File inventory ``` NightclubArcadia/Assets/Scripts/Skills/ (asmdef: NightclubArcadia.Skills) Data/SkillDefinition.cs ScriptableObject: id, displayName, domain, description, startingRank, accentColor. Has #if UNITY_EDITOR EditorSet(...). Data/SkillDatabase.cs ScriptableObject: List + case-insensitive TryGet lookup + OnValidate duplicate/empty-id checks. Data/SkillDomain.cs enum { Reason, Instinct, Presence, Body } Data/DcBand.cs enum DcBand { Trivial=5, Easy=8, Standard=11, Hard=14, Formidable=17, Legendary=20 } + DcBands.TryParse(string,out int) which accepts band names (case-insensitive) OR raw integers. Data/SkillCheckDegree.cs enum { CriticalFailure, Failure, Success, CriticalSuccess } Data/SkillCheckResult.cs readonly struct: skill id/display, roll, rankMod, situationalMod, stateMod, total, dc, success, degree. Runtime/PlayerSkills.cs Plain C# class. Ranks only, seeded from SkillDatabase.StartingRank. Runtime/SkillStateModifiers.cs Plain C#. Nested dict sourceKey -> (skillId -> delta). Supports skillId "*" as a wildcard. TotalFor(skillId) sums all. Runtime/SkillCheckSystem.cs Plain C#, no UnityEngine types. Resolve(skillId, dc, situationalMod): d20 + rank + situational + state; nat 1 = CriticalFailure, nat 20 = CriticalSuccess, else total >= dc. Runtime/IRollSource.cs Roll(sides) -> [1,sides] Runtime/UnityRollSource.cs Random.Range Runtime/SeededRollSource.cs System.Random, for tests / determinism Runtime/SkillRuntime.cs MonoBehaviour singleton + composition root. Holds Database, DialogueRunner, ResultView, Commentary; constructs PlayerSkills, SkillStateModifiers, SkillCheckSystem, IRollSource in Awake(). Yarn/YarnSkillCommands.cs static class. [YarnCommand("check")] Check(skillId, difficulty, modifier=0) -> IEnumerator; [YarnFunction("skill_rank")]. Writes $check_result/_roll/_total/_dc/_modifier/_degree/_skill. UI/SkillCheckResultView.cs Fading TMP banner. Prints roll, each of the three mods, total, DC, and outcome word. Tints by SkillDefinition.accentColor. Commentary/CommentarySystem.cs Passive barks. Looks for Yarn node "Commentary_{contextId}_{skillId}"; only skills whose node exists AND rank >= minRankToSpeak are candidates. Global cooldown minSecondsBetween (20s), recency-window weight halving, weight = max(1, rank). Public static Pick(...) is unit-tested. Fires exactly one node per request. Commentary/CommentaryTrigger.cs, Commentary/CommentaryIdleTimer.cs context sources. NightclubArcadia/Assets/Skills/ SkillDatabase.asset references the 12 definition assets by GUID Definitions/*.asset (+.meta) deduction, streetwise, recall, read_room, paranoia, reflexes, persuasion, intimidation, rapport, endurance, sleight_of_hand, nerve NightclubArcadia/Assets/Editor/ SkillSystemSetup.cs SOURCE OF TRUTH for the roster today: a static SkillSpec[] Roster, a menu item "Tools/Nightclub Arcadia/Set Up Skill System" that regenerates the definition assets + database and rewires Assets/Scenes/DialogueTest.unity, and a [DidReloadScripts] hook (EnsureSkillAssetsOnly) that regenerates the ASSETS ONLY once per editor session. <-- see §7.1, this will overwrite hand-edits. SkillSystemTestRunner.cs menu item to run the EditMode test assembly. NightclubArcadia/Assets/Tests/EditMode/ (asmdef: NightclubArcadia.Skills.Tests) DcBandTests.cs asserts "standard" -> 11 SkillCheckSystemTests.cs modifier math, nat 1/20, wildcard state mods, unknown skill CommentaryPickerTests.cs weighted pick + recency halving NightclubArcadia/Assets/Dialogue/ Common.yarn declares $check_* variables (never <> by hand) Commentary.yarn Commentary_Entrance_Queue_{paranoia,read_room,streetwise} Scenes/Entrance.yarn Start / Entrance_Inside / Entrance_Outside — no skill references Scenes/Debug.yarn <>, <>, <> (deliberate error-path test) Characters/Bouncer.yarn <>, skill_rank("intimidation") >= 2, <>, branches on $check_degree ``` ### 1.2 Every existing skill-name reference in the project (the complete change surface) | File | Reference | |---|---| | `Assets/Editor/SkillSystemSetup.cs` | the 12-row `Roster` array | | `Assets/Skills/Definitions/*.asset` | 12 assets, `id:` + `displayName:` fields | | `Assets/Skills/SkillDatabase.asset` | 12 GUID references | | `Assets/Dialogue/Scenes/Debug.yarn` | `persuasion`, `deduction` | | `Assets/Dialogue/Characters/Bouncer.yarn` | `persuasion` ×1, `intimidation` ×2 | | `Assets/Dialogue/Commentary.yarn` | node titles for `paranoia`, `read_room`, `streetwise` | | `Assets/Tests/EditMode/*.cs` | `persuasion`, `reflexes`, `read_room`, `paranoia` as literals | Nothing else in the project references a skill by name. The scene `DialogueTest.unity` references the **database asset**, not individual definitions, so replacing definition assets does not break scene wiring. --- ## 2. The reconciliation principle The target system is described in two documents: - **`/Users/lennart/Obsidian-Vaults/obsidian-vault/Projects/Active/NightclubArcadia/Skill System Draft.md`** — the Skill Bible. Eleven named voices, four opposed pairs plus three specialists, with per-voice prose (domain, blind spot, register, verbal tics, forbidden domains, success/failure/passive voice samples), firing-frequency targets, and a trace-channel coverage audit in §6. **Read it.** Its full content is not reproduced here; §4 below reproduces only the machine-relevant fields. - An architecture **skeleton** (a rough C# sketch, reproduced in §3 below), which was handed over as "a starting spec, not a finished implementation." **The player never sees an implementation. They see a name on a banner and a line of prose.** Therefore: > Where the existing implementation already produces the target behaviour under a different > internal name, **rename it. Do not rebuild it.** Only write new code where the target requires > behaviour that does not exist. The skeleton in §3 is *less* capable than what is already in the repo in several places (it has no degrees of success, no seedable roll source, no error handling, no node-existence gate, and it puts game logic in MonoBehaviours that are currently plain testable C# classes). **Do not regress the implementation to match the skeleton.** Match its *vocabulary and its data model*, keep the existing mechanics. ### 2.1 Decision table — what each difference actually costs | Skeleton / Bible says | Repo has | Verdict | |---|---|---| | 11 named voices | 12 placeholder skills | **Data change.** Rewrite the roster; no runtime code touched. §5.2 | | `DCBand { Trivial=8, Routine=11, Hard=14, Specialist=17, BuildDefining=20 }` | `DcBand { Trivial=5, Easy=8, Standard=11, Hard=14, Formidable=17, Legendary=20 }` | **Rename + drop one.** Same parser, same call convention. §5.3 | | Axes Reason / Body / Social / Self | `SkillDomain { Reason, Instinct, Presence, Body }` | **Rename enum + add `Specialist`.** §5.4 | | `targetFiringsPerHour` per skill | single global `minSecondsBetween` | **Real new behaviour.** Small, do it. §5.6 | | `GetTotalModifier` = rank + situational + state | identical three sources, split across `PlayerSkills` (rank), the `<>` argument (situational) and `SkillStateModifiers` (state) | **Already satisfied.** Keep. Add Yarn commands so writers can drive mods (§5.7). | | `[YarnFunction("rank")]` | `[YarnFunction("skill_rank")]` | **Keep `skill_rank`.** Not player-visible; `rank` is a likely collision with future generic helpers, and `skill_rank` is already used in `Bouncer.yarn`. Note the deviation in the file header. | | `<>` (raw int DC) | `< [mod]>>` | **Already a superset.** Keep. Prefer band names in authored Yarn. | | `CheckResult { success, roll, total, dc }` | `SkillCheckResult` with degrees + per-source mod breakdown | **Already a superset.** Keep. | | "show the number, always" | `SkillCheckResultView` already prints roll / rank / situation / state / total / DC / outcome | **Already satisfied.** No change. | | Bible §7 "Wrong Truth": failure grants a wrong deduction, *no failure signal in the prose*, "roll shown as failed in UI only" | UI shows the roll; prose is authored in Yarn | **Already satisfied — this is a writing rule, not a code feature.** Do not add a "quiet failure" mode. Document the convention in `Commentary.yarn` / a Yarn style comment. | | Bible §6: documentary routes should be *skill-free*, gated on Act + errand state | n/a | **Authoring guidance, not code.** Record it in the doc header; build nothing. | | Bible per-skill "Domains it must NOT comment on" | `CommentarySystem` only considers a skill if `Commentary_{context}_{skillId}` exists | **Already enforced structurally** — a voice with no node for a context cannot speak there. Zero code. Say so in the doc so nobody builds a forbidden-domain table. | | Bible "cost gates, not ability gates" for PLACEMENT | n/a | **Authoring guidance.** Build nothing. | | Bible trace channels + clue yield (§6 audit table) | n/a | **Data + an editor audit.** Cheap and it protects the coverage rule. §5.5 | | Opposed / allied pairs | n/a | **Data + a symmetry validation.** §5.5 | --- ## 3. The skeleton, for reference
Architecture skeleton as handed over (do not implement literally — see §2) ```csharp [CreateAssetMenu(menuName = "Skills/SkillDefinition")] public class SkillDefinition : ScriptableObject { public string skillId; // e.g. "confluence" — matches flag naming convention public string displayName; // e.g. "CONFLUENCE" public string domain; // one-line, from the skill bible [TextArea] public string notes; // full bible entry, for your reference in-editor public float targetFiringsPerHour = 5f; } public enum DCBand { Trivial = 8, Routine = 11, Hard = 14, Specialist = 17, BuildDefining = 20 } public class PlayerSkills : MonoBehaviour { private Dictionary ranks = new(); private Dictionary situationalMods = new(); // cleared per-scene private Dictionary stateMods = new(); // consumables/status effects public int GetTotalModifier(string skillId) => GetRank(skillId) + situationalMods.GetValueOrDefault(skillId, 0) + stateMods.GetValueOrDefault(skillId, 0); } public struct CheckResult { public bool success; public int roll; public int total; public int dc; } public class SkillCheckSystem : MonoBehaviour { public CheckResult RollCheck(string skillId, DCBand dc) { /* d20 + mod vs dc */ } } public class YarnSkillCommands : MonoBehaviour { [YarnCommand("check")] public void Check(string skillId, int dc) { /* sets $check_result, $check_roll */ } [YarnFunction("rank")] public static int Rank(string skillId) { ... } } public class CommentarySystem : MonoBehaviour { public void RequestCommentary(string context, List eligibleSkillIds) { // minInterval = 3600f / def.targetFiringsPerHour; skip if not refreshed; // weighted pick; one passive per request } } ``` Two ideas from this skeleton are genuinely new and must be adopted: **`targetFiringsPerHour` as a per-skill budget** (§5.6) and the **`domain` one-liner + `notes` bible blob** on the definition asset (§5.5). Everything else is already present in better form.
--- ## 4. The canonical roster Ids are lowercase `^[a-z][a-z0-9_]*$` — they are used as Yarn command arguments and as suffixes in Yarn node titles, so they cannot contain spaces. **Article rule:** drop the leading "THE" from ids, **except `the_float`** — bare `float` is a C# keyword and a Yarn type name, and would break any future generated id constants. | # | displayName | id | axis | opposedTo | alliedWith | firings/hr | primary channel | secondary | clue yield | |---|---|---|---|---|---|---|---|---|---| | 1 | `PROVENANCE` | `provenance` | Reason | `confluence` | `the_float`, `room_tone` | 7 | Documentary | Physical | High | | 2 | `CONFLUENCE` | `confluence` | Reason | `provenance` | `undisclosed`, `house_pour` | 9 | Behavioural | Testimonial | Low | | 3 | `HOUSE POUR` | `house_pour` | Body | `long_shift` | `facework`, `amnesty` | 8 | Behavioural | Physical | Medium | | 4 | `THE LONG SHIFT` | `long_shift` | Body | `house_pour` | `standing_order`, `room_tone` | 6 | Physical | Behavioural | Medium | | 5 | `FACEWORK` | `facework` | Social | `placement` | `house_pour`, `room_tone` | 10 | Testimonial | Behavioural | High | | 6 | `PLACEMENT` | `placement` | Social | `facework` | `the_float`, `confluence` | 6 | Testimonial | None | Medium | | 7 | `AMNESTY` | `amnesty` | Self | `standing_order` | `house_pour`, `facework` | 4 | Behavioural | None | Low | | 8 | `STANDING ORDER` | `standing_order` | Self | `amnesty` | `long_shift`, `provenance` | 5 | Testimonial | None | Low | | 9 | `UNDISCLOSED` | `undisclosed` | Specialist | `provenance` | `confluence`, `placement` | 3 | Physical | Behavioural | Low | | 10 | `THE FLOAT` | `the_float` | Specialist | `amnesty` | `provenance`, `placement` | 5 | Documentary | Physical | High | | 11 | `ROOM TONE` | `room_tone` | Specialist | `placement` | `facework`, `long_shift` | 8 | Physical | Testimonial | High | Sums and tallies that a validation pass must reproduce (Bible §6): - Firing total = **71 / hour**. - Primary-channel tally: physical **3**, testimonial **3**, behavioural **3**, documentary **2**. - `opposedTo` is **symmetric within the four pairs** (1↔2, 3↔4, 5↔6, 7↔8). The three specialists' `opposedTo` entries are **deliberately asymmetric** — `undisclosed→provenance`, `the_float→amnesty`, `room_tone→placement` are one-directional thematic oppositions stated in the Bible, and the targets already oppose their own pair partner. Validation must allow this: assert symmetry only when both skills have `axis != Specialist`. - `startingRank`: **1 for every skill.** The Bible does not assign starting ranks; character creation is out of scope. One exception for the demo — see §5.9. - `accentColor`: pick something plausible per axis (Reason cool blue, Body amber, Social violet, Self pale green, Specialists desaturated). Purely cosmetic; the current values are placeholders too. The one-line `domain` strings and the long-form `notes` blob come verbatim from the Bible's per-skill `domain:` field and the whole `SKILL` block respectively. ### 4.1 Old → new mapping for the five existing call sites The roster is a **replacement, not a migration**; there is no 1:1 map for all 12. Only these call sites need a decision, and these are the decisions: | Where | Old | New | Why | |---|---|---|---| | `Bouncer.yarn` — "I'm on the list" | `persuasion` | `placement` | PLACEMENT's domain is literally "the ask, the favour, the comp, **the door**". | | `Bouncer.yarn` — "Let me through" (gate + check) | `intimidation` | `undisclosed` | UNDISCLOSED is leverage never spelled out — "He never threatened you." Exactly this beat. | | `Debug.yarn` | `persuasion` | `placement` | as above | | `Debug.yarn` | `deduction` | `confluence` | CONFLUENCE is the deduction engine. | | `Commentary.yarn` — "hasn't looked at his phone once" | `paranoia` | `facework` | Reading one individual. | | `Commentary.yarn` — "pretending they're not cold" | `read_room` | `long_shift` | Bodies paying a cost. | | `Commentary.yarn` — "that's a price, not a queue" | `streetwise` | `the_float` | Money in motion. | | Tests | `persuasion`, `reflexes`, `read_room`, `paranoia` | `placement`, `long_shift`, `facework`, `house_pour` | arbitrary; tests only need valid ids | `<>` in `Debug.yarn` is a **deliberate** bad-id error path. Keep the bad id; only update its DC band name. --- ## 5. Work items Do these in order. Each is independently compilable. ### 5.1 Add a JSON bible as the roster source of truth **Problem:** the roster currently lives as a C# `SkillSpec[]` in an editor script. The new roster carries ~800 characters of authored prose per voice (`domain`, `notes`). Eleven verbatim string literals inside an editor script is unmaintainable, and the writer who owns this content should not have to edit C#. **Do:** create `NightclubArcadia/Assets/Skills/skill_bible.json` as the machine-readable mirror of the Bible, and make `SkillSystemSetup` read it instead of a hardcoded array. ```jsonc { "skills": [ { "id": "provenance", "displayName": "PROVENANCE", "axis": "Reason", "domain": "Chains of custody. Paperwork, licences, deeds, freight stamps, serial numbers, rebuilds, previous owners. Reading an object as a descendant of the decisions that moved it.", "notes": "", "targetFiringsPerHour": 7, "opposedTo": "confluence", "alliedWith": ["the_float", "room_tone"], "primaryChannel": "Documentary", "secondaryChannel": "Physical", "clueYield": "High", "startingRank": 1, "accentColor": { "r": 0.62, "g": 0.72, "b": 0.95, "a": 1 } } // ... 10 more ] } ``` Notes: - `JsonUtility` cannot deserialize a top-level array, hence the `skills` wrapper object. Use a `[Serializable]` DTO; enum-valued fields are strings in JSON and parsed with `Enum.TryParse` (fail loudly on a typo — this file is content, and a silent default is a content bug). - Put the file under `Assets/` so it is an addressable project asset; it is editor-time-only input, so a `TextAsset` load via `AssetDatabase.LoadAssetAtPath` is fine. - Keep the human-facing Bible in the Obsidian vault as the source of the prose; the JSON is a derived, checked-in mirror. State this in a comment at the top of `SkillSystemSetup.cs`. ### 5.2 Rewrite `SkillDefinition` Add fields; keep the existing style (private `[SerializeField]` + public getters + the `#if UNITY_EDITOR EditorSet(...)` writer). ``` id string (unchanged) displayName string (unchanged) axis SkillAxis <- renamed from `domain`, see §5.4 domain string <- NEW: the Bible's one-line domain notes string [TextArea] <- NEW: full Bible entry (replaces `description`) targetFiringsPerHour float <- NEW opposedTo string <- NEW (skill id) alliedWith string[] <- NEW (skill ids) primaryChannel TraceChannel <- NEW secondaryChannel TraceChannel <- NEW clueYield ClueYield <- NEW startingRank int (unchanged) accentColor Color (unchanged) ``` - Drop `description`; `notes` supersedes it. The only consumer of `description` is the placeholder string in `SkillSystemSetup`. - The `EditorSet(...)` signature grows past readability. Replace the positional parameter list with a single `[Serializable]` DTO parameter (reuse the JSON DTO from §5.1) — this also makes the three test files' helpers shorter, not longer. - New enums, in `Data/`: `enum TraceChannel { None, Documentary, Behavioural, Testimonial, Physical }` `enum ClueYield { Low, Medium, High }` Keep the British `Behavioural` spelling — the Bible uses it and it will be grepped against the Bible constantly. ### 5.3 `DcBand` → the five target bands ```csharp public enum DcBand { Trivial = 8, Routine = 11, Hard = 14, Specialist = 17, BuildDefining = 20, } ``` - The old `Trivial = 5` is **removed**, and `Trivial` is **reused at 8**. No call site uses `trivial` today, so nothing silently changes value — but flag this in the commit message, because it is exactly the kind of rename that would silently rebalance an existing script. - `DcBands.TryParse` keeps accepting raw integers (`<>` is used in `Debug.yarn`, and the skeleton's own example passes a raw int). The Bible says "never use in-between values", so **add a `Debug.LogWarning` when a parsed integer is not one of the five band values** — a nudge for the author, not an error. Do not make it throw. - Do **not** rename the C# type to `DCBand`; `DcBand` matches the project's existing casing convention and is not player-visible. ### 5.4 `SkillDomain` → `SkillAxis` (a terminology collision worth fixing) The Bible uses "domain" to mean *the subject matter a voice covers* ("Chains of custody…"), while the current enum uses it to mean *the axis a skill sits on*. Leaving both named `domain` will cause persistent confusion for the writer. ```csharp public enum SkillAxis { Reason, Body, Social, Self, Specialist } ``` - Rename the file `Data/SkillDomain.cs` → `Data/SkillAxis.cs` (and its `.meta`). - Rename the `SkillDefinition` field `domain` → `axis` and free the name `domain` for the Bible's one-liner (§5.2). Because the definition assets are regenerated wholesale in §5.8, `[FormerlySerializedAs]` is not needed — but the *serialized value* changes meaning (old `domain: 2` meant `Presence`), so the old assets **must be deleted, not edited in place**. ### 5.5 Roster validation in `SkillDatabase.OnValidate` Extend the existing duplicate/empty-id checks (do not create a new validator class) with: 1. Every `id` matches `^[a-z][a-z0-9_]*$`. 2. `opposedTo` and every `alliedWith` entry resolve to a skill in this database. 3. `opposedTo` is symmetric **when both skills have `axis != Specialist`**. 4. Exactly one pair per axis for `Reason` / `Body` / `Social` / `Self` (2 skills each), and 3 `Specialist`. 5. `targetFiringsPerHour > 0` for every skill; log the **sum** at info level so drift from the Bible's 71 is visible. 6. Log the primary-channel tally so drift from `physical 3 / testimonial 3 / behavioural 3 / documentary 2` is visible. Bible §6 flags documentary as the scarce channel and the whole coverage rule depends on it. Rules 5 and 6 are **informational logs, not errors** — they are design budgets the writer is expected to adjust deliberately. ### 5.6 Per-skill firing budgets in `CommentarySystem` This is the one genuinely new runtime behaviour. Keep everything else about the class — including the node-existence gate, the `_recent` recency halving, and the public static `Pick(...)` that the tests exercise. Change `RequestCommentary(contextId, repeatable)`: - Add `readonly Dictionary _lastFiredBySkill`. - A skill is a candidate only if, in addition to the existing gates (non-null, non-empty id, `rank >= minRankToSpeak`, node exists): `Time.time - _lastFiredBySkill.GetValueOrDefault(id, float.NegativeInfinity) >= 3600f / def.TargetFiringsPerHour`. - Guard `TargetFiringsPerHour <= 0` (treat as "never fires", and let `OnValidate` complain). - Keep the existing global `minSecondsBetween` floor. It is the aggregate limiter; 71 firings/hour is roughly one per minute, so a 20s global floor and per-skill budgets are complementary, not redundant. - On a successful fire, set `_lastFiredBySkill[picked.Id] = Time.time` alongside the existing `_lastFiredAt` / `_firedContexts` / `_recent` bookkeeping. - Keep `Pick(...)`'s signature and weighting unchanged so `CommentaryPickerTests` stays valid. Budget eligibility belongs in the candidate filter, not in the weighting. **Optional, only if it costs nothing:** an ambient fallback. If no `Commentary_{context}_{id}` node exists for any eligible skill, look for `Commentary_Ambient_{id}` — the Bible gives every voice one `PASSIVE VOICE` line that is context-free. Implement only if it does not complicate the candidate loop; otherwise leave it out and note it as future work. ### 5.7 Yarn commands for situational / state modifiers The skeleton exposes `SetSituationalMod` / `ClearSituationalMods`. The repo's `SkillStateModifiers` (source-keyed, wildcard-capable) is strictly more capable, but there is currently **no way for a writer to set a modifier from Yarn** — the only situational input is the third argument to `<>`. Close that gap in `YarnSkillCommands`: ``` <> // set: source key, skill id (or "*"), delta <> // clear one source key ``` Convention (document it in the file header and in `Common.yarn`): **source key `scene` is the "situational" bucket** and must be cleared on scene exit; any other source key (`drunk`, `injured`, …) is the "state" bucket. This preserves the "exactly three modifier sources" rule from the design doc without adding a fourth dictionary. Both commands must fail soft (log an error, do not throw) when `SkillRuntime.Instance` is null, matching the existing `<>` error handling. ### 5.8 Regenerate the assets The definition assets must be **replaced**, not edited — the meaning of the serialized `domain` integer changes (§5.4) and the roster is not a 1:1 map. **Preferred path (Unity available):** 1. Delete `NightclubArcadia/Assets/Skills/Definitions/` entirely (all 12 `.asset` + `.meta`). 2. Add a cleanup step to `SkillSystemSetup`: after generating, delete any `.asset` in `Definitions/` whose filename is not in the roster. This prevents the exact orphan problem that made this step manual. 3. Run the menu item headlessly: ```bash /Applications/Unity/Hub/Editor/6000.5.8f1/Unity.app/Contents/MacOS/Unity \ -batchmode -quit -nographics \ -projectPath /Users/lennart/Dev/nightlcub-arcadia/NightclubArcadia \ -executeMethod SkillSystemSetup.SetUpSkillSystem \ -logFile - ``` Note `SetUpSkillSystem` also opens and re-saves `Assets/Scenes/DialogueTest.unity`. That is fine and idempotent, but it will produce scene diff noise — check the scene diff before committing and revert it if it is pure reserialization. **Fallback path (Unity unavailable):** hand-author the YAML. Each definition is a single `MonoBehaviour` asset; copy the shape of an existing file (`Assets/Skills/Definitions/persuasion.asset`), keep `m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}` (the `SkillDefinition` script GUID — verify it against `Scripts/Skills/Data/SkillDefinition.cs.meta`), and write a fresh 32-hex-char `guid:` into each `.meta`: ```yaml fileFormatVersion: 2 guid: <32 hex chars> NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 userData: assetBundleName: assetBundleVariant: ``` Then rewrite `Assets/Skills/SkillDatabase.asset`'s `skills:` list to `- {fileID: 11400000, guid: , type: 2}` in roster order. Multi-line `notes` in YAML is the only fiddly part — use a block scalar or escape newlines as `\n` in a double-quoted scalar, matching how Unity already escapes the em-dash in `persuasion.asset`. ### 5.9 Update the Yarn call sites Apply the §4.1 mapping. - `Debug.yarn`: `<>`, `<>`, `<>` (keep the deliberate bad id). - `Bouncer.yarn`: `<>`; `skill_rank("undisclosed") >= 2`; `<>`. Give `undisclosed` `startingRank: 2` in the JSON bible so the gated option is reachable in the demo — this is the one starting-rank exception, and it replaces the identical existing exception for `intimidation`. Comment it in the JSON. - `Commentary.yarn`: rename the three nodes to `Commentary_Entrance_Queue_{facework,long_shift,the_float}` and **rewrite the three lines in each voice's register**. The current lines are placeholders. Use the Bible's `PASSIVE VOICE` samples as the model — e.g. ROOM TONE gives direction before content and transcribes with the gaps marked; THE FLOAT converts everything to a nightly rate and never moralises. Also drop the `"Paranoia: "` / `"Read the Room: "` speaker prefixes if the display name is already surfaced elsewhere; if not, use the exact `displayName` (`THE FLOAT:`, `FACEWORK:`). - `Common.yarn`: no `$check_*` change needed. Add a short comment block recording the two writing rules that the code deliberately does not enforce (Bible §7 / §2.1 above): *Wrong Truth failures carry no failure signal in the prose — the UI banner is the only tell;* *documentary routes are gated on Act and errand state, never on a voice.* ### 5.10 Update the tests, then add three Existing tests need mechanical fixes: - `DcBandTests`: `"standard" -> 11` becomes `"routine" -> 11`. Add a case asserting `"trivial" -> 8` (not 5) so the reused name is pinned. - `SkillCheckSystemTests`, `CommentaryPickerTests`: the `EditorSet(...)` helper calls change to the DTO form (§5.2), and the literal skill ids change per §4.1. No assertions about check maths should change — if one does, you have regressed the implementation. Stop and re-read §2. New tests worth the keystrokes: 1. **Firing budget gating** (`CommentarySystem`): a skill with `targetFiringsPerHour = 3600` (one per second) is eligible again immediately; a skill with `targetFiringsPerHour = 1` is not. This needs the budget check to be reachable without a `DialogueRunner` — extract the eligibility predicate into a `public static bool IsOffCooldown(float lastFired, float now, float targetPerHour)` and test that, rather than trying to instantiate the MonoBehaviour. 2. **Roster shape**: load `Assets/Skills/SkillDatabase.asset` in an EditMode test and assert 11 skills, id regex, pair symmetry (non-specialists), the axis counts, the 71 firing total, and the channel tally. This is the test that catches a JSON typo before it reaches the writer. 3. **Id/display divergence**: assert `displayName` is the Bible's uppercase form and `id` is the snake_case form for all 11 — the two are intentionally different and easy to accidentally unify. Run them: ```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/skill-tests.xml -logFile - ``` --- ## 6. Explicit non-goals Do not build any of these. Each is either already satisfied or is a writing convention: - A "quiet failure" / suppressed-failure-signal check mode. The UI banner *is* the tell; the prose is the writer's job (Bible §7). - A forbidden-domains table per skill. Node absence already enforces it (§2.1). - Clue / deduction / evidence objects (`CL-0xx`, `DED-0xx`, criticality, routes). The Bible §7 worked example references a clue ledger from a separate handbook. **That system does not exist in this repo and is out of scope.** Do not invent a schema for it. - Act / errand state, or any gating for documentary routes. - Character creation, rank progression, or skill-point spending. - Converting `PlayerSkills` / `SkillCheckSystem` into MonoBehaviours to match the skeleton. They are plain C# classes precisely so they can be unit-tested; the skeleton is a sketch, not a target. - Renaming `skill_rank` to `rank`, or `SkillCheckResult` to `CheckResult`. --- ## 7. Traps ### 7.1 `[DidReloadScripts]` will overwrite hand-edited assets `SkillSystemSetup.AutoSetupAfterReload` calls `EnsureSkillAssetsOnly()` once per editor session, which regenerates every definition asset and the database from the roster. **If you hand-edit an `.asset` without updating the roster source (now `skill_bible.json`), your edit will be silently reverted the next time scripts reload.** The JSON is the source of truth; assets are build output. Consider adding that sentence as a comment at the top of every generated asset's owning script. ### 7.2 Skill ids are Yarn node-title fragments `CommentarySystem` builds `Commentary_{contextId}_{skillId}`. Yarn node titles must be valid identifiers. This is the reason for the id regex in §5.5 — an id with a space or a hyphen produces a node title that either fails to compile or silently never matches. ### 7.3 Lookups are case-insensitive, node names are not `SkillDatabase` and `PlayerSkills` use `StringComparer.OrdinalIgnoreCase`; `CommentarySystem`'s `NodeExists` uses `StringComparison.Ordinal`. So `<>` works but `Commentary_Entrance_Queue_PLACEMENT` does not. Keep every authored id lowercase. ### 7.4 The scene references the database, not the definitions `Assets/Scenes/DialogueTest.unity` wires `SkillRuntime.database` → `SkillDatabase.asset`, and `CommentarySystem.database` likewise. Replacing definition assets does not break the scene, provided `SkillDatabase.asset` keeps its own GUID. **Do not delete or recreate `SkillDatabase.asset`** — rewrite its `skills:` list in place. ### 7.5 `float` is a keyword Covered in §4, restated because it will be tempting: the id for THE FLOAT is `the_float`. --- ## 8. Definition of done - [ ] `skill_bible.json` contains all 11 voices with every field in §4, prose copied from the Bible. - [ ] `Assets/Skills/Definitions/` contains exactly 11 assets, named `.asset`; no orphans. - [ ] `SkillDatabase.asset` keeps its original GUID and lists the 11 in roster order. - [ ] `DcBand` has exactly the five target bands; a non-band integer DC logs a warning. - [ ] `SkillAxis` replaces `SkillDomain`; `SkillDefinition.domain` is now the Bible one-liner. - [ ] `CommentarySystem` enforces a per-skill `3600 / targetFiringsPerHour` budget on top of the existing global floor. - [ ] `<>` / `<>` exist and fail soft. - [ ] No Yarn file, test, or script references any of the 12 old skill names. - [ ] EditMode tests pass, including the three new ones in §5.10. - [ ] `grep -ri` for each old id across `NightclubArcadia/Assets` (excluding `Library/`) returns nothing. - [ ] The check maths in `SkillCheckSystem` is byte-for-byte unchanged apart from the DC band constants. If it is not, §2 was violated.