mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1) Two crossfading video backdrop planes in the highway_3d venue background style, driven by a new venue-crowd.js state machine that maps v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic) with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers on streak milestones and end-of-song accuracy. Inert without a venue pack manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the static bg plate behaves exactly as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions Codex preflight P2s: (1) videos created before highway_3d registered its globals never reached the backdrop planes — binding is now idempotent and retried from start/perf-event/re-activation paths; (2) a crowd-state switch committing while a stinger played was dropped because the machine had already advanced — it is now deferred and played when the stinger ends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): per-video load tokens + unbind renderer on stop Codex preflight round 2: (1) the global load token let a stinger cancel a committed loop load on the other layer — tokens are now per-element, and a stinger preempting an in-flight loop on its own layer requeues that loop for when the stinger ends; (2) setManifest(null)/deactivate left the last crowd frame bound and visible over the static plate — stop() now unbinds both layers from the renderer and zeroes the mix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events Codex preflight round 3: (1) a failed/timed-out stinger left a deferred loop switch queued forever; the failure path now flushes it. (2) stats:recorded only carries {filename, arrangement}, so the end-of-song reaction now uses the accuracyPct from the song's last v3:live-performance-state event (a real percentage) instead of a field that never existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap Codex preflight round 4: (1) idleLayer() still points at the fading-in layer during a crossfade, so a stinger firing mid-fade overwrote the new loop with nothing requeued — the fading loop is now tracked and requeued like an in-flight load; (2) swapping venue packs while active now goes through stop() so _stopGen invalidates the old manifest's in-flight loads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): generation-gate stinger handlers; recrop on video size change Codex preflight round 5: (1) an ended/timeout handler orphaned by stop() could fire into a later stinger's lifecycle on the reused element — handlers now detach unconditionally and carry a generation token; (2) the renderer only re-applied cover-crop on camera aspect changes, so a src swap with a different intrinsic size kept stale repeat/offset — it now recrops when videoWidth/Height change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer Codex preflight round 6: the loop crossfade's completion callback could still run between a stinger's start and its canplaythrough, promoting the stinger's layer to active and pausing the real loop — it now bails when the fading loop was preempted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): keep rear video layer opaque during crossfades Two half-transparent layers let the static bg plate bleed through (~25% at mid-fade) — visible as a flash of the old still image on every state transition. The crossfade is now always the front layer fading over an opaque rear layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): reset active layer with mix on stop Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a restart flashed layer 0's stale frame until the new loop loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): reset crowd mood to neutral on song load Codex preflight: a song ending in ecstatic/bored left the next song's crowd stuck in that mood until the hysteresis window passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): cancel in-flight fade when a stinger preempts it Codex preflight: the orphaned ramp kept pushing the mix toward the layer whose src the stinger had just replaced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value Codex preflight: Number(null) is 0, so idle HUD resets overwrote _lastAccuracyPct before stats:recorded consumed it, suppressing the end-of-song stinger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): abort stale stinger state on song load Codex preflight: a stinger straddling a song change could fade back into the previous song's layer or flush its pending loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): always detach load listeners, gate only the callback Codex preflight: superseded loads left canplaythrough/error listeners attached to the persistent video elements — unbounded growth over a session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(venue-crowd): flyover intro with crowd-ambience ducking On song:loaded, an optional pack intro plays once: a camera flyover video (idle layer, one-shot) with bar-crowd ambience audio that ducks out on song:play, near the flyover's landing, or at handoff — whichever first. Machine commits and stingers defer during the intro; stop()/song-change abort it. Packs without an intro behave as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(venue-crowd): fall back to the loop when the intro fails to load Codex preflight: a failed/timed-out intro left the song with no crowd loop at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.8 KiB
JavaScript
131 lines
5.8 KiB
JavaScript
'use strict';
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const crowd = require('../../static/v3/venue-crowd.js');
|
|
|
|
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
|
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
|
const SCENE_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'venue-scene-3d.js');
|
|
|
|
test('perf state → crowd state mapping', () => {
|
|
assert.equal(crowd.crowdStateOfPerf('smoke'), 'bored');
|
|
assert.equal(crowd.crowdStateOfPerf('recovery'), 'bored');
|
|
assert.equal(crowd.crowdStateOfPerf('idle'), 'neutral');
|
|
assert.equal(crowd.crowdStateOfPerf('steady'), 'neutral');
|
|
assert.equal(crowd.crowdStateOfPerf('strong'), 'engaged');
|
|
assert.equal(crowd.crowdStateOfPerf('fire'), 'ecstatic');
|
|
assert.equal(crowd.crowdStateOfPerf('FIRE'), 'ecstatic');
|
|
assert.equal(crowd.crowdStateOfPerf('bogus'), 'neutral');
|
|
assert.equal(crowd.crowdStateOfPerf(undefined), 'neutral');
|
|
});
|
|
|
|
test('machine: target must be stable for STABLE_MS before committing', () => {
|
|
const m = crowd.createCrowdMachine();
|
|
assert.equal(m.current, 'neutral');
|
|
assert.equal(m.update('fire', 0), null);
|
|
assert.equal(m.update('fire', crowd.STABLE_MS - 1), null);
|
|
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic');
|
|
assert.equal(m.current, 'ecstatic');
|
|
});
|
|
|
|
test('machine: flapping target restarts the stability window', () => {
|
|
const m = crowd.createCrowdMachine();
|
|
m.update('fire', 0);
|
|
// Target changes → candidate resets.
|
|
m.update('strong', 1000);
|
|
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS - 1), null);
|
|
assert.equal(m.update('strong', 1000 + crowd.STABLE_MS), 'engaged');
|
|
});
|
|
|
|
test('machine: returning to current state clears the candidate', () => {
|
|
const m = crowd.createCrowdMachine();
|
|
m.update('fire', 0);
|
|
m.update('steady', 1000); // back to neutral (current) — candidate dropped
|
|
// 'fire' again must wait a full stability window from scratch.
|
|
assert.equal(m.update('fire', 2000), null);
|
|
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS - 1), null);
|
|
assert.equal(m.update('fire', 2000 + crowd.STABLE_MS), 'ecstatic');
|
|
});
|
|
|
|
test('machine: DWELL_MS enforced between switches', () => {
|
|
const m = crowd.createCrowdMachine();
|
|
m.update('fire', 0);
|
|
assert.equal(m.update('fire', crowd.STABLE_MS), 'ecstatic'); // switch at t=3000
|
|
const t = crowd.STABLE_MS;
|
|
// Immediately drop to smoke: stable window passes but dwell hasn't.
|
|
m.update('smoke', t + 1);
|
|
assert.equal(m.update('smoke', t + 1 + crowd.STABLE_MS), null);
|
|
// After the dwell expires the pending candidate commits.
|
|
assert.equal(m.update('smoke', t + crowd.DWELL_MS), 'bored');
|
|
});
|
|
|
|
test('machine: multi-step jumps allowed (bored → ecstatic)', () => {
|
|
const m = crowd.createCrowdMachine();
|
|
m.update('smoke', 0);
|
|
assert.equal(m.update('smoke', crowd.STABLE_MS), 'bored');
|
|
const t = crowd.DWELL_MS + 1000;
|
|
m.update('fire', t);
|
|
assert.equal(m.update('fire', t + crowd.STABLE_MS), 'ecstatic');
|
|
});
|
|
|
|
test('stingerForStreak fires on rising milestone crossings only', () => {
|
|
assert.equal(crowd.stingerForStreak(24, 25), 'cheer');
|
|
assert.equal(crowd.stingerForStreak(0, 100), 'cheer');
|
|
assert.equal(crowd.stingerForStreak(25, 26), null);
|
|
assert.equal(crowd.stingerForStreak(50, 50), null);
|
|
assert.equal(crowd.stingerForStreak(30, 0), null); // streak reset
|
|
});
|
|
|
|
test('stingerForAccuracy thresholds', () => {
|
|
assert.equal(crowd.stingerForAccuracy(95), 'cheer');
|
|
assert.equal(crowd.stingerForAccuracy(90), 'cheer');
|
|
assert.equal(crowd.stingerForAccuracy(80), 'clap');
|
|
assert.equal(crowd.stingerForAccuracy(75), 'clap');
|
|
assert.equal(crowd.stingerForAccuracy(60), null);
|
|
assert.equal(crowd.stingerForAccuracy('nope'), null);
|
|
assert.equal(crowd.stingerForAccuracy(undefined), null);
|
|
});
|
|
|
|
test('normalizeManifest requires all four loops, resolves base', () => {
|
|
assert.equal(crowd.normalizeManifest(null), null);
|
|
assert.equal(crowd.normalizeManifest({}), null);
|
|
assert.equal(crowd.normalizeManifest({ loops: { bored: 'b.mp4' } }), null);
|
|
const m = crowd.normalizeManifest({
|
|
base: '/api/plugins/career/venues/bar/',
|
|
loops: { bored: 'bored.mp4', neutral: 'neutral.mp4', engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4' },
|
|
stingers: { cheer: 'cheer.mp4' },
|
|
});
|
|
assert.equal(m.loops.ecstatic, '/api/plugins/career/venues/bar/ecstatic.mp4');
|
|
assert.equal(m.stingers.cheer, '/api/plugins/career/venues/bar/cheer.mp4');
|
|
assert.equal(m.stingers.clap, '');
|
|
});
|
|
|
|
test('highway_3d exposes the crowd backdrop globals', () => {
|
|
const src = fs.readFileSync(H3D_JS, 'utf8');
|
|
assert.match(src, /window\.h3dVenueBackdropSetVideo\s*=/);
|
|
assert.match(src, /window\.h3dVenueBackdropSetMix\s*=/);
|
|
// The venue style must own crowd plane teardown (VideoTexture dispose).
|
|
assert.match(src, /_venueCrowdVideos/);
|
|
assert.match(src, /_venueCrowdMix/);
|
|
});
|
|
|
|
test('index.html loads venue-crowd.js deferred, after venue-scene-3d.js', () => {
|
|
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
|
const crowdIdx = html.indexOf('/static/v3/venue-crowd.js');
|
|
const sceneIdx = html.indexOf('/static/v3/venue-scene-3d.js');
|
|
assert.ok(crowdIdx > 0, 'venue-crowd.js script tag missing');
|
|
assert.ok(crowdIdx > sceneIdx, 'venue-crowd.js must load after venue-scene-3d.js');
|
|
assert.match(html, /<script defer src="\/static\/v3\/venue-crowd\.js"><\/script>/);
|
|
});
|
|
|
|
test('venue-scene-3d activates/deactivates the crowd layer', () => {
|
|
const src = fs.readFileSync(SCENE_JS, 'utf8');
|
|
assert.match(src, /syncCrowd\(true\)/);
|
|
assert.match(src, /syncCrowd\(false\)/);
|
|
assert.match(src, /v3VenueCrowd/);
|
|
});
|