diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 7f8b423..7ec4a17 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -522,27 +522,39 @@ def _current_venue(): return best -def _unplayed_genre_songs(gkey, exclude, limit): - """Library songs of a genre with no stats yet — a young passport's gig - still gets a full set (playing them is how stubs start). - ponytail: full stat-less scan + python-side genre match (a few ms at 7k - songs, single-user); push the match into SQL if propose ever feels slow.""" +def _fill_genre_songs(gkey, exclude, limit): + """Library songs of a genre to round out a gig — ANY song of the genre the + set hasn't already picked. + + Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`. + That restriction created a hole: a song you'd played on a DIFFERENT + instrument's arrangement has a stats row, so it was excluded here — and it + lives in the played bucket for THAT instrument, not this passport's, so it + was excluded there too. It could never be gigged. A player with 137 metalcore + songs, all played on another instrument, got a 404 (reproduced). The player's + library is the pool; whether a song has stats on some other instrument has no + bearing on whether it can be in THIS gig. + + Shuffled, so re-roll actually changes the set. The old version returned the + library's first N in table order every time, so re-roll was a no-op for any + set drawn from the filler (reproduced). + + ponytail: full genre scan + python-side match + shuffle (a few ms at 7k + songs, single-user); push into SQL if propose ever feels slow. + """ db = _state["meta_db"] if db is None: return [] rows = db.conn.execute( - f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs " - "WHERE filename NOT IN (SELECT filename FROM song_stats)" + f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs" ).fetchall() - out = [] - for filename, title, artist, genre in rows: - if _genre_key(genre) != gkey or filename in exclude: - continue - out.append({"filename": filename, "title": title or filename, - "artist": artist or ""}) - if len(out) >= limit: - break - return out + pool = [ + {"filename": filename, "title": title or filename, "artist": artist or ""} + for filename, title, artist, genre in rows + if _genre_key(genre) == gkey and filename not in exclude + ] + random.shuffle(pool) # re-roll must vary; free per call + return pool[:limit] def _validate_pack_dir(pack_dir: Path): @@ -836,7 +848,7 @@ def setup(app, context): picks.append(s) if len(picks) < size: exclude = {s["filename"] for s in picks} - picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks))) + picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks))) if not picks: raise HTTPException(404, "No songs of this genre in the library.") venue = _current_venue() diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 7600735..4e47db1 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -1192,7 +1192,21 @@ if (typeof window.setViz === 'function') window.setViz('venue'); } catch (_) { /* viz optional — restore stays intact */ } } + // Push the gig's venue pack to the crowd layer NOW. + // + // crowd.setManifest(venue) is reached only through pushCrowdManifest, + // and pushCrowdManifest is called only from refresh() — the career + // tab's own reload. A gig navigates AWAY from the career tab to the + // player, so refresh() never runs during it, and setting the override + // above does nothing on its own. The result the testers saw: the venue + // visualization turns on (3D highway) but its crowd/stage pack never + // loads, so the song plays over the bare highway backdrop ("standard + // particles"), or over whatever venue a previous refresh() happened to + // leave applied. We just changed the override to this gig's venue, so + // re-push for it. _state is the career state the booking screen already + // fetched; guard for the rare null. _appliedManifestVenue = null; + if (_state) pushCrowdManifest(_state); _ppGigRun = { songs: prop.songs, venue_id: prop.venue_id, diff --git a/tests/js/career_plugin.test.js b/tests/js/career_plugin.test.js index f038727..d7f0405 100644 --- a/tests/js/career_plugin.test.js +++ b/tests/js/career_plugin.test.js @@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => { // Degrades without the crowd layer (PR1 not merged / older desktop). assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/); }); + +// feedBack#… (tester): "Venue doesn't load when starting song from passport. +// Loads standard particles." crowd.setManifest(venue) is reached ONLY through +// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the +// career tab's own reload). A gig navigates away from that tab, so refresh() +// never runs during it — the venue viz turns on but its crowd/stage pack never +// loads. startGig must push the manifest itself after setting the override. +test('startGig pushes the crowd manifest for the gig venue', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const src = fs.readFileSync( + path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8'); + const start = src.indexOf('async function startGig('); + assert.ok(start !== -1, 'startGig not found'); + const open = src.indexOf('{', src.indexOf(')', start)); + let depth = 1, i = open + 1; + while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; } + const fn = src.slice(start, i); + // The override is set, then the manifest must be (re)pushed for it. + const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/); + const pushIdx = fn.search(/pushCrowdManifest\s*\(/); + assert.ok(overrideIdx !== -1, 'startGig must set the venue override'); + assert.ok(pushIdx !== -1, + 'startGig must push the crowd manifest — refresh() (its only other caller) ' + + 'never runs during a gig, so the venue pack would never load'); + assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue'); +}); diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 8687565..5b09a23 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db): res = client.post("/api/plugins/career/drill-state", json={"byNode": {}, "goldImprov": blob}) assert res.status_code == 413 + + +def test_gig_includes_songs_played_on_another_instrument(client, meta_db): + # feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list". + # A song played on a DIFFERENT instrument's arrangement has a stats row, so it + # was excluded from the unplayed filler — and its played bucket is that other + # instrument's, not this passport's — so it fell into a gap and could never be + # gigged. A guitar passport with a library of bass-played metalcore got a 404. + for i in range(137): + meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS) + res = client.post("/api/plugins/career/gigs/propose", + json={"instrument": "guitar", "genre": "Metalcore", "size": 4}) + assert res.status_code == 200, "a full library of the genre must never 404" + assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap" + + +def test_gig_reroll_changes_the_set(client, meta_db): + # feedBack#… (tester): "Passport re-roll does not change songs". A set drawn + # from the filler used to be the library's first N in table order, every time. + for i in range(40): + meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore") + sets = set() + for _ in range(5): + r = client.post("/api/plugins/career/gigs/propose", + json={"instrument": "guitar", "genre": "Metalcore", "size": 4}) + sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"]))) + assert len(sets) > 1, "re-roll must be able to produce a different set"