feat(WP-10): pack opening hook — evolution_card_state initialization #107
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
2 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: cal/paper-dynasty-database#107
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/wp10-pack-opening-hook"
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 #75.
Summary
app/services/evolution_init.pywithinitialize_card_evolutionand_determine_card_typePOST /api/v2/cardsto initialize evolution state on pack openingcards.pyandtest_evolution_models.pyWhat was built
app/services/evolution_init.py(new)_determine_card_type(player)— pure function, no DB, mapspos_1to track type:pos_1containsSP→'sp'pos_1containsRPorCP→'rp''batter'initialize_card_evolution(player_id, team_id, card_type)— get_or_create pattern:EvolutionTrackmatchingcard_typeEvolutionCardState.get_or_create(player_id, team_id, defaults={...})current_tier=0,current_value=0.0,fully_evolved=False(player_id, team_id)is a no-op — existing progress is never resetNoneon any failure (missing track seed, DB error); never raisesapp/routers_v2/cards.py(modified)Added WP-10 hook after
Card.bulk_createand before the 200 response:Also fixed 3 pre-existing ruff violations in
cards.py(unusedlc_id, bare f-string, unusedein except).tests/test_evolution_init.py(new, 16 tests)Unit (
TestDetermineCardType) — 7 tests, no DB:'sp', RP →'rp', CP →'rp''batter'Integration (
TestInitializeCardEvolution) — 9 tests against in-memory SQLite:current_tier=0,current_value=0.0,fully_evolved=Falsecurrent_tier=2) is preservedsptrack, RP/CP player getsrptrackNonewithout raising_determine_card_type+initialize_card_evolutioncorrectly routes all three typesTest results
No regressions in any other test file.
Design decisions
try/exceptin the router ensures evolution failures can never break pack opening. The innertry/exceptin the service catches DB-level surprises.Paperdex, all teams get an evolution state. The spec does not exclude them and back-filling would be complex.evolution_progressrows: Out of scope for WP-10 per spec. Progress accumulation is WP-07/WP-08.get_or_createnotinsert_or_ignore: Peewee'sget_or_createreturns the existing instance, making the "duplicate is a no-op" guarantee testable and explicit.🤖 Generated with Claude Code
AI Code Review
Files Reviewed
app/services/evolution_init.py(added)app/routers_v2/cards.py(modified)tests/test_evolution_init.py(added)tests/test_evolution_models.py(modified — import fix)Findings
Correctness
_determine_card_type: Substring matching via.upper()is correct for all known position strings."SP" in pos,"RP" in pos,"CP" in posare unambiguous for the baseball position value space.get_or_create(player_id=player_id, team_id=team_id): Valid Peewee syntax — the raw FK column accessorsplayer_id/team_idresolve against the UNIQUE index on(EvolutionCardState.player, EvolutionCardState.team). Consistent with theProcessedGamepattern from PR #106.DoesNotExist+ unexpected errors on track lookup; outer catchesget_or_createerrors. Never raises. Fire-and-forget guarantee is solid.Card.bulk_create(and after the atomic block), before the 200 response. Sincebulk_createis insidedb.atomic(), any bulk-create failure aborts the function before the hook runs — no phantom evolution states for uninserted cards.test_evolution_models.pyfix: Import reorder (PlayerSeasonStatsmoved to head of import block) + docstring update (PlayerSeasonStats→BattingSeasonStats) reflecting thenext-releaserename. Minimal, correct.Security
player_idandteam_idflow from an authenticated, validated request body through internal Peewee ORM calls — no user-controlled strings reach raw SQL.[REDACTED]log pattern preserved throughoutcards.py.Style & Conventions
app/services/package has a proper__init__.py— import paths work correctly._determine_card_type(private by convention) across modules is slightly unconventional, but the PR documents this explicitly and the function is genuinely shared between router and tests. Acceptable.lc_id) are clean and scoped.Suggestions
Player.get_by_id(x.player_id)per card in a separate loop. Sinceplayer_idsis already collected in the preceding loop, a single{p.player_id: p for p in Player.select().where(Player.player_id << player_ids)}dict would replace N queries with 1. Similarly,initialize_card_evolutionre-fetchesEvolutionTrackper call (3 possible values). Both are acceptable for fire-and-forget semantics and small pack sizes — noting for future optimization if pack sizes grow.raise HTTPException(status_code=200)at the end ofv1_cards_postis a pre-existing issue (not introduced here); noted for a follow-up cleanup.Verdict: APPROVED
Clean, correct implementation. The service layer is well-factored, the error-handling guarantees are sound, and the 16 tests cover all meaningful paths including idempotency, cross-player isolation, track routing, and the safe-failure (missing seed) path. No blocking issues.
Automated review by Claude PR Reviewer
AI Code Review
Files Reviewed
app/routers_v2/cards.py(modified)app/services/evolution_init.py(added)migrations/2026-03-17_add_evolution_tables.sql(modified)tests/test_evolution_init.py(added)tests/test_evolution_models.py(modified)tests/test_evolution_state_api.py(added)Findings
Correctness
The core logic is correct.
Hook placement relative to
bulk_create: The evolution hook loops overcards.cards(the raw input payload), not the returned ORM instances frombulk_create. This is fine —player_idandteam_idcome directly from the input, and the hook only needs those two values.Hook runs outside the atomic block:
Card.bulk_createruns insidewith db.atomic(). The evolution hook runs after that block closes, so an evolution init failure cannot roll back the card insert. This is correct and intentional per the fire-and-forget design.EvolutionTrack.get()with no uniqueness constraint oncard_type: TheEvolutionTrackmodel definescard_type = CharField()with no unique constraint. If two tracks were seeded with the samecard_type,EvolutionTrack.get(EvolutionTrack.card_type == card_type)would silently return the first matching row — Peewee'sget()usesresult[0]internally and does not raise on multiple results. The broadexcept Exceptionin the outer layer does not catch this because no exception is raised; the wrong track would be silently assigned. In practice this is a seeding concern (the spec defines exactly three tracks), but the assumption is unenforced at the DB level._determine_card_typesubstring matching: The.upper()call is correct. Theinoperator checks for substring presence, so anypos_1value containing"SP"as a substring would route tosp. With the tightly controlled two-to-three character position strings in this codebase (SP,RP,CP,1B, etc.) this is not a real risk, but the assumption should hold.test_evolution_models.pydocstring drift: The diff updates the section header comment and the class docstring to sayBattingSeasonStats, but the Python import, fixture, and class under test remainPlayerSeasonStats(correctly — the ORM class name has not changed, only the SQL table name was renamed in the migration). This creates a comment-vs-code mismatch that could confuse a future reader but does not affect correctness.Security
No issues. The hook receives
player_idandteam_idfrom an already-authenticated, already-validated payload. No new endpoints are exposed. The service is not callable from any public route.Style & Conventions
The new code follows all established patterns:
get_or_createwith adefaultsdict matches the existingPaperdex.get_or_createpattern directly above the hook incards.py.logger = logging.getLogger(__name__)is consistent withseason_stats.pyand other services.get_or_create, outer forEvolutionTrack.get) with explicitDoesNotExisthandling before the broadexcept Exceptionfallback is well-reasoned._determine_card_typeleading underscore correctly signals internal-but-exported.The
lc_id/last_cardremoval is a clean, correct fix — that variable was fetched beforebulk_createand only used in the commented-outsheets.post_new_cardscall.Suggestions (non-blocking)
EvolutionTrack.card_typeunique constraint: Consider adding a unique constraint onevolution_track.card_typein the migration (or a follow-on migration). As written, duplicate seed data causes silent wrong-track assignment rather than a loud error. A DB-level constraint would enforce the three-track invariant without any code change.Comment drift in
test_evolution_models.py: The section header# BattingSeasonStatsand the class docstring sayingTests for BattingSeasonStatsshould be reverted toPlayerSeasonStatssince the Python class name did not change. The rename only applied to the SQL table name.test_evolution_state_api.pyskip scope: The_skip_no_pgmark is applied per-test function, which is correct. Just confirming the Gitea Actions CI runner either hasPOSTGRES_HOSTset or the skip fires cleanly — the module-level guard pattern used here is the right approach regardless.Verdict: APPROVED
The implementation is correct, safe, and well-tested. The fire-and-forget wrapper, get-or-create idempotency, and track-type routing all behave as specified in WP-10. The 16 unit and integration tests cover the key behavioral requirements including idempotency, cross-player isolation, and the missing-track safe-failure path. The pre-existing lint fixes are welcome.
The
card_typeuniqueness observation is worth a follow-up but does not block this PR — the seeding data is controlled and the spec is unambiguous about exactly three tracks.Automated review by Claude PR Reviewer