feat: Card Evolution Phase 1 — full backend implementation #130

Merged
cal merged 32 commits from card-evolution into main 2026-03-23 18:20:22 +00:00
Owner

Summary

Merges the complete Card Evolution backend from the card-evolution integration branch into main. This is Phase 1 of the progression system (to be renamed to "Refractor" in a follow-up PR).

What's included

  • WP-01: Evolution Peewee models (EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic)
  • WP-02: BattingSeasonStats / PitchingSeasonStats models
  • WP-03: Evolution track seed data (Batter, SP, RP tracks with calibrated thresholds)
  • WP-04: SQL migration script for all evolution tables
  • WP-05: Season stats full-recalculation service
  • WP-07: Card State API endpoints (GET /evolution/cards/{card_id})
  • WP-08: Evaluate endpoint + evolution evaluator service
  • WP-09: Formula engine (batter/SP/RP value computation + tier resolution)
  • WP-10: Pack opening hook — auto-initializes EvolutionCardState on acquisition
  • WP-13: Post-game callback endpoints for season stats + evolution evaluation
  • ProcessedGame ledger: Idempotency layer for game processing
  • Team evolutions endpoint: GET /teams/{id}/evolutions

Stats

  • 25 files changed, +5,139 lines
  • ~2,500 lines of tests across 8 test files
  • Clean merge with no conflicts

Test plan

  • python -m pytest passes on the merged code
  • Migration SQL reviewed against prod schema
  • Follow-up PR will rename all evolution references to refractor

🤖 Generated with Claude Code

## Summary Merges the complete Card Evolution backend from the `card-evolution` integration branch into `main`. This is Phase 1 of the progression system (to be renamed to "Refractor" in a follow-up PR). ### What's included - **WP-01**: Evolution Peewee models (`EvolutionCardState`, `EvolutionTierBoost`, `EvolutionCosmetic`) - **WP-02**: `BattingSeasonStats` / `PitchingSeasonStats` models - **WP-03**: Evolution track seed data (Batter, SP, RP tracks with calibrated thresholds) - **WP-04**: SQL migration script for all evolution tables - **WP-05**: Season stats full-recalculation service - **WP-07**: Card State API endpoints (`GET /evolution/cards/{card_id}`) - **WP-08**: Evaluate endpoint + evolution evaluator service - **WP-09**: Formula engine (batter/SP/RP value computation + tier resolution) - **WP-10**: Pack opening hook — auto-initializes `EvolutionCardState` on acquisition - **WP-13**: Post-game callback endpoints for season stats + evolution evaluation - **ProcessedGame ledger**: Idempotency layer for game processing - **Team evolutions endpoint**: `GET /teams/{id}/evolutions` ### Stats - 25 files changed, +5,139 lines - ~2,500 lines of tests across 8 test files - Clean merge with no conflicts ## Test plan - [ ] `python -m pytest` passes on the merged code - [ ] Migration SQL reviewed against prod schema - [ ] Follow-up PR will rename all `evolution` references to `refractor` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
cal added 29 commits 2026-03-23 17:39:01 +00:00
Add EvolutionTrack, EvolutionCardState, EvolutionTierBoost, and
EvolutionCosmetic models to db_engine.py with composite unique indexes
and create_tables blocks. Also includes PlayerSeasonStats (WP-02).

Add ruff.toml to suppress pre-existing F403/F405 from intentional
`from peewee import *` wildcard import pattern in db_engine.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the PlayerSeasonStats model to db_engine.py with 14 batting stat
fields, 11 pitching stat fields, last_game/last_updated_at meta fields,
and composite indexes: UNIQUE(player,team,season), (team,season),
(player,season). Also simplifies ruff.toml to a global ignore for
F403/F405 (intentional peewee star import pattern).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creates player_season_stats, evolution_track, evolution_card_state,
evolution_tier_boost, and evolution_cosmetic tables with IF NOT EXISTS
guards, appropriate indexes, and rollback statements. Also extends card,
battingcard, and pitchingcard with variant and image_url columns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement update_season_stats(game_id) in app/services/season_stats.py.
Aggregates StratPlay batting/pitching stats and Decision win/loss/save
data into PlayerSeasonStats with idempotency guard and dual-backend
upsert (PostgreSQL EXCLUDED increments, SQLite read-modify-write).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JSON definitions and idempotent seed function for the 3 universal
evolution tracks (Batter, Starting Pitcher, Relief Pitcher) with
locked threshold values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test_evolution_models: 12 tests for EvolutionTrack, EvolutionCardState,
  EvolutionTierBoost, EvolutionCosmetic, and PlayerSeasonStats models
- test_evolution_seed: 7 tests for seed idempotency, thresholds, formulas
- test_season_stats_update: 6 tests for batting/pitching aggregation,
  Decision integration, double-count prevention, multi-game accumulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CRITICAL: Fix migration FK refs player(id) → player(player_id)
- Remove dead is_start flag from pitching groups (no starts column)
- Fix hr → homerun in test make_play helper
- Add explanatory comment to ruff.toml
- Replace print() with logging in seed script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Correct idempotency guard docstring in update_season_stats() to
  accurately describe the last_game FK check limitation: only detects
  replay of the most-recently-processed game; out-of-order re-delivery
  (game G after G+1) bypasses the guard. References issue #105 for the
  planned ProcessedGame ledger fix.
- Fix migration card_type comment: 'batting' or 'pitching' → 'batter',
  'sp', or 'rp' to match actual seeded values.
- Remove local rarity fixture in test_season_stats_update.py that
  shadowed the conftest.py fixture; remove unused rarity parameter from
  player_batter and player_pitcher fixtures.
- Update test_double_count_prevention docstring to note the known
  out-of-order re-delivery limitation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes #105

