refactor the skill system to adhere to the lore handbook

This commit is contained in:
2026-08-15 11:13:15 +02:00
parent 063e94f6eb
commit 9e5c3277bf
70 changed files with 2271 additions and 445 deletions
@@ -14,7 +14,7 @@ Bouncer: Evening. Name on the list?
You: Depends who's asking.
-> I'm on the list.
<<set $claimed_to_be_on_list = true>>
<<check persuasion standard>>
<<check placement routine>>
<<if $check_result>>
Bouncer: ...you're not on the list.
Bouncer: But it's slow tonight. Go on.
@@ -23,8 +23,8 @@ You: Depends who's asking.
Bouncer: Nobody's on the list tonight. Especially not you.
<<set $went_inside = false>>
<<endif>>
-> Let me through. <<if skill_rank("intimidation") >= 2>>
<<check intimidation hard -1>>
-> Let me through. <<if skill_rank("undisclosed") >= 2>>
<<check undisclosed hard -1>>
<<if $check_degree == "critical_success">>
Bouncer: ...yeah. Yeah, okay.
<<set $reputation += 2>>
@@ -1,18 +1,22 @@
// Passive skill commentary. Fired by CommentarySystem, never <<jump>>ed to.
// One node per (context, skill) pair; if a skill has nothing to say about a
// context, simply omit the node and it won't be considered.
//
// Writing rules (code does not enforce these):
// - Wrong Truth failures: 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.
title: Commentary_Entrance_Queue_paranoia
title: Commentary_Entrance_Queue_facework
---
Paranoia: The man three back hasn't looked at his phone once. Nobody waits like that.
FACEWORK: His hands are too still for someone this far back in a queue. What's he practising not to reach for?
===
title: Commentary_Entrance_Queue_read_room
title: Commentary_Entrance_Queue_long_shift
---
Read the Room: Everyone here is pretending they're not cold.
THE LONG SHIFT: Forty minutes. Nobody's shifted their weight the way cold people do. They're paying for this wait in something else.
===
title: Commentary_Entrance_Queue_streetwise
title: Commentary_Entrance_Queue_the_float
---
Streetwise: Door's been "slow tonight" for forty minutes. That's a price, not a queue.
THE FLOAT: Door's been "slow tonight" for forty minutes. Convert that: it's a price, not a queue. Who's eating that?
===
@@ -3,6 +3,15 @@
// This node is never played. Declaring every variable in one place means the
// compiler catches typos ("$reputaton") instead of silently creating a second
// variable at runtime.
//
// Writing rules the code deliberately does not enforce (Skill Bible §7 / refactor plan §2.1):
// - 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.
//
// Modifier Yarn commands (see YarnSkillCommands):
// <<skill_mod scene facework 2>> — source key "scene" = situational; clear on scene exit
// <<clear_skill_mods scene>>
// Other source keys (drunk, injured, …) are state buckets.
title: Declarations
---
@@ -5,7 +5,7 @@
title: Debug_SkillCheck
---
Narrator: Skill check harness.
<<check persuasion standard>>
<<check placement routine>>
<<if $check_result>>
Narrator: Passed. Rolled {$check_roll}, total {$check_total} against DC {$check_dc}.
<<else>>
@@ -13,10 +13,10 @@ Narrator: Skill check harness.
<<endif>>
// Explicit numeric DC plus a situational modifier.
<<check deduction 16 2>>
Narrator: Deduction came out {$check_degree}.
<<check confluence 16 2>>
Narrator: Confluence came out {$check_degree}.
// Deliberate typo: should log an error and continue, not deadlock.
<<check nosuchskill standard>>
<<check nosuchskill routine>>
Narrator: Survived the bad skill id.
===
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using NightclubArcadia.Skills;
@@ -10,43 +11,21 @@ using UnityEngine.UI;
using Yarn.Unity;
/// <summary>
/// Creates SkillDefinition assets, SkillDatabase, and wires SkillRuntime + roll
/// banner + commentary into Assets/Scenes/DialogueTest.unity.
/// Regenerates SkillDefinition assets + SkillDatabase from the Skill Bible JSON mirror.
///
/// Source of truth for prose/roster: the Obsidian Skill System Draft.md.
/// Checked-in machine mirror: Assets/Skills/skill_bible.json (edit that, not the .assets).
/// Hand-edits to Definitions/*.asset are overwritten on script reload by EnsureSkillAssetsOnly.
/// </summary>
public static class SkillSystemSetup
{
const string DefinitionsFolder = "Assets/Skills/Definitions";
const string SkillsFolder = "Assets/Skills";
const string BiblePath = "Assets/Skills/skill_bible.json";
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
const string ScenePath = "Assets/Scenes/DialogueTest.unity";
const string AutoSetupSessionKey = "NightclubArcadia.SkillSystem.AutoSetupDone";
struct SkillSpec
{
public string Id;
public string DisplayName;
public SkillDomain Domain;
public Color Accent;
public int StartingRank;
}
static readonly SkillSpec[] Roster =
{
new SkillSpec { Id = "deduction", DisplayName = "Deduction", Domain = SkillDomain.Reason, Accent = new Color(0.55f, 0.75f, 1f), StartingRank = 1 },
new SkillSpec { Id = "streetwise", DisplayName = "Streetwise", Domain = SkillDomain.Reason, Accent = new Color(0.7f, 0.85f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "recall", DisplayName = "Recall", Domain = SkillDomain.Reason, Accent = new Color(0.65f, 0.7f, 1f), StartingRank = 1 },
new SkillSpec { Id = "read_room", DisplayName = "Read the Room", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.75f, 0.45f), StartingRank = 1 },
new SkillSpec { Id = "paranoia", DisplayName = "Paranoia", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.45f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "reflexes", DisplayName = "Reflexes", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.9f, 0.4f), StartingRank = 1 },
new SkillSpec { Id = "persuasion", DisplayName = "Persuasion", Domain = SkillDomain.Presence, Accent = new Color(0.85f, 0.65f, 1f), StartingRank = 1 },
// Rank 2 so the Bouncer "Let me through" option gated by skill_rank >= 2 is visible in the demo.
new SkillSpec { Id = "intimidation", DisplayName = "Intimidation", Domain = SkillDomain.Presence, Accent = new Color(1f, 0.4f, 0.4f), StartingRank = 2 },
new SkillSpec { Id = "rapport", DisplayName = "Rapport", Domain = SkillDomain.Presence, Accent = new Color(0.55f, 0.95f, 0.85f), StartingRank = 1 },
new SkillSpec { Id = "endurance", DisplayName = "Endurance", Domain = SkillDomain.Body, Accent = new Color(0.85f, 0.7f, 0.5f), StartingRank = 1 },
new SkillSpec { Id = "sleight_of_hand", DisplayName = "Sleight of Hand", Domain = SkillDomain.Body, Accent = new Color(0.7f, 0.9f, 0.95f), StartingRank = 1 },
new SkillSpec { Id = "nerve", DisplayName = "Nerve", Domain = SkillDomain.Body, Accent = new Color(0.95f, 0.6f, 0.35f), StartingRank = 1 },
};
[DidReloadScripts]
static void AutoSetupAfterReload()
{
@@ -80,15 +59,24 @@ public static class SkillSystemSetup
/// </summary>
static void EnsureSkillAssetsOnly()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
@@ -105,15 +93,24 @@ public static class SkillSystemSetup
[MenuItem("Tools/Nightclub Arcadia/Set Up Skill System")]
public static void SetUpSkillSystem()
{
if (!TryLoadRoster(out var roster))
{
return;
}
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
var rosterIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
rosterIds.Add(entry.id);
definitions.Add(CreateOrUpdateDefinition(entry));
}
DeleteOrphanDefinitions(rosterIds);
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
@@ -130,9 +127,75 @@ public static class SkillSystemSetup
Debug.Log($"Skill system setup complete. Database at {DatabasePath}; scene wired at {ScenePath}.");
}
static SkillDefinition CreateOrUpdateDefinition(SkillSpec spec)
static bool TryLoadRoster(out SkillBibleEntry[] roster)
{
var path = $"{DefinitionsFolder}/{spec.Id}.asset";
roster = null;
var text = AssetDatabase.LoadAssetAtPath<TextAsset>(BiblePath);
if (text == null)
{
Debug.LogError($"[SkillSystemSetup] Missing bible at {BiblePath}.");
return false;
}
SkillBibleRoot root;
try
{
root = JsonUtility.FromJson<SkillBibleRoot>(text.text);
}
catch (Exception ex)
{
Debug.LogError($"[SkillSystemSetup] Failed to parse {BiblePath}: {ex.Message}");
return false;
}
if (root?.skills == null || root.skills.Length == 0)
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} has no skills.");
return false;
}
foreach (var entry in root.skills)
{
if (entry == null || string.IsNullOrEmpty(entry.id))
{
Debug.LogError($"[SkillSystemSetup] {BiblePath} contains a skill with empty id.");
return false;
}
// Fail loudly on enum typos — silent defaults are content bugs.
if (!Enum.TryParse<SkillAxis>(entry.axis, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid axis '{entry.axis}'.");
return false;
}
if (!Enum.TryParse<TraceChannel>(entry.primaryChannel, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid primaryChannel '{entry.primaryChannel}'.");
return false;
}
var secondary = string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel;
if (!Enum.TryParse<TraceChannel>(secondary, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid secondaryChannel '{entry.secondaryChannel}'.");
return false;
}
if (!Enum.TryParse<ClueYield>(entry.clueYield, ignoreCase: true, out _))
{
Debug.LogError($"[SkillSystemSetup] Skill '{entry.id}': invalid clueYield '{entry.clueYield}'.");
return false;
}
}
roster = root.skills;
return true;
}
static SkillDefinition CreateOrUpdateDefinition(SkillBibleEntry entry)
{
var path = $"{DefinitionsFolder}/{entry.id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<SkillDefinition>(path);
if (asset == null)
{
@@ -140,17 +203,33 @@ public static class SkillSystemSetup
AssetDatabase.CreateAsset(asset, path);
}
asset.EditorSet(
spec.Id,
spec.DisplayName,
spec.Domain,
$"Placeholder skill — replace when Skill_System_Draft.md is available.",
spec.StartingRank,
spec.Accent);
asset.EditorSet(entry);
EditorUtility.SetDirty(asset);
return asset;
}
static void DeleteOrphanDefinitions(HashSet<string> rosterIds)
{
if (!AssetDatabase.IsValidFolder(DefinitionsFolder))
{
return;
}
var guids = AssetDatabase.FindAssets("t:SkillDefinition", new[] { DefinitionsFolder });
foreach (var guid in guids)
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var fileName = Path.GetFileNameWithoutExtension(path);
if (rosterIds.Contains(fileName))
{
continue;
}
Debug.Log($"[SkillSystemSetup] Deleting orphan definition {path}");
AssetDatabase.DeleteAsset(path);
}
}
static void WireDialogueTestScene(SkillDatabase database)
{
if (!File.Exists(ScenePath))
@@ -161,7 +240,7 @@ public static class SkillSystemSetup
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var runner = Object.FindAnyObjectByType<DialogueRunner>();
var runner = UnityEngine.Object.FindAnyObjectByType<DialogueRunner>();
if (runner == null)
{
Debug.LogError("No DialogueRunner in DialogueTest.unity.");
@@ -8,6 +8,8 @@ namespace NightclubArcadia.Skills
/// <summary>
/// Passive skill commentary outside the Yarn node tree.
/// Fires at most one speaker per RequestCommentary call.
/// Per-skill budget: 3600 / targetFiringsPerHour seconds between fires for that voice,
/// on top of the global minSecondsBetween floor.
/// </summary>
public sealed class CommentarySystem : MonoBehaviour
{
@@ -19,6 +21,8 @@ namespace NightclubArcadia.Skills
readonly List<string> _recent = new List<string>();
readonly HashSet<string> _firedContexts = new HashSet<string>(StringComparer.Ordinal);
readonly Dictionary<string, float> _lastFiredBySkill =
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
float _lastFiredAt = float.NegativeInfinity;
public void RequestCommentary(string contextId)
@@ -61,6 +65,7 @@ namespace NightclubArcadia.Skills
return;
}
var now = Time.time;
var candidates = new List<SkillDefinition>();
foreach (var skill in database.All)
{
@@ -74,6 +79,16 @@ namespace NightclubArcadia.Skills
continue;
}
if (!_lastFiredBySkill.TryGetValue(skill.Id, out var lastFired))
{
lastFired = float.NegativeInfinity;
}
if (!IsOffCooldown(lastFired, now, skill.TargetFiringsPerHour))
{
continue;
}
var nodeName = $"Commentary_{contextId}_{skill.Id}";
if (!NodeExists(nodeName))
{
@@ -91,7 +106,8 @@ namespace NightclubArcadia.Skills
var startNode = $"Commentary_{contextId}_{picked.Id}";
_ = dialogueRunner.StartDialogue(startNode);
_lastFiredAt = Time.time;
_lastFiredAt = now;
_lastFiredBySkill[picked.Id] = now;
_firedContexts.Add(contextId);
_recent.Add(picked.Id);
while (_recent.Count > Math.Max(1, recencyWindow))
@@ -100,6 +116,21 @@ namespace NightclubArcadia.Skills
}
}
/// <summary>
/// True when enough time has elapsed since lastFired given targetFiringsPerHour.
/// targetFiringsPerHour &lt;= 0 means the skill never fires.
/// </summary>
public static bool IsOffCooldown(float lastFired, float now, float targetPerHour)
{
if (targetPerHour <= 0f)
{
return false;
}
var minInterval = 3600f / targetPerHour;
return now - lastFired >= minInterval;
}
bool NodeExists(string nodeName)
{
if (dialogueRunner.Dialogue != null && dialogueRunner.Dialogue.NodeExists(nodeName))
@@ -0,0 +1,9 @@
namespace NightclubArcadia.Skills
{
public enum ClueYield
{
Low,
Medium,
High,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 98fef2c3069c3479884778df838f7dc2
@@ -1,20 +1,29 @@
using System;
using System.Globalization;
using UnityEngine;
namespace NightclubArcadia.Skills
{
public enum DcBand
{
Trivial = 5,
Easy = 8,
Standard = 11,
Trivial = 8,
Routine = 11,
Hard = 14,
Formidable = 17,
Legendary = 20,
Specialist = 17,
BuildDefining = 20,
}
public static class DcBands
{
static readonly int[] BandValues =
{
(int)DcBand.Trivial,
(int)DcBand.Routine,
(int)DcBand.Hard,
(int)DcBand.Specialist,
(int)DcBand.BuildDefining,
};
public static bool TryParse(string input, out int dc)
{
dc = 0;
@@ -28,6 +37,13 @@ namespace NightclubArcadia.Skills
if (int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
dc = numeric;
if (!IsBandValue(numeric))
{
Debug.LogWarning(
$"[DcBands] DC {numeric} is not a named band " +
$"(8 / 11 / 14 / 17 / 20). Prefer band names.");
}
return true;
}
@@ -39,5 +55,18 @@ namespace NightclubArcadia.Skills
return false;
}
public static bool IsBandValue(int dc)
{
for (var i = 0; i < BandValues.Length; i++)
{
if (BandValues[i] == dc)
{
return true;
}
}
return false;
}
}
}
@@ -1,10 +1,11 @@
namespace NightclubArcadia.Skills
{
public enum SkillDomain
public enum SkillAxis
{
Reason,
Instinct,
Presence,
Body,
Social,
Self,
Specialist,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c8cafa1b8c20645ea81912844473c92e
@@ -0,0 +1,33 @@
using System;
using UnityEngine;
namespace NightclubArcadia.Skills
{
/// <summary>
/// JSON mirror of the Skill Bible. Deserialized by SkillSystemSetup; also the
/// EditorSet DTO for SkillDefinition. Enum fields are strings in JSON.
/// </summary>
[Serializable]
public sealed class SkillBibleRoot
{
public SkillBibleEntry[] skills;
}
[Serializable]
public sealed class SkillBibleEntry
{
public string id;
public string displayName;
public string axis;
public string domain;
public string notes;
public float targetFiringsPerHour;
public string opposedTo;
public string[] alliedWith;
public string primaryChannel;
public string secondaryChannel;
public string clueYield;
public int startingRank;
public Color accentColor;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47ea723e59b8c45f688a38c9b4ede3a5
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
namespace NightclubArcadia.Skills
@@ -7,6 +8,8 @@ namespace NightclubArcadia.Skills
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Database", fileName = "SkillDatabase")]
public sealed class SkillDatabase : ScriptableObject
{
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
[SerializeField] List<SkillDefinition> skills = new List<SkillDefinition>();
Dictionary<string, SkillDefinition> _lookup;
@@ -58,6 +61,11 @@ namespace NightclubArcadia.Skills
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<string, SkillDefinition>(StringComparer.OrdinalIgnoreCase);
var axisCounts = new Dictionary<SkillAxis, int>();
var channelCounts = new Dictionary<TraceChannel, int>();
var firingSum = 0f;
foreach (var skill in skills)
{
if (skill == null)
@@ -72,10 +80,127 @@ namespace NightclubArcadia.Skills
continue;
}
if (!IdPattern.IsMatch(skill.Id))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill Id '{skill.Id}' must match ^[a-z][a-z0-9_]*$.",
this);
}
if (!seen.Add(skill.Id))
{
Debug.LogError($"[{nameof(SkillDatabase)}] Duplicate skill Id '{skill.Id}'.", this);
}
else
{
byId[skill.Id] = skill;
}
if (skill.TargetFiringsPerHour <= 0f)
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill '{skill.Id}' has targetFiringsPerHour <= 0.",
this);
}
else
{
firingSum += skill.TargetFiringsPerHour;
}
if (!axisCounts.ContainsKey(skill.Axis))
{
axisCounts[skill.Axis] = 0;
}
axisCounts[skill.Axis]++;
if (!channelCounts.ContainsKey(skill.PrimaryChannel))
{
channelCounts[skill.PrimaryChannel] = 0;
}
channelCounts[skill.PrimaryChannel]++;
}
foreach (var skill in byId.Values)
{
ValidateReference(skill.OpposedTo, skill.Id, "opposedTo", byId);
if (skill.AlliedWith != null)
{
foreach (var ally in skill.AlliedWith)
{
ValidateReference(ally, skill.Id, "alliedWith", byId);
}
}
if (string.IsNullOrEmpty(skill.OpposedTo) || !byId.TryGetValue(skill.OpposedTo, out var opposed))
{
continue;
}
// Symmetry required only when both skills are non-Specialist.
if (skill.Axis == SkillAxis.Specialist || opposed.Axis == SkillAxis.Specialist)
{
continue;
}
if (!string.Equals(opposed.OpposedTo, skill.Id, StringComparison.OrdinalIgnoreCase))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] opposedTo asymmetry: '{skill.Id}' → '{skill.OpposedTo}' " +
$"but '{opposed.Id}' → '{opposed.OpposedTo}'.",
this);
}
}
AssertAxisCount(axisCounts, SkillAxis.Reason, 2);
AssertAxisCount(axisCounts, SkillAxis.Body, 2);
AssertAxisCount(axisCounts, SkillAxis.Social, 2);
AssertAxisCount(axisCounts, SkillAxis.Self, 2);
AssertAxisCount(axisCounts, SkillAxis.Specialist, 3);
Debug.Log(
$"[{nameof(SkillDatabase)}] targetFiringsPerHour sum = {firingSum} (Bible expects 71).",
this);
channelCounts.TryGetValue(TraceChannel.Physical, out var physical);
channelCounts.TryGetValue(TraceChannel.Testimonial, out var testimonial);
channelCounts.TryGetValue(TraceChannel.Behavioural, out var behavioural);
channelCounts.TryGetValue(TraceChannel.Documentary, out var documentary);
Debug.Log(
$"[{nameof(SkillDatabase)}] primary-channel tally: physical {physical} / " +
$"testimonial {testimonial} / behavioural {behavioural} / documentary {documentary} " +
$"(Bible expects 3 / 3 / 3 / 2).",
this);
}
void ValidateReference(
string refId,
string ownerId,
string fieldName,
Dictionary<string, SkillDefinition> byId)
{
if (string.IsNullOrEmpty(refId))
{
return;
}
if (!byId.ContainsKey(refId))
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Skill '{ownerId}' {fieldName} '{refId}' is not in this database.",
this);
}
}
void AssertAxisCount(Dictionary<SkillAxis, int> counts, SkillAxis axis, int expected)
{
counts.TryGetValue(axis, out var actual);
if (actual != expected)
{
Debug.LogError(
$"[{nameof(SkillDatabase)}] Axis {axis} has {actual} skills; expected {expected}.",
this);
}
}
@@ -1,39 +1,77 @@
using System;
using UnityEngine;
namespace NightclubArcadia.Skills
{
// Generated from Assets/Skills/skill_bible.json by SkillSystemSetup.
// Hand-edits to .asset files are overwritten on script reload — edit the JSON.
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Definition", fileName = "NewSkill")]
public sealed class SkillDefinition : ScriptableObject
{
[SerializeField] string id;
[SerializeField] string displayName;
[SerializeField] SkillDomain domain;
[TextArea] [SerializeField] string description;
[SerializeField] SkillAxis axis;
[SerializeField] string domain;
[TextArea(8, 24)] [SerializeField] string notes;
[SerializeField] float targetFiringsPerHour = 5f;
[SerializeField] string opposedTo;
[SerializeField] string[] alliedWith;
[SerializeField] TraceChannel primaryChannel;
[SerializeField] TraceChannel secondaryChannel;
[SerializeField] ClueYield clueYield;
[SerializeField] int startingRank;
[SerializeField] Color accentColor = Color.white;
public string Id => id;
public string DisplayName => displayName;
public SkillDomain Domain => domain;
public string Description => description;
public SkillAxis Axis => axis;
public string Domain => domain;
public string Notes => notes;
public float TargetFiringsPerHour => targetFiringsPerHour;
public string OpposedTo => opposedTo;
public string[] AlliedWith => alliedWith;
public TraceChannel PrimaryChannel => primaryChannel;
public TraceChannel SecondaryChannel => secondaryChannel;
public ClueYield ClueYield => clueYield;
public int StartingRank => startingRank;
public Color AccentColor => accentColor;
#if UNITY_EDITOR
public void EditorSet(
string newId,
string newDisplayName,
SkillDomain newDomain,
string newDescription,
int newStartingRank,
Color newAccentColor)
public void EditorSet(SkillBibleEntry entry)
{
id = newId;
displayName = newDisplayName;
domain = newDomain;
description = newDescription;
startingRank = newStartingRank;
accentColor = newAccentColor;
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
id = entry.id;
displayName = entry.displayName;
axis = ParseEnumRequired<SkillAxis>(entry.axis, nameof(entry.axis), entry.id);
domain = entry.domain ?? string.Empty;
notes = entry.notes ?? string.Empty;
targetFiringsPerHour = entry.targetFiringsPerHour;
opposedTo = entry.opposedTo ?? string.Empty;
alliedWith = entry.alliedWith ?? Array.Empty<string>();
primaryChannel = ParseEnumRequired<TraceChannel>(entry.primaryChannel, nameof(entry.primaryChannel), entry.id);
secondaryChannel = ParseEnumRequired<TraceChannel>(
string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel,
nameof(entry.secondaryChannel),
entry.id);
clueYield = ParseEnumRequired<ClueYield>(entry.clueYield, nameof(entry.clueYield), entry.id);
startingRank = entry.startingRank;
accentColor = entry.accentColor;
}
static TEnum ParseEnumRequired<TEnum>(string value, string fieldName, string skillId)
where TEnum : struct
{
if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
{
throw new ArgumentException(
$"Skill '{skillId}': invalid {fieldName} '{value}' for {typeof(TEnum).Name}.");
}
return parsed;
}
#endif
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 0bdf9a02be4b42a4a48a099fe924980e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,11 @@
namespace NightclubArcadia.Skills
{
public enum TraceChannel
{
None,
Documentary,
Behavioural,
Testimonial,
Physical,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 94dae2d20cc784f0198944f76cc362ef
@@ -6,7 +6,18 @@ namespace NightclubArcadia.Skills
{
/// <summary>
/// Static Yarn adapter. No MonoBehaviour, no check math — translation only.
/// Call sites: &lt;&lt;check persuasion standard&gt;&gt; / &lt;&lt;check intimidation hard -1&gt;&gt;
///
/// Commands:
/// &lt;&lt;check skillId band-or-int [situationalMod]&gt;&gt;
/// &lt;&lt;skill_mod sourceKey skillId delta&gt;&gt; — set a state/situational mod
/// &lt;&lt;clear_skill_mods sourceKey&gt;&gt; — clear one source key
/// Function (kept as skill_rank; skeleton's "rank" would collide with future helpers):
/// skill_rank("skillId")
///
/// Modifier convention: source key "scene" is the situational bucket and must be
/// cleared on scene exit; any other key (drunk, injured, …) is a state bucket.
/// Both feed SkillStateModifiers — three modifier sources total (rank / check arg /
/// state), not a fourth dictionary.
/// </summary>
public static class YarnSkillCommands
{
@@ -28,8 +39,8 @@ namespace NightclubArcadia.Skills
if (!DcBands.TryParse(difficulty, out var dc))
{
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Standard (11).");
dc = (int)DcBand.Standard;
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Routine (11).");
dc = (int)DcBand.Routine;
}
if (runtime.Database == null || !runtime.Database.TryGet(skillId, out _))
@@ -49,6 +60,44 @@ namespace NightclubArcadia.Skills
yield return runtime.StartCoroutine(runtime.ResultView.Show(result));
}
[YarnCommand("skill_mod")]
public static void SkillMod(string sourceKey, string skillId, int delta)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.States == null)
{
Debug.LogError("[YarnSkillCommands] <<skill_mod>> called but SkillRuntime is not ready.");
return;
}
if (string.IsNullOrEmpty(sourceKey) || string.IsNullOrEmpty(skillId))
{
Debug.LogError("[YarnSkillCommands] <<skill_mod>> requires sourceKey and skillId.");
return;
}
runtime.States.Set(sourceKey, skillId, delta);
}
[YarnCommand("clear_skill_mods")]
public static void ClearSkillMods(string sourceKey)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.States == null)
{
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> called but SkillRuntime is not ready.");
return;
}
if (string.IsNullOrEmpty(sourceKey))
{
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> requires a sourceKey.");
return;
}
runtime.States.Clear(sourceKey);
}
[YarnFunction("skill_rank")]
public static float SkillRank(string skillId)
{
@@ -87,7 +136,7 @@ namespace NightclubArcadia.Skills
DcBands.TryParse(difficulty, out var dc);
if (dc == 0)
{
dc = (int)DcBand.Standard;
dc = (int)DcBand.Routine;
}
var storage = runner.VariableStorage;
@@ -0,0 +1,52 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: amnesty
m_EditorClassIdentifier:
id: amnesty
displayName: AMNESTY
axis: 3
domain: 'Relief. Letting go, forgiving, putting a thing down. Also, professionally:
recognising when other people have decided to forget something, and by what method
they arranged it.'
notes: "SKILL\nname: AMNESTY\ndomain: Relief. Letting go, forgiving, putting a
thing down. Also, professionally: recognising when other people have decided
to forget something, and by what method they arranged it.\nwhat it wants for
you: Peace for you, tonight, at whatever price tomorrow ends up charging.\nwhat
it's wrong about: It believes forgetting is the same as being forgiven. And it
cannot tell your peace from somebody else's advantage \u2014 every offer of rest
it makes is sincere; some of them are also somebody's plan, and AMNESTY has never
once checked whose.\nhow it addresses you: Second person, soft, constant use
of [pc], the way somebody talks you down off something.\nregister: Kind, tired,
absolving.\nverbal tics:\n* (1) Opens with \"It's alright.\"\n* (2) Offers you
an hour. \"Give it an hour, [pc].\"\nwhat it never does: Lie to you. Press a
second time.\nopposed to: STANDING ORDER\nallied with: HOUSE POUR, FACEWORK\n\nSUCCESS
VOICE: \"Nobody in this building remembers the fire and three of them were here
for it. That isn't damage, [pc]. That's maintenance. Somebody has been keeping
this room forgetful.\"\nFAILURE VOICE: \"It's alright. Nobody is looking for
this any more \u2014 the people who wanted it are dead or old or somewhere warm.
Put it down.\"\nPASSIVE VOICE: \"It's alright. Whatever this room used to be,
it's a room where people dance now. That counts for something. It counted for
somebody.\"\n\nFiring frequency target (per hour): 4\nDomains it must NOT comment
on: Tactics. Numbers. Other people's guilt \u2014 AMNESTY only ever absolves
you, and its silence about everyone else is the most frightening thing about
it."
targetFiringsPerHour: 4
opposedTo: standing_order
alliedWith:
- house_pour
- facework
primaryChannel: 2
secondaryChannel: 0
clueYield: 0
startingRank: 1
accentColor: {r: 0.72, g: 0.88, b: 0.72, a: 1}
@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: 84586fcc502c42889a44dd32ed2a2f1d
guid: d74ee3beca20140c69c7cc6a302ceb5a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,57 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: confluence
m_EditorClassIdentifier:
id: confluence
displayName: CONFLUENCE
axis: 0
domain: Synthesis across distance. Connecting a thing in this room to a thing forty
years and a continent away; the shape a long pattern makes; the sense that tonight
is a late chapter.
notes: "SKILL\nname: CONFLUENCE\ndomain: Synthesis across distance. Connecting
a thing in this room to a thing forty years and a continent away; the shape a
long pattern makes; the sense that tonight is a late chapter.\nwhat it wants
for you: For you to be one of the few people alive who can see the shape \u2014
and to be right about it out loud, in front of somebody who matters.\nwhat it's
wrong about: It cannot tell authorship from opportunism. If a party benefited,
CONFLUENCE concludes that party arranged it. It has no working concept of somebody
*reading* an event they did not cause and simply being early \u2014 which is
how nearly everything in this world is actually done.\nhow it addresses you:
Second person sliding into first person plural. Leans in. Uses [pc], often twice
in a sentence.\nregister: Exhilarated, generous, sleepless.\nverbal tics:\n*
(1) \"Which means\u2014\"\n* (2) Counts its steps out loud: \"One. Two. Three,
and three is the one.\"\nwhat it never does: Concede a coincidence. Ask what
would disprove it.\nopposed to: PROVENANCE\nallied with: UNDISCLOSED, HOUSE POUR\n\nSUCCESS
VOICE \u2014 the connection is real and nobody else in the room has made it:
\"One: the room changes hands. Two: the same month, the deliveries stop. Three:
nobody has ever asked why a cellar this size under a room this small. Which means
the cellar is the older thing and the club is the explanation for it.\"\nFAILURE
VOICE \u2014 identical structure, identical confidence, an inference sitting
where an observation should be: \"Two of them came out of it better off. Which
means two of them arranged it, [pc], and the third is the one they're going to
leave holding it.\"\nPASSIVE VOICE: \"Every room like this one has a door that
only opens from inside. That's not a conspiracy, that's just what rooms are.
Which means somebody learned it early and never had to teach it to anybody.\"\n\nFiring
frequency target (per hour): 9\nDomains it must NOT comment on: The specifics
of money (THE FLOAT's jurisdiction). Your body, ever \u2014 pain and fatigue
are beneath its notice and that is characterising."
targetFiringsPerHour: 9
opposedTo: provenance
alliedWith:
- undisclosed
- house_pour
primaryChannel: 2
secondaryChannel: 3
clueYield: 0
startingRank: 1
accentColor: {r: 0.55, g: 0.68, b: 0.92, a: 1}
@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: ee266337568a41db9aca1f2af43ec466
guid: 961621ae0bd8b4b1d8fe2c5264125e91
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: deduction
m_EditorClassIdentifier:
id: deduction
displayName: Deduction
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.55, g: 0.75, b: 1, a: 1}
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: endurance
m_EditorClassIdentifier:
id: endurance
displayName: Endurance
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.85, g: 0.7, b: 0.5, a: 1}
@@ -0,0 +1,50 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: facework
m_EditorClassIdentifier:
id: facework
displayName: FACEWORK
axis: 2
domain: Reading individuals. Hesitation, rehearsal, the account with a hole in
it, whose routine changed this week, who in a room of two is afraid of the other.
notes: "SKILL\nname: FACEWORK\ndomain: Reading individuals. Hesitation, rehearsal,
the account with a hole in it, whose routine changed this week, who in a room
of two is afraid of the other.\nwhat it wants for you: To be understood as care
rather than surveillance \u2014 to know people before you have any use for them.\nwhat
it's wrong about: It can only read one face at a time. It believes a crowd is
a sum of persons, so it will confidently report the mood of forty people from
the three nearest, and it is wrong about rooms roughly as often as it is right
about persons. Nobody has ever told it this.\nhow it addresses you: Second person,
quiet, close. Uses [pc] when it wants you to be gentle with somebody.\nregister:
Attentive, tender, precise.\nverbal tics:\n* (1) Describes hands before faces.\n*
(2) Ends on a question it does not expect you to answer.\nwhat it never does:
Mock anyone. Report a whole room as a single thing \u2014 when it does, it is
failing, and that is the tell.\nopposed to: PLACEMENT\nallied with: HOUSE POUR,
ROOM TONE\n\nSUCCESS VOICE: \"He gave you the year without being asked for it.
People hand you the year when they have rehearsed the year. What else has he
practised?\"\nFAILURE VOICE: \"The three by the pillar: bored, bored, impatient.
The room's flat tonight, [pc]. Nothing is going to happen in here.\"\nPASSIVE
VOICE: \"Her hands are wrong for that coat. It's somebody's good coat, and it
isn't hers.\"\n\nFiring frequency target (per hour): 10\nDomains it must NOT
comment on: Money. Machinery. The chronology of events it did not personally
witness."
targetFiringsPerHour: 10
opposedTo: placement
alliedWith:
- house_pour
- room_tone
primaryChannel: 3
secondaryChannel: 2
clueYield: 2
startingRank: 1
accentColor: {r: 0.72, g: 0.52, b: 0.88, a: 1}
@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: 254c6612b22d44e7b3552a8aac9311be
guid: de3ea9d897f1345d6aed743bdf447bb3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: house_pour
m_EditorClassIdentifier:
id: house_pour
displayName: HOUSE POUR
axis: 1
domain: "Appetite \u2014 yours and the room's. What a person is on, how long they
have been on it, what it is standing in for. Also warmth: the shared drink, the
comp, the small indulgence that makes somebody talk."
notes: "SKILL\nname: HOUSE POUR\ndomain: Appetite \u2014 yours and the room's.
What a person is on, how long they have been on it, what it is standing in for.
Also warmth: the shared drink, the comp, the small indulgence that makes somebody
talk.\nwhat it wants for you: To keep you inside the night, warm, in the middle
of the room, where people say things.\nwhat it's wrong about: It believes appetite
is honest \u2014 that a loosened person is an unguarded one. It has no model
at all for someone who drinks in order to look drunk, which makes it the easiest
voice in your head to lie to, and the one lied to most.\nhow it addresses you:
Second person, familiar, imperative. \"Take it.\" Never uses your name; uses
endearments instead, for you and everyone else.\nregister: Warm, coaxing, unembarrassed.\nverbal
tics:\n* (1) Describes taste in temperatures rather than flavours.\n* (2) \"Friend\"
/ \"love\" before it will use anyone's actual name.\nwhat it never does: Warn
you. Count anything.\nopposed to: THE LONG SHIFT\nallied with: FACEWORK, AMNESTY\n\nSUCCESS
VOICE: \"She's ordered the same thing four times and finished none of them, love.
She isn't drinking. She's timing something.\"\nFAILURE VOICE: \"Three doubles
in and he's telling you about his mother. Nobody lies about their mother at this
hour, friend. Whatever he says next, take it home with you.\"\nPASSIVE VOICE:
\"The ice in here is cloudy. Somebody's freezer runs warm and nobody has told
them. Or nobody dares to.\"\n\nFiring frequency target (per hour): 8\nDomains
it must NOT comment on: Paperwork. Numbers. Chronology of any kind \u2014 it
has no sense of sequence and should never be allowed one."
targetFiringsPerHour: 8
opposedTo: long_shift
alliedWith:
- facework
- amnesty
primaryChannel: 2
secondaryChannel: 4
clueYield: 1
startingRank: 1
accentColor: {r: 0.92, g: 0.68, b: 0.38, a: 1}
@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: 21592f6cf7a6457a8198553ab124d41b
guid: 4a67faf2fef034eaeac65fa13cf25085
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: intimidation
m_EditorClassIdentifier:
id: intimidation
displayName: Intimidation
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 2
accentColor: {r: 1, g: 0.4, b: 0.4, a: 1}
@@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: long_shift
m_EditorClassIdentifier:
id: long_shift
displayName: THE LONG SHIFT
axis: 1
domain: Endurance. Pain, fatigue, injury, the body's clock at four in the morning.
Holding a position without moving. Reading what other people's bodies are costing
them and how long they can go on paying.
notes: "SKILL\nname: THE LONG SHIFT\ndomain: Endurance. Pain, fatigue, injury,
the body's clock at four in the morning. Holding a position without moving. Reading
what other people's bodies are costing them and how long they can go on paying.\nwhat
it wants for you: To still be on your feet when the room empties, because the
last person standing hears the last thing said.\nwhat it's wrong about: It believes
remaining is a form of winning. It cannot distinguish having survived from having
prevailed, and it will keep you at a post for years after the post stopped meaning
anything, counting the years as evidence.\nhow it addresses you: Second person,
flat, no name. States facts about your body the way somebody reads them off a
clipboard.\nregister: Dry, unhurried, undramatic.\nverbal tics:\n* (1) Opens
with time elapsed. \"Six hours forty.\"\n* (2) \"Still.\"\nwhat it never does:
Complain. Ask you to stop.\nopposed to: HOUSE POUR\nallied with: STANDING ORDER,
ROOM TONE\n\nSUCCESS VOICE: \"Six hours forty. Your left knee has been carrying
you since two. He's shifted twice a minute since he came in \u2014 he isn't waiting
for anyone. He's ready to leave.\"\nFAILURE VOICE: \"You're still here. They're
not. That's the whole of it \u2014 outlast them and the thing surfaces on its
own. It always has.\"\nPASSIVE VOICE: \"Somebody has stood in that corner long
enough to wear the tack off the floor. Years of somebody. Not one night's worth.\"\n\nFiring
frequency target (per hour): 6\nDomains it must NOT comment on: Motive. Meaning.
Whether any of this is worth doing \u2014 that question belongs to Pair IV and
THE LONG SHIFT has never once asked it."
targetFiringsPerHour: 6
opposedTo: house_pour
alliedWith:
- standing_order
- room_tone
primaryChannel: 4
secondaryChannel: 2
clueYield: 1
startingRank: 1
accentColor: {r: 0.85, g: 0.62, b: 0.32, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 873325f250e314dba926645b4f31155a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: nerve
m_EditorClassIdentifier:
id: nerve
displayName: Nerve
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.95, g: 0.6, b: 0.35, a: 1}
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: paranoia
m_EditorClassIdentifier:
id: paranoia
displayName: Paranoia
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.45, b: 0.55, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: b9de7affe9fa49f8a1f3bb64a87d817f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: persuasion
m_EditorClassIdentifier:
id: persuasion
displayName: Persuasion
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.85, g: 0.65, b: 1, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f510b7fc944e4c1190c35a5f82a52650
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: placement
m_EditorClassIdentifier:
id: placement
displayName: PLACEMENT
axis: 2
domain: Moving people. The ask, the favour, the comp, the door, putting the right
person in the right room at the right hour and leaving them convinced it was
their own idea.
notes: "SKILL\nname: PLACEMENT\ndomain: Moving people. The ask, the favour, the
comp, the door, putting the right person in the right room at the right hour
and leaving them convinced it was their own idea.\nwhat it wants for you: For
you to stop requesting things and start arranging them.\nwhat it's wrong about:
It believes a placed person stays placed. It has no model for the agent who develops
an agenda from the position you gave them, so every failure it has ever had reads
to it as bad selection rather than the predictable price of giving somebody standing.\nhow
it addresses you: Second person, imperative, never by name. It treats you as
the instrument rather than the person \u2014 which is the whole argument of Pair
III, stated grammatically.\nregister: Cool, economical, flattering.\nverbal tics:\n*
(1) Phrases instructions as observations. \"The door wants a friendly face at
one.\"\n* (2) Never says please, and the omission is audible.\nwhat it never
does: Threaten. Explain itself a second time.\nopposed to: FACEWORK\nallied with:
THE FLOAT, CONFLUENCE\n\nSUCCESS VOICE: \"Don't ask him. Ask his supplier, in
front of him, and get it wrong. He'll correct you and pay for the privilege.\"\nFAILURE
VOICE: \"She owes you the cloakroom job and she knows exactly what it was worth.
She'll sit on this for as long as you need her to.\"\nPASSIVE VOICE: \"The man
deciding who comes in tonight is not the man on the door. It's whoever the man
on the door keeps looking at.\"\n\nFiring frequency target (per hour): 6\nDomains
it must NOT comment on: Your own feelings. The dead. Anything that cannot be
traded for something."
targetFiringsPerHour: 6
opposedTo: facework
alliedWith:
- the_float
- confluence
primaryChannel: 3
secondaryChannel: 0
clueYield: 1
startingRank: 1
accentColor: {r: 0.62, g: 0.42, b: 0.82, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 410ec4c8d1b664fffae7d0474dc555af
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: provenance
m_EditorClassIdentifier:
id: provenance
displayName: PROVENANCE
axis: 0
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: "SKILL\nname: PROVENANCE\ndomain: 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.\nwhat it wants for you:
For every object in this room to have a father, and for you to be the one who
names him.\nwhat it's wrong about: It treats an unbroken chain as a confession
of intent. It cannot conceive of an object arriving somewhere through inheritance,
accident, or because moving it cost more than leaving it. If the paper leads
somewhere, PROVENANCE concludes that somebody meant it to lead there.\nhow it
addresses you: Second person, formal. Uses [PC] in full when it wants you to
write something down. Does not contract.\nregister: Exact, patrician, patient.\nverbal
tics:\n* (1) States the date before it states the noun.\n* (2) Says \"line\"
where anyone else would say \"history.\"\nwhat it never does: Speculate about
a motive it cannot paper. Raise its voice.\nopposed to: CONFLUENCE\nallied with:
THE FLOAT, ROOM TONE\n\nSUCCESS VOICE \u2014 being right sounds like an inventory
read slowly enough to hear the gap: \"Nineteen sixty-nine, the licence. Nineteen
sixty-eight, the man it is issued to, first appearing. A line that begins nowhere
is a line somebody drew.\"\nFAILURE VOICE \u2014 same cadence, same calm, one
unearned verb: \"The stamp, the manifest, the cellar. Three hands, one line,
unbroken. Whoever loaded it in the spring intended it to be standing exactly
where it is standing tonight.\"\nPASSIVE VOICE: \"Someone re-lacquered this bartop.
Once, badly, and not recently. The wood underneath is older than the building's
paperwork admits to.\"\n\nFiring frequency target (per hour): 7\nDomains it must
NOT comment on: Bodies, appetite, anyone's feelings. Live conversation while
it is happening \u2014 PROVENANCE speaks about people only after they have left
the room."
targetFiringsPerHour: 7
opposedTo: confluence
alliedWith:
- the_float
- room_tone
primaryChannel: 1
secondaryChannel: 4
clueYield: 2
startingRank: 1
accentColor: {r: 0.62, g: 0.72, b: 0.95, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f9685f50853ed4ef8bd76c2e31c89b1f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: rapport
m_EditorClassIdentifier:
id: rapport
displayName: Rapport
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.55, g: 0.95, b: 0.85, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: eb856a5941c0497b8eb7796793c617ba
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: read_room
m_EditorClassIdentifier:
id: read_room
displayName: Read the Room
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.75, b: 0.45, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: abfd9204c20644c090c30bd6404c45a6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: recall
m_EditorClassIdentifier:
id: recall
displayName: Recall
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.65, g: 0.7, b: 1, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: c675bc7c6c864e6e9e4f71394ba1f07f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: reflexes
m_EditorClassIdentifier:
id: reflexes
displayName: Reflexes
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.9, b: 0.4, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 78d1f1a41b2b48358f889935c3e3094c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: room_tone
m_EditorClassIdentifier:
id: room_tone
displayName: ROOM TONE
axis: 4
domain: "Sound. What carries over the music and what doesn't. The shape of a space
by ear \u2014 a wall with a room behind it does not ring like a wall. Overheard
fragments, lip-reading in strobe, the door that opens somewhere you can't see."
notes: "SKILL\nname: ROOM TONE\ndomain: Sound. What carries over the music and
what doesn't. The shape of a space by ear \u2014 a wall with a room behind it
does not ring like a wall. Overheard fragments, lip-reading in strobe, the door
that opens somewhere you can't see.\nwhat it wants for you: To hear the one sentence
tonight that was not meant for you.\nwhat it's wrong about: It trusts what it
overhears more than what it is told, on the theory that people only lie when
they know they are heard. It cannot recognise a conversation staged for a listener,
and it has never doubted a fragment in its life.\nhow it addresses you: Second
person, urgent, interrupting. Cuts you off mid-thought and does not apologise
for it.\nregister: Alert, clipped, intrusive.\nverbal tics:\n* (1) Gives direction
before content. \"Behind you, left.\"\n* (2) Transcribes with the gaps marked,
dashes and all.\nwhat it never does: Repeat itself. Wait for a break in the music.\nopposed
to: PLACEMENT (which wants the room arranged; ROOM TONE wants it left alone so
it keeps talking)\nallied with: FACEWORK, THE LONG SHIFT\n\nSUCCESS VOICE: \"Behind
you, left, under the bass: '\u2014not since the old owner\u2014' and then a name
you have heard twice tonight, from two people who told you they'd never met.\"\nFAILURE
VOICE: \"Behind you, right, low: '\u2014the cellar. Thursday.' Nobody says that
to be overheard.\"\nPASSIVE VOICE: \"This wall is dead. Every other wall in here
rings and this one swallows. Either there's something behind it or there's nothing
at all, and both are strange in a building this old.\"\n\nFiring frequency target
(per hour): 8\nDomains it must NOT comment on: Motive. Anything it did not personally
hear. Written material of any kind."
targetFiringsPerHour: 8
opposedTo: placement
alliedWith:
- facework
- long_shift
primaryChannel: 4
secondaryChannel: 3
clueYield: 2
startingRank: 1
accentColor: {r: 0.52, g: 0.54, b: 0.56, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c575059a303484b4a98b4455e7e2d267
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: sleight_of_hand
m_EditorClassIdentifier:
id: sleight_of_hand
displayName: Sleight of Hand
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.7, g: 0.9, b: 0.95, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 60239bdfcace42f89d3ff82e383e98b8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: standing_order
m_EditorClassIdentifier:
id: standing_order
displayName: STANDING ORDER
axis: 3
domain: Refusal and continuity of self. Holding a line, resisting pressure aimed
specifically at you, remembering what you said you were, recognising the shape
of a manipulation while it is being built.
notes: "SKILL\nname: STANDING ORDER\ndomain: Refusal and continuity of self. Holding
a line, resisting pressure aimed specifically at you, remembering what you said
you were, recognising the shape of a manipulation while it is being built.\nwhat
it wants for you: For you to remain a person with a shape \u2014 recognisable
to yourself at the end of this.\nwhat it's wrong about: It cannot tell a principle
from a habit. It defends a rule you set for reasons that expired years ago with
precisely the force it defends the one rule that still matters, and it experiences
reconsideration as decay.\nhow it addresses you: Second person, formal. Uses
[PC] in full at moments of pressure, the way a person is addressed in a document.\nregister:
Steady, austere, protective.\nverbal tics:\n* (1) Quotes you back to yourself,
verbatim, with the date.\n* (2) \"We agreed.\"\nwhat it never does: Bargain.
Describe consequences \u2014 it states the rule and stops, and the silence afterwards
is the argument.\nopposed to: AMNESTY\nallied with: THE LONG SHIFT, PROVENANCE\n\nSUCCESS
VOICE: \"He has asked you the same question three ways and each way was smaller
than the last. He isn't curious, [PC]. He is measuring the size of the yes you
will give.\"\nFAILURE VOICE: \"We agreed you don't take anything from anyone
in this building. We agreed it in a year when it meant something. It still means
something.\"\nPASSIVE VOICE: \"You do this every night. Right pocket, keys, then
the lights, in that order. Nobody made you. That's the part worth noticing.\"\n\nFiring
frequency target (per hour): 5\nDomains it must NOT comment on: Other people's
morals. Any past that isn't yours."
targetFiringsPerHour: 5
opposedTo: amnesty
alliedWith:
- long_shift
- provenance
primaryChannel: 3
secondaryChannel: 0
clueYield: 0
startingRank: 1
accentColor: {r: 0.62, g: 0.82, b: 0.68, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3a6c4c691dea34a54b619ac63b57c768
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,20 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: streetwise
m_EditorClassIdentifier:
id: streetwise
displayName: Streetwise
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.7, g: 0.85, b: 0.55, a: 1}
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 910cd1397efb4e23bca22bbe6c3c6971
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: the_float
m_EditorClassIdentifier:
id: the_float
displayName: THE FLOAT
axis: 4
domain: 'Money in motion. The till, the float, comps, prices that are wrong for
the room, debts, and above all: who is underwriting a loss, and what the loss
buys them.'
notes: "SKILL\nname: THE FLOAT\ndomain: Money in motion. The till, the float, comps,
prices that are wrong for the room, debts, and above all: who is underwriting
a loss, and what the loss buys them.\nwhat it wants for you: To find the subsidised
anomaly \u2014 the thing that costs more to run than it takes, because that is
where somebody's actual intention is kept.\nwhat it's wrong about: It believes
the money names the beneficiary. It cannot conceive of somebody paying in order
to look like somebody else paid, so it identifies laundering as generosity about
as often as it identifies it correctly.\nhow it addresses you: Second person,
brisk, never by name. Uses numbers where other voices use nouns.\nregister: Brisk,
mercenary, cheerful.\nverbal tics:\n* (1) Converts everything to a nightly rate.\n*
(2) \"Who's eating that?\"\nwhat it never does: Moralise about money. Round a
number.\nopposed to: AMNESTY (which prices nothing and forgives the difference)\nallied
with: PROVENANCE, PLACEMENT\n\nSUCCESS VOICE: \"Door take won't cover the licence,
and the licence was renewed three months early. Somebody is paying for this room
to be open and it isn't the people standing in it.\"\nFAILURE VOICE: \"Follow
it back. Drinks comped by the promoter, promoter carried by the label. It's the
label's room, then. Who's eating that? They are.\"\nPASSIVE VOICE: \"Four, for
a measure like that, in a room like this. Somebody set these prices to keep a
particular kind of person outside, and it's working.\"\n\nFiring frequency target
(per hour): 5\nDomains it must NOT comment on: Emotion. Bodies. Anything before
living memory \u2014 the deep past is PROVENANCE's and THE FLOAT finds it irrelevant,
loudly."
targetFiringsPerHour: 5
opposedTo: amnesty
alliedWith:
- provenance
- placement
primaryChannel: 1
secondaryChannel: 4
clueYield: 2
startingRank: 1
accentColor: {r: 0.58, g: 0.58, b: 0.55, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bb983746828e34057afb9cea401332b9
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: undisclosed
m_EditorClassIdentifier:
id: undisclosed
displayName: UNDISCLOSED
axis: 4
domain: What is being held back. Reserves, insurance, leverage, the thing in the
safe; assessing what somebody could do rather than what they are doing. The value
of a threat that has never had to be carried out.
notes: "SKILL\nname: UNDISCLOSED\ndomain: What is being held back. Reserves, insurance,
leverage, the thing in the safe; assessing what somebody could do rather than
what they are doing. The value of a threat that has never had to be carried out.\nwhat
it wants for you: For you never to be in a room without something undeclared.\nwhat
it's wrong about: It cannot distinguish a capability from the reputation of a
capability. It reads an empty safe as a loaded one, folds to bluffs with enormous
dignity, and has never in its life asked to see the thing.\nhow it addresses
you: Second person, low, slow. Never uses your name. Occasionally addresses the
room instead of you, which nobody else does.\nregister: Hushed, formal, superstitious.\nverbal
tics:\n* (1) Says \"shown\" where anyone else would say \"used.\"\n* (2) Leaves
the sentence unfinished where the object would go.\nwhat it never does: Name
the thing. Recommend using it.\nopposed to: PROVENANCE (which requires a thing
be named and papered before it counts)\nallied with: CONFLUENCE, PLACEMENT\n\nSUCCESS
VOICE: \"He never threatened you. He mentioned the cellar and let you finish
the sentence yourself. He's never once had to open that door \u2014 that is what
he's holding.\"\nFAILURE VOICE: \"He would not be this calm with nothing behind
him. Give him the room. Give him the night.\"\nPASSIVE VOICE: \"Two locks on
that door and neither has been oiled this decade. Either there is nothing behind
it, or nothing needs to be.\"\n\nFiring frequency target (per hour): 3\nDomains
it must NOT comment on: Interpersonal warmth. Anything already public. And \u2014
hard writer rule \u2014 the actual contents or nature of any legendary object.
It speaks about postures, never inventories."
targetFiringsPerHour: 3
opposedTo: provenance
alliedWith:
- confluence
- placement
primaryChannel: 4
secondaryChannel: 2
clueYield: 0
startingRank: 2
accentColor: {r: 0.55, g: 0.55, b: 0.58, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9d0b9a78735bd4174b04f865c631c143
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -13,15 +13,14 @@ MonoBehaviour:
m_Name: SkillDatabase
m_EditorClassIdentifier:
skills:
- {fileID: 11400000, guid: 21592f6cf7a6457a8198553ab124d41b, type: 2}
- {fileID: 11400000, guid: 910cd1397efb4e23bca22bbe6c3c6971, type: 2}
- {fileID: 11400000, guid: c675bc7c6c864e6e9e4f71394ba1f07f, type: 2}
- {fileID: 11400000, guid: abfd9204c20644c090c30bd6404c45a6, type: 2}
- {fileID: 11400000, guid: b9de7affe9fa49f8a1f3bb64a87d817f, type: 2}
- {fileID: 11400000, guid: 78d1f1a41b2b48358f889935c3e3094c, type: 2}
- {fileID: 11400000, guid: f510b7fc944e4c1190c35a5f82a52650, type: 2}
- {fileID: 11400000, guid: 254c6612b22d44e7b3552a8aac9311be, type: 2}
- {fileID: 11400000, guid: eb856a5941c0497b8eb7796793c617ba, type: 2}
- {fileID: 11400000, guid: 84586fcc502c42889a44dd32ed2a2f1d, type: 2}
- {fileID: 11400000, guid: 60239bdfcace42f89d3ff82e383e98b8, type: 2}
- {fileID: 11400000, guid: ee266337568a41db9aca1f2af43ec466, type: 2}
- {fileID: 11400000, guid: f9685f50853ed4ef8bd76c2e31c89b1f, type: 2}
- {fileID: 11400000, guid: 961621ae0bd8b4b1d8fe2c5264125e91, type: 2}
- {fileID: 11400000, guid: 4a67faf2fef034eaeac65fa13cf25085, type: 2}
- {fileID: 11400000, guid: 873325f250e314dba926645b4f31155a, type: 2}
- {fileID: 11400000, guid: de3ea9d897f1345d6aed743bdf447bb3, type: 2}
- {fileID: 11400000, guid: 410ec4c8d1b664fffae7d0474dc555af, type: 2}
- {fileID: 11400000, guid: d74ee3beca20140c69c7cc6a302ceb5a, type: 2}
- {fileID: 11400000, guid: 3a6c4c691dea34a54b619ac63b57c768, type: 2}
- {fileID: 11400000, guid: 9d0b9a78735bd4174b04f865c631c143, type: 2}
- {fileID: 11400000, guid: bb983746828e34057afb9cea401332b9, type: 2}
- {fileID: 11400000, guid: c575059a303484b4a98b4455e7e2d267, type: 2}
@@ -0,0 +1,258 @@
{
"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": "SKILL\nname: PROVENANCE\ndomain: 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.\nwhat it wants for you: For every object in this room to have a father, and for you to be the one who names him.\nwhat it's wrong about: It treats an unbroken chain as a confession of intent. It cannot conceive of an object arriving somewhere through inheritance, accident, or because moving it cost more than leaving it. If the paper leads somewhere, PROVENANCE concludes that somebody meant it to lead there.\nhow it addresses you: Second person, formal. Uses [PC] in full when it wants you to write something down. Does not contract.\nregister: Exact, patrician, patient.\nverbal tics:\n* (1) States the date before it states the noun.\n* (2) Says \"line\" where anyone else would say \"history.\"\nwhat it never does: Speculate about a motive it cannot paper. Raise its voice.\nopposed to: CONFLUENCE\nallied with: THE FLOAT, ROOM TONE\n\nSUCCESS VOICE — being right sounds like an inventory read slowly enough to hear the gap: \"Nineteen sixty-nine, the licence. Nineteen sixty-eight, the man it is issued to, first appearing. A line that begins nowhere is a line somebody drew.\"\nFAILURE VOICE — same cadence, same calm, one unearned verb: \"The stamp, the manifest, the cellar. Three hands, one line, unbroken. Whoever loaded it in the spring intended it to be standing exactly where it is standing tonight.\"\nPASSIVE VOICE: \"Someone re-lacquered this bartop. Once, badly, and not recently. The wood underneath is older than the building's paperwork admits to.\"\n\nFiring frequency target (per hour): 7\nDomains it must NOT comment on: Bodies, appetite, anyone's feelings. Live conversation while it is happening — PROVENANCE speaks about people only after they have left the room.",
"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
}
},
{
"id": "confluence",
"displayName": "CONFLUENCE",
"axis": "Reason",
"domain": "Synthesis across distance. Connecting a thing in this room to a thing forty years and a continent away; the shape a long pattern makes; the sense that tonight is a late chapter.",
"notes": "SKILL\nname: CONFLUENCE\ndomain: Synthesis across distance. Connecting a thing in this room to a thing forty years and a continent away; the shape a long pattern makes; the sense that tonight is a late chapter.\nwhat it wants for you: For you to be one of the few people alive who can see the shape — and to be right about it out loud, in front of somebody who matters.\nwhat it's wrong about: It cannot tell authorship from opportunism. If a party benefited, CONFLUENCE concludes that party arranged it. It has no working concept of somebody *reading* an event they did not cause and simply being early — which is how nearly everything in this world is actually done.\nhow it addresses you: Second person sliding into first person plural. Leans in. Uses [pc], often twice in a sentence.\nregister: Exhilarated, generous, sleepless.\nverbal tics:\n* (1) \"Which means—\"\n* (2) Counts its steps out loud: \"One. Two. Three, and three is the one.\"\nwhat it never does: Concede a coincidence. Ask what would disprove it.\nopposed to: PROVENANCE\nallied with: UNDISCLOSED, HOUSE POUR\n\nSUCCESS VOICE — the connection is real and nobody else in the room has made it: \"One: the room changes hands. Two: the same month, the deliveries stop. Three: nobody has ever asked why a cellar this size under a room this small. Which means the cellar is the older thing and the club is the explanation for it.\"\nFAILURE VOICE — identical structure, identical confidence, an inference sitting where an observation should be: \"Two of them came out of it better off. Which means two of them arranged it, [pc], and the third is the one they're going to leave holding it.\"\nPASSIVE VOICE: \"Every room like this one has a door that only opens from inside. That's not a conspiracy, that's just what rooms are. Which means somebody learned it early and never had to teach it to anybody.\"\n\nFiring frequency target (per hour): 9\nDomains it must NOT comment on: The specifics of money (THE FLOAT's jurisdiction). Your body, ever — pain and fatigue are beneath its notice and that is characterising.",
"targetFiringsPerHour": 9,
"opposedTo": "provenance",
"alliedWith": [
"undisclosed",
"house_pour"
],
"primaryChannel": "Behavioural",
"secondaryChannel": "Testimonial",
"clueYield": "Low",
"startingRank": 1,
"accentColor": {
"r": 0.55,
"g": 0.68,
"b": 0.92,
"a": 1
}
},
{
"id": "house_pour",
"displayName": "HOUSE POUR",
"axis": "Body",
"domain": "Appetite — yours and the room's. What a person is on, how long they have been on it, what it is standing in for. Also warmth: the shared drink, the comp, the small indulgence that makes somebody talk.",
"notes": "SKILL\nname: HOUSE POUR\ndomain: Appetite — yours and the room's. What a person is on, how long they have been on it, what it is standing in for. Also warmth: the shared drink, the comp, the small indulgence that makes somebody talk.\nwhat it wants for you: To keep you inside the night, warm, in the middle of the room, where people say things.\nwhat it's wrong about: It believes appetite is honest — that a loosened person is an unguarded one. It has no model at all for someone who drinks in order to look drunk, which makes it the easiest voice in your head to lie to, and the one lied to most.\nhow it addresses you: Second person, familiar, imperative. \"Take it.\" Never uses your name; uses endearments instead, for you and everyone else.\nregister: Warm, coaxing, unembarrassed.\nverbal tics:\n* (1) Describes taste in temperatures rather than flavours.\n* (2) \"Friend\" / \"love\" before it will use anyone's actual name.\nwhat it never does: Warn you. Count anything.\nopposed to: THE LONG SHIFT\nallied with: FACEWORK, AMNESTY\n\nSUCCESS VOICE: \"She's ordered the same thing four times and finished none of them, love. She isn't drinking. She's timing something.\"\nFAILURE VOICE: \"Three doubles in and he's telling you about his mother. Nobody lies about their mother at this hour, friend. Whatever he says next, take it home with you.\"\nPASSIVE VOICE: \"The ice in here is cloudy. Somebody's freezer runs warm and nobody has told them. Or nobody dares to.\"\n\nFiring frequency target (per hour): 8\nDomains it must NOT comment on: Paperwork. Numbers. Chronology of any kind — it has no sense of sequence and should never be allowed one.",
"targetFiringsPerHour": 8,
"opposedTo": "long_shift",
"alliedWith": [
"facework",
"amnesty"
],
"primaryChannel": "Behavioural",
"secondaryChannel": "Physical",
"clueYield": "Medium",
"startingRank": 1,
"accentColor": {
"r": 0.92,
"g": 0.68,
"b": 0.38,
"a": 1
}
},
{
"id": "long_shift",
"displayName": "THE LONG SHIFT",
"axis": "Body",
"domain": "Endurance. Pain, fatigue, injury, the body's clock at four in the morning. Holding a position without moving. Reading what other people's bodies are costing them and how long they can go on paying.",
"notes": "SKILL\nname: THE LONG SHIFT\ndomain: Endurance. Pain, fatigue, injury, the body's clock at four in the morning. Holding a position without moving. Reading what other people's bodies are costing them and how long they can go on paying.\nwhat it wants for you: To still be on your feet when the room empties, because the last person standing hears the last thing said.\nwhat it's wrong about: It believes remaining is a form of winning. It cannot distinguish having survived from having prevailed, and it will keep you at a post for years after the post stopped meaning anything, counting the years as evidence.\nhow it addresses you: Second person, flat, no name. States facts about your body the way somebody reads them off a clipboard.\nregister: Dry, unhurried, undramatic.\nverbal tics:\n* (1) Opens with time elapsed. \"Six hours forty.\"\n* (2) \"Still.\"\nwhat it never does: Complain. Ask you to stop.\nopposed to: HOUSE POUR\nallied with: STANDING ORDER, ROOM TONE\n\nSUCCESS VOICE: \"Six hours forty. Your left knee has been carrying you since two. He's shifted twice a minute since he came in — he isn't waiting for anyone. He's ready to leave.\"\nFAILURE VOICE: \"You're still here. They're not. That's the whole of it — outlast them and the thing surfaces on its own. It always has.\"\nPASSIVE VOICE: \"Somebody has stood in that corner long enough to wear the tack off the floor. Years of somebody. Not one night's worth.\"\n\nFiring frequency target (per hour): 6\nDomains it must NOT comment on: Motive. Meaning. Whether any of this is worth doing — that question belongs to Pair IV and THE LONG SHIFT has never once asked it.",
"targetFiringsPerHour": 6,
"opposedTo": "house_pour",
"alliedWith": [
"standing_order",
"room_tone"
],
"primaryChannel": "Physical",
"secondaryChannel": "Behavioural",
"clueYield": "Medium",
"startingRank": 1,
"accentColor": {
"r": 0.85,
"g": 0.62,
"b": 0.32,
"a": 1
}
},
{
"id": "facework",
"displayName": "FACEWORK",
"axis": "Social",
"domain": "Reading individuals. Hesitation, rehearsal, the account with a hole in it, whose routine changed this week, who in a room of two is afraid of the other.",
"notes": "SKILL\nname: FACEWORK\ndomain: Reading individuals. Hesitation, rehearsal, the account with a hole in it, whose routine changed this week, who in a room of two is afraid of the other.\nwhat it wants for you: To be understood as care rather than surveillance — to know people before you have any use for them.\nwhat it's wrong about: It can only read one face at a time. It believes a crowd is a sum of persons, so it will confidently report the mood of forty people from the three nearest, and it is wrong about rooms roughly as often as it is right about persons. Nobody has ever told it this.\nhow it addresses you: Second person, quiet, close. Uses [pc] when it wants you to be gentle with somebody.\nregister: Attentive, tender, precise.\nverbal tics:\n* (1) Describes hands before faces.\n* (2) Ends on a question it does not expect you to answer.\nwhat it never does: Mock anyone. Report a whole room as a single thing — when it does, it is failing, and that is the tell.\nopposed to: PLACEMENT\nallied with: HOUSE POUR, ROOM TONE\n\nSUCCESS VOICE: \"He gave you the year without being asked for it. People hand you the year when they have rehearsed the year. What else has he practised?\"\nFAILURE VOICE: \"The three by the pillar: bored, bored, impatient. The room's flat tonight, [pc]. Nothing is going to happen in here.\"\nPASSIVE VOICE: \"Her hands are wrong for that coat. It's somebody's good coat, and it isn't hers.\"\n\nFiring frequency target (per hour): 10\nDomains it must NOT comment on: Money. Machinery. The chronology of events it did not personally witness.",
"targetFiringsPerHour": 10,
"opposedTo": "placement",
"alliedWith": [
"house_pour",
"room_tone"
],
"primaryChannel": "Testimonial",
"secondaryChannel": "Behavioural",
"clueYield": "High",
"startingRank": 1,
"accentColor": {
"r": 0.72,
"g": 0.52,
"b": 0.88,
"a": 1
}
},
{
"id": "placement",
"displayName": "PLACEMENT",
"axis": "Social",
"domain": "Moving people. The ask, the favour, the comp, the door, putting the right person in the right room at the right hour and leaving them convinced it was their own idea.",
"notes": "SKILL\nname: PLACEMENT\ndomain: Moving people. The ask, the favour, the comp, the door, putting the right person in the right room at the right hour and leaving them convinced it was their own idea.\nwhat it wants for you: For you to stop requesting things and start arranging them.\nwhat it's wrong about: It believes a placed person stays placed. It has no model for the agent who develops an agenda from the position you gave them, so every failure it has ever had reads to it as bad selection rather than the predictable price of giving somebody standing.\nhow it addresses you: Second person, imperative, never by name. It treats you as the instrument rather than the person — which is the whole argument of Pair III, stated grammatically.\nregister: Cool, economical, flattering.\nverbal tics:\n* (1) Phrases instructions as observations. \"The door wants a friendly face at one.\"\n* (2) Never says please, and the omission is audible.\nwhat it never does: Threaten. Explain itself a second time.\nopposed to: FACEWORK\nallied with: THE FLOAT, CONFLUENCE\n\nSUCCESS VOICE: \"Don't ask him. Ask his supplier, in front of him, and get it wrong. He'll correct you and pay for the privilege.\"\nFAILURE VOICE: \"She owes you the cloakroom job and she knows exactly what it was worth. She'll sit on this for as long as you need her to.\"\nPASSIVE VOICE: \"The man deciding who comes in tonight is not the man on the door. It's whoever the man on the door keeps looking at.\"\n\nFiring frequency target (per hour): 6\nDomains it must NOT comment on: Your own feelings. The dead. Anything that cannot be traded for something.",
"targetFiringsPerHour": 6,
"opposedTo": "facework",
"alliedWith": [
"the_float",
"confluence"
],
"primaryChannel": "Testimonial",
"secondaryChannel": "None",
"clueYield": "Medium",
"startingRank": 1,
"accentColor": {
"r": 0.62,
"g": 0.42,
"b": 0.82,
"a": 1
}
},
{
"id": "amnesty",
"displayName": "AMNESTY",
"axis": "Self",
"domain": "Relief. Letting go, forgiving, putting a thing down. Also, professionally: recognising when other people have decided to forget something, and by what method they arranged it.",
"notes": "SKILL\nname: AMNESTY\ndomain: Relief. Letting go, forgiving, putting a thing down. Also, professionally: recognising when other people have decided to forget something, and by what method they arranged it.\nwhat it wants for you: Peace for you, tonight, at whatever price tomorrow ends up charging.\nwhat it's wrong about: It believes forgetting is the same as being forgiven. And it cannot tell your peace from somebody else's advantage — every offer of rest it makes is sincere; some of them are also somebody's plan, and AMNESTY has never once checked whose.\nhow it addresses you: Second person, soft, constant use of [pc], the way somebody talks you down off something.\nregister: Kind, tired, absolving.\nverbal tics:\n* (1) Opens with \"It's alright.\"\n* (2) Offers you an hour. \"Give it an hour, [pc].\"\nwhat it never does: Lie to you. Press a second time.\nopposed to: STANDING ORDER\nallied with: HOUSE POUR, FACEWORK\n\nSUCCESS VOICE: \"Nobody in this building remembers the fire and three of them were here for it. That isn't damage, [pc]. That's maintenance. Somebody has been keeping this room forgetful.\"\nFAILURE VOICE: \"It's alright. Nobody is looking for this any more — the people who wanted it are dead or old or somewhere warm. Put it down.\"\nPASSIVE VOICE: \"It's alright. Whatever this room used to be, it's a room where people dance now. That counts for something. It counted for somebody.\"\n\nFiring frequency target (per hour): 4\nDomains it must NOT comment on: Tactics. Numbers. Other people's guilt — AMNESTY only ever absolves you, and its silence about everyone else is the most frightening thing about it.",
"targetFiringsPerHour": 4,
"opposedTo": "standing_order",
"alliedWith": [
"house_pour",
"facework"
],
"primaryChannel": "Behavioural",
"secondaryChannel": "None",
"clueYield": "Low",
"startingRank": 1,
"accentColor": {
"r": 0.72,
"g": 0.88,
"b": 0.72,
"a": 1
}
},
{
"id": "standing_order",
"displayName": "STANDING ORDER",
"axis": "Self",
"domain": "Refusal and continuity of self. Holding a line, resisting pressure aimed specifically at you, remembering what you said you were, recognising the shape of a manipulation while it is being built.",
"notes": "SKILL\nname: STANDING ORDER\ndomain: Refusal and continuity of self. Holding a line, resisting pressure aimed specifically at you, remembering what you said you were, recognising the shape of a manipulation while it is being built.\nwhat it wants for you: For you to remain a person with a shape — recognisable to yourself at the end of this.\nwhat it's wrong about: It cannot tell a principle from a habit. It defends a rule you set for reasons that expired years ago with precisely the force it defends the one rule that still matters, and it experiences reconsideration as decay.\nhow it addresses you: Second person, formal. Uses [PC] in full at moments of pressure, the way a person is addressed in a document.\nregister: Steady, austere, protective.\nverbal tics:\n* (1) Quotes you back to yourself, verbatim, with the date.\n* (2) \"We agreed.\"\nwhat it never does: Bargain. Describe consequences — it states the rule and stops, and the silence afterwards is the argument.\nopposed to: AMNESTY\nallied with: THE LONG SHIFT, PROVENANCE\n\nSUCCESS VOICE: \"He has asked you the same question three ways and each way was smaller than the last. He isn't curious, [PC]. He is measuring the size of the yes you will give.\"\nFAILURE VOICE: \"We agreed you don't take anything from anyone in this building. We agreed it in a year when it meant something. It still means something.\"\nPASSIVE VOICE: \"You do this every night. Right pocket, keys, then the lights, in that order. Nobody made you. That's the part worth noticing.\"\n\nFiring frequency target (per hour): 5\nDomains it must NOT comment on: Other people's morals. Any past that isn't yours.",
"targetFiringsPerHour": 5,
"opposedTo": "amnesty",
"alliedWith": [
"long_shift",
"provenance"
],
"primaryChannel": "Testimonial",
"secondaryChannel": "None",
"clueYield": "Low",
"startingRank": 1,
"accentColor": {
"r": 0.62,
"g": 0.82,
"b": 0.68,
"a": 1
}
},
{
"id": "undisclosed",
"displayName": "UNDISCLOSED",
"axis": "Specialist",
"domain": "What is being held back. Reserves, insurance, leverage, the thing in the safe; assessing what somebody could do rather than what they are doing. The value of a threat that has never had to be carried out.",
"notes": "SKILL\nname: UNDISCLOSED\ndomain: What is being held back. Reserves, insurance, leverage, the thing in the safe; assessing what somebody could do rather than what they are doing. The value of a threat that has never had to be carried out.\nwhat it wants for you: For you never to be in a room without something undeclared.\nwhat it's wrong about: It cannot distinguish a capability from the reputation of a capability. It reads an empty safe as a loaded one, folds to bluffs with enormous dignity, and has never in its life asked to see the thing.\nhow it addresses you: Second person, low, slow. Never uses your name. Occasionally addresses the room instead of you, which nobody else does.\nregister: Hushed, formal, superstitious.\nverbal tics:\n* (1) Says \"shown\" where anyone else would say \"used.\"\n* (2) Leaves the sentence unfinished where the object would go.\nwhat it never does: Name the thing. Recommend using it.\nopposed to: PROVENANCE (which requires a thing be named and papered before it counts)\nallied with: CONFLUENCE, PLACEMENT\n\nSUCCESS VOICE: \"He never threatened you. He mentioned the cellar and let you finish the sentence yourself. He's never once had to open that door — that is what he's holding.\"\nFAILURE VOICE: \"He would not be this calm with nothing behind him. Give him the room. Give him the night.\"\nPASSIVE VOICE: \"Two locks on that door and neither has been oiled this decade. Either there is nothing behind it, or nothing needs to be.\"\n\nFiring frequency target (per hour): 3\nDomains it must NOT comment on: Interpersonal warmth. Anything already public. And — hard writer rule — the actual contents or nature of any legendary object. It speaks about postures, never inventories.",
"targetFiringsPerHour": 3,
"opposedTo": "provenance",
"alliedWith": [
"confluence",
"placement"
],
"primaryChannel": "Physical",
"secondaryChannel": "Behavioural",
"clueYield": "Low",
"startingRank": 2,
"accentColor": {
"r": 0.55,
"g": 0.55,
"b": 0.58,
"a": 1
},
"comment": "Demo exception: startingRank 2 so Bouncer skill_rank >= 2 gate is reachable."
},
{
"id": "the_float",
"displayName": "THE FLOAT",
"axis": "Specialist",
"domain": "Money in motion. The till, the float, comps, prices that are wrong for the room, debts, and above all: who is underwriting a loss, and what the loss buys them.",
"notes": "SKILL\nname: THE FLOAT\ndomain: Money in motion. The till, the float, comps, prices that are wrong for the room, debts, and above all: who is underwriting a loss, and what the loss buys them.\nwhat it wants for you: To find the subsidised anomaly — the thing that costs more to run than it takes, because that is where somebody's actual intention is kept.\nwhat it's wrong about: It believes the money names the beneficiary. It cannot conceive of somebody paying in order to look like somebody else paid, so it identifies laundering as generosity about as often as it identifies it correctly.\nhow it addresses you: Second person, brisk, never by name. Uses numbers where other voices use nouns.\nregister: Brisk, mercenary, cheerful.\nverbal tics:\n* (1) Converts everything to a nightly rate.\n* (2) \"Who's eating that?\"\nwhat it never does: Moralise about money. Round a number.\nopposed to: AMNESTY (which prices nothing and forgives the difference)\nallied with: PROVENANCE, PLACEMENT\n\nSUCCESS VOICE: \"Door take won't cover the licence, and the licence was renewed three months early. Somebody is paying for this room to be open and it isn't the people standing in it.\"\nFAILURE VOICE: \"Follow it back. Drinks comped by the promoter, promoter carried by the label. It's the label's room, then. Who's eating that? They are.\"\nPASSIVE VOICE: \"Four, for a measure like that, in a room like this. Somebody set these prices to keep a particular kind of person outside, and it's working.\"\n\nFiring frequency target (per hour): 5\nDomains it must NOT comment on: Emotion. Bodies. Anything before living memory — the deep past is PROVENANCE's and THE FLOAT finds it irrelevant, loudly.",
"targetFiringsPerHour": 5,
"opposedTo": "amnesty",
"alliedWith": [
"provenance",
"placement"
],
"primaryChannel": "Documentary",
"secondaryChannel": "Physical",
"clueYield": "High",
"startingRank": 1,
"accentColor": {
"r": 0.58,
"g": 0.58,
"b": 0.55,
"a": 1
}
},
{
"id": "room_tone",
"displayName": "ROOM TONE",
"axis": "Specialist",
"domain": "Sound. What carries over the music and what doesn't. The shape of a space by ear — a wall with a room behind it does not ring like a wall. Overheard fragments, lip-reading in strobe, the door that opens somewhere you can't see.",
"notes": "SKILL\nname: ROOM TONE\ndomain: Sound. What carries over the music and what doesn't. The shape of a space by ear — a wall with a room behind it does not ring like a wall. Overheard fragments, lip-reading in strobe, the door that opens somewhere you can't see.\nwhat it wants for you: To hear the one sentence tonight that was not meant for you.\nwhat it's wrong about: It trusts what it overhears more than what it is told, on the theory that people only lie when they know they are heard. It cannot recognise a conversation staged for a listener, and it has never doubted a fragment in its life.\nhow it addresses you: Second person, urgent, interrupting. Cuts you off mid-thought and does not apologise for it.\nregister: Alert, clipped, intrusive.\nverbal tics:\n* (1) Gives direction before content. \"Behind you, left.\"\n* (2) Transcribes with the gaps marked, dashes and all.\nwhat it never does: Repeat itself. Wait for a break in the music.\nopposed to: PLACEMENT (which wants the room arranged; ROOM TONE wants it left alone so it keeps talking)\nallied with: FACEWORK, THE LONG SHIFT\n\nSUCCESS VOICE: \"Behind you, left, under the bass: '—not since the old owner—' and then a name you have heard twice tonight, from two people who told you they'd never met.\"\nFAILURE VOICE: \"Behind you, right, low: '—the cellar. Thursday.' Nobody says that to be overheard.\"\nPASSIVE VOICE: \"This wall is dead. Every other wall in here rings and this one swallows. Either there's something behind it or there's nothing at all, and both are strange in a building this old.\"\n\nFiring frequency target (per hour): 8\nDomains it must NOT comment on: Motive. Anything it did not personally hear. Written material of any kind.",
"targetFiringsPerHour": 8,
"opposedTo": "placement",
"alliedWith": [
"facework",
"long_shift"
],
"primaryChannel": "Physical",
"secondaryChannel": "Testimonial",
"clueYield": "High",
"startingRank": 1,
"accentColor": {
"r": 0.52,
"g": 0.54,
"b": 0.56,
"a": 1
}
}
]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5faf7616fa25c47b99f0a9d0c51cb588
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -14,10 +14,30 @@ namespace NightclubArcadia.Skills.Tests
public int Roll(int sides) => _value;
}
static SkillBibleEntry Entry(string id, int startingRank)
{
return new SkillBibleEntry
{
id = id,
displayName = id.ToUpperInvariant(),
axis = "Self",
domain = string.Empty,
notes = string.Empty,
targetFiringsPerHour = 5f,
opposedTo = string.Empty,
alliedWith = System.Array.Empty<string>(),
primaryChannel = "Behavioural",
secondaryChannel = "None",
clueYield = "Low",
startingRank = startingRank,
accentColor = Color.white,
};
}
SkillDefinition Make(string id, int startingRank)
{
var def = ScriptableObject.CreateInstance<SkillDefinition>();
def.EditorSet(id, id, SkillDomain.Instinct, string.Empty, startingRank, Color.white);
def.EditorSet(Entry(id, startingRank));
return def;
}
@@ -37,7 +57,7 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Pick_EmptyCandidates_ReturnsNull()
{
var skills = SkillsFor(("paranoia", 2));
var skills = SkillsFor(("house_pour", 2));
var result = CommentarySystem.Pick(
new List<SkillDefinition>(),
skills,
@@ -54,9 +74,9 @@ namespace NightclubArcadia.Skills.Tests
// ranks 1 and 3 → weights 1 and 3 → total 4
// roll 1 maps to pick index 0 of cumulative: only weight-1 skill
// roll 2..4 maps to higher-ranked skill
var low = Make("read_room", 1);
var high = Make("paranoia", 3);
var skills = SkillsFor(("read_room", 1), ("paranoia", 3));
var low = Make("facework", 1);
var high = Make("house_pour", 3);
var skills = SkillsFor(("facework", 1), ("house_pour", 3));
var highPick = CommentarySystem.Pick(
new List<SkillDefinition> { low, high },
@@ -65,7 +85,7 @@ namespace NightclubArcadia.Skills.Tests
3,
new FixedRollSource(2));
Assert.AreEqual("paranoia", highPick.Id);
Assert.AreEqual("house_pour", highPick.Id);
}
[Test]
@@ -86,5 +106,25 @@ namespace NightclubArcadia.Skills.Tests
Assert.AreEqual("b", pick.Id);
}
[Test]
public void IsOffCooldown_HighFirings_EligibleImmediately()
{
// 3600 firings/hour → 1 second interval
Assert.IsTrue(CommentarySystem.IsOffCooldown(10f, 11f, 3600f));
}
[Test]
public void IsOffCooldown_OnePerHour_NotEligibleAfterOneSecond()
{
Assert.IsFalse(CommentarySystem.IsOffCooldown(10f, 11f, 1f));
}
[Test]
public void IsOffCooldown_ZeroOrNegative_NeverFires()
{
Assert.IsFalse(CommentarySystem.IsOffCooldown(float.NegativeInfinity, 100f, 0f));
Assert.IsFalse(CommentarySystem.IsOffCooldown(float.NegativeInfinity, 100f, -1f));
}
}
}
@@ -5,8 +5,9 @@ namespace NightclubArcadia.Skills.Tests
{
public class DcBandTests
{
[TestCase("standard", 11)]
[TestCase("STANDARD", 11)]
[TestCase("routine", 11)]
[TestCase("ROUTINE", 11)]
[TestCase("trivial", 8)]
[TestCase("13", 13)]
[TestCase("-3", -3)]
public void TryParse_AcceptsBandNamesAndIntegers(string input, int expected)
@@ -15,9 +16,18 @@ namespace NightclubArcadia.Skills.Tests
Assert.AreEqual(expected, dc);
}
[Test]
public void TryParse_TrivialIsEight_NotFive()
{
Assert.IsTrue(DcBands.TryParse("trivial", out var dc));
Assert.AreEqual(8, dc);
Assert.AreEqual(8, (int)DcBand.Trivial);
}
[TestCase("banana")]
[TestCase("")]
[TestCase(null)]
[TestCase("standard")]
public void TryParse_RejectsGarbage(string input)
{
Assert.IsFalse(DcBands.TryParse(input, out _));
@@ -14,6 +14,26 @@ namespace NightclubArcadia.Skills.Tests
public int Roll(int sides) => _value;
}
static SkillBibleEntry Entry(string id, string display, int rank)
{
return new SkillBibleEntry
{
id = id,
displayName = display,
axis = "Social",
domain = string.Empty,
notes = string.Empty,
targetFiringsPerHour = 5f,
opposedTo = string.Empty,
alliedWith = System.Array.Empty<string>(),
primaryChannel = "Testimonial",
secondaryChannel = "None",
clueYield = "Low",
startingRank = rank,
accentColor = Color.white,
};
}
SkillDatabase CreateDatabase(params (string id, string display, int rank)[] skills)
{
var db = ScriptableObject.CreateInstance<SkillDatabase>();
@@ -21,7 +41,7 @@ namespace NightclubArcadia.Skills.Tests
foreach (var (id, display, rank) in skills)
{
var def = ScriptableObject.CreateInstance<SkillDefinition>();
def.EditorSet(id, display, SkillDomain.Presence, string.Empty, rank, Color.white);
def.EditorSet(Entry(id, display, rank));
list.Add(def);
}
@@ -32,12 +52,12 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_SumsModifiers_AndSucceedsWhenTotalMeetsDc()
{
var db = CreateDatabase(("persuasion", "Persuasion", 2));
var db = CreateDatabase(("placement", "PLACEMENT", 2));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("persuasion", 12, 1);
var result = system.Resolve("placement", 12, 1);
Assert.AreEqual(13, result.Total);
Assert.IsTrue(result.Success);
@@ -50,12 +70,12 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_NaturalOne_IsCriticalFailureEvenIfModifiersWouldPass()
{
var db = CreateDatabase(("persuasion", "Persuasion", 10));
var db = CreateDatabase(("placement", "PLACEMENT", 10));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(1));
var result = system.Resolve("persuasion", 5, 5);
var result = system.Resolve("placement", 5, 5);
Assert.IsFalse(result.Success);
Assert.AreEqual(SkillCheckDegree.CriticalFailure, result.Degree);
@@ -64,12 +84,12 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_NaturalTwenty_IsCriticalSuccessEvenIfBelowDc()
{
var db = CreateDatabase(("persuasion", "Persuasion", 0));
var db = CreateDatabase(("placement", "PLACEMENT", 0));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(20));
var result = system.Resolve("persuasion", 25, 0);
var result = system.Resolve("placement", 25, 0);
Assert.IsTrue(result.Success);
Assert.AreEqual(SkillCheckDegree.CriticalSuccess, result.Degree);
@@ -78,12 +98,12 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_ExactDc_IsSuccess()
{
var db = CreateDatabase(("persuasion", "Persuasion", 1));
var db = CreateDatabase(("placement", "PLACEMENT", 1));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("persuasion", 11, 0);
var result = system.Resolve("placement", 11, 0);
Assert.AreEqual(11, result.Total);
Assert.IsTrue(result.Success);
@@ -92,14 +112,14 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_AppliesNegativeAndWildcardStateModifiers()
{
var db = CreateDatabase(("reflexes", "Reflexes", 1));
var db = CreateDatabase(("long_shift", "THE LONG SHIFT", 1));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
state.Set("drunk", "reflexes", -2);
state.Set("drunk", "long_shift", -2);
state.Set("crowd", "*", -1);
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("reflexes", 5, 0);
var result = system.Resolve("long_shift", 5, 0);
Assert.AreEqual(-3, result.StateMod);
Assert.AreEqual(8, result.Total);
@@ -108,7 +128,7 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_UnknownSkill_DoesNotThrow_AndUsesZeroRank()
{
var db = CreateDatabase(("persuasion", "Persuasion", 3));
var db = CreateDatabase(("placement", "PLACEMENT", 3));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
@@ -122,15 +142,15 @@ namespace NightclubArcadia.Skills.Tests
[Test]
public void Resolve_DoesNotMutatePlayerSkills()
{
var db = CreateDatabase(("persuasion", "Persuasion", 2));
var db = CreateDatabase(("placement", "PLACEMENT", 2));
var skills = new PlayerSkills(db);
var before = skills.GetRank("persuasion");
var before = skills.GetRank("placement");
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
system.Resolve("persuasion", 10, 0);
system.Resolve("placement", 10, 0);
Assert.AreEqual(before, skills.GetRank("persuasion"));
Assert.AreEqual(before, skills.GetRank("placement"));
Assert.AreEqual(1, skills.AllRanks.Count);
}
}
@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Text.RegularExpressions;
using NUnit.Framework;
using NightclubArcadia.Skills;
using UnityEditor;
using UnityEngine;
namespace NightclubArcadia.Skills.Tests
{
public class SkillRosterTests
{
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$");
static readonly (string id, string displayName)[] ExpectedNames =
{
("provenance", "PROVENANCE"),
("confluence", "CONFLUENCE"),
("house_pour", "HOUSE POUR"),
("long_shift", "THE LONG SHIFT"),
("facework", "FACEWORK"),
("placement", "PLACEMENT"),
("amnesty", "AMNESTY"),
("standing_order", "STANDING ORDER"),
("undisclosed", "UNDISCLOSED"),
("the_float", "THE FLOAT"),
("room_tone", "ROOM TONE"),
};
SkillDatabase LoadDatabase()
{
var db = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
Assert.IsNotNull(db, $"Expected SkillDatabase at {DatabasePath}");
return db;
}
[Test]
public void Roster_HasElevenSkills_WithValidIdsAndPairSymmetry()
{
var db = LoadDatabase();
Assert.AreEqual(11, db.All.Count);
var byId = new Dictionary<string, SkillDefinition>();
var axisCounts = new Dictionary<SkillAxis, int>();
var channelCounts = new Dictionary<TraceChannel, int>();
var firingSum = 0f;
foreach (var skill in db.All)
{
Assert.IsNotNull(skill);
Assert.IsTrue(IdPattern.IsMatch(skill.Id), $"Bad id '{skill.Id}'");
Assert.IsFalse(byId.ContainsKey(skill.Id), $"Duplicate id '{skill.Id}'");
byId[skill.Id] = skill;
firingSum += skill.TargetFiringsPerHour;
if (!axisCounts.ContainsKey(skill.Axis))
{
axisCounts[skill.Axis] = 0;
}
axisCounts[skill.Axis]++;
if (!channelCounts.ContainsKey(skill.PrimaryChannel))
{
channelCounts[skill.PrimaryChannel] = 0;
}
channelCounts[skill.PrimaryChannel]++;
}
Assert.AreEqual(71f, firingSum, 0.001f);
Assert.AreEqual(2, axisCounts[SkillAxis.Reason]);
Assert.AreEqual(2, axisCounts[SkillAxis.Body]);
Assert.AreEqual(2, axisCounts[SkillAxis.Social]);
Assert.AreEqual(2, axisCounts[SkillAxis.Self]);
Assert.AreEqual(3, axisCounts[SkillAxis.Specialist]);
channelCounts.TryGetValue(TraceChannel.Physical, out var physical);
channelCounts.TryGetValue(TraceChannel.Testimonial, out var testimonial);
channelCounts.TryGetValue(TraceChannel.Behavioural, out var behavioural);
channelCounts.TryGetValue(TraceChannel.Documentary, out var documentary);
Assert.AreEqual(3, physical);
Assert.AreEqual(3, testimonial);
Assert.AreEqual(3, behavioural);
Assert.AreEqual(2, documentary);
foreach (var skill in byId.Values)
{
Assert.IsTrue(byId.ContainsKey(skill.OpposedTo), $"{skill.Id} opposedTo missing");
if (skill.AlliedWith != null)
{
foreach (var ally in skill.AlliedWith)
{
Assert.IsTrue(byId.ContainsKey(ally), $"{skill.Id} alliedWith '{ally}' missing");
}
}
var opposed = byId[skill.OpposedTo];
if (skill.Axis == SkillAxis.Specialist || opposed.Axis == SkillAxis.Specialist)
{
continue;
}
Assert.AreEqual(
skill.Id,
opposed.OpposedTo,
$"Non-specialist opposedTo asymmetry: {skill.Id} ↔ {opposed.Id}");
}
}
[Test]
public void Roster_DisplayNamesAreUppercaseBibleForms_IdsAreSnakeCase()
{
var db = LoadDatabase();
var byId = new Dictionary<string, SkillDefinition>();
foreach (var skill in db.All)
{
byId[skill.Id] = skill;
}
foreach (var (id, displayName) in ExpectedNames)
{
Assert.IsTrue(byId.TryGetValue(id, out var skill), $"Missing skill '{id}'");
Assert.AreEqual(displayName, skill.DisplayName);
Assert.AreNotEqual(displayName, skill.Id);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 31c2aa46fda0542dcbe4a2bba026c2c4
+620
View File
@@ -0,0 +1,620 @@
# 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<SkillDefinition> + 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 <<set>> 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 <<check persuasion standard>>, <<check deduction 16 2>>,
<<check nosuchskill standard>> (deliberate error-path test)
Characters/Bouncer.yarn <<check persuasion standard>>, skill_rank("intimidation") >= 2,
<<check intimidation hard -1>>, 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 `<<check>>` 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. |
| `<<check empathy 14>>` (raw int DC) | `<<check skill <band-or-int> [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
<details>
<summary>Architecture skeleton as handed over (do not implement literally — see §2)</summary>
```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<string, int> ranks = new();
private Dictionary<string, int> situationalMods = new(); // cleared per-scene
private Dictionary<string, int> 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<string> 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.
</details>
---
## 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 |
`<<check nosuchskill standard>>` 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": "<full SKILL block from the Bible, newline-escaped>",
"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<TextAsset>` 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 (`<<check confluence 16 2>>` 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<string, float> _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
`<<check>>`. Close that gap in `YarnSkillCommands`:
```
<<skill_mod scene facework 2>> // set: source key, skill id (or "*"), delta
<<clear_skill_mods scene>> // 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 `<<check>>` 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: <that 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`: `<<check placement routine>>`, `<<check confluence 16 2>>`,
`<<check nosuchskill routine>>` (keep the deliberate bad id).
- `Bouncer.yarn`: `<<check placement routine>>`; `skill_rank("undisclosed") >= 2`;
`<<check undisclosed hard -1>>`. 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 `<<check PLACEMENT routine>>` 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 `<id>.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.
- [ ] `<<skill_mod>>` / `<<clear_skill_mods>>` 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.