diff --git a/plugins/career/passports.json b/plugins/career/passports.json index 4e55d424..d2a44e47 100644 --- a/plugins/career/passports.json +++ b/plugins/career/passports.json @@ -3,6 +3,13 @@ "songs": 5, "min_stars": 2 }, + "families": [ + { "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] }, + { "key": "blues", "match": ["blues"] }, + { "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] }, + { "key": "funk", "match": ["funk", "disco"] }, + { "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] } + ], "genres": { "blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } }, "rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } }, diff --git a/plugins/career/routes.py b/plugins/career/routes.py index fd52c808..3eed518d 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -276,12 +276,33 @@ def _library_genres(): key=lambda r: (-r["songs_in_library"], r["genre_key"])) +def _genre_family(gkey): + """First family whose keyword appears in the genre key (substring — MB's + vocabulary is open: 'metalcore' must hit the 'metal' family without an + exact alias). List order decides ambiguity: families are checked top to + bottom, so 'blues rock' lands on whichever of blues/rock is listed first.""" + for fam in _state["passports_content"].get("families") or []: + if not isinstance(fam, dict): + continue + for kw in fam.get("match") or []: + if isinstance(kw, str) and kw and kw in gkey: + return fam.get("key") + return None + + def _badge_requirement(gkey, instrument="guitar"): cfg = _state["passports_content"] req = dict(cfg.get("badge_requirement") or {}) req.setdefault("songs", 5) req.setdefault("min_stars", 2) - override = (cfg.get("genres") or {}).get(gkey) + # Exact per-genre override wins; otherwise the genre inherits its FAMILY's + # requirement — so 'death metal' / 'metalcore' passports carry the metal + # drill without curating every MB sub-genre by hand. + genres_cfg = cfg.get("genres") or {} + override = genres_cfg.get(gkey) + if not isinstance(override, dict): + family = _genre_family(gkey) + override = genres_cfg.get(family) if family else None if isinstance(override, dict): req.update(override) # virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 0e2c9b88..27decf05 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -208,3 +208,40 @@ def test_drill_state_merge_is_gained_only(client, meta_db): p = _passport(client) assert p["drills"]["cleared"] == ["blues_shuffle"] assert p["badge"] == "earned" + + +def test_genre_families_inherit_drills(client, meta_db): + # 'death metal' has no exact entry — it inherits the metal family's drill. + for i in range(5): + meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD) + _open(client, "guitar", "Death Metal") + p = _passport(client, "guitar", "death metal") + assert p["drills"]["required"] == ["melodic_metal_gallop"] + assert p["badge"] == "in_progress" + # 'metalcore' (single word) matches by substring, no alias needed. + _open(client, "guitar", "Metalcore") + assert _passport(client, "guitar", "metalcore")["drills"]["required"] == \ + ["melodic_metal_gallop"] + # 'blues rock' resolves by family LIST ORDER: blues comes before rock. + _open(client, "guitar", "Blues Rock") + assert _passport(client, "guitar", "blues rock")["drills"]["required"] == \ + ["blues_shuffle"] + # A genre outside every family stays songs-only. + _open(client, "guitar", "Reggae") + assert _passport(client, "guitar", "reggae")["drills"]["required"] == [] + # Exact per-genre entries still beat the family (the shipped 'metal' entry + # IS the exact entry for genre key 'metal'). + _open(client, "guitar", "Metal") + assert _passport(client, "guitar", "metal")["drills"]["required"] == \ + ["melodic_metal_gallop"] + + +def test_family_drills_stay_per_instrument(client, meta_db): + # Family inheritance must not leak guitar drills onto other instruments. + keys_arr = [{"type": "lead", "name": "Keys"}] + for i in range(5): + meta_db.add(f"kdm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=keys_arr) + _open(client, "keys", "Death Metal") + p = _passport(client, "keys", "death metal") + assert p["drills"]["required"] == [] + assert p["badge"] == "earned"