Replace the last_game FK guard in update_season_stats() with an atomic
INSERT into a new processed_game ledger table. The old guard only blocked
same-game immediate replay; it was silently bypassed if game G+1 was
processed first (last_game already overwritten). The ledger is keyed on
game_id so any re-delivery — including out-of-order — is caught reliably.

Changes:
- app/db_engine.py: add ProcessedGame model (game FK PK + processed_at)
- app/services/season_stats.py: replace last_game check with
  ProcessedGame.get_or_create(); import ProcessedGame; update docstrings
- migrations/2026-03-18_add_processed_game.sql: CREATE TABLE IF NOT EXISTS
  processed_game with FK to stratgame ON DELETE CASCADE
- tests/conftest.py: add ProcessedGame to imports and _TEST_MODELS list
- tests/test_season_stats_update.py: add test_out_of_order_replay_prevented;
  update test_double_count_prevention docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reviewed-on: #104
Reviewed-by: Claude <cal.corum+openclaw@gmail.com>
Closes #75.

New file app/services/evolution_init.py:
- _determine_card_type(player): pure fn mapping pos_1 to 'batter'/'sp'/'rp'
- initialize_card_evolution(player_id, team_id, card_type): get_or_create
  EvolutionCardState with current_tier=0, current_value=0.0, fully_evolved=False
- Safe failure: all exceptions caught and logged, never raises
- Idempotent: duplicate calls for same (player_id, team_id) are no-ops
  and do NOT reset existing evolution progress

Modified app/routers_v2/cards.py:
- Add WP-10 hook after Card.bulk_create in the POST endpoint
- For each card posted, call _determine_card_type + initialize_card_evolution
- Wrapped in try/except so evolution failures cannot block pack opening
- Fix pre-existing lint violations (unused lc_id, bare f-string, unused e)

New file tests/test_evolution_init.py (16 tests, all passing):
- Unit: track assignment for batter / SP / RP / CP positions
- Integration: first card creates state with zeroed fields
- Integration: duplicate card is a no-op (progress not reset)
- Integration: different players on same team get separate states
- Integration: card_type routes to correct EvolutionTrack
- Integration: missing track returns None gracefully

Fix tests/test_evolution_models.py: correct PlayerSeasonStats import/usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Mirror the batter_id is None guard in _build_pitching_groups() so that
  a StratPlay row with a null pitcher_id is skipped rather than creating
  a None key in the groups dict (which would fail on the NOT NULL FK
  constraint during upsert).
- Revert Dockerfile to the next-release base: drop the COPY path change
  and CMD addition that were already merged in PR #101 and are unrelated
  to the ProcessedGame ledger feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
formula_engine.tier_from_value read track.t1/t2/t3/t4 but the
EvolutionTrack model defines t1_threshold/t2_threshold/etc. Updated
both the function and test fixtures to use the _threshold suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR retargeted from next-release to card-evolution. Restore the
Dockerfile with correct COPY path and CMD from card-evolution base.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge PR #106: ProcessedGame ledger for full idempotency in update_season_stats()
Closes #73

Adds POST /api/v2/evolution/cards/{card_id}/evaluate — force-recalculates
a card's evolution state from career totals (SUM across all
player_season_stats rows for the player-team pair).

Changes:
- app/services/evolution_evaluator.py: evaluate_card() function that
  aggregates career stats, delegates to formula engine for value/tier
  computation, updates evolution_card_state with no-regression guarantee
- app/routers_v2/evolution.py: POST /cards/{card_id}/evaluate endpoint
  plus existing GET /tracks and GET /tracks/{id} endpoints (WP-06)
- tests/test_evolution_evaluator.py: 15 unit tests covering tier
  assignment, advancement, partial progress, idempotency, fully evolved,
  no regression, multi-season aggregation, missing state error, and
  return shape
- tests/__init__.py, tests/conftest.py: shared test infrastructure

All 15 tests pass. Models and formula engine are lazily imported so
this module is safely importable before WP-01/WP-05/WP-07/WP-09 merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename _CareerTotals.k → .strikeouts to match formula engine's
  stats.strikeouts Protocol
- Update test stubs: TrackStub fields t1→t1_threshold etc. to match
  EvolutionTrack model
- Fix fully_evolved logic: derive from post-max current_tier, not
  new_tier (prevents contradictory state on tier regression)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge PR #98: WP-08 evaluate endpoint and evolution evaluator service
Merge PR #107: WP-10 pack opening hook — evolution_card_state initialization
Add two endpoints for reading EvolutionCardState:

  GET /api/v2/teams/{team_id}/evolutions
    - Optional filters: card_type, tier
    - Pagination: page / per_page (default 10, max 100)
    - Joins EvolutionTrack so card_type filter is a single query
    - Returns {count, items} with full card state + threshold context

  GET /api/v2/evolution/cards/{card_id}
    - Resolves card_id -> (player_id, team_id) via Card table
    - Duplicate cards for same player+team share one state row
    - Returns 404 when card missing or has no evolution state

Both endpoints:
  - Require bearer token auth (valid_token dependency)
  - Embed the EvolutionTrack in each item (not just the FK id)
  - Compute next_threshold: threshold for tier above current (null at T4)
  - Share _build_card_state_response() helper in evolution.py

Also cleans up 30 pre-existing ruff violations in teams.py that were
blocking the pre-commit hook: F541 bare f-strings, E712 boolean
comparisons (now noqa where Peewee ORM requires == False/True),
and F841 unused variable assignments.

