diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 7d7b2476..87a7d2da 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,6 +1,7 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, // section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human +// subset below is PROVISIONAL until Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; export type Size = 'S' | 'M' | 'L'; export type Override = 'None' | 'Muted'; -export interface ActivityFrame { +interface FrameBase { mind: string; estate: Estate; - kind: Kind; - energy: Energy; size: Size; - neutral: boolean; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } +/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +export interface NeutralFrame extends FrameBase { + neutral: true; + kind?: undefined; + energy?: undefined; +} +export interface LiveFrame extends FrameBase { + neutral: false; + kind: Kind; + energy: Energy; +} +export type ActivityFrame = NeutralFrame | LiveFrame; const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; const KINDS: readonly Kind[] = [ @@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10; const oneOf = (set: readonly T[], v: unknown): T | undefined => set.find((s) => s === v); -/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ -export const clampEnergy = (estate: Estate, energy: Energy): Energy => +/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ +export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; + +/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ +const clampKind = (estate: Estate, kind: unknown): Kind | undefined => + estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); + +/** A human's energy is three buckets: Peak reads as High. */ +const clampEnergy = (estate: Estate, energy: Energy): Energy => estate === 'human' && energy === 'Peak' ? 'High' : energy; /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * Returns undefined for anything malformed; the caller treats that as no frame (A4). + * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { if (typeof raw !== 'object' || raw === null) return undefined; const r = raw as Record; + if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const kind = oneOf(KINDS, r.kind); - const energy = oneOf(ENERGIES, r.energy); const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !kind || !energy || !size || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') - return undefined; + if (!estate || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - return { - mind: r.mind, - estate, - kind, - energy: clampEnergy(estate, energy), - size, - neutral: r.neutral, - override, - seq: r.seq as number, - at: r.at as number, - }; + const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + if (r.neutral) return { ...base, neutral: true }; + const kind = clampKind(estate, r.kind); + const energy = oneOf(ENERGIES, r.energy); + if (!kind || !energy) return undefined; + return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index e07db5b9..c292222a 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; import { isWeakDevice } from './device'; import { createAudioSocket, silentSource, type ActivitySource } from './socket'; -const frame = (o: Partial = {}): ActivityFrame => ({ +const frame = (o: Partial = {}): LiveFrame => ({ mind: 'void', estate: 'mind', kind: 'Working', @@ -17,6 +17,11 @@ const frame = (o: Partial = {}): ActivityFrame => ({ at: 100, ...o, }); +const neutralFrame = (o: Partial = {}): ActivityFrame => + ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); +/** A raw frame as a producer sends it, with the named keys omitted. */ +const without = (f: object, ...keys: string[]) => + Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); const noop = () => undefined; const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; @@ -25,9 +30,7 @@ describe('neutral overrides everything', () => { const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => - frame({ neutral: true, estate, kind, energy, size }) - ) + (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) ) ) ); @@ -45,6 +48,55 @@ describe('neutral overrides everything', () => { }); }); +describe('neutral is read first', () => { + it('yields no kind and no energy, even when the producer sent them', () => { + (['human', 'mind', 'persona'] as const).forEach((estate) => { + const f = readFrame({ + ...frame({ estate, kind: 'Speaking', energy: 'High' }), + neutral: true, + }); + expect(f).toBeDefined(); + expect(f?.neutral).toBe(true); + expect(f?.kind).toBeUndefined(); + expect(f?.energy).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('kind'); + expect(Object.keys(f ?? {})).not.toContain('energy'); + }); + }); + it('a neutral human with kind omitted is not an Idle', () => { + const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); + const f = readFrame({ ...bare, neutral: true }); + expect(f?.kind).toBeUndefined(); + expect(drawnCreatures([f as ActivityFrame])).toEqual([]); + expect(planVoices([f as ActivityFrame], on)).toEqual([]); + }); +}); + +describe('human kinds are the provisional subset', () => { + it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); + }); + it('keeps Idle, Working and Speaking', () => { + (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) + ); + }); + it('an omitted kind is Idle for a non-neutral human only', () => { + const noKind = without(frame({ estate: 'human' }), 'kind'); + expect(readFrame(noKind)?.kind).toBe('Idle'); + expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); + }); + it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { + (['mind', 'persona'] as const).forEach((estate) => { + (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( + (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) + ); + const noKind = without(frame({ estate }), 'kind'); + expect(readFrame(noKind)).toBeUndefined(); + }); + }); +}); + describe('human energy is clamped to three buckets by the consumer', () => { it('turns Peak into High for humans only', () => { expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts index d20e55ad..676ae853 100644 --- a/src/app/features/soundscape/voices.ts +++ b/src/app/features/soundscape/voices.ts @@ -1,4 +1,4 @@ -import type { ActivityFrame, Energy, Kind } from './frame'; +import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; /** Grammar V3 (proposed, unmeasured): voices audible at once. */ export const DEFAULT_VOICE_CAP = 4; @@ -42,7 +42,9 @@ export const planVoices = ( if (gain === 0) return []; const cap = Math.max(0, Math.floor(prefs.voiceCap)); return frames - .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .filter( + (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) + ) .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) .slice(0, cap) .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain }));