Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
6.4 kB · 176 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177// Normalize a raw `actor.rpg.generator` record into clue-ready `SuspectAttributes`, plus the hex →// named-colour-family bucketing the clue phrasing relies on. Pure: the caller fetches the record.
import type { SuspectAttributes, Trait } from './types.js'
/** Layers held in a hand rather than worn. */const HELD_CATEGORIES = new Set(['righthand', 'lefthand'])
/** Base/under layers that are always present (or not visible attire) — never clue-worthy. */const SKIP_CATEGORIES = new Set(['body', 'undies', 'hind'])
/** A raw generator layer as it appears in the record JSON. */interface RawLayer { id?: unknown title?: unknown colors?: { main?: unknown } | unknown}
/** Loosely-typed view of an `actor.rpg.generator` record value. */export interface GeneratorRecord { bodyType?: unknown skin?: { hex?: unknown } | unknown eyes?: { hex?: unknown } | unknown items?: unknown layers?: unknown}
function str(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined}
function mainColor(colors: RawLayer['colors']): string | undefined { if (colors && typeof colors === 'object' && 'main' in colors) return str((colors as { main?: unknown }).main) return undefined}
/** * Bucket a `#rrggbb` hex colour into a named family used for clue phrasing ('green', 'navy blue', * 'fair', …). Returns undefined for an unparseable hex. Greys/near-blacks/near-whites bucket by * lightness; otherwise by hue. Deliberately coarse — clues should group "greenish" shirts together, * not split #60974d from #4e8a3f. */interface ColorHsl { h: number // hue 0..360 s: number // saturation 0..1 l: number // lightness 0..1 chroma: number // max-min, 0..1 lum: number // perceived (Rec.601) luminance 0..1}
/** Parse `#rrggbb` into hue/sat/lightness/chroma + perceived luminance; null if unparseable. */function hexToHsl(hex: string | undefined): ColorHsl | null { if (!hex) return null const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()) if (!m) return null const n = parseInt(m[1]!, 16) const R = (n >> 16) & 0xff const G = (n >> 8) & 0xff const B = n & 0xff const r = R / 255 const g = G / 255 const b = B / 255 const max = Math.max(r, g, b) const min = Math.min(r, g, b) const l = (max + min) / 2 const d = max - min let h = 0 if (d > 0) { if (max === r) h = ((g - b) / d) % 6 else if (max === g) h = (b - r) / d + 2 else h = (r - g) / d + 4 h = (h * 60 + 360) % 360 } const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)) const lum = (0.299 * R + 0.587 * G + 0.114 * B) / 255 return { h, s, l, chroma: d, lum }}
/** Map an HSL triple to a named colour family. */function bucketFromHsl({ h, s, l, chroma }: ColorHsl): string { // Achromatic: classify by lightness. if (chroma < 0.08) { if (l < 0.15) return 'black' if (l > 0.85) return 'white' return 'grey' } // Brown is a dark/low-sat orange — call it out before the hue table. if (h < 45 && l < 0.4 && s < 0.8) return 'brown' if (h < 15 || h >= 345) return 'red' if (h < 45) return 'orange' if (h < 70) return 'yellow' if (h < 165) return 'green' if (h < 195) return 'teal' if (h < 255) return l < 0.4 ? 'navy blue' : 'blue' if (h < 290) return 'purple' if (h < 345) return 'pink' return 'red'}
export function hexToBucket(hex: string | undefined): string | undefined { const hsl = hexToHsl(hex) return hsl ? bucketFromHsl(hsl) : undefined}
/** * Skin descriptor. Natural skin is "roughly orange" and reads by lightness, so a near-neutral or * lightly-tinted fleshy tone buckets as fair/light/tan/dark. But fantasy skin can be vividly tinted — * a dominant non-fleshy hue (green, blue, purple, …) is named by its colour family instead, so an orc * reads "green" skin and a tiefling "purple" skin. */function skinBucket(hex: string | undefined): string | undefined { const hsl = hexToHsl(hex) if (!hsl) return undefined const { h, lum, chroma } = hsl // Natural = near-neutral, or a hue in the fleshy orange band (~10–55°) that isn't vividly colourful. // Gate vividness on CHROMA, not HSL saturation — pale skin has high HSL saturation but low chroma. const natural = chroma < 0.15 || (h >= 10 && h <= 55 && chroma < 0.5) if (!natural) return bucketFromHsl(hsl) // vivid tint → "green"/"blue"/"purple"/… skin (incl. vivid orange) if (lum > 0.72) return 'fair' if (lum > 0.5) return 'light' if (lum > 0.32) return 'tan' return 'dark'}
function trait(category: string, title: string, hex: string | undefined): Trait { const bucket = hexToBucket(hex) return { category, title, ...(hex ? { colorHex: hex } : {}), ...(bucket ? { colorBucket: bucket } : {}), }}
/** * Normalize an `actor.rpg.generator` record into `SuspectAttributes`. Drops base/under layers, * splits hand-held items from worn attire, and buckets skin/eye/item colours. Returns null if the * record carries no usable worn layers (a featureless or malformed generator we can't build clues * from). */export function attributesFromGenerator( did: string, name: string, rec: GeneratorRecord,): SuspectAttributes | null { const layers = Array.isArray(rec.layers) ? (rec.layers as RawLayer[]) : [] const worn: Trait[] = [] const held: Trait[] = []
for (const layer of layers) { const category = str(layer.id) const title = str(layer.title) if (!category || !title) continue if (SKIP_CATEGORIES.has(category)) continue const hex = mainColor(layer.colors) if (HELD_CATEGORIES.has(category)) held.push(trait(category, title, hex)) else worn.push(trait(category, title, hex)) }
if (worn.length === 0 && held.length === 0) return null
const skinHex = str((rec.skin as { hex?: unknown } | undefined)?.hex) const eyeHex = str((rec.eyes as { hex?: unknown } | undefined)?.hex) const items = Array.isArray(rec.items) ? (rec.items as unknown[]).map(str).filter((s): s is string => !!s) : []
return { did, name, ...(str(rec.bodyType) ? { bodyType: str(rec.bodyType) } : {}), ...(skinHex ? { skinHex, ...(skinBucket(skinHex) ? { skinBucket: skinBucket(skinHex) } : {}) } : {}), ...(eyeHex ? { eyeHex, ...(hexToBucket(eyeHex) ? { eyeBucket: hexToBucket(eyeHex) } : {}) } : {}), worn, held, items, }}