Tests: tests/test_evolution_state_api.py — 10 integration tests that
skip automatically without POSTGRES_HOST, following the same pattern as
test_evolution_track_api.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session-scoped psycopg2 fixture that skips gracefully when
POSTGRES_HOST is absent (local dev) and connects in CI. Required
by seeded_data/seeded_tracks fixtures in evolution API tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge PR #108: WP-07 card state API endpoints
Implements two new API endpoints the bot calls after a game completes:

  POST /api/v2/season-stats/update-game/{game_id}
    Delegates to update_season_stats() service (WP-05). Returns
    {"updated": N, "skipped": bool} with idempotency via ProcessedGame ledger.

  POST /api/v2/evolution/evaluate-game/{game_id}
    Finds all (player_id, team_id) pairs from the game's StratPlay rows,
    calls evaluate_card() for each pair that has an EvolutionCardState,
    and returns {"evaluated": N, "tier_ups": [...]} with full tier-up detail.

New files:
  app/services/evolution_evaluator.py — evaluate_card() service (WP-08)
  tests/test_postgame_evolution.py    — 10 integration tests (all pass)

Modified files:
  app/routers_v2/season_stats.py — rewritten to delegate to the service
  app/routers_v2/evolution.py    — evaluate-game endpoint added
  app/main.py                    — season_stats router registered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merge PR #109: WP-13 post-game callback API endpoints
The previous approach accumulated per-game deltas into season stats rows,
which was fragile — partial processing corrupted stats, upsert bugs
compounded, and there was no self-healing mechanism.

Now update_season_stats() recomputes full season totals from all StratPlay
rows for each affected player whenever a game is processed. The result
replaces whatever was stored, eliminating double-counting and enabling
self-healing via force=True.

Also fixes:
- evolution_evaluator.py: broken PlayerSeasonStats import → queries
  BattingSeasonStats or PitchingSeasonStats based on card_type
