diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7c7383..a4214ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,9 @@ jobs: # Pin ruff — unpinned it floats to whatever released last, and 0.16.0 # turned on a batch of new default rules (ISC/I001/…) that red-lit the # whole repo overnight with pre-existing style nits. Bump deliberately. - - run: pip install -r requirements.txt ruff==0.15.21 + - run: pip install -r requirements.txt ruff==0.15.21 pytest==8.4.1 - run: ruff check . + - run: pytest tests/ -q - name: Import smoke test env: SUPABASE_URL: https://example.supabase.co diff --git a/api/main.py b/api/main.py index c2439f1..0d0371c 100644 --- a/api/main.py +++ b/api/main.py @@ -30,6 +30,8 @@ get_players as sleeper_get_players, ) from engine.sleeper_sync import build_nfl_rules, compute_pick_inventory, build_roster_items +from engine import fantasycalc +from engine.nfl_dynasty import build_payload as build_nfl_dynasty_payload, derive_fc_params from engine.nfl_trade import ( build_nfl_trade_context, build_nfl_trade_prompt, @@ -2294,6 +2296,62 @@ async def sleeper_sync(body: SleeperSyncRequest, user: dict = Depends(get_curren raise HTTPException(status_code=500, detail=str(e)) +@app.post("/dashboard/nfl_roster") +async def dashboard_nfl_roster(body: DashboardRequest, user: dict = Depends(get_current_user)): + """Dynasty roster view payload for Sleeper NFL leagues: FantasyCalc market + values joined onto the synced roster + picks, plus deterministic age-curve, + depth, and roster-window reads (engine/nfl_dynasty.py). No AI call and no + dashboard_cache row — the only cached piece is the FantasyCalc response + (in-process per settings combo, engine/fantasycalc.py), so the page is + always fresh after a sync. `force` refetches FantasyCalc. Named under + /dashboard/ so the frontend's useDashboardWidget hook works unchanged.""" + try: + sb = get_supabase() + require_league_owner(sb, user, body.league_id) + + def _load(): + league = ( + sb.table("leagues").select("rules").eq("id", body.league_id) + .single().execute() + ).data or {} + rows = ( + sb.table("rosters") + .select("team_name, roster_items, draft_picks") + .eq("league_id", body.league_id) + .eq("fantrax_team_id", body.my_team_id) + .limit(1) + .execute() + ).data + return league, (rows[0] if rows else None) + + league, roster_row = await asyncio.to_thread(_load) + rules = league.get("rules") or {} + if (rules.get("sport") or "").upper() != "NFL": + raise HTTPException(status_code=400, detail="Not an NFL league.") + if not roster_row: + raise HTTPException( + status_code=404, detail="No synced roster yet — sync your league first." + ) + + params = derive_fc_params(rules) + fc = await asyncio.to_thread( + fantasycalc.get_values, + params["num_qbs"], params["ppr"], params["num_teams"], body.force, + ) + payload = build_nfl_dynasty_payload( + roster_row.get("roster_items"), roster_row.get("draft_picks"), + rules, fc["entries"], + ) + payload["team_name"] = roster_row.get("team_name") or "Your Team" + payload["values_updated_at"] = fc["fetched_at"] + return payload + except HTTPException: + raise + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/roster/sync") async def roster_sync( background_tasks: BackgroundTasks, diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..4d78e1c --- /dev/null +++ b/conftest.py @@ -0,0 +1,2 @@ +"""Repo-root conftest: its presence puts the repo root on sys.path so tests can +`import engine.*` without packaging. Keep even if empty.""" diff --git a/engine/fantasycalc.py b/engine/fantasycalc.py new file mode 100644 index 0000000..2987acb --- /dev/null +++ b/engine/fantasycalc.py @@ -0,0 +1,60 @@ +"""FantasyCalc dynasty market values (free API, no key). Values are identical +for every league sharing a settings combo (numQbs, ppr, numTeams), so the +response is cached in-process per combo — ~12h TTL, stale-if-error — instead of +per-league in dashboard_cache: there is no AI cost to protect here, and a +per-league payload cache would go stale the moment a sync changes the roster.""" + +import threading +import time +from datetime import datetime, timezone + +import httpx + +_URL = "https://api.fantasycalc.com/values/current" +_TTL_S = 12 * 3600 + +# combo key -> (monotonic fetch time, wall-clock ISO, entries) +_cache: dict[tuple, tuple[float, str, list]] = {} +# Single-flight: concurrent cache misses share one fetch instead of stampeding +# the free API. Coarse (all combos serialize) but the fetch takes ~a second and +# runs on worker threads, so contention is negligible at this app's scale. +_lock = threading.Lock() + + +def get_values(num_qbs: int, ppr: float, num_teams: int, force: bool = False) -> dict: + """Current dynasty values for a settings combo. Returns + {"entries": [...], "fetched_at": iso} — stale entries when a refetch fails, + and {"entries": None, "fetched_at": None} only when nothing was ever + fetched. Blocking (httpx) — call via asyncio.to_thread.""" + key = (num_qbs, ppr, num_teams) + with _lock: + return _get_values_locked(key, num_qbs, ppr, num_teams, force) + + +def _get_values_locked(key: tuple, num_qbs: int, ppr: float, num_teams: int, + force: bool) -> dict: + hit = _cache.get(key) + if hit and not force and time.monotonic() - hit[0] < _TTL_S: + return {"entries": hit[2], "fetched_at": hit[1]} + try: + resp = httpx.get( + _URL, + params={ + "isDynasty": "true", + "numQbs": num_qbs, + "ppr": ppr, + "numTeams": num_teams, + }, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + if isinstance(data, list) and data: + fetched_at = datetime.now(timezone.utc).isoformat() + _cache[key] = (time.monotonic(), fetched_at, data) + return {"entries": data, "fetched_at": fetched_at} + except Exception as e: + print(f"[fantasycalc] fetch failed for {key}: {e}") + if hit: # stale-if-error: old market values beat no values + return {"entries": hit[2], "fetched_at": hit[1]} + return {"entries": None, "fetched_at": None} diff --git a/engine/nfl_dynasty.py b/engine/nfl_dynasty.py new file mode 100644 index 0000000..4b816eb --- /dev/null +++ b/engine/nfl_dynasty.py @@ -0,0 +1,221 @@ +"""Deterministic dynasty logic for the NFL roster view: age-curve bands, +FantasyCalc param derivation and joins, positional depth flags, and the +roster-window verdict. Pure functions over already-loaded data — no I/O and no +AI, so the always-on roster page costs nothing per view. Unit-test target +(tests/test_nfl_dynasty.py).""" + +from collections import Counter + +# Per-position (ascending ≤, prime ≤, aging ≤) age edges; older = cliff. +# K/DEF get no curve — age barely moves their dynasty value. +AGE_BANDS = { + "RB": (22, 25, 27), + "WR": (23, 28, 30), + "TE": (24, 29, 31), + "QB": (24, 32, 35), +} + +_STAGE_SCORE = {"ascending": 0.0, "prime": 1.0, "aging": 2.0, "cliff": 3.0} + +# Sleeper roster_positions entries that aren't starting lineup slots. +_NON_STARTING = {"BN", "TAXI", "IR"} + +# Statuses that can actually take a lineup slot this week. +_STARTABLE = {"starter", "bench"} + + +def age_band(position: str | None, age) -> str | None: + """ascending | prime | aging | cliff, or None (no curve / unknown age).""" + edges = AGE_BANDS.get(position or "") + if not edges or not isinstance(age, (int, float)): + return None + ascending, prime, aging = edges + if age <= ascending: + return "ascending" + if age <= prime: + return "prime" + if age <= aging: + return "aging" + return "cliff" + + +def derive_fc_params(rules: dict) -> dict: + """FantasyCalc query params from a synced Sleeper rules blob. `ppr` is the + raw rec value (leagues run 0.25, 1.5, …) snapped to FantasyCalc's 0/0.5/1; + numQbs counts QB + SUPER_FLEX slots so true 2-QB leagues also price QBs as + premium.""" + rules = rules or {} + positions = rules.get("roster_positions") or [] + qb_slots = sum(1 for p in positions if p in ("QB", "SUPER_FLEX")) + num_qbs = 2 if (qb_slots >= 2 or rules.get("superflex")) else 1 + raw = rules.get("ppr") + raw = raw if isinstance(raw, (int, float)) else 0 + ppr = 1 if raw >= 0.75 else (0.5 if raw >= 0.25 else 0) + size = rules.get("league_size") + num_teams = int(size) if isinstance(size, (int, float)) and size else 10 + return {"num_qbs": num_qbs, "ppr": ppr, "num_teams": num_teams} + + +def index_fc(entries: list | None) -> tuple[dict, dict]: + """Split a FantasyCalc response into lookups: players keyed by sleeperId + (exact match to our roster item ids) and picks keyed by name — generic + future-pick names ("2027 1st") exactly match compute_pick_inventory labels. + Slot-specific current-year picks ("2026 Pick 1.01") simply never match.""" + players: dict[str, dict] = {} + picks: dict[str, dict] = {} + for e in entries or []: + p = e.get("player") or {} + if p.get("position") == "PICK": + picks[(p.get("name") or "").strip()] = e + else: + sid = str(p.get("sleeperId") or "") + if sid: + players[sid] = e + return players, picks + + +def starting_slots(roster_positions: list | None) -> list[str]: + return [p for p in roster_positions or [] if p and p not in _NON_STARTING] + + +def depth_flags(players: list[dict], rules: dict) -> list[dict]: + """At most 3 deterministic lineup-construction warnings, worst first, from + startable bodies (taxi/IR excluded) vs dedicated slots. FLEX-type slots are + ignored on purpose: a dedicated slot can only be filled by its position, so + `have < need` is a real hole regardless of flex math.""" + rules = rules or {} + slots = starting_slots(rules.get("roster_positions")) + dedicated = Counter(s for s in slots if s in ("QB", "RB", "WR", "TE", "K", "DEF")) + available = Counter( + p.get("position") for p in players if p.get("status") in _STARTABLE + ) + superflex = bool(rules.get("superflex")) or "SUPER_FLEX" in slots + + flags: list[dict] = [] + qb_critical = False + for pos in ("QB", "RB", "WR", "TE", "K", "DEF"): + need, have = dedicated.get(pos, 0), available.get(pos, 0) + if need and have < need: + qb_critical = qb_critical or pos == "QB" + flags.append({ + "level": "critical", + "text": f"Only {have} startable {pos} for {need} {pos} " + f"slot{'s' if need != 1 else ''} — you can't field a legal lineup.", + }) + if superflex and not qb_critical and available.get("QB", 0) < 2: + flags.append({ + "level": "critical", + "text": f"Superflex league with only {available.get('QB', 0)} startable QB — " + "a second QB is the top roster priority.", + }) + for pos in ("QB", "RB", "WR", "TE"): + need, have = dedicated.get(pos, 0), available.get(pos, 0) + if pos == "QB" and superflex: + continue # QB thinness in superflex is already the flag above + if need and have == need: + flags.append({ + "level": "warn", + "text": f"No depth behind your {pos} starter{'s' if need != 1 else ''} " + f"({have} rostered for {need} slot{'s' if need != 1 else ''}).", + }) + flags.sort(key=lambda f: 0 if f["level"] == "critical" else 1) + return flags[:3] + + +def roster_window(players: list[dict], picks: list[dict], rules: dict) -> dict: + """One-line dynasty window verdict: value-weighted life-stage of the core + (top-N by market value, N = starting slots so chaff doesn't skew) plus pick + capital's share of total asset value. Taxi/IR players count — a stashed + rookie is part of the young core.""" + n = max(1, len(starting_slots((rules or {}).get("roster_positions")))) + valued = [p for p in players if p.get("value") and p.get("band")] + core = sorted(valued, key=lambda p: p["value"], reverse=True)[:n] + if not core: + return {"verdict": None, "detail": "No market values available to read the window."} + + core_value = sum(p["value"] for p in core) + pick_value = sum(pk.get("value") or 0 for pk in picks) + stage = sum(_STAGE_SCORE[p["band"]] * p["value"] for p in core) / core_value + pick_share = pick_value / (core_value + pick_value) if core_value + pick_value else 0.0 + + if stage < 0.9: + verdict, blurb = "Ascending", "young core still gaining value" + elif stage <= 1.5: + if pick_share >= 0.15: + verdict, blurb = "Balanced", "prime core backed by real pick capital" + else: + verdict, blurb = "Win-now", "prime core, light on picks — push your chips in" + else: + verdict, blurb = "Aging — sell high", "core past its prime; move vets while they still hold value" + return { + "verdict": verdict, + "detail": f"{blurb} (core life-stage {stage:.1f}/3, picks {round(pick_share * 100)}% of asset value)", + "stage": round(stage, 2), + "pick_share": round(pick_share, 3), + } + + +def build_payload(roster_items: list | None, draft_picks: list | None, + rules: dict, fc_entries: list | None) -> dict: + """The ready-to-render /dashboard/nfl_roster body (minus team_name / + values_updated_at, which the endpoint adds). Joins are best-effort: players + or picks FantasyCalc doesn't know keep value None and still render.""" + fc_players, fc_picks = index_fc(fc_entries) + + players = [] + for it in roster_items or []: + e = fc_players.get(str(it.get("id") or "")) + meta = (e or {}).get("player") or {} + age = it.get("age") + if not isinstance(age, (int, float)): + age = meta.get("maybeAge") # backfill: Sleeper dump ages go missing + yexp = it.get("years_exp") + players.append({ + "id": it.get("id"), + "name": it.get("name"), + "position": it.get("position"), + "team": it.get("team"), + "status": it.get("status") or "bench", + "injury_status": it.get("injury_status"), + "age": age, + "band": age_band(it.get("position"), age), + "rookie_tag": "Rookie" if yexp == 0 else ("2nd yr" if yexp == 1 else None), + "value": (e or {}).get("value"), + "overall_rank": (e or {}).get("overallRank"), + "pos_rank": (e or {}).get("positionRank"), + "tier": (e or {}).get("maybeTier"), + "trend": (e or {}).get("trend30Day"), + }) + + picks = [] + for pk in sorted( + draft_picks or [], key=lambda p: (p.get("season") or 0, p.get("round") or 0) + ): + e = fc_picks.get((pk.get("label") or "").strip()) + picks.append({ + "label": pk.get("label"), + "season": pk.get("season"), + "round": pk.get("round"), + "value": (e or {}).get("value"), + "trend": (e or {}).get("trend30Day"), + }) + + groups: dict[str, list] = {"starter": [], "bench": [], "taxi": [], "ir": []} + for p in players: + groups.get(p["status"], groups["bench"]).append(p) + for group in groups.values(): + group.sort(key=lambda p: (-(p.get("value") or 0), p.get("name") or "")) + + valued_picks = [p for p in picks if p["value"]] + return { + "fc_available": bool(fc_entries), + "players": groups, + "picks": picks, + "pick_capital": { + "total": sum(p["value"] for p in valued_picks), + "valued": len(valued_picks), + "unvalued": len(picks) - len(valued_picks), + }, + "window": roster_window(players, picks, rules), + "depth_flags": depth_flags(players, rules), + } diff --git a/tests/test_nfl_dynasty.py b/tests/test_nfl_dynasty.py new file mode 100644 index 0000000..1006088 --- /dev/null +++ b/tests/test_nfl_dynasty.py @@ -0,0 +1,254 @@ +"""Unit tests for engine/nfl_dynasty.py — the pure dynasty logic behind +POST /dashboard/nfl_roster. First test suite in the repo (CLAUDE.md's +"pure logic first" target).""" + +from engine.nfl_dynasty import ( + age_band, + build_payload, + depth_flags, + derive_fc_params, + index_fc, + roster_window, + starting_slots, +) + + +def fc_player(sleeper_id, value, position="WR", name="Player", age=25, **extra): + return { + "player": { + "sleeperId": sleeper_id, "position": position, "name": name, + "maybeAge": age, + }, + "value": value, + "overallRank": extra.get("overall_rank", 10), + "positionRank": extra.get("pos_rank", 5), + "maybeTier": extra.get("tier", 2), + "trend30Day": extra.get("trend", 0), + } + + +def fc_pick(name, value): + return {"player": {"position": "PICK", "name": name}, "value": value, "trend30Day": 0} + + +def item(pid, position="WR", age=25, status="bench", years_exp=3, name=None): + return { + "id": pid, "name": name or f"P{pid}", "position": position, "team": "KC", + "status": status, "injury_status": None, "age": age, "years_exp": years_exp, + } + + +# --- age_band --------------------------------------------------------------- + +def test_age_band_rb_edges(): + assert age_band("RB", 22) == "ascending" + assert age_band("RB", 23) == "prime" + assert age_band("RB", 25) == "prime" + assert age_band("RB", 26) == "aging" + assert age_band("RB", 27) == "aging" + assert age_band("RB", 28) == "cliff" + + +def test_age_band_other_positions(): + assert age_band("WR", 24) == "prime" + assert age_band("WR", 31) == "cliff" + assert age_band("TE", 30) == "aging" + assert age_band("QB", 32) == "prime" + assert age_band("QB", 36) == "cliff" + + +def test_age_band_no_curve_or_age(): + assert age_band("K", 30) is None + assert age_band("DEF", 5) is None + assert age_band("RB", None) is None + assert age_band(None, 25) is None + + +# --- derive_fc_params ------------------------------------------------------- + +def test_params_superflex_slot_counts_as_2qb(): + rules = {"roster_positions": ["QB", "SUPER_FLEX", "WR", "BN"], "ppr": 1, + "league_size": 12} + assert derive_fc_params(rules) == {"num_qbs": 2, "ppr": 1, "num_teams": 12} + + +def test_params_true_2qb_league_without_superflex_flag(): + rules = {"roster_positions": ["QB", "QB", "WR"], "superflex": False, "ppr": 0.5} + assert derive_fc_params(rules)["num_qbs"] == 2 + + +def test_params_ppr_snapping(): + assert derive_fc_params({"ppr": 0})["ppr"] == 0 + assert derive_fc_params({"ppr": 0.25})["ppr"] == 0.5 + assert derive_fc_params({"ppr": 0.5})["ppr"] == 0.5 + assert derive_fc_params({"ppr": 0.75})["ppr"] == 1 + assert derive_fc_params({"ppr": 1.5})["ppr"] == 1 + assert derive_fc_params({"ppr": None})["ppr"] == 0 + + +def test_params_defaults(): + assert derive_fc_params({}) == {"num_qbs": 1, "ppr": 0, "num_teams": 10} + + +# --- starting_slots / depth_flags ------------------------------------------ + +def test_starting_slots_excludes_bench_taxi_ir(): + slots = starting_slots(["QB", "RB", "RB", "FLEX", "BN", "BN", "TAXI", "IR"]) + assert slots == ["QB", "RB", "RB", "FLEX"] + + +def test_depth_flag_superflex_one_qb(): + rules = {"roster_positions": ["QB", "SUPER_FLEX", "WR", "BN"], "superflex": True} + players = [item("1", "QB"), item("2", "WR"), item("3", "WR")] + flags = depth_flags(players, rules) + assert any("Superflex" in f["text"] for f in flags) + assert flags[0]["level"] == "critical" + + +def test_depth_flag_cannot_fill_dedicated_slots(): + rules = {"roster_positions": ["QB", "RB", "RB", "WR", "BN"]} + players = [item("1", "QB"), item("2", "RB"), item("3", "WR"), item("4", "WR")] + flags = depth_flags(players, rules) + assert any(f["level"] == "critical" and "RB" in f["text"] for f in flags) + + +def test_depth_flags_ignore_taxi_and_ir(): + rules = {"roster_positions": ["QB", "RB", "BN"]} + players = [ + item("1", "QB"), item("2", "RB"), + item("3", "RB", status="taxi"), item("4", "RB", status="ir"), + ] + flags = depth_flags(players, rules) + # Only 1 startable RB for 1 slot -> zero-depth warn, not critical + assert any(f["level"] == "warn" and "RB" in f["text"] for f in flags) + + +def test_depth_flags_capped_at_three_and_sorted(): + rules = {"roster_positions": ["QB", "RB", "WR", "TE", "K", "DEF"]} + flags = depth_flags([], rules) + assert len(flags) == 3 + assert all(f["level"] == "critical" for f in flags) + + +def test_depth_flags_healthy_roster_is_quiet(): + rules = {"roster_positions": ["QB", "RB", "WR", "BN", "BN", "BN"]} + players = [ + item("1", "QB"), item("2", "QB"), + item("3", "RB"), item("4", "RB"), + item("5", "WR"), item("6", "WR"), + ] + assert depth_flags(players, rules) == [] + + +# --- roster_window ---------------------------------------------------------- + +def _windowed(players, picks=(), slots=("QB", "RB", "WR")): + return roster_window(players, list(picks), {"roster_positions": list(slots)}) + + +def test_window_young_core_is_ascending(): + players = [ + dict(item("1", "WR", 22), value=9000, band="ascending"), + dict(item("2", "RB", 22), value=8000, band="ascending"), + dict(item("3", "WR", 25), value=7000, band="prime"), + ] + assert _windowed(players)["verdict"] == "Ascending" + + +def test_window_old_core_is_aging(): + players = [ + dict(item("1", "RB", 29), value=6000, band="cliff"), + dict(item("2", "WR", 31), value=6000, band="cliff"), + dict(item("3", "TE", 30), value=4000, band="aging"), + ] + assert _windowed(players)["verdict"] == "Aging — sell high" + + +def test_window_prime_core_pick_capital_splits_winnow_vs_balanced(): + players = [ + dict(item("1", "WR", 26), value=8000, band="prime"), + dict(item("2", "RB", 24), value=7000, band="prime"), + dict(item("3", "QB", 28), value=7000, band="prime"), + ] + assert _windowed(players)["verdict"] == "Win-now" + picks = [{"label": "2027 1st", "value": 6000}] + assert _windowed(players, picks)["verdict"] == "Balanced" + + +def test_window_no_values_degrades(): + players = [dict(item("1", "WR"), value=None, band="prime")] + out = _windowed(players) + assert out["verdict"] is None + + +def test_window_core_limited_to_starting_slots(): + # Chaff (many low-value old players) must not drag the verdict when the + # core (top-N = 2 slots here) is young. + players = [ + dict(item("1", "WR", 22), value=9000, band="ascending"), + dict(item("2", "RB", 22), value=8000, band="ascending"), + ] + [ + dict(item(str(10 + i), "RB", 30), value=100, band="cliff") for i in range(10) + ] + out = roster_window(players, [], {"roster_positions": ["QB", "RB"]}) + assert out["verdict"] == "Ascending" + + +# --- index_fc / build_payload ---------------------------------------------- + +RULES = {"sport": "NFL", "roster_positions": ["QB", "RB", "WR", "BN"], + "superflex": False, "ppr": 1, "league_size": 12} + + +def test_index_fc_splits_players_and_picks(): + entries = [fc_player("123", 5000), fc_pick("2027 1st", 4000)] + players, picks = index_fc(entries) + assert "123" in players and "2027 1st" in picks + + +def test_build_payload_joins_and_groups(): + roster = [ + item("123", "WR", 24, status="starter"), + item("456", "RB", 28, status="bench"), + item("789", "QB", 23, status="taxi", years_exp=0), + ] + picks = [ + {"season": 2027, "round": 1, "label": "2027 1st", "original_roster_id": 3}, + {"season": 2029, "round": 2, "label": "2029 2nd", "original_roster_id": 3}, + ] + entries = [fc_player("123", 5000), fc_pick("2027 1st", 4000)] + out = build_payload(roster, picks, RULES, entries) + + assert out["fc_available"] is True + starter = out["players"]["starter"][0] + assert starter["value"] == 5000 and starter["band"] == "prime" + # Unknown to FantasyCalc -> renders with value None + assert out["players"]["bench"][0]["value"] is None + assert out["players"]["taxi"][0]["rookie_tag"] == "Rookie" + # Pick join is best-effort: 2029 class not in FC -> unvalued but present + assert [p["value"] for p in out["picks"]] == [4000, None] + assert out["pick_capital"] == {"total": 4000, "valued": 1, "unvalued": 1} + + +def test_build_payload_age_backfill_from_fc(): + roster = [item("123", "WR", age=None, status="starter")] + entries = [fc_player("123", 5000, age=27)] + out = build_payload(roster, [], RULES, entries) + starter = out["players"]["starter"][0] + assert starter["age"] == 27 and starter["band"] == "prime" + + +def test_build_payload_fc_down_degrades(): + roster = [item("123", "WR", 24, status="starter")] + out = build_payload(roster, [{"season": 2027, "round": 1, "label": "2027 1st"}], + RULES, None) + assert out["fc_available"] is False + assert out["players"]["starter"][0]["band"] == "prime" # curve still works + assert out["window"]["verdict"] is None + assert out["pick_capital"]["total"] == 0 + + +def test_build_payload_unknown_status_lands_in_bench(): + roster = [item("123", "WR", 24, status="weird")] + out = build_payload(roster, [], RULES, []) + assert out["players"]["bench"][0]["id"] == "123"