feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
AetherForge
2026-06-04 22:36:17 -07:00
parent 1551bd5dad
commit a32860b0d9
154 changed files with 17383 additions and 601 deletions

View File

@@ -9,6 +9,7 @@ import {
BGM_STORAGE_KEY,
BGM_VOLUME_KEY,
AMBIENT_MUSIC_SRC,
MODAL_AMBIENT_DUCK_FACTOR,
} from './ambientMusic';
describe('ambientMusic prefs', () => {
@@ -40,4 +41,15 @@ describe('ambientMusic prefs', () => {
it('points at public audio path', () => {
expect(AMBIENT_MUSIC_SRC).toBe('/audio/ambient.mp3');
});
it('ducks effective volume 30% while modal registered', () => {
const p = new AmbientMusicPlayer();
p.setVolume(1);
p.setPageIntensity(0.8);
const base = p.getEffectiveVolume();
const unregister = p.registerModalDuck();
expect(p.getEffectiveVolume()).toBeCloseTo(base * MODAL_AMBIENT_DUCK_FACTOR);
unregister();
expect(p.isModalDuckActive()).toBe(true);
});
});

View File

@@ -7,6 +7,36 @@ export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
/** Route → playback multiplier (01). User volume × intensity = effective output. */
export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
'/forge': 1,
'/builder': 1,
'/mission-deck': 0.95,
'/emberwake': 0.8,
'/spread': 0.8,
'/crucible': 0.75,
'/agents': 0.7,
'/dashboard': 0.65,
'/builds': 0.55,
'/pathtracer': 0.4,
'/settings': 0.25,
};
/** Multiply page intensity by this when a modal/wizard is open (30% duck). */
export const MODAL_AMBIENT_DUCK_FACTOR = 0.7;
export const MODAL_AMBIENT_SWELL_MS = 400;
export function resolvePageAmbientIntensity(pathname: string): number {
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
if (PAGE_AMBIENT_INTENSITY[path] !== undefined) {
return PAGE_AMBIENT_INTENSITY[path];
}
for (const [prefix, intensity] of Object.entries(PAGE_AMBIENT_INTENSITY)) {
if (prefix !== '/' && path.startsWith(prefix)) return intensity;
}
return 0.65;
}
export function loadBgmEnabled(): boolean {
try {
const v = localStorage.getItem(BGM_STORAGE_KEY);
@@ -47,6 +77,11 @@ export class AmbientMusicPlayer {
private audio: HTMLAudioElement | null = null;
private enabled = loadBgmEnabled();
private volume = loadBgmVolume();
private pageIntensity = 1;
private modalDuckRegistrations = 0;
/** 0 = full duck, 1 = no duck — animated on swell. */
private duckBlend = 1;
private swellFrame: number | null = null;
private unlocked = false;
private playing = false;
private listeners = new Set<(playing: boolean) => void>();
@@ -63,6 +98,82 @@ export class AmbientMusicPlayer {
return this.volume;
}
getPageIntensity() {
return this.pageIntensity;
}
getEffectiveVolume() {
return this.volume * this.pageIntensity * this.getDuckMultiplier();
}
isModalDuckActive() {
return this.modalDuckRegistrations > 0 || this.duckBlend < 1;
}
private getDuckMultiplier() {
return MODAL_AMBIENT_DUCK_FACTOR + this.duckBlend * (1 - MODAL_AMBIENT_DUCK_FACTOR);
}
/** Register an open modal/wizard; returns unregister (runs swell when last closes). */
registerModalDuck(): () => void {
this.cancelSwell();
this.modalDuckRegistrations += 1;
if (this.modalDuckRegistrations === 1) {
this.duckBlend = 0;
this.applyVolume();
}
return () => {
this.modalDuckRegistrations = Math.max(0, this.modalDuckRegistrations - 1);
if (this.modalDuckRegistrations === 0) {
this.startSwell();
}
};
}
private cancelSwell() {
if (this.swellFrame !== null && typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(this.swellFrame);
this.swellFrame = null;
}
}
private startSwell() {
this.cancelSwell();
const startBlend = this.duckBlend;
const startTime = typeof performance !== 'undefined' ? performance.now() : 0;
const duration = MODAL_AMBIENT_SWELL_MS;
const tick = (now: number) => {
const t = Math.min(1, (now - startTime) / duration);
const eased = 1 - (1 - t) * (1 - t);
this.duckBlend = startBlend + (1 - startBlend) * eased;
this.applyVolume();
if (t < 1 && typeof requestAnimationFrame !== 'undefined') {
this.swellFrame = requestAnimationFrame(tick);
} else {
this.duckBlend = 1;
this.swellFrame = null;
this.applyVolume();
}
};
if (typeof requestAnimationFrame !== 'undefined') {
this.swellFrame = requestAnimationFrame(tick);
} else {
this.duckBlend = 1;
this.applyVolume();
}
}
setPageIntensity(intensity: number) {
this.pageIntensity = Math.min(1, Math.max(0, intensity));
this.applyVolume();
}
private applyVolume() {
if (this.audio) this.audio.volume = this.getEffectiveVolume();
}
subscribe(fn: (playing: boolean) => void) {
this.listeners.add(fn);
return () => { this.listeners.delete(fn); };
@@ -88,7 +199,7 @@ export class AmbientMusicPlayer {
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistBgmVolume(this.volume);
if (this.audio) this.audio.volume = this.volume;
this.applyVolume();
}
/** Browsers block autoplay until a user gesture unlocks audio. */
@@ -116,7 +227,7 @@ export class AmbientMusicPlayer {
const el = new Audio(AMBIENT_MUSIC_SRC);
el.loop = true;
el.preload = 'auto';
el.volume = this.volume;
el.volume = this.getEffectiveVolume();
el.addEventListener('play', () => this.setPlaying(true));
el.addEventListener('pause', () => this.setPlaying(false));
el.addEventListener('ended', () => this.setPlaying(false));
@@ -136,7 +247,7 @@ export class AmbientMusicPlayer {
if (!this.enabled) return false;
this.ensureAudio();
if (!this.audio) return false;
this.audio.volume = this.volume;
this.applyVolume();
try {
await this.audio.play();
this.setPlaying(true);