- evolution_evaluator.py: r.k → r.strikeouts
- test_evolution_models.py, test_postgame_evolution.py: PlayerSeasonStats
  → BattingSeasonStats (model never existed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- fn.COUNT(fn.DISTINCT(expr)) → fn.COUNT(expr.distinct()) for correct
  COUNT(DISTINCT ...) SQL on PostgreSQL
- _get_player_pairs() now also scans Decision table to include pitchers
  who have a Decision row but no StratPlay rows (rare edge case)
- Updated stale docstring references to PlayerSeasonStats and r.k

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- evaluate_card() docstring: "Override for PlayerSeasonStats" →
  "Override for BattingSeasonStats/PitchingSeasonStats"
- New test_decision_only_pitcher: exercises the edge case where a pitcher
  has a Decision row but no StratPlay rows, verifying _get_player_pairs()
  correctly includes them via the Decision table scan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reviewed-on: #111
Claude added the
ai-reviewing
label 2026-03-23 17:45:54 +00:00
Author
Owner

AI Code Review (pr-reviewer agent)

Files Reviewed

  • app/db_engine.py — new models: BattingSeasonStats, PitchingSeasonStats, ProcessedGame, EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic
  • app/main.py — router registration
  • app/routers_v2/evolution.py — tracks catalog + card state + evaluate endpoints
  • app/routers_v2/season_stats.py — POST /season-stats/update-game/{game_id}
  • app/routers_v2/teams.py — GET /{team_id}/evolutions
  • app/routers_v2/cards.py — pack opening hook
  • app/services/formula_engine.py — batter/SP/RP formula + tier resolution
  • app/services/evolution_evaluator.py — evaluate_card entry point
  • app/services/evolution_init.py — WP-10 pack opening hook
  • app/services/season_stats.py — full-recalculation engine
  • app/seed/evolution_tracks.{py,json} — t1_threshold keys + upsert-on-update
  • migrations/2026-03-17_add_evolution_tables.sql
  • migrations/2026-03-18_add_processed_game.sql
  • tests/conftest.py + 8 test files

Findings

Correctness

BUG (BLOCKER): season_stats router is not registered in app/main.py.

app/routers_v2/season_stats.py defines POST /api/v2/season-stats/update-game/{game_id} (WP-13), but neither the import nor app.include_router(season_stats.router) appears in app/main.py. The endpoint is unreachable — every post-game stats call from the bot will hit 404. This must be added before merge.

BUG (MEDIUM): _get_player_pairs in season_stats.py — missing null guard for pitcher_id.

There is a null guard for batter_id but not for pitcher_id:

pitching_pairs.add((pitcher_id, pitcher_team_id))  # pitcher_id may be None

If any StratPlay row has pitcher_id = NULL, this adds (None, team_id) to pitching_pairs, which will either cause a FK lookup error or a silent corrupt write into pitching_season_stats. Add if pitcher_id is not None: to mirror the batter guard.

CONCERN: EvolutionTrack.card_type is not unique at the model or migration level.

evolution_init.py uses EvolutionTrack.get(EvolutionTrack.card_type == card_type) which raises MultipleObjectsReturned if two tracks share the same card_type. The Peewee model lacks unique=True on that field, and the SQL migration adds no unique index on card_type (only on name). Adding unique=True to the model field and a CREATE UNIQUE INDEX in the migration would prevent a subtle future failure.

Security

No SQL injection risks. All paths use parameterized queries or Peewee ORM. Path parameters are typed as int. Auth is correctly applied on every mutating endpoint and on all new read endpoints. No hardcoded credentials.

Style & Conventions

  • evolution.py mixes logging.warning() (module-level) with the module-level logger instance for auth-failure lines. Low severity inconsistency.
  • Lazy imports inside handlers are used consistently throughout this PR and match the pattern established by earlier work on this branch. Not a blocker.
  • season_stats.py wraps the entire game update in db.atomic(). Correct — no partial game writes.
  • evolution_init.py is correctly fire-and-forget with exceptions caught at all levels. Pack opening is never blocked.

Suggestions (non-blocking)

  • The module-level _season_stats_module.db = _test_db patch in test_season_stats_update.py works but is fragile if import order changes. A monkeypatch fixture or DI would be more robust.
  • Add a test for the pitcher_id = None edge case in _get_player_pairs once the null guard is in place.
  • Add unique=True to EvolutionTrack.card_type in the Peewee model.

Verdict: REQUEST CHANGES

The overall architecture is solid. Full-recalculation with the ProcessedGame ledger is a clean, correct approach. Formula engine is pure and well-tested. Pack opening hook is safely fire-and-forget. Test coverage across 8 files is thorough.

Two items must be fixed before merge:

  1. season_stats.router not registered in main.py — WP-13 post-game callback is completely unreachable until this is added.
  2. Missing pitcher_id null guard in _get_player_pairs — can silently corrupt pitching stats for edge-case play types.

Automated review by Claude PR Reviewer (claude-sonnet-4-6)

## AI Code Review (pr-reviewer agent) ### Files Reviewed - `app/db_engine.py` — new models: BattingSeasonStats, PitchingSeasonStats, ProcessedGame, EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic - `app/main.py` — router registration - `app/routers_v2/evolution.py` — tracks catalog + card state + evaluate endpoints - `app/routers_v2/season_stats.py` — POST /season-stats/update-game/{game_id} - `app/routers_v2/teams.py` — GET /{team_id}/evolutions - `app/routers_v2/cards.py` — pack opening hook - `app/services/formula_engine.py` — batter/SP/RP formula + tier resolution - `app/services/evolution_evaluator.py` — evaluate_card entry point - `app/services/evolution_init.py` — WP-10 pack opening hook - `app/services/season_stats.py` — full-recalculation engine - `app/seed/evolution_tracks.{py,json}` — t1_threshold keys + upsert-on-update - `migrations/2026-03-17_add_evolution_tables.sql` - `migrations/2026-03-18_add_processed_game.sql` - `tests/conftest.py` + 8 test files --- ### Findings #### Correctness **BUG (BLOCKER): `season_stats` router is not registered in `app/main.py`.** `app/routers_v2/season_stats.py` defines `POST /api/v2/season-stats/update-game/{game_id}` (WP-13), but neither the import nor `app.include_router(season_stats.router)` appears in `app/main.py`. The endpoint is unreachable — every post-game stats call from the bot will hit 404. This must be added before merge. **BUG (MEDIUM): `_get_player_pairs` in `season_stats.py` — missing null guard for `pitcher_id`.** There is a null guard for `batter_id` but not for `pitcher_id`: ```python pitching_pairs.add((pitcher_id, pitcher_team_id)) # pitcher_id may be None ``` If any StratPlay row has `pitcher_id = NULL`, this adds `(None, team_id)` to `pitching_pairs`, which will either cause a FK lookup error or a silent corrupt write into `pitching_season_stats`. Add `if pitcher_id is not None:` to mirror the batter guard. **CONCERN: `EvolutionTrack.card_type` is not unique at the model or migration level.** `evolution_init.py` uses `EvolutionTrack.get(EvolutionTrack.card_type == card_type)` which raises `MultipleObjectsReturned` if two tracks share the same `card_type`. The Peewee model lacks `unique=True` on that field, and the SQL migration adds no unique index on `card_type` (only on `name`). Adding `unique=True` to the model field and a `CREATE UNIQUE INDEX` in the migration would prevent a subtle future failure. #### Security No SQL injection risks. All paths use parameterized queries or Peewee ORM. Path parameters are typed as `int`. Auth is correctly applied on every mutating endpoint and on all new read endpoints. No hardcoded credentials. #### Style & Conventions - `evolution.py` mixes `logging.warning()` (module-level) with the module-level `logger` instance for auth-failure lines. Low severity inconsistency. - Lazy imports inside handlers are used consistently throughout this PR and match the pattern established by earlier work on this branch. Not a blocker. - `season_stats.py` wraps the entire game update in `db.atomic()`. Correct — no partial game writes. - `evolution_init.py` is correctly fire-and-forget with exceptions caught at all levels. Pack opening is never blocked. #### Suggestions (non-blocking) - The module-level `_season_stats_module.db = _test_db` patch in `test_season_stats_update.py` works but is fragile if import order changes. A `monkeypatch` fixture or DI would be more robust. - Add a test for the `pitcher_id = None` edge case in `_get_player_pairs` once the null guard is in place. - Add `unique=True` to `EvolutionTrack.card_type` in the Peewee model. --- ### Verdict: REQUEST CHANGES The overall architecture is solid. Full-recalculation with the ProcessedGame ledger is a clean, correct approach. Formula engine is pure and well-tested. Pack opening hook is safely fire-and-forget. Test coverage across 8 files is thorough. **Two items must be fixed before merge:** 1. **`season_stats.router` not registered in `main.py`** — WP-13 post-game callback is completely unreachable until this is added. 2. **Missing `pitcher_id` null guard in `_get_player_pairs`** — can silently corrupt pitching stats for edge-case play types. --- *Automated review by Claude PR Reviewer (claude-sonnet-4-6)*
cal added 1 commit 2026-03-23 17:50:23 +00:00
fix: add pitcher_id null guard in _get_player_pairs
All checks were successful
Build Docker Image / build (pull_request) Successful in 8m53s
d15fc97afb
Prevents (None, team_id) tuples from being added to pitching_pairs
when a StratPlay row has no pitcher (edge case matching the existing
batter_id guard).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude requested changes 2026-03-23 17:50:35 +00:00
Dismissed
Claude left a comment
Collaborator

AI Code Review

Files Reviewed

  • app/db_engine.py (modified — Evolution models added, ProcessedGame added)
  • app/main.py (modified — season_stats router registered)
  • app/routers_v2/cards.py (modified — WP-10 evolution hook)
  • app/routers_v2/evolution.py (modified — card state + evaluate endpoints)
  • app/routers_v2/season_stats.py (modified — delegated to service layer)
  • app/routers_v2/teams.py (modified — evolutions endpoint + f-string cleanup)
  • app/seed/evolution_tracks.json (modified — renamed threshold fields)
  • app/seed/evolution_tracks.py (modified — rewritten seed script)
  • app/services/evolution_evaluator.py (added — WP-08)
  • app/services/evolution_init.py (added — WP-10)
  • app/services/formula_engine.py (modified — t1..t4 → t1_threshold..t4_threshold)
  • app/services/season_stats.py (added — full-recalc season stats service)
  • migrations/2026-03-17_add_evolution_tables.sql (added — WP-04)
  • migrations/2026-03-18_add_processed_game.sql (added — ProcessedGame)
  • ruff.toml (added)
  • tests/conftest.py (modified — SQLite in-memory fixtures + pg_conn)
  • tests/test_evolution_*.py (8 test files added/modified)

Findings

Correctness

[BLOCKER] datetime.utcnow() reintroduced in evolution_evaluator.py

PR #118 (chore: replace deprecated datetime.utcnow()) was merged to systematically replace all datetime.utcnow() calls with datetime.now(). This PR reintroduces it in app/services/evolution_evaluator.py at the point where last_evaluated_at is written back to the card state:

# app/services/evolution_evaluator.py (~line 170)
now = datetime.utcnow()
card_state.last_evaluated_at = now

This is a direct regression from the fix in #118. Replace with datetime.now() to match the established codebase convention.

[INFO] Season stats response schema change is intentional but documented inconsistently

The old /update-game/{game_id} returned {"updated": N}. The new shape is {"updated": N, "skipped": bool}. The router correctly maps batters_updated + pitchers_updatedupdated. This is a breaking change for bot callers that check the response shape, but the docstring in the router is clear about the new shape. No code fix required — just flagging for the bot-side consumer to verify.

[INFO] Two critical gaps from PR #100 are now resolved

  • EvolutionTrack and evolution models are now properly defined in db_engine.py and registered with db.create_tables() — closes the runtime ImportError that would have hit /api/v2/evolution/tracks.
  • season_stats.router is now registered in main.py — closes the unreachable endpoint gap.

Security

No issues found. Token validation is consistently applied across all new endpoints. logging.warning("Bad Token: [REDACTED]") (not interpolated) is used correctly throughout — no token leakage. No raw SQL with user input in the new service layer. The force query param is auth-gated (behind the same bearer token check) so it cannot be abused without a valid token.


Style & Conventions

[SUGGESTION] EvolutionTrack.get(card_type == ...) could raise MultipleObjectsReturned

app/services/evolution_init.py:

track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)

