mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
Reword comments/docstrings/strings and rename identifiers that referenced the external game and its file formats: - format-id "psarc" -> "archive"; local vars psarc_path -> song_path, psarc_base -> tone_base - lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted) - highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy values fall back to the default, preserving behaviour) - neutralise references in prose, test names/data, .gitattributes and docs No functional change beyond the renamed identifiers; all Python compiles.
643 lines
24 KiB
Python
643 lines
24 KiB
Python
"""Tests for lib/progression.py — the pure progression engine (spec 010)."""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from progression import (
|
|
active_challenges,
|
|
evaluate_event,
|
|
goal_matches_event,
|
|
instrument_for_arrangement,
|
|
load_content,
|
|
mastery_rank,
|
|
path_max_level,
|
|
period_keys,
|
|
period_resets_at,
|
|
select_quests,
|
|
threshold_goal_met,
|
|
wallet_balance,
|
|
)
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
BUNDLED_CONTENT = REPO_ROOT / "data" / "progression"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Content loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bundled_content_loads_clean():
|
|
content, warnings = load_content(BUNDLED_CONTENT)
|
|
assert warnings == []
|
|
assert set(content["paths"]) == {"guitar", "bass", "drums", "keys"}
|
|
assert content["challenge_index"]
|
|
assert content["quests"]["daily"]["count"] == 3
|
|
assert content["quests"]["weekly"]["count"] == 2
|
|
assert content["quests"]["daily"]["pool"]
|
|
assert content["shop"]
|
|
# Every indexed challenge id is namespaced under its path.
|
|
for cid, entry in content["challenge_index"].items():
|
|
assert cid.startswith(entry["path_id"] + ".")
|
|
|
|
|
|
def _write_content(root: Path, paths=None, quests=None, shop=None):
|
|
(root / "paths").mkdir(parents=True, exist_ok=True)
|
|
for name, data in (paths or {}).items():
|
|
(root / "paths" / f"{name}.json").write_text(
|
|
data if isinstance(data, str) else json.dumps(data)
|
|
)
|
|
(root / "quests.json").write_text(json.dumps(quests if quests is not None else {}))
|
|
(root / "shop.json").write_text(json.dumps(shop if shop is not None else {}))
|
|
|
|
|
|
def _minimal_path(pid="guitar", level=1, required=1, challenges=None):
|
|
return {
|
|
"id": pid,
|
|
"name": pid.title(),
|
|
"levels": [
|
|
{
|
|
"level": level,
|
|
"required": required,
|
|
"challenges": challenges
|
|
or [
|
|
{
|
|
"id": f"{pid}.l{level}.c1",
|
|
"title": "C1",
|
|
"goal": {"type": "song_completed", "target": 1},
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_load_content_skips_bad_json_file(tmp_path):
|
|
_write_content(tmp_path, paths={"guitar": _minimal_path()})
|
|
(tmp_path / "paths" / "broken.json").write_text("{not json")
|
|
content, warnings = load_content(tmp_path)
|
|
assert "guitar" in content["paths"]
|
|
assert any("broken.json" in w for w in warnings)
|
|
|
|
|
|
def test_load_content_rejects_unknown_goal_type(tmp_path):
|
|
bad = _minimal_path(
|
|
challenges=[
|
|
{"id": "guitar.l1.bad", "goal": {"type": "moonwalk", "target": 1}},
|
|
{"id": "guitar.l1.ok", "goal": {"type": "song_completed", "target": 1}},
|
|
]
|
|
)
|
|
_write_content(tmp_path, paths={"guitar": bad})
|
|
content, warnings = load_content(tmp_path)
|
|
ids = list(content["challenge_index"])
|
|
assert ids == ["guitar.l1.ok"]
|
|
assert any("unknown goal type" in w for w in warnings)
|
|
|
|
|
|
def test_load_content_rejects_duplicate_challenge_ids(tmp_path):
|
|
p = _minimal_path(
|
|
challenges=[
|
|
{"id": "guitar.l1.dup", "goal": {"type": "song_completed", "target": 1}},
|
|
{"id": "guitar.l1.dup", "goal": {"type": "minigame_run", "target": 1}},
|
|
]
|
|
)
|
|
_write_content(tmp_path, paths={"guitar": p})
|
|
content, warnings = load_content(tmp_path)
|
|
assert list(content["challenge_index"]) == ["guitar.l1.dup"]
|
|
assert content["challenge_index"]["guitar.l1.dup"]["challenge"]["goal"]["type"] == (
|
|
"song_completed"
|
|
)
|
|
assert any("duplicate challenge id" in w for w in warnings)
|
|
|
|
|
|
def test_load_content_clamps_required_and_warns_on_level_gap(tmp_path):
|
|
p = _minimal_path(required=5)
|
|
p["levels"].append(
|
|
{
|
|
"level": 3, # gap: level 2 missing
|
|
"required": 1,
|
|
"challenges": [
|
|
{"id": "guitar.l3.c1", "goal": {"type": "minigame_run", "target": 1}}
|
|
],
|
|
}
|
|
)
|
|
_write_content(tmp_path, paths={"guitar": p})
|
|
content, warnings = load_content(tmp_path)
|
|
assert content["paths"]["guitar"]["levels"][0]["required"] == 1 # clamped
|
|
assert any("clamped" in w for w in warnings)
|
|
assert any("numbering gap" in w for w in warnings)
|
|
|
|
|
|
def test_load_content_validates_quests_and_shop(tmp_path):
|
|
_write_content(
|
|
tmp_path,
|
|
paths={"guitar": _minimal_path()},
|
|
quests={
|
|
"daily": {
|
|
"count": 2,
|
|
"pool": [
|
|
{"id": "d.ok", "reward_db": 10, "goal": {"type": "minigame_run", "target": 1}},
|
|
{"id": "d.neg", "reward_db": -5, "goal": {"type": "minigame_run", "target": 1}},
|
|
{"id": "d.badgoal", "reward_db": 10, "goal": {"type": "nope", "target": 1}},
|
|
],
|
|
}
|
|
},
|
|
shop={
|
|
"items": [
|
|
{"id": "theme.ok", "slot": "theme", "cost": 100, "payload": {"colors": {}}},
|
|
{"id": "hat.bad", "slot": "hat", "cost": 100, "payload": {}},
|
|
{"id": "theme.negcost", "slot": "theme", "cost": -1, "payload": {}},
|
|
]
|
|
},
|
|
)
|
|
content, warnings = load_content(tmp_path)
|
|
assert list(content["quests"]["daily"]["pool"]) == ["d.ok"]
|
|
assert any("missing" in w and "weekly" in w for w in warnings)
|
|
assert list(content["shop"]) == ["theme.ok"]
|
|
assert any("unknown slot" in w for w in warnings)
|
|
assert any("cost" in w for w in warnings)
|
|
assert any("reward_db" in w for w in warnings)
|
|
|
|
|
|
def test_load_content_missing_root_is_nonfatal(tmp_path):
|
|
content, warnings = load_content(tmp_path / "nope")
|
|
assert content["paths"] == {}
|
|
assert warnings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Instrument attribution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"entry,expected",
|
|
[
|
|
({"type": "lead"}, "guitar"),
|
|
({"type": "rhythm"}, "guitar"),
|
|
({"type": "combo"}, "guitar"),
|
|
({"type": "bass"}, "bass"),
|
|
({"type": "drums"}, "drums"),
|
|
({"name": "Bass"}, "bass"),
|
|
({"name": "Drum Kit"}, "drums"),
|
|
({"name": "Percussion"}, "drums"),
|
|
({"name": "Vocals"}, "vocals"),
|
|
({"name": "Lead"}, "guitar"),
|
|
({"type": "piano"}, "keys"),
|
|
({"type": "keys"}, "keys"),
|
|
({"name": "Keys"}, "keys"),
|
|
({"name": "Piano"}, "keys"),
|
|
({"name": "Keyboard"}, "keys"),
|
|
({"name": "Synth Lead"}, "keys"),
|
|
# bass name check wins before the keys word-match
|
|
({"name": "Bass Synth"}, "bass"),
|
|
# word-boundary: "keys" inside another word is not keys
|
|
({"name": "Monkeys Medley"}, "guitar"),
|
|
({}, "guitar"),
|
|
(None, "guitar"),
|
|
# name overrides generic guitar type (legacy archive keys arrangements)
|
|
({"type": "lead", "name": "Keys"}, "keys"),
|
|
({"type": "combo", "name": "Piano"}, "keys"),
|
|
({"type": "lead", "name": "Bass"}, "bass"),
|
|
],
|
|
)
|
|
def test_instrument_for_arrangement(entry, expected):
|
|
assert instrument_for_arrangement(entry) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Quest periods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_period_keys():
|
|
keys = period_keys(datetime(2026, 6, 12, 15, 30))
|
|
assert keys == {"daily": "2026-06-12", "weekly": "2026-W24"}
|
|
|
|
|
|
def test_period_keys_iso_year_boundary():
|
|
# 2024-12-30 is a Monday belonging to ISO week 1 of 2025.
|
|
keys = period_keys(datetime(2024, 12, 30, 8, 0))
|
|
assert keys["daily"] == "2024-12-30"
|
|
assert keys["weekly"] == "2025-W01"
|
|
|
|
|
|
def test_period_resets_at_daily():
|
|
resets = period_resets_at("daily", datetime(2026, 6, 12, 23, 59))
|
|
assert resets == datetime(2026, 6, 13, 0, 0)
|
|
|
|
|
|
def test_period_resets_at_weekly():
|
|
# 2026-06-12 is a Friday; next Monday is 2026-06-15.
|
|
assert period_resets_at("weekly", datetime(2026, 6, 12, 10, 0)) == datetime(2026, 6, 15)
|
|
# From a Monday the period runs a full week.
|
|
assert period_resets_at("weekly", datetime(2026, 6, 15, 0, 0)) == datetime(2026, 6, 22)
|
|
|
|
|
|
def test_period_resets_at_rejects_unknown():
|
|
with pytest.raises(ValueError):
|
|
period_resets_at("hourly", datetime(2026, 6, 12))
|
|
|
|
|
|
def test_select_quests_deterministic():
|
|
pool = [f"q{i}" for i in range(10)]
|
|
first = select_quests(pool, "daily", "2026-06-12", 3)
|
|
assert first == select_quests(list(reversed(pool)), "daily", "2026-06-12", 3)
|
|
assert len(first) == 3
|
|
assert first == sorted(first)
|
|
assert set(first) <= set(pool)
|
|
|
|
|
|
def test_select_quests_varies_across_periods():
|
|
pool = [f"q{i}" for i in range(10)]
|
|
selections = {
|
|
tuple(select_quests(pool, "daily", f"2026-06-{day:02d}", 3))
|
|
for day in range(1, 15)
|
|
}
|
|
assert len(selections) > 1
|
|
|
|
|
|
def test_select_quests_small_pool_returns_all():
|
|
assert select_quests(["b", "a"], "weekly", "2026-W24", 5) == ["a", "b"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Goal matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _song_event(**payload):
|
|
base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
|
|
base.update(payload)
|
|
return {"type": "song_completed", "payload": base}
|
|
|
|
|
|
def test_goal_matches_song_accuracy_boundary():
|
|
goal = {"type": "song_completed", "min_accuracy": 0.8, "target": 1}
|
|
assert goal_matches_event(goal, _song_event(accuracy=0.8))
|
|
assert not goal_matches_event(goal, _song_event(accuracy=0.799))
|
|
assert not goal_matches_event(goal, _song_event(accuracy=None))
|
|
|
|
|
|
def test_goal_matches_song_filters():
|
|
assert not goal_matches_event(
|
|
{"type": "song_completed", "instrument": "bass", "target": 1}, _song_event()
|
|
)
|
|
assert goal_matches_event(
|
|
{"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
|
|
)
|
|
assert goal_matches_event(
|
|
{"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
|
|
)
|
|
assert not goal_matches_event(
|
|
{"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
|
|
)
|
|
assert not goal_matches_event(
|
|
{"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
|
|
)
|
|
|
|
|
|
def test_goal_matches_songs_played_total():
|
|
goal = {"type": "songs_played_total", "target": 3}
|
|
assert goal_matches_event(goal, _song_event(instrument="bass", accuracy=0.1))
|
|
assert not goal_matches_event(goal, {"type": "minigame_run", "payload": {}})
|
|
|
|
|
|
def test_diagnostic_plays_do_not_count_as_regular_songs():
|
|
# The calibration sloppak must not feed song challenges/quests — finishing
|
|
# onboarding at 100% yields rank 1 exactly, not rank 1 + first-challenge
|
|
# completions (it IS a perfect guitar play, after all).
|
|
diag = _song_event(filename="diagnostics-builtin/diag.sloppak",
|
|
is_diagnostic=True, accuracy=1.0)
|
|
assert not goal_matches_event(
|
|
{"type": "song_completed", "instrument": "guitar", "target": 1}, diag)
|
|
assert not goal_matches_event(
|
|
{"type": "song_completed", "min_accuracy": 0.8, "target": 1}, diag)
|
|
assert not goal_matches_event({"type": "songs_played_total", "target": 3}, diag)
|
|
# …unless a goal explicitly targets the diagnostic by filename.
|
|
assert goal_matches_event(
|
|
{"type": "song_completed", "filename": "diagnostics-builtin/diag.sloppak", "target": 1},
|
|
diag)
|
|
|
|
|
|
def test_diagnostic_play_still_completes_calibration_but_no_challenges():
|
|
outcome = evaluate_event(
|
|
_song_event(is_diagnostic=True, accuracy=1.0),
|
|
_content(),
|
|
_snapshot(calibration_status="pending"),
|
|
)
|
|
assert outcome["calibration_completed"] is True
|
|
assert outcome["challenges"] == []
|
|
assert outcome["level_ups"] == []
|
|
|
|
|
|
def test_goal_matches_minigame():
|
|
event = {"type": "minigame_run", "payload": {"game_id": "chord_sprint", "score": 500}}
|
|
assert goal_matches_event({"type": "minigame_run", "target": 1}, event)
|
|
assert goal_matches_event(
|
|
{"type": "minigame_run", "game_id": "chord_sprint", "min_score": 500, "target": 1}, event
|
|
)
|
|
assert not goal_matches_event(
|
|
{"type": "minigame_run", "game_id": "flappy_bend", "target": 1}, event
|
|
)
|
|
assert not goal_matches_event(
|
|
{"type": "minigame_run", "min_score": 501, "target": 1}, event
|
|
)
|
|
|
|
|
|
def test_goal_matches_quest_completed():
|
|
event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
|
|
assert goal_matches_event({"type": "quest_completed", "target": 3}, event)
|
|
assert goal_matches_event({"type": "quest_completed", "period": "daily", "target": 3}, event)
|
|
assert not goal_matches_event(
|
|
{"type": "quest_completed", "period": "weekly", "target": 3}, event
|
|
)
|
|
|
|
|
|
def test_threshold_goals():
|
|
assert threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 7})
|
|
assert not threshold_goal_met({"type": "streak_reached", "days": 7}, {"streak": 6})
|
|
assert threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 100})
|
|
assert not threshold_goal_met({"type": "db_earned", "amount": 100}, {"xp_total": 99})
|
|
assert not threshold_goal_met({"type": "song_completed", "target": 1}, {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# evaluate_event
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _content(levels=None, daily_pool=None, weekly_pool=None):
|
|
"""Small in-memory content fixture."""
|
|
levels = levels or [
|
|
{
|
|
"level": 1,
|
|
"required": 2,
|
|
"challenges": [
|
|
{
|
|
"id": "guitar.l1.any",
|
|
"title": "Any",
|
|
"description": "",
|
|
"goal": {"type": "song_completed", "instrument": "guitar", "target": 1},
|
|
},
|
|
{
|
|
"id": "guitar.l1.acc",
|
|
"title": "Acc",
|
|
"description": "",
|
|
"goal": {
|
|
"type": "song_completed",
|
|
"instrument": "guitar",
|
|
"min_accuracy": 0.8,
|
|
"target": 1,
|
|
},
|
|
},
|
|
{
|
|
"id": "guitar.l1.three",
|
|
"title": "Three",
|
|
"description": "",
|
|
"goal": {"type": "song_completed", "target": 3},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"level": 2,
|
|
"required": 1,
|
|
"challenges": [
|
|
{
|
|
"id": "guitar.l2.distinct",
|
|
"title": "Distinct",
|
|
"description": "",
|
|
"goal": {"type": "song_completed", "distinct": True, "target": 2},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
paths = {"guitar": {"id": "guitar", "name": "Guitar", "icon": "", "order": 1, "levels": levels}}
|
|
index = {}
|
|
for entry in levels:
|
|
for ch in entry["challenges"]:
|
|
index[ch["id"]] = {"path_id": "guitar", "level": entry["level"], "challenge": ch}
|
|
return {
|
|
"paths": paths,
|
|
"challenge_index": index,
|
|
"quests": {
|
|
"daily": {"count": 1, "pool": {q["id"]: q for q in (daily_pool or [])}},
|
|
"weekly": {"count": 1, "pool": {q["id"]: q for q in (weekly_pool or [])}},
|
|
},
|
|
"shop": {},
|
|
}
|
|
|
|
|
|
def _snapshot(**overrides):
|
|
snap = {
|
|
"calibration_status": "completed",
|
|
"paths": {"guitar": 0},
|
|
"challenges": {},
|
|
"quests": [],
|
|
"streak": 0,
|
|
"xp_total": 0,
|
|
}
|
|
snap.update(overrides)
|
|
return snap
|
|
|
|
|
|
def test_evaluate_event_increments_and_completes_challenges():
|
|
outcome = evaluate_event(_song_event(accuracy=0.95), _content(), _snapshot())
|
|
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
|
|
assert by_id["guitar.l1.any"]["completed"] is True
|
|
assert by_id["guitar.l1.acc"]["completed"] is True
|
|
assert by_id["guitar.l1.three"]["count"] == 1
|
|
assert by_id["guitar.l1.three"]["completed"] is False
|
|
# Two completions in one event meet required=2 -> level up.
|
|
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
|
|
|
|
|
|
def test_evaluate_event_no_levelup_below_required():
|
|
outcome = evaluate_event(_song_event(accuracy=0.5), _content(), _snapshot())
|
|
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
|
|
assert by_id["guitar.l1.any"]["completed"] is True
|
|
assert "guitar.l1.acc" not in by_id
|
|
assert outcome["level_ups"] == []
|
|
|
|
|
|
def test_evaluate_event_counts_prior_completions_toward_levelup():
|
|
snapshot = _snapshot(challenges={"guitar.l1.acc": {"count": 1, "completed": True}})
|
|
outcome = evaluate_event(_song_event(accuracy=0.5), _content(), snapshot)
|
|
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
|
|
|
|
|
|
def test_evaluate_event_distinct_dedupes_replays():
|
|
content = _content()
|
|
snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set
|
|
first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
|
|
ch = first["challenges"][0]
|
|
assert ch["count"] == 1 and not ch["completed"]
|
|
assert ch["detail"] == {"seen": ["a.archive"]}
|
|
|
|
snapshot["challenges"] = {
|
|
"guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
|
|
}
|
|
replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
|
|
assert replay["challenges"] == [] # same song again: no advance
|
|
|
|
other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
|
|
ch2 = other["challenges"][0]
|
|
assert ch2["count"] == 2 and ch2["completed"]
|
|
assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]
|
|
|
|
|
|
def test_evaluate_event_default_counts_replays():
|
|
# guitar.l1.three has no distinct flag: the same song three times completes it.
|
|
content = _content()
|
|
snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
|
|
outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
|
|
by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
|
|
assert by_id["guitar.l1.three"]["count"] == 3
|
|
assert by_id["guitar.l1.three"]["completed"] is True
|
|
|
|
|
|
def test_evaluate_event_threshold_challenges_complete_on_any_event():
|
|
levels = [
|
|
{
|
|
"level": 1,
|
|
"required": 1,
|
|
"challenges": [
|
|
{
|
|
"id": "guitar.l1.streak",
|
|
"title": "Streak",
|
|
"description": "",
|
|
"goal": {"type": "streak_reached", "days": 3},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
content = _content(levels=levels)
|
|
quiet = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=2))
|
|
assert quiet["challenges"] == []
|
|
outcome = evaluate_event({"type": "minigame_run", "payload": {}}, content, _snapshot(streak=3))
|
|
assert outcome["challenges"][0]["completed"] is True
|
|
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
|
|
|
|
|
|
def test_evaluate_event_quests():
|
|
daily = [
|
|
{
|
|
"id": "d.two",
|
|
"title": "Two songs",
|
|
"description": "",
|
|
"reward_db": 50,
|
|
"goal": {"type": "song_completed", "target": 2},
|
|
}
|
|
]
|
|
content = _content(daily_pool=daily)
|
|
quests = [
|
|
{"period_type": "daily", "quest_id": "d.two", "count": 0, "completed": False,
|
|
"reward_db": 50, "detail": None},
|
|
{"period_type": "daily", "quest_id": "d.gone", "count": 0, "completed": False,
|
|
"reward_db": 10, "detail": None}, # no longer in pool: ignored
|
|
]
|
|
snapshot = _snapshot(quests=quests)
|
|
first = evaluate_event(_song_event(), content, snapshot)
|
|
assert len(first["quests"]) == 1
|
|
q = first["quests"][0]
|
|
assert (q["quest_id"], q["count"], q["completed"]) == ("d.two", 1, False)
|
|
|
|
snapshot["quests"][0]["count"] = 1
|
|
second = evaluate_event(_song_event(), content, snapshot)
|
|
q = second["quests"][0]
|
|
assert q["completed"] is True and q["reward_db"] == 50
|
|
|
|
|
|
def test_evaluate_event_quest_completion_feeds_challenges():
|
|
levels = [
|
|
{
|
|
"level": 1,
|
|
"required": 1,
|
|
"challenges": [
|
|
{
|
|
"id": "guitar.l1.quests",
|
|
"title": "Quests",
|
|
"description": "",
|
|
"goal": {"type": "quest_completed", "period": "daily", "target": 1},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
content = _content(levels=levels)
|
|
event = {"type": "quest_completed", "payload": {"period_type": "daily", "quest_id": "d.x"}}
|
|
outcome = evaluate_event(event, content, _snapshot())
|
|
assert outcome["challenges"][0]["completed"] is True
|
|
assert outcome["level_ups"] == [{"path_id": "guitar", "new_level": 1}]
|
|
|
|
|
|
def test_evaluate_event_calibration():
|
|
content = _content()
|
|
diag = _song_event(is_diagnostic=True, accuracy=1.0)
|
|
assert evaluate_event(diag, content, _snapshot(calibration_status="pending"))[
|
|
"calibration_completed"
|
|
]
|
|
# A skipped player can still upgrade to completed.
|
|
assert evaluate_event(diag, content, _snapshot(calibration_status="skipped"))[
|
|
"calibration_completed"
|
|
]
|
|
assert not evaluate_event(diag, content, _snapshot(calibration_status="completed"))[
|
|
"calibration_completed"
|
|
]
|
|
near_miss = _song_event(is_diagnostic=True, accuracy=0.99)
|
|
assert not evaluate_event(near_miss, content, _snapshot(calibration_status="pending"))[
|
|
"calibration_completed"
|
|
]
|
|
not_diag = _song_event(accuracy=1.0)
|
|
assert not evaluate_event(not_diag, content, _snapshot(calibration_status="pending"))[
|
|
"calibration_completed"
|
|
]
|
|
|
|
|
|
def test_evaluate_event_ignores_unselected_paths():
|
|
outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={}))
|
|
assert outcome["challenges"] == []
|
|
assert outcome["level_ups"] == []
|
|
|
|
|
|
def test_evaluate_event_at_max_level_is_quiet():
|
|
outcome = evaluate_event(_song_event(), _content(), _snapshot(paths={"guitar": 2}))
|
|
assert outcome["challenges"] == []
|
|
assert outcome["level_ups"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rank / wallet / helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_mastery_rank():
|
|
assert mastery_rank("pending", {}) == 0
|
|
assert mastery_rank(None, {}) == 0
|
|
assert mastery_rank("skipped", {}) == 1
|
|
assert mastery_rank("completed", {}) == 1
|
|
assert mastery_rank("completed", {"guitar": 2, "bass": 1}) == 4
|
|
assert mastery_rank("pending", {"guitar": 2}) == 2 # paths count even pre-calibration
|
|
|
|
|
|
def test_wallet_balance():
|
|
assert wallet_balance(1000, 250) == 750
|
|
assert wallet_balance(100, 500) == 0 # source reset below spent: clamp
|
|
assert wallet_balance(None, None) == 0
|
|
|
|
|
|
def test_active_challenges_and_max_level():
|
|
content = _content()
|
|
assert [c["id"] for c in active_challenges(content, "guitar", 0)] == [
|
|
"guitar.l1.any",
|
|
"guitar.l1.acc",
|
|
"guitar.l1.three",
|
|
]
|
|
assert [c["id"] for c in active_challenges(content, "guitar", 1)] == ["guitar.l2.distinct"]
|
|
assert active_challenges(content, "guitar", 2) == [] # max level
|
|
assert active_challenges(content, "nope", 0) == []
|
|
assert path_max_level(content, "guitar") == 2
|
|
assert path_max_level(content, "nope") == 0
|