diff --git a/.prettierignore b/.prettierignore index 1b18b3d..c134f41 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,10 @@ dist/ build/ *.min.js +# Vendored libraries — kept byte-for-byte as upstream emits them (re-vendor, +# don't reformat). Our co-located .d.ts boundaries are NOT ignored. +src/vendor/**/*.js + # Generated files *.xml diff --git a/components/FinnShop.ts b/components/FinnShop.ts index 208ff21..baf7fc3 100644 --- a/components/FinnShop.ts +++ b/components/FinnShop.ts @@ -22,6 +22,7 @@ import { } from '/src/game/salvage.js'; import { ITEM_ID, ITEM_SCOPE } from '/src/game/items.js'; import type { Item } from '/src/game/items.js'; +import { itemPreview, type ItemPreview } from '/src/game/shopPreview.js'; import type { Crew as CrewMember } from '/src/game/Crew.js'; type CrewMemberSnapshot = { @@ -37,6 +38,13 @@ type CrewMemberSnapshot = { * shop greys out the same purchases `Campaign.purchase` would refuse. */ saturatedGearIds: string[]; + /** + * Per-item "what you already have" readouts, keyed by catalog item id — + * held quantity for consumables, the moved stat for gear. Precomputed at + * `setCatalog` time (catalog × crew is a handful of rows) so target + * selection is a pure lookup. See `src/game/shopPreview.ts`. + */ + previews: Record; }; const SALVAGE_TYPE_LABELS: Record = { @@ -339,6 +347,29 @@ button.target-row[aria-current='true'] .cursor { visibility: visible; } +/* + * The per-operator "what you already have" readout on a target row: held + * quantity for consumables, the moved stat for gear. Right-aligned so the + * column scans vertically when comparing crew members. + */ +.target-preview { + margin-left: auto; + padding-left: 0.6rem; + font-size: 0.82rem; + color: var(--shop-gold); + white-space: nowrap; + letter-spacing: 0.04em; +} + +.target-preview .preview-label { + color: var(--shop-dim); + margin-right: 0.35em; +} + +.target-preview.at-cap { + color: var(--shop-danger); +} + .hint { text-align: center; font-size: 0.85rem; @@ -357,7 +388,7 @@ button.target-row[aria-current='true'] .cursor { `; const SCOPE_LABELS: Record = { - [ITEM_SCOPE.JOB]: 'CONSUMABLES', + [ITEM_SCOPE.JOB]: 'CONSUMABLES (Single use)', [ITEM_SCOPE.CAMPAIGN]: 'CREW GEAR (Applies to a single crew member)', }; @@ -456,6 +487,7 @@ class FinnShop extends HTMLElement { maxHp: member.maxHp, flatlined: !!member.flatlined, saturatedGearIds: gearIds.filter(id => member.gearAtCap(id)), + previews: Object.fromEntries(catalog.map(item => [item.id, itemPreview(item.id, member)])), })); this.#credits = balances.credits ?? 0; this.#salvage = balances.salvage ?? emptySalvage(); @@ -644,8 +676,25 @@ class FinnShop extends HTMLElement { }) as HTMLButtonElement; btn.dataset.targetIndex = String(i); btn.addEventListener('click', () => this.#confirmTarget(i)); - const capLabel = ITEM_CAP_LABELS[item.id] ?? ''; - const suffix = member.flatlined ? ' FLATLINED' : atCap ? ` ${capLabel}` : ''; + // Relevant-inventory column: held count (consumables) or moved stat + // (gear), with the cap label appended once the purchase would be a no-op. + const preview = member.previews[item.id]; + // Identity column: archetype + HP, or FLATLINED when they're gone. HP is + // dropped when the preview itself is HP (Bone Lacing) — one column, once. + const archetype = member.archetype.toUpperCase(); + const identity = member.flatlined + ? `${archetype} FLATLINED` + : preview?.label === 'HP' + ? archetype + : `${archetype} HP ${member.hp}/${member.maxHp}`; + const capLabel = atCap ? (ITEM_CAP_LABELS[item.id] ?? 'AT CAP') : ''; + const previewEl = h('span', { className: `target-preview${atCap ? ' at-cap' : ''}` }); + if (preview) { + previewEl.append( + h('span', { className: 'preview-label', textContent: preview.label }), + h('span', { textContent: preview.value + (capLabel ? ` · ${capLabel}` : '') }) + ); + } btn.append( h('span', { className: 'cursor', textContent: '>' }), h('span', { @@ -654,8 +703,9 @@ class FinnShop extends HTMLElement { }), h('span', { className: 'item-desc', - textContent: `${member.archetype.toUpperCase()} HP ${member.hp}/${member.maxHp}${suffix}`, - }) + textContent: identity, + }), + previewEl ); rows.appendChild(btn); this.#targetButtons.push(btn); diff --git a/components/GameOver.ts b/components/GameOver.ts index 431bae5..5f58606 100644 --- a/components/GameOver.ts +++ b/components/GameOver.ts @@ -366,8 +366,13 @@ class GameOver extends HTMLElement { this.#els.banner.textContent = flavor.banner; this.#els.reason.textContent = flavor.reason; this.#els.detail.textContent = flavor.detail; - // Score prize — present on a win that stole a specific blueprint. - const reward = summary.result === 'win' ? (summary.scoreReward ?? null) : null; + // Score prize — present on any Score that stole a specific blueprint. P3.6: + // that now includes a costly (`partial`) win, where the payload got out but + // an operator did not; the kicker copy carries the difference in tone. + const reward = + summary.result === 'win' || summary.result === 'partial' + ? (summary.scoreReward ?? null) + : null; if (reward && flavor.rewardKicker) { this.#els.rewardKicker.textContent = flavor.rewardKicker; this.#els.rewardName.textContent = reward.label; diff --git a/components/SettingsModal.ts b/components/SettingsModal.ts new file mode 100644 index 0000000..1e75dbd --- /dev/null +++ b/components/SettingsModal.ts @@ -0,0 +1,370 @@ +/** + * — player preferences. The app's first real DataStore.prefs + * UI; audio (mute + volume, per channel) is the pathfinder consumer, and future + * prefs plug in here. + * + * Data flow is one-way: this modal writes prefs to DataStore; AudioManager is + * subscribed to DataStore's `change` event and reacts (updates the master and + * music gains). The modal never talks to AudioManager directly. It reads current + * values from DataStore.prefs each time it opens. + * + * SFX and music are two instances of the same control, described by `CHANNELS` + * and built in a loop rather than written twice. + * + * Events: + * - `dismiss` — player pressed Esc / clicked the backdrop / hit CLOSE. + */ + +import { h } from '/src/domUtils.js'; +import dataStore from '/src/DataStore.js'; +import { + AUDIO_MUTED_PREF, + AUDIO_VOLUME_PREF, + AUDIO_MUSIC_MUTED_PREF, + AUDIO_MUSIC_VOLUME_PREF, + DEFAULT_VOLUME, + DEFAULT_MUSIC_VOLUME, + parseAudioPrefs, + type AudioPrefs, +} from '/src/audio/AudioManager.js'; + +const CSS = ` +:host { + --settings-bg: rgba(7, 18, 16, 0.96); + --settings-border: var(--accent-color, #00d9a5); + --settings-text: #c5efdf; + --settings-dim: #6ae8c8; + --settings-accent: var(--accent-color, #00d9a5); + --settings-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.5); + + display: none; + position: fixed; + inset: 0; + z-index: 50; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--settings-text); +} + +:host([open]) { + display: flex; +} + +.panel { + background: var(--settings-bg); + border: 1px solid var(--settings-border); + border-radius: 6px; + padding: 1.25rem 1.5rem 1.4rem; + box-shadow: var(--settings-shadow); + min-width: min(420px, 92vw); + max-width: min(520px, 96vw); +} + +.title { + margin: 0 0 1rem; + text-align: center; + font-size: 0.95rem; + letter-spacing: 0.18em; + color: var(--settings-accent); + border-bottom: 1px dashed var(--settings-border); + padding-bottom: 0.5rem; +} + +.section-label { + margin: 0 0 0.6rem; + font-size: 0.72rem; + letter-spacing: 0.16em; + color: var(--settings-dim); +} + +.row { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.75rem 1rem; + align-items: center; + margin-bottom: 0.9rem; +} + +.row-name { + font-size: 0.82rem; + letter-spacing: 0.06em; + color: var(--settings-text); +} + +button.control, +button.close { + appearance: none; + -webkit-appearance: none; + background: rgba(0, 217, 165, 0.08); + color: var(--settings-text); + border: 1px solid rgba(0, 217, 165, 0.45); + border-radius: 4px; + padding: 0.4rem 0.6rem; + font: inherit; + font-size: 0.78rem; + letter-spacing: 0.06em; + cursor: pointer; + min-height: 32px; +} + +button.control[aria-pressed='true'] { + border-color: var(--settings-accent); + background: rgba(0, 217, 165, 0.2); + color: var(--settings-accent); +} + +button.control:hover, +button.control:focus-visible, +button.close:hover, +button.close:focus-visible { + outline: none; + border-color: var(--settings-accent); + background: rgba(0, 217, 165, 0.18); +} + +.volume-cell { + display: flex; + align-items: center; + gap: 0.6rem; +} + +input[type='range'] { + flex: 1; + accent-color: var(--settings-accent); + cursor: pointer; + min-width: 0; +} + +input[type='range']:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.volume-readout { + font-size: 0.78rem; + color: var(--settings-dim); + min-width: 3ch; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.footer { + margin-top: 1rem; + display: flex; + justify-content: center; +} + +.hint { + margin: 0.85rem 0 0; + text-align: center; + font-size: 0.7rem; + letter-spacing: 0.08em; + color: var(--settings-dim); +} +`; + +/** Volume slider granularity: 0–100 integer, mapped to the 0–1 pref. */ +const VOLUME_STEPS = 100; + +/** + * One mute/volume pair — SFX and music are the same control twice over, so the + * rows are built from a shared description rather than duplicated. Adding a + * third channel later means adding a `ChannelSpec`, not another four fields and + * four methods. + */ +interface ChannelSpec { + /** Row label for the mute toggle. */ + label: string; + /** Row label for the slider. */ + volumeLabel: string; + mutedPref: string; + volumePref: string; + defaultVolume: number; + /** Pulls this channel's pair out of a parsed prefs object. */ + read: (prefs: AudioPrefs) => { muted: boolean; volume: number }; +} + +const CHANNELS: readonly ChannelSpec[] = [ + { + label: 'Sound', + volumeLabel: 'Volume', + mutedPref: AUDIO_MUTED_PREF, + volumePref: AUDIO_VOLUME_PREF, + defaultVolume: DEFAULT_VOLUME, + read: prefs => ({ muted: prefs.muted, volume: prefs.volume }), + }, + { + label: 'Music', + volumeLabel: 'Music vol.', + mutedPref: AUDIO_MUSIC_MUTED_PREF, + volumePref: AUDIO_MUSIC_VOLUME_PREF, + defaultVolume: DEFAULT_MUSIC_VOLUME, + read: prefs => ({ muted: prefs.musicMuted, volume: prefs.musicVolume }), + }, +]; + +/** Live controls + current values for one channel. */ +interface ChannelControls { + spec: ChannelSpec; + button: HTMLButtonElement; + slider: HTMLInputElement; + readout: HTMLElement; + muted: boolean; + volume: number; +} + +class SettingsModal extends HTMLElement { + #ready = false; + #panelEl: HTMLElement | null = null; + #channels: ChannelControls[] = []; + + #onKeyDown: ((this: HTMLElement, ev: KeyboardEvent) => void) | null = null; + #onBackdrop: ((this: HTMLElement, ev: MouseEvent) => void) | null = null; + + connectedCallback() { + if (this.#ready) return; + this.tabIndex = -1; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = CSS; + shadow.appendChild(style); + + const channelRows: HTMLElement[] = []; + this.#channels = CHANNELS.map(spec => { + const button = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + }) as HTMLButtonElement; + + const slider = h('input', { + type: 'range', + min: '0', + max: String(VOLUME_STEPS), + step: '1', + 'aria-label': spec.volumeLabel, + }) as HTMLInputElement; + + const readout = h('span', { className: 'volume-readout' }); + const controls: ChannelControls = { + spec, + button, + slider, + readout, + muted: false, + volume: spec.defaultVolume, + }; + + button.addEventListener('click', () => this.#toggleMuted(controls)); + // `input` fires continuously as the player drags — write each step so the + // gain tracks live via DataStore → AudioManager. + slider.addEventListener('input', () => this.#onVolumeInput(controls)); + + channelRows.push( + h('div', { className: 'row' }, [ + h('span', { className: 'row-name', textContent: spec.label }), + button, + ]), + h('div', { className: 'row' }, [ + h('span', { className: 'row-name', textContent: spec.volumeLabel }), + h('div', { className: 'volume-cell' }, [slider, readout]), + ]) + ); + return controls; + }); + + const closeBtn = h('button', { + type: 'button', + className: 'close', + textContent: 'CLOSE', + }) as HTMLButtonElement; + closeBtn.addEventListener('click', () => this.#emit('dismiss')); + + this.#panelEl = h('section', { className: 'panel' }, [ + h('h2', { className: 'title', textContent: '── OPTIONS ──' }), + h('p', { className: 'section-label', textContent: 'AUDIO' }), + ...channelRows, + h('div', { className: 'footer' }, [closeBtn]), + h('p', { className: 'hint', textContent: 'Esc close' }), + ]); + shadow.appendChild(this.#panelEl); + + this.#onKeyDown = evt => { + if (!this.isOpen) return; + if (evt.key === 'Escape') { + evt.preventDefault(); + this.#emit('dismiss'); + } + }; + this.addEventListener('keydown', this.#onKeyDown); + this.#onBackdrop = evt => { + if (!evt.composedPath().includes(this.#panelEl as EventTarget)) this.#emit('dismiss'); + }; + this.addEventListener('click', this.#onBackdrop); + + this.#ready = true; + this.#render(); + } + + disconnectedCallback() { + if (this.#onKeyDown) this.removeEventListener('keydown', this.#onKeyDown); + if (this.#onBackdrop) this.removeEventListener('click', this.#onBackdrop); + } + + show() { + // Pull the latest persisted values every open — the store is the source of truth. + const prefs = parseAudioPrefs(dataStore.prefs); + for (const channel of this.#channels) { + ({ muted: channel.muted, volume: channel.volume } = channel.spec.read(prefs)); + } + this.setAttribute('open', ''); + this.#render(); + queueMicrotask(() => this.#channels[0]?.button.focus()); + } + + hide() { + this.removeAttribute('open'); + } + + get isOpen() { + return this.hasAttribute('open'); + } + + #toggleMuted(channel: ChannelControls) { + channel.muted = !channel.muted; + dataStore.setPref(channel.spec.mutedPref, channel.muted); + this.#render(); + } + + #onVolumeInput(channel: ChannelControls) { + const steps = Number(channel.slider.value); + channel.volume = Math.min(1, Math.max(0, steps / VOLUME_STEPS)); + dataStore.setPref(channel.spec.volumePref, channel.volume); + this.#syncVolumeReadout(channel); + } + + #render() { + if (!this.#ready) return; + for (const channel of this.#channels) { + channel.button.setAttribute('aria-pressed', channel.muted ? 'false' : 'true'); + channel.button.textContent = channel.muted ? 'OFF' : 'ON'; + channel.slider.value = String(Math.round(channel.volume * VOLUME_STEPS)); + channel.slider.disabled = channel.muted; + this.#syncVolumeReadout(channel); + } + } + + #syncVolumeReadout(channel: ChannelControls) { + channel.readout.textContent = `${Math.round(channel.volume * 100)}%`; + } + + #emit(eventName: string, detail: Record = {}) { + this.dispatchEvent(new CustomEvent(eventName, { detail })); + } +} + +customElements.define('settings-modal', SettingsModal); + +export default SettingsModal; diff --git a/debug/music.html b/debug/music.html new file mode 100644 index 0000000..9d3ad9d --- /dev/null +++ b/debug/music.html @@ -0,0 +1,134 @@ + + + + Kernel Panic — Music test + + + + + + + +
+