card_type has no UNIQUE constraint in either the ORM model or the migration SQL. The seed script (get_or_create keyed on name) prevents duplicates from the seeder, but a manually inserted row or a future extended seed could cause MultipleObjectsReturned — which is not caught by the except DoesNotExist below it, so it would propagate as an unhandled 500.

Consider adding UNIQUE constraint on card_type to evolution_track in the migration, or using .get_or_none() with a fallback, or catching MultipleObjectsReturned explicitly. At minimum, catch it in the outer except Exception (which is present and will swallow it safely) — but the current explicit except DoesNotExist before the except Exception will not catch it.

Wait — looking again at the structure: the outer try catches Exception, so MultipleObjectsReturned IS caught at line ~95 and returns None. False alarm on crash risk, but the track lookup would silently fail for that player rather than returning the right track. Worth noting as a latent bug.

[SUGGESTION] N+1 on state.track.name in evaluate_game

app/routers_v2/evolution.pyevaluate_game:

state = EvolutionCardState.get_or_none(...)   # no JOIN on track
result = evaluate_card(player_id, team_id)
...
"track_name": state.track.name if state.track else "Unknown",

state.track triggers a lazy-load FK query per tier-up. Since tier-ups are infrequent this is low-impact, but the state was already loaded at the top of the loop. A simple fix would be to use state.track_id to look up the track name in a pre-fetched dict, or join the track in the get_or_none query.

[INFO] noqa: E402 added to main.py imports — correct, as these imports follow the logging setup block.

[INFO] ruff.toml addedF403/F405 suppression for db_engine.py's star import is appropriate.


Test Coverage

