feat: NPC noir genre table pack + genre registry

Implements Slice 2 Task 1: Noir/Blade Runner genre pack with curated
string tables (names, looks, manners, wants, secrets, voices) and a
registry mapping id->pack for use by offline NPC generator.

Tests: 2/2 pass (noir structure + registry resolution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 15:10:35 +01:00
co-authored by Claude Opus 4.8
parent 61fb2228b9
commit 03ce5e1606
3 changed files with 83 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
import { noir } from './noir.js';
const packs = new Map([[noir.id, noir]]);
export function getGenrePack(id) { return packs.get(id) || null; }
export function registerGenrePack(pack) { packs.set(pack.id, pack); }
export function listGenrePacks() {
return [...packs.values()].map((p) => ({ id: p.id, label: p.label }));
}
+51
View File
@@ -0,0 +1,51 @@
export const noir = {
id: 'noir',
label: 'Noir / Blade Runner',
tables: {
firstNames: ['Otto', 'Mara', 'Cyrus', 'Dita', 'Lev', 'Priya', 'Sol', 'Neena', 'Bram', 'Vesna'],
surnames: ['Brandt', 'Okoye', 'Vance', 'Marlow', 'Sato', 'Kessler', 'Reyes', 'Dahl', 'Cole', 'Nyx'],
looks: [
'Heavyset, oil-stained cuffs, a rosary they keep touching',
'Rail-thin, chrome tooth, coat two sizes too big',
'Ex-military posture gone to seed, nicotine-yellow fingers',
'Immaculate suit, one cracked lens in their glasses',
'Burn scar down one wrist, always half in shadow',
'Too-bright dye job, roots showing, tired eyes',
'Synthetic-smooth skin that never quite reads as alive',
'Missing a fingertip, hides the hand when they notice you looking',
],
manners: [
'Over-friendly, laughs a beat too late',
'Answers questions with questions',
'Won\'t sit with their back to the door',
'Counts their words like they cost money',
'Flatters you, then watches to see if it landed',
'Keeps checking a watch that isn\'t there',
'Talks fast when lying, slow when it\'s worse',
],
wants: [
'The debt gone before their shift ends',
'A name - someone else to take the fall',
'Out of the city on the last transport',
'To be believed, just once',
'The photograph back before anyone else sees it',
'Protection they\'re too proud to ask for',
],
secrets: [
'Signed off the container they never inspected',
'Is already talking to the other side',
'The person you\'re looking for is family',
'Sold the access codes months ago',
'Isn\'t who their papers say they are',
'Watched it happen and did nothing',
],
voices: [
'Says "you understand me?" after everything',
'Never finishes the last word of a sentence',
'Drops into a second language when rattled',
'Calls everyone "friend" like a threat',
'Over-enunciates, like reciting',
'Laughs at their own lines before they land',
],
},
};
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { noir } from '../../src/npc/packs/noir.js';
import { getGenrePack, listGenrePacks } from '../../src/npc/packs/index.js';
const FIELDS = ['firstNames', 'surnames', 'looks', 'manners', 'wants', 'secrets', 'voices'];
describe('noir genre pack', () => {
it('has an id, label, and all non-empty tables', () => {
expect(noir.id).toBe('noir');
expect(noir.label).toMatch(/noir/i);
for (const f of FIELDS) {
expect(Array.isArray(noir.tables[f]), `${f} is array`).toBe(true);
expect(noir.tables[f].length, `${f} non-empty`).toBeGreaterThanOrEqual(6);
expect(noir.tables[f].every((x) => typeof x === 'string' && x.length > 0)).toBe(true);
}
});
it('is resolvable from the registry', () => {
expect(getGenrePack('noir')).toBe(noir);
expect(getGenrePack('missing')).toBe(null);
expect(listGenrePacks()).toEqual([{ id: 'noir', label: noir.label }]);
});
});