feat(WP-13): post-game callback endpoints for season stats and evolution #109
No reviewers
Labels
No Label
ai-changes-requested
ai-failed
ai-merged
ai-pr-opened
ai-reviewed
ai-reviewing
ai-reviewing
ai-working
bug
enhancement
evolution
performance
phase-0
phase-1a
phase-1b
phase-1c
phase-1d
security
tech-debt
todo
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#109
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/wp-13-postgame-callbacks"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #78
Summary
Implements the two post-game callback API endpoints described in WP-13.
POST /api/v2/season-stats/update-game/{game_id}— delegates to the existingupdate_season_stats()service (WP-05) and returns{"updated": N, "skipped": bool}. The service's ProcessedGame ledger enforces full idempotency against re-delivery.POST /api/v2/evolution/evaluate-game/{game_id}— collects all unique(player_id, team_id)pairs from the game's StratPlay rows, callsevaluate_card()for each pair that has an EvolutionCardState, and returns{"evaluated": N, "tier_ups": [...]}with player name, old/new tier, current value, and track name for any tier advancements.New files
app/services/evolution_evaluator.pyevaluate_card()service (WP-08) — sums career PlayerSeasonStats, delegates formula computation to the formula engine, updates EvolutionCardState with no-regression guaranteetests/test_postgame_evolution.pyChanged files
app/routers_v2/season_stats.pyupdate_season_stats()service instead of raw SQLapp/routers_v2/evolution.pyPOST /evaluate-game/{game_id}endpointapp/main.pyseason_statsrouterTest plan
test_update_game_creates_season_stats_rows— POST creates player_season_stats rowstest_update_game_response_shape— response shape{"updated": N, "skipped": false}test_update_game_idempotent— second call returnsskipped: true, stats unchangedtest_evaluate_game_increases_current_value— current_value increases after stats + evaltest_evaluate_game_tier_advancement— tier advances when threshold crossedtest_evaluate_game_no_tier_advancement— tier stays at 0 when insufficient statstest_evaluate_game_tier_ups_in_response— tier_ups list contains correct fieldstest_evaluate_game_skips_players_without_state— missing states silently skippedtest_auth_required_update_game— 401 without bearer tokentest_auth_required_evaluate_game— 401 without bearer tokenAll 81 tests pass, 5 PostgreSQL integration tests skipped (no POSTGRES_HOST).
Design notes
Thread-safe SQLite for tests: The TestClient runs route handlers in a separate thread. SQLite
:memory:databases are per-connection, so a new thread would get an empty DB. The test suite uses a named shared-cache URI (file:wp13test?mode=memory&cache=shared) with a db_middleware that callsconnect(reuse_if_open=True)so the handler thread joins the same in-memory database as the fixture thread.Non-fatal batch evaluation: Individual evaluate_card() failures are logged but do not abort the batch. The endpoint always returns a summary even if some players fail.
tier_ups shape:
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>AI Code Review
Files Reviewed
app/main.py(modified)app/routers_v2/season_stats.py(modified)app/routers_v2/evolution.py(modified)app/services/evolution_evaluator.py(added)tests/test_postgame_evolution.py(added)Also reviewed as context:
app/services/season_stats.py,app/services/formula_engine.py,app/db_engine.py,app/dependencies.py.Findings
Correctness
season-stats endpoint (
season_stats.py): Correctly delegates toupdate_season_stats()rather than raw SQL. The response assembly correctly sumsbatters_updatedandpitchers_updatedfrom the service result dict and passes throughskipped. The service'slast_gameFK guard handles idempotency. The documented limitation (out-of-order re-delivery bypasses the guard) is honestly disclosed in both the service docstring and the issue reference (#105).evaluate-game endpoint (
evolution.py): Correctly collects unique(player_id, team_id)pairs fromStratPlayrows by checking bothbatter_idandpitcher_id. The pre-fetch ofEvolutionCardStatebefore callingevaluate_card()is the correct skip gate — pairs without a state row are skipped without error.old_tieris captured before the call,new_tiercomes from the returned dict, and the comparisonnew_tier > old_tiercorrectly identifies tier advancements.evaluate_card service (
evolution_evaluator.py): Career totals are correctly summed across all seasons for the(player_id, team_id)pair. The no-regression guarantee (max(current_tier, new_tier)) is correctly implemented.fully_evolvedis set from the post-max tier value (card_state.current_tier >= 4), not fromnew_tier, which is correct.last_evaluated_atis set unconditionally on every call.Field naming in
evaluate_card(): The service uses_state_model.player_id == player_idand_state_model.team_id == team_idin where-clauses. In Peewee, FK fields expose both.player(the FK accessor) and.player_id(the raw integer column). Both forms are valid and this usage is consistent with the rest of the codebase (e.g._upsert_sqliteinseason_stats.pyusesplayer_id=andteam_id=inget_or_create). No bug here._CareerTotals.strikeoutsmapping:PlayerSeasonStatsstores pitcher strikeouts ask, and_CareerTotalsmapsr.ktoself.strikeouts. The formula engine'sPitcherStatsProtocol expectsstrikeouts, so the translation is correct and well-commented.main.py router registration:
season_statsis added to both the import tuple and theapp.include_router()call. Correct.Response shapes: Both endpoints return the shapes specified in the PR description and verified against the spec.
Security
Both endpoints guard with
valid_token(token)before any DB access, consistent with every other authenticated endpoint in the codebase.valid_tokenuseshmac.compare_digest(timing-safe). No injection risk — all queries use Peewee ORM, no raw SQL is introduced by this PR. No secrets or credentials in new code.The error detail on 500 responses (
f"Season stats update failed for game {game_id}: {exc}") leaks the exception message to the caller. This is a pre-existing pattern in this codebase and is acceptable for an internal bot API.Style & Conventions
All new code follows the established patterns: lazy imports inside route handlers,
logging.getLogger(__name__)at module level, auth check at the top of each handler, and structured log messages with%sformat strings rather than f-strings. Thenoqa: PLC0415suppression for lazy imports inside functions is used correctly and consistently with the rest of the codebase.The
# noqa: E402additions tomain.pyimports are cosmetic cleanup and harmless.Edge Cases
evaluate-gamewith agame_idthat has zeroStratPlayrows returns{"evaluated": 0, "tier_ups": []}with 200. Correct.evaluate_card()failure: Logged at ERROR level, skipped, batch continues. The failed player is not counted inevaluated. Correct non-fatal behavior."player_{player_id}"string. This prevents a tier-up from being silently dropped if the Player row is missing.Suggestions (non-blocking)
Missing test for evaluate-game with a non-existent game_id: The endpoint would return
{"evaluated": 0, "tier_ups": []}becauseStratPlay.select().where(StratPlay.game == game_id)returns an empty queryset for an unknown game. This is reasonable behavior, but an explicit test would lock in the contract.fully_evolvedthreshold hardcoded to 4:card_state.current_tier >= 4assumes T4 is always the max tier. If the track system ever gains a T5, this will silently mis-classify. Consider deriving it from the track definition, or at minimum adding a comment explaining the invariant.Test db patch is permanent for the module:
_season_stats_module.db = _wp13_dbis set once at import time and never restored. This is fine for an isolated test module, but amonkeypatchfixture would be safer if this module ever runs alongside others that depend on the realdbreference.Verdict: APPROVED
The implementation is correct and complete. Both endpoints delegate properly to their respective service layers, auth is enforced on both routes, idempotency is preserved through the existing
last_gameguard, tier-up detection is logically sound, and non-fatal batch evaluation is correctly implemented. The 10-test suite covers the critical paths including idempotency, tier advancement, no-advancement, skip-without-state, and auth. The suggestions above are minor and do not block merging.Automated review by Claude PR Reviewer
b151923480toa2d2aa3d31