Kernel Panic — Music test

+
+
+

+ The generative score from src/audio/MusicDirector.ts, driven by hand instead of + by game state. Tuning this by ear is the point — the unit tests can prove the bed is + continuous and in-scale, but not that it sounds good. Mute and volume read/write the same + DataStore prefs as the in-game Options modal. +

+

+ Things worth listening for: no gaps at pad retriggers; tension changes landing on a bar + without a click; the cyber palette reading as distinctly colder; and music mute leaving SFX + audible. +

+
+

+
+

Scheduled notes

+

+ Every note the director hands to the audio clock, newest last. Times are audio-clock + seconds. +

+
(not started)
+
+
+ + + diff --git a/debug/music.ts b/debug/music.ts new file mode 100644 index 0000000..adf18ba --- /dev/null +++ b/debug/music.ts @@ -0,0 +1,354 @@ +/** + * Music test harness — the generative score driven by hand, with no game state. + * + * This is the primary tuning tool for `src/audio/music.ts`. The unit tests can + * prove structural things (the bed has no holes, pitches stay in scale, layers + * gate on tension) but not that the result is pleasant, so the defs get tuned by + * ear here and the tests keep the invariants honest. + * + * It plays through the real `audioManager` (so the music bus, the shared FX and + * the volume prefs are all the production ones) but drives its own + * `MusicDirector` instead of the `musicDirector` singleton. That is deliberate: + * the harness needs to observe every scheduled note, and tapping the singleton + * would mean monkey-patching the sink the shell also uses. Composing a second + * director over the same sink costs nothing — the director holds no state the + * shell cares about — and keeps production wiring untouched. + */ +import { h } from '/src/domUtils.js'; +import { audioManager } from '/src/audio/soundBoard.js'; +import { MusicDirector } from '/src/audio/MusicDirector.js'; +import { + MUSIC_DEFS, + TENSION_CONFIG, + type MusicTension, + type MusicPaletteName, + type MusicVoiceName, + type MusicModulation, +} from '/src/audio/music.js'; +import { + AUDIO_MUSIC_MUTED_PREF, + AUDIO_MUSIC_VOLUME_PREF, + AUDIO_MUTED_PREF, + AUDIO_VOLUME_PREF, + DEFAULT_MUSIC_VOLUME, + DEFAULT_VOLUME, + parseAudioPrefs, +} from '/src/audio/AudioManager.js'; +import dataStore from '/src/DataStore.js'; + +const VOLUME_STEPS = 100; +/** Keep the log bounded — the bed emits continuously. */ +const MUSIC_LOG_LIMIT = 300; + +const TENSIONS: MusicTension[] = [0, 1, 2]; +const TENSION_LABELS: Record = { + 0: '0 — hub (pad only)', + 1: '1 — run baseline (full bed)', + 2: '2 — alarm (kinetic)', +}; +const PALETTES: MusicPaletteName[] = ['meat', 'cyber']; + +const VOICE_NAMES = Object.keys(MUSIC_DEFS) as MusicVoiceName[]; + +let muted = false; +let volume = DEFAULT_VOLUME; +let musicMuted = false; +let musicVolume = DEFAULT_MUSIC_VOLUME; + +let statusEl: HTMLElement; +let logEl: HTMLElement; +let startBtn: HTMLButtonElement; +const tensionBtns = new Map(); +const paletteBtns = new Map(); +const controls: Record< + string, + { btn: HTMLButtonElement; slider: HTMLInputElement; readout: HTMLElement } +> = {}; + +const logLines: string[] = []; +/** Re-reads each sweep slider from the bus — run after the director changes it. */ +const sweepSyncs: (() => void)[] = []; + +/** + * Recovers a voice name from a scheduled def. The director re-pitches a base + * def, so every field except the two frequencies still identifies the voice. + */ +function voiceOf(def: (typeof MUSIC_DEFS)[MusicVoiceName]): string { + for (const name of VOICE_NAMES) { + const base = MUSIC_DEFS[name]; + let matches = true; + for (const key of Object.keys(base) as (keyof typeof base)[]) { + if (key === 'freqStart' || key === 'freqEnd') continue; + if (base[key] !== def[key]) { + matches = false; + break; + } + } + if (matches) return name; + } + return '(unknown)'; +} + +function logNote(def: (typeof MUSIC_DEFS)[MusicVoiceName], when: number) { + const rel = when - audioManager.currentTime; + logLines.push( + `${when.toFixed(3)} (+${rel.toFixed(3)}) ${voiceOf(def).padEnd(14)} ${def.freqStart.toFixed(1)} Hz` + ); + if (logLines.length > MUSIC_LOG_LIMIT) logLines.splice(0, logLines.length - MUSIC_LOG_LIMIT); + logEl.textContent = logLines.join('\n'); + logEl.scrollTop = logEl.scrollHeight; +} + +/** The harness's own director: real sink, real audio clock, observable notes. */ +const musicDirector = new MusicDirector({ + emit: (def, when) => { + audioManager.playMusicNote(def, when); + logNote(def as (typeof MUSIC_DEFS)[MusicVoiceName], when); + }, + modulate: modulation => audioManager.setMusicModulation(modulation), + now: () => audioManager.currentTime, + schedule: (fn, ms) => globalThis.setInterval(fn, ms) as unknown as number, + cancel: id => globalThis.clearInterval(id), + seed: 1234, +}); + +function setStatus(message: string) { + const cfg = TENSION_CONFIG[musicDirector.tension]; + const bar = (cfg.secondsPerBeat * cfg.beatsPerBar).toFixed(2); + statusEl.textContent = + `> ${message} — ${musicDirector.running ? 'RUNNING' : 'STOPPED'}, ` + + `palette ${musicDirector.palette}, tension ${musicDirector.tension} ` + + `(beat ${cfg.secondsPerBeat}s, bar ${bar}s)` + + `${musicMuted ? ' [music muted — no sound]' : ''}` + + `${muted ? ' [all audio muted]' : ''}`; +} + +function render() { + startBtn.textContent = musicDirector.running ? 'STOP' : 'START'; + startBtn.setAttribute('aria-pressed', musicDirector.running ? 'true' : 'false'); + for (const [tension, btn] of tensionBtns) { + // Reflects what is actually sounding, which lags a requested change by up + // to one bar — that deferral is the behaviour worth being able to see. + btn.setAttribute('aria-pressed', musicDirector.tension === tension ? 'true' : 'false'); + } + for (const [palette, btn] of paletteBtns) { + btn.setAttribute('aria-pressed', musicDirector.palette === palette ? 'true' : 'false'); + } + + const sfx = controls.sfx; + sfx.btn.setAttribute('aria-pressed', muted ? 'false' : 'true'); + sfx.btn.textContent = muted ? 'OFF' : 'ON'; + sfx.slider.value = String(Math.round(volume * VOLUME_STEPS)); + sfx.slider.disabled = muted; + sfx.readout.textContent = `${Math.round(volume * 100)}%`; + + const music = controls.music; + music.btn.setAttribute('aria-pressed', musicMuted ? 'false' : 'true'); + music.btn.textContent = musicMuted ? 'OFF' : 'ON'; + music.slider.value = String(Math.round(musicVolume * VOLUME_STEPS)); + music.slider.disabled = musicMuted; + music.readout.textContent = `${Math.round(musicVolume * 100)}%`; + + for (const sync of sweepSyncs) sync(); +} + +function toggleRunning() { + audioManager.resume(); + if (musicDirector.running) { + musicDirector.stop(); + audioManager.stopMusic(); + setStatus('stopped'); + } else { + musicDirector.start(); + setStatus('started'); + } + render(); +} + +function buildTransport() { + const container = document.getElementById('music-controls') as HTMLElement; + + startBtn = h('button', { type: 'button', className: 'control' }) as HTMLButtonElement; + startBtn.addEventListener('click', toggleRunning); + + const tensionRow = h('span', { className: 'btn-row' }); + for (const tension of TENSIONS) { + const btn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + textContent: TENSION_LABELS[tension], + }) as HTMLButtonElement; + btn.addEventListener('click', () => { + musicDirector.setTension(tension); + // Deliberately not re-rendering the active state immediately: the change + // lands on the next bar, and the buttons show what is sounding. + setStatus(`tension ${tension} requested (lands next bar)`); + render(); + }); + tensionBtns.set(tension, btn); + tensionRow.append(btn); + } + + const paletteRow = h('span', { className: 'btn-row' }); + for (const palette of PALETTES) { + const btn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + textContent: palette, + }) as HTMLButtonElement; + btn.addEventListener('click', () => { + musicDirector.setPalette(palette); + setStatus(`palette ${palette} requested (lands next bar)`); + render(); + }); + paletteBtns.set(palette, btn); + paletteRow.append(btn); + } + + const seedInput = h('input', { + type: 'number', + value: '1234', + 'aria-label': 'Seed', + }) as HTMLInputElement; + const reseedBtn = h('button', { + type: 'button', + className: 'control', + textContent: 'RESEED', + }) as HTMLButtonElement; + reseedBtn.addEventListener('click', () => { + const seed = Number(seedInput.value); + if (!Number.isFinite(seed)) { + setStatus('seed must be a finite number'); + return; + } + musicDirector.reseed(seed >>> 0); + setStatus(`reseeded to ${seed >>> 0}`); + }); + + const clearBtn = h('button', { + type: 'button', + className: 'control', + textContent: 'CLEAR LOG', + }) as HTMLButtonElement; + clearBtn.addEventListener('click', () => { + logLines.length = 0; + logEl.textContent = '(cleared)'; + }); + + container.append( + h('label', {}, [h('span', { textContent: 'Transport' }), startBtn]), + h('label', {}, [h('span', { textContent: 'Tension' }), tensionRow]), + h('label', {}, [h('span', { textContent: 'Palette' }), paletteRow]), + h('label', {}, [h('span', { textContent: 'Seed' }), seedInput, reseedBtn]), + ...buildSweepControls(), + buildChannel('sfx', 'Sound', AUDIO_MUTED_PREF, AUDIO_VOLUME_PREF), + buildChannel('music', 'Music', AUDIO_MUSIC_MUTED_PREF, AUDIO_MUSIC_VOLUME_PREF), + h('label', {}, [clearBtn]) + ); +} + +/** + * Live controls for the bus filter sweep — the alarm's signal. + * + * These write straight to `AudioManager`, bypassing the tension table, so the + * values can be found by ear. The director only pushes modulation when the + * sounding tension or palette actually *changes*, so a hand-dialled setting + * survives until you press a tension button — at which point it snaps back to + * whatever `TENSION_CONFIG` says, which is also how you A/B a candidate against + * the committed value. + */ +function buildSweepControls(): HTMLElement[] { + const spec: { + key: keyof MusicModulation; + label: string; + min: number; + max: number; + step: number; + unit: string; + }[] = [ + { key: 'baseCutoff', label: 'Cutoff', min: 200, max: 12000, step: 50, unit: 'Hz' }, + { key: 'q', label: 'Resonance', min: 0.5, max: 12, step: 0.1, unit: 'Q' }, + { key: 'depthCents', label: 'Depth', min: 0, max: 4800, step: 50, unit: '¢' }, + { key: 'hz', label: 'LFO rate', min: 0.05, max: 6, step: 0.05, unit: 'Hz' }, + ]; + + const current = () => audioManager.musicModulation ?? musicDirector.modulation; + const rows: HTMLElement[] = []; + + for (const field of spec) { + const slider = h('input', { + type: 'range', + min: String(field.min), + max: String(field.max), + step: String(field.step), + 'aria-label': field.label, + }) as HTMLInputElement; + const readout = h('span', { className: 'readout' }); + + const sync = () => { + const value = current()[field.key]; + slider.value = String(value); + readout.textContent = `${Math.round(value * 100) / 100} ${field.unit}`; + }; + slider.addEventListener('input', () => { + audioManager.setMusicModulation({ ...current(), [field.key]: Number(slider.value) }); + sync(); + }); + sweepSyncs.push(sync); + rows.push(h('label', {}, [h('span', { textContent: field.label }), slider, readout])); + } + return rows; +} + +function buildChannel( + key: string, + label: string, + mutedPref: string, + volumePref: string +): HTMLElement { + const btn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + }) as HTMLButtonElement; + btn.addEventListener('click', () => { + if (key === 'sfx') { + muted = !muted; + dataStore.setPref(mutedPref, muted); + } else { + musicMuted = !musicMuted; + dataStore.setPref(mutedPref, musicMuted); + } + render(); + }); + + const slider = h('input', { + type: 'range', + min: '0', + max: String(VOLUME_STEPS), + step: '1', + 'aria-label': `${label} volume`, + }) as HTMLInputElement; + slider.addEventListener('input', () => { + const value = Math.min(1, Math.max(0, Number(slider.value) / VOLUME_STEPS)); + if (key === 'sfx') volume = value; + else musicVolume = value; + dataStore.setPref(volumePref, value); + render(); + }); + + const readout = h('span', { className: 'readout' }); + controls[key] = { btn, slider, readout }; + return h('label', {}, [h('span', { textContent: label }), btn, slider, readout]); +} + +statusEl = document.getElementById('music-status') as HTMLElement; +logEl = document.getElementById('music-log') as HTMLElement; + +({ muted, volume, musicMuted, musicVolume } = parseAudioPrefs(dataStore.prefs)); + +buildTransport(); +render(); +setStatus('ready — press START (Web Audio needs a gesture first)'); diff --git a/debug/sound.html b/debug/sound.html new file mode 100644 index 0000000..fec7654 --- /dev/null +++ b/debug/sound.html @@ -0,0 +1,137 @@ + + + + Kernel Panic — Sound test + + + + + + + +
+

Kernel Panic — Sound test

+
+
+

+ Every entry in src/audio/sounds.ts, playable on demand through the real + AudioManager / vendored TONEBENCH engine — no game state required. Mute and + volume here read/write the same DataStore prefs as the in-game Options modal, + so they persist across pages. +

+
+

+
+

Sounds

+
+
+
+

Sequences & chains

+

+ Multi-hit cues that re-pitch or chain the defs above via playSequence / + playChain rather than being defs of their own. +

+
+
+
+

Params

+

The SynthParams (or sequence/chain spec) behind the last-played button.

+
(nothing played yet)
+
+
+ + + diff --git a/debug/sound.ts b/debug/sound.ts new file mode 100644 index 0000000..c24214a --- /dev/null +++ b/debug/sound.ts @@ -0,0 +1,213 @@ +/** + * Sound test harness — every def in `src/audio/sounds.ts`, playable on demand + * through the real `AudioManager` (soundBoard's production singleton, wired to + * the vendored TONEBENCH engine). No game state: this is a synth palette + * browser, not a scenario harness like index.ts/map.ts. + * + * `KERNEL_PANIC_DEFS` is sounds.ts's own single source of truth (see its + * docstring), so the sound grid is generated from `Object.keys()` rather than + * a hand-maintained list here — add a sound there and it appears here for free. + * + * Sequences/chains (`playSequence`, `playChain`) re-pitch or chain a base def + * rather than defining a new one, so they're listed separately with their + * step data rather than mixed into the flat sound grid. + */ +import { h } from '/src/domUtils.js'; +import { audioManager } from '/src/audio/soundBoard.js'; +import { + KERNEL_PANIC_DEFS, + EXTRACTION_MOTIF, + TRANSACTION_MOTIF, + type SoundName, + type SequenceStep, +} from '/src/audio/sounds.js'; +import { + AUDIO_MUTED_PREF, + AUDIO_VOLUME_PREF, + DEFAULT_VOLUME, + parseAudioPrefs, +} from '/src/audio/AudioManager.js'; +import dataStore from '/src/DataStore.js'; + +const VOLUME_STEPS = 100; + +const soundNames = Object.keys(KERNEL_PANIC_DEFS) as SoundName[]; + +/** Turret deploy's two-beat chain, matching sceneListeners.ts's TURRET_DEPLOYED handler. */ +const DEPLOY_CHAIN: readonly { name: SoundName; when: number }[] = [ + { name: 'deploy', when: 0 }, + { name: 'deployOnline', when: 0.09 }, +]; + +let muted = false; +let volume = DEFAULT_VOLUME; +let soundBtn: HTMLButtonElement; +let volumeInput: HTMLInputElement; +let volumeReadout: HTMLElement; +let statusEl: HTMLElement; +let paramsEl: HTMLElement; +let playCount = 0; + +function timestamp() { + return new Date().toLocaleTimeString(undefined, { hour12: false }); +} + +function setStatus(label: string) { + playCount += 1; + statusEl.textContent = `> [${timestamp()}] #${playCount} played: ${label}${muted ? ' (muted — no sound)' : ''}`; +} + +function setParams(value: unknown) { + paramsEl.textContent = JSON.stringify(value, null, 2); +} + +function markActive(grid: HTMLElement, btn: HTMLButtonElement) { + for (const child of Array.from(grid.children)) { + if (child instanceof HTMLButtonElement) child.setAttribute('aria-pressed', 'false'); + } + btn.setAttribute('aria-pressed', 'true'); +} + +function playSound(name: SoundName, grid: HTMLElement, btn: HTMLButtonElement) { + audioManager.resume(); + audioManager.play(name); + markActive(grid, btn); + setStatus(`"${name}"`); + setParams(KERNEL_PANIC_DEFS[name]); +} + +function playMotif( + label: string, + base: SoundName, + steps: readonly SequenceStep[], + grid: HTMLElement, + btn: HTMLButtonElement +) { + audioManager.resume(); + audioManager.playSequence(base, steps); + markActive(grid, btn); + setStatus(`"${label}" (playSequence base="${base}")`); + setParams({ base, steps }); +} + +function playChain( + label: string, + steps: readonly { name: SoundName; when: number }[], + grid: HTMLElement, + btn: HTMLButtonElement +) { + audioManager.resume(); + audioManager.playChain(steps); + markActive(grid, btn); + setStatus(`"${label}" (playChain)`); + setParams({ steps }); +} + +function buildSoundGrid() { + const grid = document.getElementById('sound-grid') as HTMLElement; + const buttons = soundNames.map(name => { + const btn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: name, + }) as HTMLButtonElement; + btn.addEventListener('click', () => playSound(name, grid, btn)); + return btn; + }); + grid.append(...buttons); +} + +function buildSequenceGrid() { + const grid = document.getElementById('sequence-grid') as HTMLElement; + + const extractionBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'extracted (EXTRACTION_MOTIF)', + }) as HTMLButtonElement; + extractionBtn.addEventListener('click', () => + playMotif('EXTRACTION_MOTIF', 'extracted', EXTRACTION_MOTIF, grid, extractionBtn) + ); + + const transactionBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'transaction (TRANSACTION_MOTIF)', + }) as HTMLButtonElement; + transactionBtn.addEventListener('click', () => + playMotif('TRANSACTION_MOTIF', 'transaction', TRANSACTION_MOTIF, grid, transactionBtn) + ); + + const deployChainBtn = h('button', { + type: 'button', + className: 'sound-btn', + 'aria-pressed': 'false', + textContent: 'deploy → deployOnline (chain)', + }) as HTMLButtonElement; + deployChainBtn.addEventListener('click', () => + playChain('turret deploy chain', DEPLOY_CHAIN, grid, deployChainBtn) + ); + + grid.append(extractionBtn, transactionBtn, deployChainBtn); +} + +function renderAudioControls() { + if (soundBtn) soundBtn.setAttribute('aria-pressed', muted ? 'false' : 'true'); + if (soundBtn) soundBtn.textContent = muted ? 'OFF' : 'ON'; + if (volumeInput) { + volumeInput.value = String(Math.round(volume * VOLUME_STEPS)); + volumeInput.disabled = muted; + } + if (volumeReadout) volumeReadout.textContent = `${Math.round(volume * 100)}%`; +} + +function buildAudioControls() { + const container = document.getElementById('audio-controls') as HTMLElement; + + soundBtn = h('button', { + type: 'button', + className: 'control', + 'aria-pressed': 'false', + }) as HTMLButtonElement; + soundBtn.addEventListener('click', () => { + muted = !muted; + dataStore.setPref(AUDIO_MUTED_PREF, muted); + renderAudioControls(); + }); + + volumeInput = h('input', { + type: 'range', + min: '0', + max: String(VOLUME_STEPS), + step: '1', + 'aria-label': 'Volume', + }) as HTMLInputElement; + volumeInput.addEventListener('input', () => { + volume = Math.min(1, Math.max(0, Number(volumeInput.value) / VOLUME_STEPS)); + dataStore.setPref(AUDIO_VOLUME_PREF, volume); + renderAudioControls(); + }); + + volumeReadout = h('span', { className: 'readout' }); + + container.append( + h('label', {}, [h('span', { textContent: 'Sound' }), soundBtn]), + h('label', {}, [h('span', { textContent: 'Volume' }), volumeInput, volumeReadout]) + ); +} + +statusEl = document.getElementById('sound-status') as HTMLElement; +paramsEl = document.getElementById('sound-params') as HTMLElement; + +// Read persisted prefs once at load; the settings modal (if opened in another +// tab) isn't listened for here — this page's own controls are the source of +// truth for the rest of this session, matching its read-once-per-open pattern. +({ muted, volume } = parseAudioPrefs(dataStore.prefs)); + +buildAudioControls(); +buildSoundGrid(); +buildSequenceGrid(); +renderAudioControls(); diff --git a/docs/kaizen.md b/docs/kaizen.md index 3a93334..c88f210 100644 --- a/docs/kaizen.md +++ b/docs/kaizen.md @@ -48,6 +48,35 @@ When an item lands, gets reclassified, or develops new context, edit it in place ## ◇ Monitored +- **No throw preview / reticle for the Molotov.** A thrown incendiary commits AP and the charge on + a single direction press with no indication of where the bottle will land or what it will hit. + This bites hardest since P3.6 made your own crew backstops (a crewmate on the ray eats the + bottle — intended and tested, but a body clustered in front of you now silently shortens the + throw). A trajectory/impact preview at aim time — highlight the resolved impact tile and flag a + friendly interceptor before commit — would make both the range and the friendly-fire rule + legible. `resolveIncendiaryImpact` is pure over grid + entity state and already returns the + impact point, so a preview is a render pass over its result, not new geometry. **Note:** an + earlier `IncendiaryImpact.steps` field was carried speculatively for exactly this and pulled in + P3.6 (unused, untested) — re-add it *with* its consumer and a test if the preview needs travel + distance. **Revisit trigger:** first playtest complaint about a throw that "went nowhere" or + burned a teammate, or when a second thrown item lands (shares the reticle work). Pairs naturally + with the fixed-`INCENDIARY_THROW_DIST` item below. + +- **Generated hazard and thrown fire are visually identical but behave differently.** Both + render as `▓`, but a map-generated pool is permanent scenery while a thrown molotov burns + out after `INCENDIARY_BURN_TURNS`. A player has no way to tell which fire will still be + there in three turns, which makes it hard to plan around either. Options: a distinct glyph + or colour ramp for burning-out fire, or an intensity ramp as the timer runs down (a natural + fit for the 3.6 "effects" work). **Revisit trigger:** first playtest where someone waits out + a permanent pool, or routes into fire that they expected to have gone out. +- **`INCENDIARY_THROW_DIST` is a fixed 3 tiles in 8 directions.** You cannot lob short, long, + or drop at your feet — the only Molotov play is "exactly 3 tiles that way". The cluster's + radius-1 spread softens this to an effective 2–4 band on-axis, but it still means a hostile + at range 1, 2, or 5+ simply cannot be targeted. Left alone in P3.6 because free targeting is + a real UX design problem (needs a cursor/reticle mode, not just a direction press) and the + fixed throw is at least legible. **Revisit trigger:** when any second thrown item wants + variable range, or the first playtest complaint about "I couldn't hit the thing next to me". + - **Debug save Import relies on `prompt()`.** The in-app browser used for local smoke tests does not support native `prompt()`, so `/debug/save.html` logs an error and cannot import a prepared save there. Normal browsers may still support it, but the debug surface should use @@ -76,8 +105,48 @@ When an item lands, gets reclassified, or develops new context, edit it in place - **`chebyshev` / `manhattan` helpers.** `Pathfinding.chebyshev(ax, ay, bx, by)` vs `Run.chebyshev` / `Run.manhattan` (GridPoint) vs `mapBuild.manhattan` — unify on GridPoint wrappers or a small `gridMath.ts` when convenient. - **`LineOfSight` inline `` `${x},${y}` `` keys.** Could import `coordKey` from `mapConnectivity.js`; isolated, low drift risk at current scale. +- **Dual-site and escort objectives have no HUD progress chip.** Surfaced 2026-07-20 while adding a distinct `checkpoint` audio cue (`src/audio/sounds.ts`) for their interim beats — sync pad 1 of 2 touched, escort contact linked up — via `handleSecuredInteract` in `shellRuntime.ts` gating on `run.isObjectiveSatisfied()`. That closes the *audio* gap, but `objectiveProgress()` in `src/game/objectiveProgress.ts` still has no `case` for `OBJECTIVES.DUAL_SITE` or `OBJECTIVES.ESCORT_EXTRACT`, unlike `RECON`/`SWEEP`/`DATA_NODE_SLICE`/`SCORE_FINAL`, which all render a `[LABEL:current/total]` chip. Players get a sound but no on-screen `[SYNC:1/2]`-style readout. Would need progress tallies analogous to `dataNodeProgress` (count `SyncPad.synced` / `EscortNpc.activated` across the world) plumbed through `buildCombatHudSnapshot`. **Revisit trigger:** first playtest confusion about dual-site/escort state, or next time the HUD chip work is touched. + ## ✓ Closed +- ~~**A body caught square on by a molotov takes no impact damage unless it's standing on FLOOR.**~~ + Surfaced in P3.6 while adding ray-walked throws; closed the same phase. `placeHazardCluster` + stamped `TILE.FLOOR` only, and `burnEntitiesOn` only damages entities on tiles it actually + ignited — so a drone on `RUBBLE` or in an existing `HAZARD` pool intercepted the bottle, was + reported as the impact point, and took zero `INCENDIARY_IMPACT_DAMAGE` because its own tile + refused to light: "Caught X square on. 0 caught." in one flash. **Fix:** thrown fire now takes + any ground fire can hold via a shared `thrownFireCanTake(tile)` predicate (constants.ts) — the + single source of truth for both `incendiary.ts`'s `canHoldFire` (where the ray centres) and + `placeHazardCluster`'s thrown branch (where fire stamps), so the two cannot drift into the + silent "used the charge, got nothing" bug. Whitelist is `FLOOR | RUBBLE | HAZARD`; `EXIT` stays + unburnable (extraction tile) and generated scenery (`thrown: false`) stays FLOOR-only. An + already-burning tile is left as-is rather than re-stamped (`applyTileEffect` refuses an effect + equal to the tile underneath, which would crash on permanent hazard scenery) but is still counted + so a body in the pool takes the impact. The one residual — a body parked on `EXIT` takes no + impact damage because that tile must never burn — no longer lies: the shell only prints "square + on" when the struck body is an actual casualty. Tests: `hazard.test.ts` (RUBBLE/HAZARD impact + + RUBBLE burnout-restore + EXIT spare), `incendiary.test.ts` (rubble/exit centring, re-centre on + burning tile, open-doorway catch). + +- ~~**Smoke tiles survive a mid-mission save as permanent LOS blockers.**~~ Surfaced and closed + in P3.6 while fixing the Molotov. `placeSmoke` stamped `TILE.SMOKE` onto the grid and handed + the restore records to the *shell*, which held them in `shellRuntime.activeSmokeOverlays` and + cleared them on `onPlayerTurnReady`. But `grid.tiles` **is** persisted and + `activeSmokeOverlays` **was not** — and autosave fires at the player→corp `turn:ended`, which + is exactly when smoke is on the grid. Save inside your own cloud, reload, and the overlay list + came back `[]` while the SMOKE tiles remained: a permanent sight-line wall nothing would ever + clean up. Reachable in ordinary play, and silent when it happened. + **Fix:** effect lifetimes moved onto `World` as one shared registry — `applyTileEffect(x, y, + tile, turns)` / `tickTileEffects()` — that thrown fire and smoke both use, snapshotted as + `RunSnapshot.tileEffects`. Two details worth keeping: the registry **reads the tile underneath + itself** rather than taking it from the caller (a caller passing FLOOR for a cloud over the + EXIT tile would have silently deleted the exit), and re-applying to an already-affected tile + keeps the *original* restore target, so terrain can't be lost to a chain of effects. Ticked + once per round from `advanceFromPlayerTurn` at the CORP→PLAYER handoff — the only point both + the shell and the debug harness share, and necessarily *after* the corp turn, since smoke + exists to blind the drones that move next. `clearSmoke` and the shell's overlay list are gone. + Tests: `items.test.ts` (smoke lifetime, EXIT restore, fire/smoke interaction), + `persistence.test.ts` (save-mid-cloud round-trip), `hazard.test.ts` (round-boundary ordering). - ~~**Unwinnable runs.**~~ Closed in Phase 2.5 — two-part fix: (1) **Abort extraction:** player can exit via the EXIT tile with an incomplete objective; forfeits all rewards (creds, salvage, rep gain) and takes `REP.ABORT_PENALTY` (−5). (2) **Breach charge auto-grant:** `Campaign.deployCrewMember` grants 1× breaching charge when accepting a breach contract if the operative doesn't already carry one, so the objective is always completable regardless of Finn's shop access. - ~~**`advanceFromPlayerTurn` advances the turn queue on a terminal run.**~~ Closed at Phase 2 closeout — TIER-1 item #1 from the [2026-05-17 adversarial review](./2026-05-17-adversarial-review-findings.md#1). Stepping onto the exit tile transitions the run to RESULT synchronously; the pipeline then still called `queue.endTurn(world)`, refreshing corp AP and bumping the turn counter on a dead run. The `isTerminal()` guard now runs *before* `queue.endTurn` / `onCorpTurnReady`. Regression test in `combatTurnPipeline.test.ts`. diff --git a/docs/phase-3-plan.md b/docs/phase-3-plan.md index 8f1b1b8..2cebb3f 100644 --- a/docs/phase-3-plan.md +++ b/docs/phase-3-plan.md @@ -408,6 +408,14 @@ Placement is collision-safe (`pickFreeRingTile` consumes one rng draw then scans **P3.M5 implementation note (2026-06-20):** `OBJECTIVES.SCORE_FINAL` is validated separately from normal `data-node-slice` contracts and requires `requiresCyberspace: true`, a positive `count`, and a stable linked `doorId`. `DataNode` emits `EVENT.DATA_NODE_SLICED`; Score runs listen for that event and unlock the linked Meatspace door once the core node is sliced. Score objective satisfaction is a conjunction of cyber core progress and secured payload state; extraction is a separate requirement handled by `Run.#extractScoreOperative`. Snapshot/restore persists the extracted operative ids plus off-grid extracted operative records so mid-finale saves can restore a partner-first or body-first extraction state without resurrecting the extracted crew onto the grid. `Campaign.onJobEnd` maps incomplete Score exits to `score-partial`, skips the normal abort Rep penalty, and ends the campaign without the Score reward; `buildCampaignSummary` now reports `win | partial | loss`, and `` renders distinct compromised-Score copy. +**P3.6 amendment — the costly Score (2026-07-24):** Playtest (`score-partial` felt bad): the crew got the payload out but lost the meat partner, and the "partial" paid nothing — double jeopardy on top of a permanent flatline, and the run record falsely reported `objectiveComplete: false`. The M5 all-or-nothing rule (bracketed above: *"both deployed operatives extracting alive are required for clean completion"* and *"awards no full Score payoff"*) is **superseded**: + +- **Survivor rule.** A Score ends when every *living* required operative has extracted. Objectives complete + payload secured = objective complete, regardless of who fell — the extraction telemetry now reports `objectiveComplete: true` on a casualty. No casualties → `score-extracted` (clean). A casualty → `score-partial`, now a **costly-win tier**, not an abort. +- **Symmetry (the Decker can die too).** A Score run no longer ends the instant the Decker flatlines. If a live meat partner is still on the grid, `Run.#onDeckerFlatlined` resolves the Cyberspace layer at its latched progress (a core sliced before the kill stays sliced — the door it opened stays open), hands the grid to the partner, and fires `onDeckerDown`. The partner can then walk the physical payload out. `Run.deckerDown` mirrors `partnerDown`; `Campaign.onJobEnd` flatlines the Decker on that EXIT. With **no** live partner, a Decker flatline still ends the run as before, and **non-Score** runs are unchanged (the Decker still *is* the run). +- **Settlement.** `score-partial` pays `SCORE_PARTIAL_CREDIT_FACTOR` (0.5) of the credit reward, keeps the salvage the survivor carried through the exit, and **writes the blueprint/archetype meta-unlock** — the prototype physically left the building, so it is stolen. This reverses the M6 rule at the milestone below (*"Written only on `score-complete`… the prototype wasn't secured"*): the unlock now writes on `score-complete` **or** `score-partial`, never on the new `score-aborted`. +- **New terminal `score-aborted`.** Walking out with objectives *unfinished* is now its own end reason (result `loss`, no reward, terminal, no Rep penalty) — the outcome `score-partial` used to be conflated with. `endReason`, `CampaignSummary`, `endFlavor` (own downbeat pool), and the chronicle all distinguish the three Score exits: complete / bought-in-blood / abandoned. +- **Presentation.** `` renders the stolen blueprint on a `partial` (with a bereaved loot kicker), the shell drops the false "ABORT — REP −X" flash on the Score (no penalty is applied), suppresses the "clean extraction" bonus on a costly win, and the chronicle names the operator lost. + **Acceptance:** - ✅ Score contract available only in Act 3, player-initiated, and gated by living Decker + living non-Decker partner. @@ -440,7 +448,7 @@ Placement is collision-safe (`pickFreeRingTile` consumes one rng draw then scans - **Abstract Score targets (pool exhausted):** When all `SCOREABLE_ITEMS` are acquired, `buildScoreContract()` shifts to abstract RNG-driven credit payloads drawn from a small fixed category set (corp payroll, exotic meta-materials, black-market data cache, prototype weapons cache, etc.). Payload category is seeded from the contract RNG so each exhausted-pool campaign gets different flavor. The full arc structure runs identically; the payout is a substantial credit sum rather than a shop unlock. No item is written to the meta-store. -- **Meta-progression store:** New `DataStore` key `unlockedScoreableItems: string[]` (item IDs, ordered by acquisition date). Written only on `score-complete` (not `score-partial` — the prototype wasn't secured). Read at campaign init and Hub load. Idempotent archival: writing a duplicate ID is a no-op (no throw, no double-entry). Half-populated or structurally invalid store throws on restore rather than silently falling back to an empty list. +- **Meta-progression store:** New `DataStore` key `unlockedScoreableItems: string[]` (item IDs, ordered by acquisition date). Written on `score-complete` (and, per the P3.6 amendment above, on `score-partial` — the payload extracted, so the blueprint is stolen; **not** on `score-aborted`). Read at campaign init and Hub load. Idempotent archival: writing a duplicate ID is a no-op (no throw, no double-entry). Half-populated or structurally invalid store throws on restore rather than silently falling back to an empty list. - **Hub surface:** Finn's shop renders only purchasable items — `DEFAULT_ITEMS + unlockedScoreableItems`. An `ACQUISITIONS: N / M` counter is deferred to P3.M7, where it fits naturally in the Chronicle / history view. @@ -620,7 +628,7 @@ unlocked scoreable). `` renders only the catalog handed to - **Why `minRepTier` is retired as a shop gate:** Rep-gated access was mechanical — the best gear was reachable by grinding rep without doing anything interesting. Two explicit catalogs make availability rules legible in the data rather than computed from a tier comparison at runtime. - **Why default items are fixed:** The interesting question is "which upgrades has the meta-crew earned?" not "will Finn have ammo today?" Fixed default stock removes friction and keeps meaningful variance on scoreable unlocks. - **Why abstract payloads instead of pool reset:** Resetting would retroactively devalue past heists. Abstract payloads acknowledge mastery — "you've stolen everything worth stealing; now you're just taking their money" — while keeping the arc valid indefinitely for long-running meta-campaigns. -- **Why partial Score doesn't unlock:** Incomplete extraction means the prototype wasn't secured. Clean win only; the fiction holds. +- **Why partial Score doesn't unlock:** ~~Incomplete extraction means the prototype wasn't secured. Clean win only; the fiction holds.~~ **Superseded by the P3.6 amendment.** A `score-partial` now *is* a secured payload (an operative was lost, not the objective), so it unlocks. The non-unlocking outcome is the new `score-aborted` — walking out with the job unfinished — where the prototype genuinely never left its rack. **Acceptance:** diff --git a/docs/phase-3.5-plan.md b/docs/phase-3.5-plan.md index b103ac0..bb6481a 100644 --- a/docs/phase-3.5-plan.md +++ b/docs/phase-3.5-plan.md @@ -532,5 +532,5 @@ Per milestone: `npm test` (typecheck + build tests + `node --test`) must pass, i - **M4:** play an Adept, confirm Influence behaves identically to the old Override (aim-sector targeting, success roll, alarm on failure, countdown-and-revert) end to end; confirm `mindInfluence.test.ts` covers the mechanic independent of any archetype wiring. - **M5:** play a Chimera, kill a hostile, collect its scrap drop, convert it to HP — confirm repeatable across turns as long as scrap lasts and HP clamps at max. - **M6:** start several fresh campaigns (different seeds) and confirm crew stats vary run-to-run, land on the 0.01 grid within the widened `[0.65,0.85]`/`[0.15,0.40]` ranges (no clustering onto a few repeated values like the old discrete buckets), and archetypes span all six non-Decker options across enough campaigns; save mid-campaign, reload, confirm rolled stats round-trip; load a pre-P3.5 save fixture (or a save snapshot lacking `baseHitChance`) and confirm it restores to the old fixed per-archetype constant rather than crashing or silently rerolling. **Run this check against a fixture with all three M7 archetypes pre-unlocked** (M7 will otherwise have shrunk the live anchor table to `{merc, razor, tech}` by the time M7 ships) so M6's own "all six reachable" claim stays independently verifiable. -- **M7:** play a fresh meta-crew (empty `unlockedArchetypes`) through a full campaign to a clean Score win and confirm the reward is drawn from the merged pool (item or archetype, never both, matches what settlement recorded); confirm a won archetype reward persists in `DataStore` across a new campaign and that a subsequent crew roll can now land the newly-unlocked archetype (a fixture forcing a roll onto its exact anchor point is the deterministic way to prove this, rather than replaying rolls until one lands); confirm a **locked** archetype's anchor point saturates to its documented nearest unlocked neighbor instead of dead-zoning or throwing; confirm a `score-partial` outcome writes nothing to either meta-store key. +- **M7:** play a fresh meta-crew (empty `unlockedArchetypes`) through a full campaign to a clean Score win and confirm the reward is drawn from the merged pool (item or archetype, never both, matches what settlement recorded); confirm a won archetype reward persists in `DataStore` across a new campaign and that a subsequent crew roll can now land the newly-unlocked archetype (a fixture forcing a roll onto its exact anchor point is the deterministic way to prove this, rather than replaying rolls until one lands); confirm a **locked** archetype's anchor point saturates to its documented nearest unlocked neighbor instead of dead-zoning or throwing; confirm a terminal Score-with-no-payload outcome writes nothing to either meta-store key. *(P3.6 note: that no-write outcome is now `score-aborted` — walking out with the job unfinished. `score-partial` was redefined to a costly **win** that secured the payload and therefore **does** write the unlock; see the P3.6 amendment in `phase-3-plan.md`.)* - Full regression: `npm test` at the end of the phase, plus a manual playthrough covering all seven archetypes (Merc/Razor/Tech/Decker/Berserk/Adept/Chimera) in one run **using a save fixture with `unlockedArchetypes` pre-seeded to all three** (per the dropped single-campaign gate above, a truly blank-slate save can't reach this state) to catch any wiring gaps in the fan-out surfaces (`Run.ts`, `persistence.ts`, `applyIntent.ts`). diff --git a/index.html b/index.html index c695be0..ff92fe8 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,15 @@
Kernel Panic logo

Kernel Panic

+