diff --git a/README.md b/README.md index 222a1649..a8522c02 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,11 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits. +The table header offers optional **Sound effects**, with mute, volume and a test +sample. Short cues mark phase changes and automatic action notices. Sound starts +off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md) +for activation, background behavior and prototype limits. + ## Verify changes Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests diff --git a/app/globals.css b/app/globals.css index 24b34d63..831e8dd5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -105,6 +105,24 @@ a:focus-visible { align-items: center; margin-left: 28px; } +.table-sounds { position: relative; max-width: 100%; font-size: 14px; } +.table-sounds summary { display: flex; align-items: center; gap: 8px; } +.table-sounds summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } +.table-sounds-panel { + position: absolute; right: 0; top: calc(100% + 8px); z-index: 45; + display: grid; gap: 12px; width: min(280px, calc(100vw - 48px)); + padding: 16px; border: 1px solid var(--border); border-radius: 8px; + background: #141814; color: #f5ecda; box-shadow: 0 8px 28px #0008; + font-size: 14px; line-height: 1.5; +} +.table-sounds-panel p { margin: 0; } +.live-table .masthead { flex-wrap: wrap; gap: 12px; } +.live-table .masthead-right { flex-wrap: wrap; } +@media (max-width: 950px) { + .live-table .masthead-right { position: relative; } + .table-sounds { position: static; } + .table-sounds-panel { left: 0; right: auto; } +} .status-dot { width: 6px; height: 6px; diff --git a/components/game-table.tsx b/components/game-table.tsx index 136101b2..b05a8851 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -72,6 +72,7 @@ import { } from './richese-allied-no-field'; import { presenceByLocation } from '@/game/force-presence'; import { AutomaticActionNotice } from './automatic-action-notice'; +import { TableSounds } from './table-sounds'; import { EcazSpice } from './ecaz-spice'; import { VictoryProgress } from './victory-progress'; import { tableActionOwner } from '@/game/table-turn'; @@ -902,6 +903,12 @@ export function GameTable({
+ Math.max(last, entry.seq), -1), + automaticSequence: automaticEvents.reduce((last, entry) => Math.max(last, entry.seq), -1), + }} /> {g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`} + + { + audio.current?.stop(); + savePreference({ ...preference, volume: soundVolume(Array.isArray(value) ? value[0] : value) }); + }} /> + +

Short cues for phase changes and automatic actions. Only this browser; silent in background tabs.

+ {unavailable && Sound is unavailable. You can keep playing and try Test sound again.} +
+ + ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 33d9db02..d43485ed 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,17 +1,17 @@ # Current development status -Updated 14 September 2026. **The full twelve-faction goal is unfinished. Basic +Updated 20 September 2026. **The full twelve-faction goal is unfinished. Basic is playable; public Advanced and unfinished expansion/module starts remain gated.** Prototypes do not open completion or publication gates. ## Current checkpoint and work -Latest verified checkpoint: `6403425`, [AI arrival preflight](BOT_ARRIVAL_PREFLIGHT.md). -Types, lint and **4,829 offline tests** passed. Eighteen genuine base and expansion -samples completed in 9,470 accepted actions with 247 periodic save/restore checks -and no rejected candidates. Accepted gameplay matched the prior same-seed runs -apart from random event identifiers. All 1,038 opening games and original -seat/recovery records were unchanged; the browser test seat reconnected. +Latest delivered rules checkpoint: `62e87d2`, [Harass allocation](HARASS_ALLOCATION.md). +Types, lint, **4,850 offline tests**, production build and 43 HTTP checks passed. +Eight genuine card-variant games completed in 9,956 accepted actions with 266 +periodic save/restore checks and no rejected candidates. Focused and browser +cases exercised ambiguous allocation. All 1,038 opening games and original +seat/recovery records were unchanged; 1,072 rooms remained after isolated QA. Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md). The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either @@ -24,17 +24,22 @@ card-specific discard precedence are explicitly labeled inferences. The Stone Burner timing question is pending; no user answer is assumed. Reinforcements remains unfinished. This does not open variant, mode or publication gates. -The existing server remained healthy after the reported power failures. Saved -games and original recovery/entry/handover records survived; SQLite integrity -and foreign-key checks passed, and refresh restored the test seat. No restart, -database reset or recurring automation was needed. Exact verification, source -fingerprints and delivery are recorded in the private report and Git message. +Current checkpoint: [optional table sounds](TABLE_SOUNDS.md) connects local mute, +volume, a test sample and short public phase/automatic-action cues. Initial and +background updates stay silent; preferences survive refresh and synchronize +between tabs. This adds no gameplay actions, private eligibility checks or game +storage. Final source-bound checks and delivery are recorded in the private +report and Git message; physical audio and screenshot acceptance remain open. -Current work: [Harass force allocation](HARASS_ALLOCATION.md) connects the missing -player choice after reveal, with shared legal AI, exact winner losses and saved -continuation. Unique returns stay automatic and sealed plans stay unchanged. -The remaining Leader Skill bands retain their existing pending rulings; this -checkpoint introduces no new arrival-order or hidden-reaction policy. +On 20 September, the stopped server was started after backing up all 1,072 rooms. +Following another reported outage, the server responded normally and every saved +game still matched that baseline. The isolated browser seat restored successfully. +No database reset or recurring automation was needed. + +Next: prototype ordinary Mirror Weapon copies using the existing shared resolver, +while retaining explicit gates for unresolved copied Tooth/Artillery disposal and +Stone Burner choices. Sapho battle aggressor is the next independent candidate. +The remaining Leader Skill bands retain their existing pending rulings. ## Authoritative navigation diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 192f76b8..f86cb14a 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,20 @@ # Dune implementation status +## 20 September 2026 — Optional table sound prototype + +[Table sound controls](TABLE_SOUNDS.md) add local mute, keyboard volume and a test +sample, with short synthesized cues for public phase changes and tagged automatic +actions. Silent baseline consumption, coalescing and background suppression avoid +replaying old updates. Preferences survive refresh and synchronize between tabs; +game actions, AI timing and saved state are unchanged. Focused audio/cursor checks +and browser controls cover the connected prototype. Physical output, screenshot +acceptance and final event coverage remain unfinished. Required check/build, +preservation and verified Git delivery belong to the source-bound private report +and commit message. No mode or publication gate is opened. + +The reported outage left all 1,072 baseline games unchanged. The responding server +was reused and the existing isolated seat reconnected without a database reset. + ## 14 September 2026 — Harass revealed physical allocation Connected ordinary/elite and multi-sector choices after public plan reveal, diff --git a/docs/TABLE_SOUNDS.md b/docs/TABLE_SOUNDS.md new file mode 100644 index 00000000..e606f087 --- /dev/null +++ b/docs/TABLE_SOUNDS.md @@ -0,0 +1,45 @@ +# Optional table sounds + +The connected sound prototype adds browser-local mute, volume and a test control +to the table header. It defaults to off at 35% volume. Settings are independent of +automatic visual notices and synchronize between tabs on the same origin. Storage +failure retains the preference in memory without interrupting play. + +Two original sine-tone cues are synthesized with Web Audio: one for a new tagged +automatic action and two for a phase/status/turn transition. Audio uses only the +existing projected log sequences and public phase identity. It does not inspect +cards, plans, private resources, pending eligibility or other seats. It sends no +actions and changes neither rules, AI pacing nor persistent game state. No audio +download, external service or microphone permission is required. + +Initial/restored snapshots establish a silent baseline. Later snapshots are +consumed even when muted, hidden or unable to play. A batch produces at most one +cue, phase changes take precedence, and a 700ms minimum interval drops rapid cues +without keeping a backlog. Log truncation and stale sequences do not replay +automatic events. Switching rooms establishes a fresh baseline. + +Audio is unlocked only by a user gesture. A saved enabled preference needs a click +or key press after refresh before new cues can sound. Enable and Test sound offer +a sample. Volume zero is silent and disables Test sound. Mute, backgrounding and +unmount stop active tones; unmount closes the context. Failed/unsupported audio +remains cosmetic; a failed explicit preview reports that play can continue. +Audio is independent of reduced-motion settings. + +## Evidence and limits + +`tests/table-sound.test.tsx` checks initial/stale/duplicate snapshots, room changes, +silent consumption, coalescing, priority, bounded volume and voice duration, +gesture-only context creation, pending unlock cancellation, mute/disposal, +unavailable audio and accessible initial controls. Its fake audio context does +not prove physical speaker output or every browser's autoplay policy. + +Browser verification covers the existing isolated saved seat, sound on/off, +keyboard volume, zero-volume test disabling, refresh persistence and same-origin +tab synchronization. The phone panel's bounds were checked at a 390px requested +viewport. Screenshot capture timed out, so this checkpoint does not claim visual +screenshot acceptance or audible output verification. No game action was needed +to change sound settings. Settings were returned to off and 35% afterward. + +This is a functional prototype, not the final sound design. Successful powers +without an automatic notice tag remain outside these cues. Per-component audio, +broader device/browser acceptance and final sound mixing remain unfinished. diff --git a/game/reference.ts b/game/reference.ts index 58c13b3a..0e2148d1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -214,6 +214,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'automatic-responses', 'automatic-decisions', 'ai-pacing', + 'table-sounds', 'seat-handover', 'ecaz-modules', 'discoveries', @@ -221,6 +222,29 @@ export const RULE_TOPICS: RuleTopic[] = [ 'advanced-combat', ], }, + { + id: 'table-sounds', + title: 'Sound effects', + category: 'Getting started', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Optional short cues for phase changes and automatic actions, with local mute and volume controls.', + steps: [ + 'Open Sound effects in the table header to enable or mute audio, change its volume or test a sample. Sound starts off. Preferences stay in this browser and are shared by its tabs.', + 'One short tone marks a new automatic action notice. Two tones mark a phase, turn or game-status change. Several updates arriving together produce at most one cue.', + 'Sound and automatic visual notices have separate controls. Muting sound does not hide notices or change AI pacing.', + 'Refresh restores your preference without replaying old events. Click or press a key to allow future sounds after refresh. Background tabs stay silent and do not queue old sounds.', + 'Volume zero is silent and disables Test sound. Muting stops current tones. If audio is unavailable, play continues normally; cues never reveal concealed cards or decisions.', + ], + related: ['automatic-casualties', 'ai-pacing', 'privacy', 'implementation-checklist'], + checklist: [ + { area: 'Implementation', status: 'Partial', detail: 'Local synthesized phase and tagged automatic-action cues are connected; broader event coverage and final sound design remain unfinished.' }, + { area: 'Player controls', status: 'Implemented', detail: 'Mute, keyboard volume, test sample and refresh-persistent browser preferences.' }, + { area: 'AI', status: 'Implemented', detail: 'Existing projected human and AI updates share the same cosmetic cues without changing legal actions or pacing.' }, + { area: 'Documentation', status: 'Implemented', detail: 'This guide explains preferences, browser activation, background silence and unavailable audio.' }, + { area: 'Verification', status: 'Partial', detail: 'Focused cursor/audio checks and browser preference controls; physical output and broader browser acceptance remain open.', evidence: ['tests/table-sound.test.tsx'] }, + ], + }, { id: 'seat-handover', title: 'Pass a seat to another player', diff --git a/lib/table-sound.ts b/lib/table-sound.ts new file mode 100644 index 00000000..7cce2b66 --- /dev/null +++ b/lib/table-sound.ts @@ -0,0 +1,119 @@ +/** Cosmetic input only: no cards, plans, resources or private decision eligibility. */ +export type TableSoundFrame = { + room: string; + phase: string; + sequence: number; + automaticSequence: number; +}; +export type TableSoundCue = 'phase' | 'automatic'; + +/** Consume silent snapshots too, so enabling sound never replays old events. */ +export class TableSoundCursor { + private previous: TableSoundFrame | null = null; + + consume(frame: TableSoundFrame): TableSoundCue | null { + const previous = this.previous; + if (!previous || previous.room !== frame.room) { + this.previous = { ...frame }; + return null; + } + if (frame.sequence < previous.sequence) return null; + this.previous = { + ...frame, + automaticSequence: Math.max(previous.automaticSequence, frame.automaticSequence), + }; + // Coalesce a polling batch into one cue, with phase changes taking priority. + if (previous.phase !== frame.phase) return 'phase'; + return frame.automaticSequence > previous.automaticSequence ? 'automatic' : null; + } +} + +export const DEFAULT_SOUND_VOLUME = 35; +export function soundVolume(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(100, Math.max(0, Math.round(value))) + : DEFAULT_SOUND_VOLUME; +} + +/** Short original tones synthesized locally; no downloads, permissions or game writes. */ +export class LocalTableAudio { + private context: AudioContext | null = null; + private voices = new Set<{ oscillator: OscillatorNode; gain: GainNode }>(); + private generation = 0; + private lastPlayed = -Infinity; + + constructor(private createContext: () => AudioContext = () => new AudioContext()) {} + + // Call directly from a user gesture, never from a game update or storage event. + async unlock(): Promise { + const generation = this.generation; + try { + const context = this.context ?? (this.context = this.createContext()); + if (context.state !== 'running') await context.resume(); + return generation === this.generation && this.context === context && context.state === 'running'; + } catch { + return false; + } + } + + play(cue: TableSoundCue, volume: number, preview = false): boolean { + const context = this.context; + const level = soundVolume(volume); + if (!context || context.state !== 'running' || level === 0) return false; + if (!preview && context.currentTime - this.lastPlayed < 0.7) return false; + this.stop(); + try { + const start = context.currentTime; + const notes = cue === 'phase' ? [330, 495] : [440]; + for (const [index, frequency] of notes.entries()) { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const voice = { oscillator, gain }; + this.voices.add(voice); + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(frequency, start); + const at = start + index * 0.12; + gain.gain.setValueAtTime(0, start); + gain.gain.setValueAtTime(0, at); + gain.gain.linearRampToValueAtTime(0.12 * level / 100, at + 0.015); + gain.gain.linearRampToValueAtTime(0, at + 0.16); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.onended = () => { + oscillator.disconnect(); + gain.disconnect(); + this.voices.delete(voice); + }; + oscillator.start(at); + oscillator.stop(at + 0.18); + } + this.lastPlayed = start; + return true; + } catch { + this.stop(); + return false; + } + } + + stop() { + this.generation++; + for (const { oscillator, gain } of this.voices) { + try { + gain.gain.cancelScheduledValues(0); + gain.gain.setValueAtTime(0, this.context?.currentTime ?? 0); + oscillator.stop(); + } catch { /* Already ended or audio is unavailable. */ } + oscillator.disconnect(); + gain.disconnect(); + } + this.voices.clear(); + } + + dispose() { + this.stop(); + const context = this.context; + this.context = null; + this.lastPlayed = -Infinity; + if (context) void context.close().catch(() => {}); + } +} diff --git a/tests/table-sound.test.tsx b/tests/table-sound.test.tsx new file mode 100644 index 00000000..2af0c10c --- /dev/null +++ b/tests/table-sound.test.tsx @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { LocalTableAudio, TableSoundCursor, soundVolume, type TableSoundFrame } from '../lib/table-sound'; +import { TableSounds } from '../components/table-sounds'; + +const opening: TableSoundFrame = { room: 'SOUND001', phase: 'playing:1:2', sequence: 15, automaticSequence: 12 }; + +void test('sound cursor consumes refresh history, duplicates, muted/hidden batches and stale snapshots without replay', () => { + const cursor = new TableSoundCursor(); + assert.equal(cursor.consume(opening), null); + assert.equal(cursor.consume({ ...opening }), null); + const next = { ...opening, sequence: 18, automaticSequence: 18 }; + // A muted or hidden consumer discards this cue; no retained playback queue exists. + assert.equal(cursor.consume(next), 'automatic'); + assert.equal(cursor.consume(next), null); + assert.equal(cursor.consume(opening), null); + assert.equal(cursor.consume(next), null); + assert.equal(cursor.consume({ ...next, sequence: 19, automaticSequence: -1 }), null); + assert.equal(cursor.consume({ ...next, sequence: 20 }), null); + assert.equal(cursor.consume({ ...next, sequence: 21, automaticSequence: 21 }), 'automatic'); +}); + +void test('phase cues take priority over automatic batches and switching rooms establishes a new silent baseline', () => { + const cursor = new TableSoundCursor(); + cursor.consume(opening); + const phase = { ...opening, phase: 'playing:1:3', sequence: 100, automaticSequence: 99 }; + assert.equal(cursor.consume(phase), 'phase'); + assert.equal(cursor.consume(phase), null); + const other = { ...opening, room: 'SOUND002' }; + assert.equal(cursor.consume(other), null); + assert.equal(cursor.consume({ ...other, phase: 'finished:1:3', sequence: 16 }), 'phase'); + assert.equal(new TableSoundCursor().consume(phase), null); +}); + +function fakeAudio() { + const nodes: { starts: number[]; stops: number[]; disconnected: number; onended: (() => void) | null; type: string; frequency: { setValueAtTime: (frequency: number) => void }; connect: () => void; start: (time: number) => void; stop: (time?: number) => void; disconnect: () => void }[] = []; + const gains: { values: number[]; disconnected: number; gain: { setValueAtTime: (value: number) => void; linearRampToValueAtTime: (value: number) => void; cancelScheduledValues: () => void }; connect: () => void; disconnect: () => void }[] = []; + let created = 0; + let closed = 0; + let resumed = 0; + const context = { + state: 'suspended', currentTime: 10, destination: {}, + async resume() { resumed++; context.state = 'running'; }, + async close() { closed++; context.state = 'closed'; }, + createOscillator() { + const node = { + starts: [] as number[], stops: [] as number[], disconnected: 0, onended: null as (() => void) | null, + type: '', frequency: { setValueAtTime() {} }, connect() {}, + start(time: number) { node.starts.push(time); }, + stop(time = context.currentTime) { node.stops.push(time); }, + disconnect() { node.disconnected++; }, + }; + nodes.push(node); + return node; + }, + createGain() { + const gain = { + values: [] as number[], disconnected: 0, + gain: { + setValueAtTime(value: number) { gain.values.push(value); }, + linearRampToValueAtTime(value: number) { gain.values.push(value); }, + cancelScheduledValues() {}, + }, connect() {}, disconnect() { gain.disconnected++; }, + }; + gains.push(gain); + return gain; + }, + }; + const audio = new LocalTableAudio(() => { created++; return context as unknown as AudioContext; }); + return { audio, context, nodes, gains, counts: () => ({ created, closed, resumed }) }; +} + +void test('audio starts only after explicit unlock, bounds volume and coalesces closely spaced updates', async () => { + const fixture = fakeAudio(); + const { audio, context, nodes, gains } = fixture; + assert.equal(audio.play('phase', 35), false); + assert.deepEqual(fixture.counts(), { created: 0, closed: 0, resumed: 0 }); + assert.equal(await audio.unlock(), true); + assert.equal(audio.play('phase', 200), true); + assert.equal(nodes.length, 2); + assert.ok(gains.every((gain) => Math.max(...gain.values) === 0.12)); + assert.ok(nodes.every((node) => node.stops[0] - node.starts[0] < 0.2)); + assert.equal(audio.play('automatic', 50), false); + context.currentTime += 1; + assert.equal(audio.play('automatic', 50), true); + assert.equal(nodes.length, 3); + assert.ok(nodes.slice(0, 2).every((node) => node.disconnected > 0)); + assert.equal(Math.max(...gains[2].values), 0.06); + nodes[2].onended?.(); + assert.equal(nodes[2].disconnected, 1); + assert.equal(gains[2].disconnected, 1); + audio.dispose(); + assert.equal(fixture.counts().closed, 1); +}); + +void test('mute, suspension and disposal prevent playback and cancel pending unlocks', async () => { + const { audio, context, nodes, gains } = fakeAudio(); + const pending = audio.unlock(); + audio.stop(); + assert.equal(await pending, false); + assert.equal(audio.play('automatic', 0), false); + assert.equal(await audio.unlock(), true); + assert.equal(audio.play('phase', 35), true); + audio.stop(); + assert.ok(gains.every((gain) => gain.values.at(-1) === 0)); + assert.ok(nodes.every((node) => node.disconnected > 0)); + context.state = 'suspended'; + assert.equal(audio.play('automatic', 35, true), false); + const pendingAgain = audio.unlock(); + audio.dispose(); + assert.equal(await pendingAgain, false); + assert.equal(audio.play('phase', 35, true), false); +}); + +void test('unavailable or rejected audio fails quietly and previews replace active voices', async () => { + const absent = new LocalTableAudio(() => { throw new Error('Unavailable'); }); + assert.equal(await absent.unlock(), false); + assert.equal(absent.play('phase', 35), false); + absent.dispose(); + const fixture = fakeAudio(); + fixture.context.resume = async () => { throw new Error('Gesture required'); }; + assert.equal(await fixture.audio.unlock(), false); + fixture.context.state = 'running'; + assert.equal(await fixture.audio.unlock(), true); + assert.equal(fixture.audio.play('phase', 35, true), true); + assert.equal(fixture.audio.play('phase', 35, true), true); + assert.equal(fixture.nodes.length, 4); + assert.ok(fixture.nodes.slice(0, 2).every((node) => node.disconnected > 0)); + fixture.audio.dispose(); +}); + +void test('volume input stays finite and initial controls expose an accessible muted preference', () => { + assert.equal(soundVolume(NaN), 35); + assert.equal(soundVolume(Infinity), 35); + assert.equal(soundVolume('80'), 35); + assert.equal(soundVolume(-1), 0); + assert.equal(soundVolume(250), 100); + const html = renderToStaticMarkup(createElement(TableSounds, { frame: opening })); + assert.match(html, /Sound effects: Off/); + assert.match(html, /aria-label="Sound effects" aria-pressed="false"/); + assert.match(html, /Volume · 35%/); + assert.match(html, /type="range"/); + assert.match(html, /aria-labelledby="table-sounds-volume-label"/); + assert.match(html, /disabled=""[^>]*>Test sound/); +});