mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 20:21:40 +00:00
fix(career): gig song selection — full-genre pool, working re-roll, and the venue pack loads (#976)
* fix(career): a gig's song pool is the whole genre, and re-roll varies it Two tester reports, one root: the gig song pool was built from only two sets — songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL (`filename NOT IN song_stats`). A song played on a DIFFERENT instrument's arrangement is in neither: it has a stats row (so the "unplayed" filler skipped it), and its played bucket is that other instrument's, not this passport's. It could never be gigged. - "Metalcore says 137 songs only shows 1 in the gig list" — a library of metalcore all played on another instrument. Reproduced: a guitar passport with 137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw was whatever handful happened to be on-instrument or truly unplayed. - "Passport re-roll does not change songs" — a set drawn from that filler was the library's first N in table ORDER, every call. Re-roll re-proposes, so it returned the identical set. Reproduced: 3 proposals, byte-identical. _unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of the genre the set hasn't already picked (a stats row on some other instrument has no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll actually re-rolls. Both reproduced against the real propose logic before the fix and pinned as regression tests (both fail on the pre-fix routes.py). Full career suite green. * fix(career): load the gig's venue pack when the gig starts Tester: "Venue doesn't load when starting song from passport. Loads standard particles." crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage pack — 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. startGig set the venue override and nulled _appliedManifestVenue but never re-pushed, so the venue visualization turned on (3D highway) while its pack never loaded — the song played over the bare highway backdrop, or over whatever venue a previous refresh() had left applied. startGig now pushes the crowd manifest for the gig venue right after setting the override, using the career state the booking screen already fetched. This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other caller and startGig is not it), but it is fixed by static analysis — I could not reproduce the user-visible symptom locally because this instance happened to have a manifest already applied from a prior refresh. On-device confirmation on a real passport gig is still owed. Guard test: startGig must push the manifest after setting the override (fails on the pre-fix source). Career suite green.
This commit is contained in:
+29
-17
@@ -522,27 +522,39 @@ def _current_venue():
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
def _fill_genre_songs(gkey, exclude, limit):
|
||||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||||
still gets a full set (playing them is how stubs start).
|
set hasn't already picked.
|
||||||
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."""
|
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"]
|
db = _state["meta_db"]
|
||||||
if db is None:
|
if db is None:
|
||||||
return []
|
return []
|
||||||
rows = db.conn.execute(
|
rows = db.conn.execute(
|
||||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
|
||||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out = []
|
pool = [
|
||||||
for filename, title, artist, genre in rows:
|
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||||
if _genre_key(genre) != gkey or filename in exclude:
|
for filename, title, artist, genre in rows
|
||||||
continue
|
if _genre_key(genre) == gkey and filename not in exclude
|
||||||
out.append({"filename": filename, "title": title or filename,
|
]
|
||||||
"artist": artist or ""})
|
random.shuffle(pool) # re-roll must vary; free per call
|
||||||
if len(out) >= limit:
|
return pool[:limit]
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
@@ -836,7 +848,7 @@ def setup(app, context):
|
|||||||
picks.append(s)
|
picks.append(s)
|
||||||
if len(picks) < size:
|
if len(picks) < size:
|
||||||
exclude = {s["filename"] for s in picks}
|
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:
|
if not picks:
|
||||||
raise HTTPException(404, "No songs of this genre in the library.")
|
raise HTTPException(404, "No songs of this genre in the library.")
|
||||||
venue = _current_venue()
|
venue = _current_venue()
|
||||||
|
|||||||
@@ -1192,7 +1192,21 @@
|
|||||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||||
} catch (_) { /* viz optional — restore stays intact */ }
|
} 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;
|
_appliedManifestVenue = null;
|
||||||
|
if (_state) pushCrowdManifest(_state);
|
||||||
_ppGigRun = {
|
_ppGigRun = {
|
||||||
songs: prop.songs,
|
songs: prop.songs,
|
||||||
venue_id: prop.venue_id,
|
venue_id: prop.venue_id,
|
||||||
|
|||||||
@@ -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).
|
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
|
|||||||
res = client.post("/api/plugins/career/drill-state",
|
res = client.post("/api/plugins/career/drill-state",
|
||||||
json={"byNode": {}, "goldImprov": blob})
|
json={"byNode": {}, "goldImprov": blob})
|
||||||
assert res.status_code == 413
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user