Excellent. pg_conn fixture finally added (resolves the pre-existing gap noted in PRs #84 and #108). 8 test files covering unit and integration scenarios:

  • test_evolution_models.py — ORM-level CRUD and constraint tests
  • test_evolution_evaluator.py — tier progression, no-regression rule, fully_evolved flag
  • test_evolution_init.py — fire-and-forget guarantee, idempotency, missing track safety
  • test_evolution_seed.py — upsert/update behavior of seed script
  • test_formula_engine.py — threshold boundary conditions
  • test_evolution_state_api.py — API endpoint contract tests
  • test_postgame_evolution.py — WP-13 post-game callback integration
  • test_season_stats_update.py — full-recalc service + idempotency + force flag

The use of a named SQLite shared-memory URI in test_postgame_evolution.py to handle cross-thread TestClient access is a thoughtful solution.


Verdict: REQUEST_CHANGES

Single blocker: datetime.utcnow() in evolution_evaluator.py is a regression from PR #118. Replace with datetime.now(). Everything else is in good shape — the core architecture is sound, migrations are complete with rollback statements, tests are comprehensive, and the two critical gaps from PR #100 are finally resolved.


Automated review by Claude PR Reviewer

## AI Code Review ### Files Reviewed - `app/db_engine.py` (modified — Evolution models added, ProcessedGame added) - `app/main.py` (modified — season_stats router registered) - `app/routers_v2/cards.py` (modified — WP-10 evolution hook) - `app/routers_v2/evolution.py` (modified — card state + evaluate endpoints) - `app/routers_v2/season_stats.py` (modified — delegated to service layer) - `app/routers_v2/teams.py` (modified — evolutions endpoint + f-string cleanup) - `app/seed/evolution_tracks.json` (modified — renamed threshold fields) - `app/seed/evolution_tracks.py` (modified — rewritten seed script) - `app/services/evolution_evaluator.py` (added — WP-08) - `app/services/evolution_init.py` (added — WP-10) - `app/services/formula_engine.py` (modified — t1..t4 → t1_threshold..t4_threshold) - `app/services/season_stats.py` (added — full-recalc season stats service) - `migrations/2026-03-17_add_evolution_tables.sql` (added — WP-04) - `migrations/2026-03-18_add_processed_game.sql` (added — ProcessedGame) - `ruff.toml` (added) - `tests/conftest.py` (modified — SQLite in-memory fixtures + pg_conn) - `tests/test_evolution_*.py` (8 test files added/modified) --- ### Findings #### Correctness **[BLOCKER] `datetime.utcnow()` reintroduced in `evolution_evaluator.py`** PR #118 (`chore: replace deprecated datetime.utcnow()`) was merged to systematically replace all `datetime.utcnow()` calls with `datetime.now()`. This PR reintroduces it in `app/services/evolution_evaluator.py` at the point where `last_evaluated_at` is written back to the card state: ```python # app/services/evolution_evaluator.py (~line 170) now = datetime.utcnow() card_state.last_evaluated_at = now ``` This is a direct regression from the fix in #118. Replace with `datetime.now()` to match the established codebase convention. **[INFO] Season stats response schema change is intentional but documented inconsistently** The old `/update-game/{game_id}` returned `{"updated": N}`. The new shape is `{"updated": N, "skipped": bool}`. The router correctly maps `batters_updated + pitchers_updated` → `updated`. This is a breaking change for bot callers that check the response shape, but the docstring in the router is clear about the new shape. No code fix required — just flagging for the bot-side consumer to verify. **[INFO] Two critical gaps from PR #100 are now resolved** - `EvolutionTrack` and evolution models are now properly defined in `db_engine.py` and registered with `db.create_tables()` — closes the runtime `ImportError` that would have hit `/api/v2/evolution/tracks`. - `season_stats.router` is now registered in `main.py` — closes the unreachable endpoint gap. --- #### Security No issues found. Token validation is consistently applied across all new endpoints. `logging.warning("Bad Token: [REDACTED]")` (not interpolated) is used correctly throughout — no token leakage. No raw SQL with user input in the new service layer. The `force` query param is auth-gated (behind the same bearer token check) so it cannot be abused without a valid token. --- #### Style & Conventions **[SUGGESTION] `EvolutionTrack.get(card_type == ...)` could raise `MultipleObjectsReturned`** `app/services/evolution_init.py`: ```python track = EvolutionTrack.get(EvolutionTrack.card_type == card_type) ``` `card_type` has no `UNIQUE` constraint in either the ORM model or the migration SQL. The seed script (`get_or_create` keyed on `name`) prevents duplicates from the seeder, but a manually inserted row or a future extended seed could cause `MultipleObjectsReturned` — which is **not** caught by the `except DoesNotExist` below it, so it would propagate as an unhandled 500. Consider adding `UNIQUE` constraint on `card_type` to `evolution_track` in the migration, or using `.get_or_none()` with a fallback, or catching `MultipleObjectsReturned` explicitly. At minimum, catch it in the outer `except Exception` (which is present and will swallow it safely) — but the current explicit `except DoesNotExist` before the `except Exception` will not catch it. Wait — looking again at the structure: the outer try catches `Exception`, so `MultipleObjectsReturned` IS caught at line ~95 and returns `None`. False alarm on crash risk, but the track lookup would silently fail for that player rather than returning the right track. Worth noting as a latent bug. **[SUGGESTION] N+1 on `state.track.name` in `evaluate_game`** `app/routers_v2/evolution.py` — `evaluate_game`: ```python state = EvolutionCardState.get_or_none(...) # no JOIN on track result = evaluate_card(player_id, team_id) ... "track_name": state.track.name if state.track else "Unknown", ``` `state.track` triggers a lazy-load FK query per tier-up. Since tier-ups are infrequent this is low-impact, but the `state` was already loaded at the top of the loop. A simple fix would be to use `state.track_id` to look up the track name in a pre-fetched dict, or join the track in the `get_or_none` query. **[INFO] `noqa: E402` added to `main.py` imports** — correct, as these imports follow the logging setup block. **[INFO] `ruff.toml` added** — `F403`/`F405` suppression for `db_engine.py`'s star import is appropriate. --- #### Test Coverage Excellent. `pg_conn` fixture finally added (resolves the pre-existing gap noted in PRs #84 and #108). 8 test files covering unit and integration scenarios: - `test_evolution_models.py` — ORM-level CRUD and constraint tests - `test_evolution_evaluator.py` — tier progression, no-regression rule, fully_evolved flag - `test_evolution_init.py` — fire-and-forget guarantee, idempotency, missing track safety - `test_evolution_seed.py` — upsert/update behavior of seed script - `test_formula_engine.py` — threshold boundary conditions - `test_evolution_state_api.py` — API endpoint contract tests - `test_postgame_evolution.py` — WP-13 post-game callback integration - `test_season_stats_update.py` — full-recalc service + idempotency + force flag The use of a named SQLite shared-memory URI in `test_postgame_evolution.py` to handle cross-thread TestClient access is a thoughtful solution. --- ### Verdict: REQUEST_CHANGES Single blocker: `datetime.utcnow()` in `evolution_evaluator.py` is a regression from PR #118. Replace with `datetime.now()`. Everything else is in good shape — the core architecture is sound, migrations are complete with rollback statements, tests are comprehensive, and the two critical gaps from PR #100 are finally resolved. --- *Automated review by Claude PR Reviewer*
Claude removed the
ai-reviewing
label 2026-03-23 17:50:57 +00:00
cal added 1 commit 2026-03-23 17:54:10 +00:00
fix: replace datetime.utcnow() with datetime.now() in evaluator
All checks were successful
Build Docker Image / build (pull_request) Successful in 8m36s
5ea4c7c86a
Fixes regression from PR #118 — utcnow() was reintroduced in
evolution_evaluator.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cal dismissed Claude’s review 2026-03-23 17:54:14 +00:00
Reason:

Both blockers resolved: pitcher_id null guard added (d15fc97), datetime.utcnow() replaced with datetime.now() (5ea4c7c). 117 tests pass.

cal reviewed 2026-03-23 17:54:30 +00:00
cal left a comment
Author
Owner

All blockers resolved. Tests pass (117 passed). Approving for merge.

All blockers resolved. Tests pass (117 passed). Approving for merge.
Claude added the
ai-reviewing
label 2026-03-23 18:15:53 +00:00
Claude approved these changes 2026-03-23 18:19:42 +00:00
Claude left a comment
Collaborator

AI Code Review

Files Reviewed

  • app/db_engine.py (modified) — EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic, ProcessedGame models added
  • app/main.py (modified) — season_stats router registered
  • app/routers_v2/cards.py (modified) — WP-10 evolution hook on card POST
  • app/routers_v2/evolution.py (modified) — GET /cards/{id}, POST /cards/{id}/evaluate, POST /evaluate-game/{id}
  • app/routers_v2/season_stats.py (modified) — POST /update-game/{id} rewritten to delegate to service
  • app/routers_v2/teams.py (modified) — GET /{team_id}/evolutions added
  • app/services/evolution_evaluator.py (added)
  • app/services/evolution_init.py (added)
  • app/services/formula_engine.py (modified) — t1/t2/t3/t4 → t1_threshold…t4_threshold
  • app/services/season_stats.py (added) — full-recalc engine with ProcessedGame idempotency
  • migrations/2026-03-17_add_evolution_tables.sql (added)
  • migrations/2026-03-18_add_processed_game.sql (added)
  • ruff.toml (added)
  • tests/conftest.py (rewritten)
  • tests/test_evolution_evaluator.py, test_evolution_init.py, test_evolution_models.py, test_evolution_seed.py, test_evolution_state_api.py, test_formula_engine.py, test_postgame_evolution.py, test_season_stats_update.py (added)

Findings

Correctness

  • Previous blocker resolved: datetime.utcnow() is gone — evolution_evaluator.py now correctly uses datetime.now() throughout. PR #118's fix is preserved.
  • PR #100 criticals all resolved: EvolutionTrack/EvolutionCardState/EvolutionTierBoost/EvolutionCosmetic in db_engine.py with create_tables(); season_stats.router registered in main.py; pg_conn fixture added to conftest.py.
  • ProcessedGame ledger: get_or_create(game_id=game_id) correctly uses the raw FK column accessor for the PK FK field. db.atomic() wrapping ensures ledger + stats writes roll back together on failure.
  • Full-recalc approach: Idempotent by design — rewriting totals from scratch eliminates the out-of-order double-count bug from PR #104. force=True bypass is auth-gated.
  • evaluate_card: max(card_state.current_tier, new_tier) correctly prevents tier regression.
  • formula_engine: tier_from_value dict key rename (t1t1_threshold) is consistent across model, migration SQL, seed JSON, and tests.
  • _build_card_state_response: _NEXT_THRESHOLD_ATTR.get(state.current_tier) + getattr(track, next_attr) if next_attr else None correctly handles tier 4 (fully evolved) with None.
  • Migration SQL: Both files wrapped in BEGIN/COMMIT, rollback DROP statements present (convention satisfied). FK references correct.
  • WP-10 hook: Placed after bulk_create atomic block — pack insertion abort before hook runs → no phantom evolution states. Fire-and-forget with broad except Exception is correct for this path.
  • list_team_evolutions: JOIN pre-loads EvolutionTrack → _build_card_state_response accesses state.track without additional queries. count() on the base (pre-paginated) query is correct for total.

Security

  • Token validation consistent: valid_token(token) checked on all protected endpoints; "Bad Token: [REDACTED]" used throughout (no raw token logging).
  • No SQL injection: all queries use Peewee ORM parameterized queries.
  • force=True idempotency bypass is gated behind the existing bearer token — acceptable for stat-correction use case.

Style & Conventions

  • noqa: E402 on main.py imports correct (logging configured before app imports).
  • ruff.toml F403/F405 suppression is the right fix for from peewee import * in db_engine.
  • Two minor ruff-prefer items (non-blocking):
    • logger.warning(f"Evolution eval failed for player={player_id}…") in evolution.py:413 — ruff prefers %-style lazy formatting in log calls.
    • f"Season stats update failed for game {game_id}: {exc}" in season_stats.py:519 (HTTPException detail) — f-string here is fine since it's not a log call.

Suggestions

  • EvolutionTrack.get(EvolutionTrack.card_type == card_type) in evolution_init.py: If multiple tracks are ever seeded for the same card_type (only name has a UNIQUE constraint, not card_type), MultipleObjectsReturned is silently swallowed by the outer except Exception → return None. Consider .order_by(EvolutionTrack.id).first() or adding a UNIQUE index on card_type if one-track-per-type is the intended invariant.
  • N+1 in evaluate_game tier-ups: state.track.name triggers a lazy FK load and Player.get_by_id(player_id) runs per tier-up. Since tier-ups are rare in practice this is acceptable; flag for cleanup if the endpoint is called in high-frequency paths.
  • Test _season_stats_module.db = _test_db patch: The module-level db rebind is the correct approach for SQLite shared-memory tests. Well-documented rationale in the test file.

Verdict: APPROVED

The previous REQUEST_CHANGES blocker (datetime.utcnow() regression) is fixed. All three criticals from PR #100 are resolved. The implementation is well-structured — full-recalc idempotency via ProcessedGame, no-tier-regression guarantee, comprehensive 8-file test suite (~2,500 lines). Remaining items are suggestions only and do not block merge.


Automated review by Claude PR Reviewer

## AI Code Review ### Files Reviewed - `app/db_engine.py` (modified) — EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic, ProcessedGame models added - `app/main.py` (modified) — season_stats router registered - `app/routers_v2/cards.py` (modified) — WP-10 evolution hook on card POST - `app/routers_v2/evolution.py` (modified) — GET /cards/{id}, POST /cards/{id}/evaluate, POST /evaluate-game/{id} - `app/routers_v2/season_stats.py` (modified) — POST /update-game/{id} rewritten to delegate to service - `app/routers_v2/teams.py` (modified) — GET /{team_id}/evolutions added - `app/services/evolution_evaluator.py` (added) - `app/services/evolution_init.py` (added) - `app/services/formula_engine.py` (modified) — t1/t2/t3/t4 → t1_threshold…t4_threshold - `app/services/season_stats.py` (added) — full-recalc engine with ProcessedGame idempotency - `migrations/2026-03-17_add_evolution_tables.sql` (added) - `migrations/2026-03-18_add_processed_game.sql` (added) - `ruff.toml` (added) - `tests/conftest.py` (rewritten) - `tests/test_evolution_evaluator.py`, `test_evolution_init.py`, `test_evolution_models.py`, `test_evolution_seed.py`, `test_evolution_state_api.py`, `test_formula_engine.py`, `test_postgame_evolution.py`, `test_season_stats_update.py` (added) ### Findings #### Correctness - **Previous blocker resolved**: `datetime.utcnow()` is gone — `evolution_evaluator.py` now correctly uses `datetime.now()` throughout. PR #118's fix is preserved. - **PR #100 criticals all resolved**: EvolutionTrack/EvolutionCardState/EvolutionTierBoost/EvolutionCosmetic in db_engine.py with `create_tables()`; `season_stats.router` registered in `main.py`; `pg_conn` fixture added to `conftest.py`. - **ProcessedGame ledger**: `get_or_create(game_id=game_id)` correctly uses the raw FK column accessor for the PK FK field. `db.atomic()` wrapping ensures ledger + stats writes roll back together on failure. - **Full-recalc approach**: Idempotent by design — rewriting totals from scratch eliminates the out-of-order double-count bug from PR #104. `force=True` bypass is auth-gated. - **evaluate_card**: `max(card_state.current_tier, new_tier)` correctly prevents tier regression. - **formula_engine**: `tier_from_value` dict key rename (`t1`→`t1_threshold`) is consistent across model, migration SQL, seed JSON, and tests. - **`_build_card_state_response`**: `_NEXT_THRESHOLD_ATTR.get(state.current_tier)` + `getattr(track, next_attr) if next_attr else None` correctly handles tier 4 (fully evolved) with `None`. - **Migration SQL**: Both files wrapped in `BEGIN`/`COMMIT`, rollback DROP statements present (convention satisfied). FK references correct. - **WP-10 hook**: Placed after `bulk_create` atomic block — pack insertion abort before hook runs → no phantom evolution states. Fire-and-forget with broad `except Exception` is correct for this path. - **list_team_evolutions**: JOIN pre-loads EvolutionTrack → `_build_card_state_response` accesses `state.track` without additional queries. `count()` on the base (pre-paginated) query is correct for total. #### Security - Token validation consistent: `valid_token(token)` checked on all protected endpoints; "Bad Token: [REDACTED]" used throughout (no raw token logging). - No SQL injection: all queries use Peewee ORM parameterized queries. - `force=True` idempotency bypass is gated behind the existing bearer token — acceptable for stat-correction use case. #### Style & Conventions - `noqa: E402` on main.py imports correct (logging configured before app imports). - `ruff.toml` F403/F405 suppression is the right fix for `from peewee import *` in db_engine. - Two minor ruff-prefer items (non-blocking): - `logger.warning(f"Evolution eval failed for player={player_id}…")` in `evolution.py:413` — ruff prefers `%`-style lazy formatting in log calls. - `f"Season stats update failed for game {game_id}: {exc}"` in `season_stats.py:519` (HTTPException detail) — f-string here is fine since it's not a log call. #### Suggestions - **`EvolutionTrack.get(EvolutionTrack.card_type == card_type)` in `evolution_init.py`**: If multiple tracks are ever seeded for the same `card_type` (only `name` has a UNIQUE constraint, not `card_type`), `MultipleObjectsReturned` is silently swallowed by the outer `except Exception → return None`. Consider `.order_by(EvolutionTrack.id).first()` or adding a UNIQUE index on `card_type` if one-track-per-type is the intended invariant. - **N+1 in `evaluate_game` tier-ups**: `state.track.name` triggers a lazy FK load and `Player.get_by_id(player_id)` runs per tier-up. Since tier-ups are rare in practice this is acceptable; flag for cleanup if the endpoint is called in high-frequency paths. - **Test `_season_stats_module.db = _test_db` patch**: The module-level `db` rebind is the correct approach for SQLite shared-memory tests. Well-documented rationale in the test file. ### Verdict: APPROVED The previous `REQUEST_CHANGES` blocker (`datetime.utcnow()` regression) is fixed. All three criticals from PR #100 are resolved. The implementation is well-structured — full-recalc idempotency via ProcessedGame, no-tier-regression guarantee, comprehensive 8-file test suite (~2,500 lines). Remaining items are suggestions only and do not block merge. --- *Automated review by Claude PR Reviewer*
cal added 1 commit 2026-03-23 18:20:12 +00:00
Claude added
ai-reviewed
and removed
ai-reviewing
labels 2026-03-23 18:20:19 +00:00
cal merged commit 0b6e85fff9 into main 2026-03-23 18:20:22 +00:00
cal deleted branch card-evolution 2026-03-23 18:20:23 +00:00
Sign in to join this conversation.
No description provided.