Compare commits

...

68 Commits

Author SHA1 Message Date
cal
7701777273 Merge pull request 'feat: include animated_url in tier-up response for T3/T4 (#201)' (#208) from issue/201-feat-include-animated-url-in-tier-up-response-for into main 2026-04-08 10:25:53 +00:00
Cal Corum
4028a24ef9 feat: include animated_url in tier-up response for T3/T4 (#201)
Closes #201

Add animated_url to evaluate-game tier-up entries when new_tier >= 3.
URL is constructed from API_BASE_URL env var + the /animated endpoint
path, using today's date as the cache-bust segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 04:35:55 -05:00
cal
c8ec976626 Merge pull request 'fix: return image/apng media type from animated card endpoint (#196)' (#204) from issue/196-bug-apng-endpoint-returns-wrong-media-type-image-p into main 2026-04-08 02:26:04 +00:00
cal
e1f3371321 Merge branch 'main' into issue/196-bug-apng-endpoint-returns-wrong-media-type-image-p 2026-04-08 02:25:58 +00:00
cal
3cc4c65717 Merge pull request 'perf: batch image_url prefetch in list_card_states to eliminate N+1 (#199)' (#206) from issue/199-perf-n-1-query-in-build-card-state-response-for-im into main 2026-04-08 02:25:52 +00:00
Cal Corum
b7196c1c56 perf: batch image_url prefetch in list_card_states to eliminate N+1 (#199)
Replace per-row CardModel.get() in _build_card_state_response with a
bulk prefetch in list_card_states: collect variant player IDs, issue at
most 2 queries (BattingCard + PitchingCard), build a (player_id, variant)
-> image_url map, and pass the resolved value directly to the helper.

The single-card get_card_state path is unchanged and still resolves
image_url inline (one extra query is acceptable for a single-item response).

Closes #199

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:04:33 -05:00
cal
3852fe1408 Merge pull request 'fix: variant filter commented out on cards list endpoint (#197)' (#203) from issue/197-bug-variant-filter-commented-out-on-cards-list-end into main 2026-04-08 01:45:07 +00:00
cal
b69b4264e8 Merge branch 'main' into issue/197-bug-variant-filter-commented-out-on-cards-list-end 2026-04-08 01:45:00 +00:00
cal
6d857a0f93 Merge pull request 'feat: add template drift check and cache management to deploy tooling' (#205) from chore/deploy-tooling-templates into main 2026-04-08 01:36:35 +00:00
Cal Corum
900f9723e5 fix: address PR review — unknown flag guard, local var scope, container map
- Reject unknown --flags with error instead of silently treating as commit SHA
- Declare remote_hash as local to prevent stale values across loop iterations
- Use associative array for container names (consistent with DEPLOY_HOST pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:32:12 -05:00
Cal Corum
cf7279a573 fix: also update cache hit path to return image/apng media type (#204)
The cache hit branch at line 773 still returned image/png, meaning
the MIME type fix was never seen in production since cached responses
dominate. Update it to match the cache miss path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:31:11 -05:00
Cal Corum
91a57454f2 feat: add template drift check and cache management to deploy tooling
deploy.sh now checks local vs remote templates via md5sum on every
deploy and warns about drift. Pass --sync-templates to push changed
files. Also reports cached card image counts on the target server.

New clear-card-cache.sh script inspects or clears cached PNG/APNG
card images inside the API container, with --apng-only and --all
modes. Added scripts/README.md documenting all operational scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:24:51 -05:00
Cal Corum
dcff8332a2 fix: return image/apng media type from animated card endpoint (#196)
Closes #196

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 20:01:48 -05:00
Cal Corum
ea36c40902 fix: uncomment variant filter and add variant to CSV export (#197)
Restores the commented-out `?variant=` query filter on GET /api/v2/cards
so callers can pass variant=0 for base cards or variant=N for a specific
refractor variant. Adds variant column to CSV output header and rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 19:31:48 -05:00
cal
19003215a3 Merge pull request 'fix: improve diamond tier indicator visibility' (#194) from fix/diamond-indicator-styling into main 2026-04-07 13:58:12 +00:00
Cal Corum
73be3dd6f3 fix: improve diamond tier indicator visibility
- Add silver backing div behind diamond so the X gap lines pop
- Darken unfilled quads for better contrast with filled tier color
- Make diamond grid background transparent (backing shows through gaps)
- Deduplicate shared positioning into a combined CSS rule
- Remove dead TIER_DIAMOND_COLORS dict and filled_bg from Python
  (template already computes these via Jinja)

NOTE: These are volume-mounted template files, NOT baked into the
Docker image. After merging, manually deploy to each server:

  scp storage/templates/tier_style.html <host>:<container-data>/storage/templates/
  scp storage/templates/player_card.html <host>:<container-data>/storage/templates/

Hosts:
  Dev:  ssh pd-database → /home/cal/container-data/dev-pd-database/
  Prod: ssh akamai → /root/container-data/paper-dynasty/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:47:24 -05:00
cal
35fbe2082d Merge pull request 'fix: pass diamond tier colors to card template' (#193) from fix/diamond-tier-colors into main
All checks were successful
Build Docker Image / build (push) Successful in 8m48s
2026-04-07 05:13:02 +00:00
Cal Corum
a105d5412a fix: pass diamond tier colors to card template
The tier_style.html template references {{ filled_bg }} for diamond
quad backgrounds but it was never set in the rendering code, making
the tier indicator invisible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:12:26 -05:00
cal
85f6783eea Merge pull request 'fix: correct apng version pin (0.3.4, not 3.1.1)' (#192) from fix/apng-version-pin into main
All checks were successful
Build Docker Image / build (push) Successful in 8m17s
2026-04-07 04:25:46 +00:00
Cal Corum
1fd681d595 fix: correct apng version pin (0.3.4, not 3.1.1)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:23:48 -05:00
cal
7cb998561e Merge pull request 'chore: add deploy script for dev/prod CI releases' (#191) from chore/deploy-script-apply into main
Some checks failed
Build Docker Image / build (push) Failing after 1m20s
2026-04-07 03:55:29 +00:00
Cal Corum
321efa4909 chore: add deploy script for dev/prod tag-based CI releases
Closes PR #190 (chore/deploy-script — applied directly due to Gitea rebase conflict from stale branch base)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:49:40 -05:00
cal
0dc096be93 Merge pull request 'feat: refractor card art pipeline — S3 upload + image_url' (#187) from feat/refractor-card-art-pipeline into main 2026-04-07 03:29:24 +00:00
Cal Corum
20f7ac5958 merge: resolve requirements.txt conflict — include both apng and boto3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:27:15 -05:00
cal
67d1f10455 Merge pull request 'feat: APNG animated card effects for T3/T4 refractor tiers (#186)' (#188) from issue/186-feat-apng-animated-card-effects-for-t3-t4-refracto into main
Reviewed-on: #188
2026-04-07 03:22:49 +00:00
Cal Corum
30b5eefa29 feat: APNG animated card effects for T3/T4 refractor tiers (#186)
Closes #186

- Add app/services/apng_generator.py: scrubs CSS animations via Playwright
  (negative animation-delay + running override), assembles frames with apng lib
- Add GET /{player_id}/{card_type}card/{d}/{variant}/animated endpoint: returns
  cached .apng for T3/T4 cards, 404 for T0-T2
- T3: 12 frames × 200ms (gold shimmer, 2.5s cycle)
- T4: 24 frames × 250ms (prismatic sweep + diamond glow, 6s cycle)
- Cache path: storage/cards/cardset-{id}/{type}/{player_id}-{d}-v{variant}.apng
- Add apng==3.1.1 to requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 03:22:05 +00:00
cal
02da6f9cc8 Merge pull request 'fix: ensure count is never null in GET /refractor/cards (#183)' (#185) from issue/183-bug-get-refractor-cards-returns-count-null-with-ve into main
Reviewed-on: #185
2026-04-07 03:15:43 +00:00
Cal Corum
543c8cddf6 fix: ensure count is never null in GET /refractor/cards (#183)
Guards against Peewee 3.17.9 returning None from .count() on a
complex multi-join query when 0 rows match the filter set.

Closes #183

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 03:15:21 +00:00
Cal Corum
fac9f66b3e docs: fix dev PostgreSQL container/db/user in CLAUDE.md
Dev environment uses sba_postgres container, paperdynasty_dev database,
sba_admin user — not pd_postgres/pd_master as previously documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:15:21 +00:00
Cal Corum
c75e2781be fix: address review feedback (#187)
- Move lazy imports to top level in card_storage.py and players.py (CLAUDE.md violation)
- Use os.environ.get() for S3_BUCKET/S3_REGION to allow dev/prod bucket separation
- Fix test patch targets from app.db_engine to app.services.card_storage (required after top-level import move)
- Fix assert_called_once_with field name: MockBatting.player → MockBatting.player_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:03:02 -05:00
Cal Corum
f4a90da629 fix: review feedback — pin boto3, use player_id consistently, add comment
- Pin boto3==1.42.65 to match project convention of exact version pins
- Use player_id (not player) for FK column access in card_storage.py
  to match the pattern used throughout the codebase
- Add comment explaining the tier is None guard in S3 upload scheduling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:36:20 +00:00
Cal Corum
be8bebe663 feat: include image_url in refractor cards API response
Adds image_url field to each card state entry in the GET
/api/v2/refractor/cards response. Resolved by looking up the variant
BattingCard/PitchingCard row. Returns null when no image has been
rendered yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:36:20 +00:00
Cal Corum
4ccc0841a8 feat: schedule S3 upload for variant cards after Playwright render
Adds BackgroundTasks to the card render endpoint. After rendering a
variant card (variant > 0) where image_url is None, schedules
backfill_variant_image_url to upload the PNG to S3 and populate
image_url on the card row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:36:20 +00:00
Cal Corum
534d50f1a8 feat: add card_storage S3 upload utility for variant cards
New service with S3 upload functions for the refractor card art
pipeline. backfill_variant_image_url reads rendered PNGs from disk,
uploads to S3, and sets image_url on BattingCard/PitchingCard rows.
18 tests covering key construction, URL formatting, upload params,
and error swallowing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:36:20 +00:00
Cal Corum
088d30b96b docs: fix dev PostgreSQL container/db/user in CLAUDE.md
Dev environment uses sba_postgres container, paperdynasty_dev database,
sba_admin user — not pd_postgres/pd_master as previously documented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:36:20 +00:00
cal
abb1c71f0a Merge pull request 'chore: pre-commit auto-fixes + remove unused Refractor tables' (#181) from chore/pre-commit-autofix into main
Reviewed-on: #181
2026-04-06 04:08:37 +00:00
Cal Corum
bf3a8ca0d5 chore: remove unused RefractorTierBoost and RefractorCosmetic tables
Speculative schema from initial Refractor design that was never used —
boosts are hardcoded in refractor_boost.py and tier visuals are embedded
in CSS templates. Both tables have zero rows on dev and prod.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:27:26 -05:00
Cal Corum
bbb5689b1f fix: address review feedback (#181)
- pre-commit: guard ruff --fix + git add with git stash --keep-index so
  partial-staging (git add -p) workflows are not silently broken
- pre-commit: use -z / xargs -0 for null-delimited filename handling
- install-hooks.sh: update echo messages to reflect auto-fix behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:03:06 -05:00
Cal Corum
0d009eb1f8 chore: pre-commit hook auto-fixes ruff violations before blocking
Instead of failing and requiring manual fix + re-commit, the hook now
runs ruff check --fix first, re-stages the fixed files, then checks
for remaining unfixable issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:19:37 -05:00
cal
ad5d5561c6 Merge pull request 'fix: refractor card art post-merge fixes — cache bypass, template guards, dev server' (#180) from fix/refractor-card-art-followup into main
All checks were successful
Build Docker Image / build (push) Successful in 8m29s
2026-04-04 17:41:05 +00:00
Cal Corum
dc9269eeed fix: refractor card art post-merge fixes — cache bypass, template guards, dev server
- Skip PNG cache when ?tier= param is set to prevent serving stale T0 images
- Move {% if %} guard before diamond_colors dict in player_card.html
- Extract base #fullCard styles outside refractor conditional in tier_style.html
- Make run-local.sh DB host configurable, clean up Playwright check

Follow-up to PR #179

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:37:30 -05:00
cal
3e84a06b23 Merge pull request 'feat: refractor tier-specific card art rendering' (#179) from feature/refractor-card-art into main 2026-04-04 17:33:36 +00:00
Cal Corum
d92ab86aa7 fix: visual tuning from live preview — diamond position, borders, corners, header z-index
- Move diamond left to align bottom point with center column divider
- Keep all border widths uniform across tiers (remove T4 bold borders)
- Remove corner accents entirely (T4 differentiated by glow + prismatic)
- Fix T4 header z-index: don't override position on absolutely-positioned
  topright stat elements (stealing, running, bunting, hit & run)
- Add ?tier= query param for dev preview of tier styling on base cards
- Add run-local.sh for local API testing against dev database
- Add .env.local and .run-local.pid to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:20:05 -05:00
Cal Corum
830e703e76 fix: address PR #179 review — consolidate CSS, extract inline styles, add tests
- Consolidate T3 duplicate #header rule into single block with overflow/position
- Add explicit T2 #resultHeader border-bottom-width (4px) for clarity
- Move diamond quad filled box-shadow from inline styles to .diamond-quad.filled CSS rule
- Add TestResolveTier: 6 parametrized tests covering tier roundtrip, base card, unknown variant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:43:27 -05:00
Cal Corum
b32e19a4ac feat: add refractor tier-specific card art rendering
Implement tier-aware visual styling for card PNG rendering (T0-T4).
Each refractor tier gets distinct borders, header backgrounds, column
header gradients, diamond tier indicators, and decorative effects.

- New tier_style.html template: per-tier CSS overrides (borders, headers,
  gradients, inset glow, diamond positioning, corner accents)
- Diamond indicator: 2x2 CSS grid rotated 45deg at header/result boundary,
  progressive fill (1B→2B→3B→Home) with tier-specific colors
- T4 Superfractor: bold gold borders, dual gold-teal glow, corner accents,
  purple diamond with glow pulse animation
- resolve_refractor_tier() helper: pure-math tier lookup from variant hash
- T3/T4 animations defined but paused for static PNG capture (APNG follow-up)

Relates-to: initiative #19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:14:33 -05:00
cal
ffe07ec54c Merge pull request 'fix: auto-initialize RefractorCardState in evaluate-game' (#178) from fix/refractor-auto-init-missing-states into main
All checks were successful
Build Docker Image / build (push) Successful in 8m15s
2026-03-31 06:25:41 +00:00
Cal Corum
add175e528 fix: auto-initialize RefractorCardState in evaluate-game for legacy cards
Cards created before the refractor system was deployed have no
RefractorCardState row. Previously evaluate-game silently skipped these
players. Now it calls initialize_card_refractor on-the-fly so any card
used in a game gets refractor tracking regardless of when it was created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:22:37 -05:00
cal
31c86525de Merge pull request 'feat: Refractor Phase 2 integration — wire boost into evaluate-game' (#177) from feature/refractor-phase2-integration into main
All checks were successful
Build Docker Image / build (push) Successful in 8m13s
2026-03-30 18:17:29 +00:00
Cal Corum
7f17c9b9f2 fix: address PR #177 review — move import os to top-level, add audit idempotency guard
- Move `import os` from inside evaluate_game() to module top-level imports
  (lazy imports are only for circular dependency avoidance)
- Add get_or_none idempotency guard before RefractorBoostAudit.create()
  inside db.atomic() to prevent IntegrityError on UNIQUE(card_state, tier)
  constraint in PostgreSQL when apply_tier_boost is called twice for the
  same tier
- Update atomicity test stub to provide card_state/tier attributes for
  the new Peewee expression in the idempotency guard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:16:27 -05:00
Cal Corum
6a176af7da feat: Refractor Phase 2 integration — wire boost into evaluate-game
When a card reaches a new Refractor tier during game evaluation, the
system now creates a boosted variant card with modified ratings. This
connects the Phase 2 Foundation pure functions (PR #176) to the live
evaluate-game endpoint.

Key changes:
- evaluate_card() gains dry_run parameter so apply_tier_boost() is the
  sole writer of current_tier, ensuring atomicity with variant creation
- apply_tier_boost() orchestrates the full boost flow: source card
  lookup, boost application, variant card + ratings creation, audit
  record, and atomic state mutations inside db.atomic()
- evaluate_game() calls evaluate_card(dry_run=True) then loops through
  intermediate tiers on tier-up, with error isolation per player
- Display stat helpers compute fresh avg/obp/slg for variant cards
- REFRACTOR_BOOST_ENABLED env var provides a kill switch
- 51 new tests: unit tests for display stats, integration tests for
  orchestration, HTTP endpoint tests for multi-tier jumps, pitcher
  path, kill switch, atomicity, idempotency, and cross-player isolation
- Clarified all "79-sum" references to note the 108-total card invariant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 13:04:52 -05:00
cal
70f984392d Merge pull request 'feat: Refractor Phase 2 foundation — boost functions, schema, tests' (#176) from feature/refractor-phase2-foundation into main 2026-03-30 16:11:07 +00:00
Cal Corum
a7d02aeb10 style: remove redundant parentheses on boost_delta_json declaration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:07:19 -05:00
Cal Corum
776f1a5302 fix: address PR review findings — rename evolution_tier to refractor_tier
- Rename `evolution_tier` parameter to `refractor_tier` in compute_variant_hash()
  to match the refractor naming convention established in PR #131
- Update hash input dict key accordingly (safe: function is new, no stored hashes)
- Update test docstrings referencing the old parameter name
- Remove redundant parentheses on boost_delta_json TextField declaration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:06:38 -05:00
Cal Corum
4a1251a734 feat: add Refractor Phase 2 foundation — boost functions, schema, tests
Pure functions for computing boosted card ratings when a player
reaches a new Refractor tier. Batter boost applies fixed +0.5 to
four offensive columns per tier; pitcher boost uses a 1.5 TB-budget
priority algorithm. Both preserve the 108-sum invariant.

- Create refractor_boost.py with apply_batter_boost, apply_pitcher_boost,
  and compute_variant_hash (Decimal arithmetic, zero-floor truncation)
- Add RefractorBoostAudit model, Card.variant, BattingCard/PitchingCard
  image_url, RefractorCardState.variant fields to db_engine.py
- Add migration SQL for refractor_card_state.variant column and
  refractor_boost_audit table (JSONB, UNIQUE constraint, transactional)
- 26 unit tests covering 108-sum invariant, deltas, truncation, TB
  accounting, determinism, x-check protection, and variant hash behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:39:03 -05:00
cal
c2c978ac47 Merge pull request 'feat: add evaluated_only filter to GET /api/v2/refractor/cards (#174)' (#175) from issue/174-get-api-v2-refractor-cards-add-evaluated-only-filt into main
All checks were successful
Build Docker Image / build (push) Successful in 8m11s
2026-03-25 22:53:05 +00:00
Cal Corum
537eabcc4d feat: add evaluated_only filter to GET /api/v2/refractor/cards (#174)
Closes #174

Adds `evaluated_only: bool = Query(default=True)` to `list_card_states()`.
When True (the default), cards with `last_evaluated_at IS NULL` are excluded —
these are placeholder rows created at pack-open time but never run through the
evaluator. At team scale this eliminates ~2739 zero-value rows from the
default response, making the Discord /refractor status command efficient
without any bot-side changes.

Set `evaluated_only=false` to include all rows (admin/pipeline use case).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:32:59 -05:00
cal
7e7ff960e2 Merge pull request 'feat: add limit/pagination to paperdex endpoint (#143)' (#167) from issue/143-feat-add-limit-pagination-to-paperdex-endpoint into main
All checks were successful
Build Docker Image / build (push) Successful in 7m53s
2026-03-25 14:52:57 +00:00
cal
792c6b96f9 Merge pull request 'feat: add limit/pagination to cardpositions endpoint (#142)' (#168) from issue/142-feat-add-limit-pagination-to-cardpositions-endpoin into main 2026-03-25 14:52:55 +00:00
cal
2c077d0fd3 Merge branch 'main' into issue/143-feat-add-limit-pagination-to-paperdex-endpoint 2026-03-25 14:52:41 +00:00
cal
3d0c99b183 Merge branch 'main' into issue/142-feat-add-limit-pagination-to-cardpositions-endpoin 2026-03-25 14:52:34 +00:00
cal
eefd4afa37 Merge pull request 'feat: add GET /api/v2/refractor/cards list endpoint (#172)' (#173) from issue/172-feat-add-get-api-v2-refractor-cards-list-endpoint into main 2026-03-25 14:52:24 +00:00
Cal Corum
0b5d0b474b feat: add GET /api/v2/refractor/cards list endpoint (#172)
Closes #172

- New GET /api/v2/refractor/cards endpoint in refractor router with
  team_id (required), card_type, tier, season, progress, limit, offset filters
- season filter uses EXISTS subquery against batting/pitching_season_stats
- progress=close filter uses CASE expression to compare current_value
  against next tier threshold (>= 80%)
- LEFT JOIN on Player so deleted players return player_name: null
- Sorting: current_tier DESC, current_value DESC
- count reflects total matching rows before pagination
- Extended _build_card_state_response() with progress_pct (computed) and
  optional player_name; single-card endpoint gains progress_pct automatically
- Added non-unique team_id index on refractor_card_state in db_engine.py
- Migration: 2026-03-25_add_refractor_card_state_team_index.sql
- Removed pre-existing unused RefractorTrack import in evaluate_game (ruff)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 01:35:18 -05:00
cal
de9b511ae9 Merge pull request 'test: refractor system Tier 3 test coverage' (#171) from test/refractor-tier3 into main
All checks were successful
Build Docker Image / build (push) Successful in 8m11s
2026-03-25 04:13:17 +00:00
Cal Corum
906d6e575a test: add Tier 3 refractor test cases (T3-1, T3-6, T3-7, T3-8)
Adds four Tier 3 (medium-priority) test cases to the existing refractor test
suite.  All tests use SQLite in-memory databases and run without a PostgreSQL
connection.

T3-1 (test_refractor_track_api.py): Two tests verifying that
  GET /api/v2/refractor/tracks?card_type= returns 200 with count=0 for both
  an unrecognised card_type value ('foo') and an empty string, rather than
  a 4xx/5xx.  A full SQLite-backed TestClient is added to the track API test
  module for these cases.

T3-6 (test_refractor_state_api.py): Verifies that
  GET /api/v2/refractor/cards/{card_id} returns last_evaluated_at: null (not
  a crash or missing key) when the RefractorCardState was initialised but
  never evaluated.  Adds the SQLite test infrastructure (models, fixtures,
  helper factories, TestClient) to the state API test module.

T3-7 (test_refractor_evaluator.py): Two tests covering fully_evolved/tier
  mismatch correction.  When the database has fully_evolved=True but
  current_tier=3 (corruption), evaluate_card must re-derive fully_evolved
  from the freshly-computed tier (False for tier 3, True for tier 4).

T3-8 (test_refractor_evaluator.py): Two tests confirming per-team stat
  isolation.  A player with BattingSeasonStats on two different teams must
  have each team's RefractorCardState reflect only that team's stats — not
  a combined total.  Covers both same-season and multi-season scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:38:25 -05:00
cal
74284fe5a3 Merge pull request 'test: refractor system Tier 1+2 test coverage' (#170) from test/refractor-tier1-tier2 into main 2026-03-24 21:18:13 +00:00
Cal Corum
569dc53c00 test: add Tier 1 and Tier 2 refractor system test cases
Implements all gap tests identified in the PO review for the refractor
card progression system (Phase 1 foundation).

TIER 1 (critical):
- T1-1: Negative singles guard in compute_batter_value — documents that
  hits=1, doubles=1, triples=1 produces singles=-1 and flows through
  unclamped (value=8.0, not 10.0)
- T1-2: SP tier boundary precision with floats — outs=29 (IP=9.666) stays
  T0, outs=30 (IP=10.0) promotes to T1; also covers T2 float boundary
- T1-3: evaluate-game with non-existent game_id returns 200 with empty results
- T1-4: Seed threshold ordering + positivity invariant (t1<t2<t3<t4, all >0)

TIER 2 (high):
- T2-1: fully_evolved=True persists when stats are zeroed or drop below
  previous tier — no-regression applies to both tier and fully_evolved flag
- T2-2: Parametrized edge cases for _determine_card_type: DH, C, 2B, empty
  string, None, and compound "SP/RP" (resolves to "sp", SP checked first)
- T2-3: evaluate-game with zero StratPlay rows returns empty batch result
- T2-4: GET /teams/{id}/refractors with valid team and zero states is empty
- T2-5: GET /teams/99999/refractors documents 200+empty (no team existence check)
- T2-6: POST /cards/{id}/evaluate with zero season stats stays at T0 value=0.0
- T2-9: Per-player error isolation — patches source module so router's local
  from-import picks up the patched version; one failure, one success = evaluated=1
- T2-10: Each card_type has exactly one RefractorTrack after seeding

All 101 tests pass (15 PostgreSQL-only tests skip without POSTGRES_HOST).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 09:02:30 -05:00
Cal Corum
8af43273d2 feat: add limit/pagination to cardpositions endpoint (#142)
Closes #142

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 07:31:59 -05:00
Cal Corum
87c200d62b feat: add limit/pagination to paperdex endpoint (#143)
Closes #143

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 07:02:47 -05:00
37 changed files with 7680 additions and 256 deletions

31
.githooks/install-hooks.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
#
# Install git hooks for this repository
#
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "Error: Not in a git repository"
exit 1
fi
HOOKS_DIR="$REPO_ROOT/.githooks"
GIT_HOOKS_DIR="$REPO_ROOT/.git/hooks"
echo "Installing git hooks..."
if [ -f "$HOOKS_DIR/pre-commit" ]; then
cp "$HOOKS_DIR/pre-commit" "$GIT_HOOKS_DIR/pre-commit"
chmod +x "$GIT_HOOKS_DIR/pre-commit"
echo "Installed pre-commit hook"
else
echo "pre-commit hook not found in $HOOKS_DIR"
fi
echo ""
echo "The pre-commit hook will:"
echo " - Auto-fix ruff lint violations (unused imports, formatting, etc.)"
echo " - Block commits only on truly unfixable issues"
echo ""
echo "To bypass in emergency: git commit --no-verify"

53
.githooks/pre-commit Executable file
View File

@ -0,0 +1,53 @@
#!/bin/bash
#
# Pre-commit hook: ruff lint check on staged Python files.
# Catches syntax errors, unused imports, and basic issues before commit.
# To bypass in emergency: git commit --no-verify
#
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.py')
if [ -z "$STAGED_PY" ]; then
exit 0
fi
echo "ruff check on staged files..."
# Stash unstaged changes so ruff only operates on staged content.
# Without this, ruff --fix runs on the full working tree file (staged +
# unstaged), and the subsequent git add would silently include unstaged
# changes in the commit — breaking git add -p workflows.
STASHED=0
if git stash --keep-index -q 2>/dev/null; then
STASHED=1
fi
# Auto-fix what we can, then re-stage the fixed files
printf '%s' "$STAGED_PY" | xargs -0 ruff check --fix --exit-zero
printf '%s' "$STAGED_PY" | xargs -0 git add
# Restore unstaged changes
if [ $STASHED -eq 1 ]; then
git stash pop -q
fi
# Now check for remaining unfixable issues
printf '%s' "$STAGED_PY" | xargs -0 ruff check
RUFF_EXIT=$?
if [ $RUFF_EXIT -ne 0 ]; then
echo ""
echo -e "${RED}Pre-commit checks failed (unfixable issues). Commit blocked.${NC}"
echo -e "${YELLOW}To bypass (not recommended): git commit --no-verify${NC}"
exit 1
fi
echo -e "${GREEN}All checks passed.${NC}"
exit 0

2
.gitignore vendored
View File

@ -59,6 +59,8 @@ pyenv.cfg
pyvenv.cfg pyvenv.cfg
docker-compose.override.yml docker-compose.override.yml
docker-compose.*.yml docker-compose.*.yml
.run-local.pid
.env.local
*.db *.db
venv venv
.claude/ .claude/

View File

@ -31,7 +31,7 @@ docker build -t paper-dynasty-db . # Build image
| **URL** | pddev.manticorum.com | pd.manticorum.com | | **URL** | pddev.manticorum.com | pd.manticorum.com |
| **Host** | `ssh pd-database` | `ssh akamai``/root/container-data/paper-dynasty` | | **Host** | `ssh pd-database` | `ssh akamai``/root/container-data/paper-dynasty` |
| **API container** | `dev_pd_database` | `pd_api` | | **API container** | `dev_pd_database` | `pd_api` |
| **PostgreSQL** | `pd_postgres` (port 5432) | `pd_postgres` | | **PostgreSQL** | `sba_postgres` / `paperdynasty_dev` / `sba_admin` | `pd_postgres` / `pd_master` |
| **Adminer** | port 8081 | — | | **Adminer** | port 8081 | — |
| **API port** | 816 | 815 | | **API port** | 816 | 815 |
| **Image** | `manticorum67/paper-dynasty-database` | `manticorum67/paper-dynasty-database` | | **Image** | `manticorum67/paper-dynasty-database` | `manticorum67/paper-dynasty-database` |

View File

@ -474,6 +474,7 @@ class Card(BaseModel):
team = ForeignKeyField(Team, null=True) team = ForeignKeyField(Team, null=True)
pack = ForeignKeyField(Pack, null=True) pack = ForeignKeyField(Pack, null=True)
value = IntegerField(default=0) value = IntegerField(default=0)
variant = IntegerField(null=True, default=None)
def __str__(self): def __str__(self):
if self.player: if self.player:
@ -755,6 +756,7 @@ class BattingCard(BaseModel):
running = IntegerField() running = IntegerField()
offense_col = IntegerField() offense_col = IntegerField()
hand = CharField(default="R") hand = CharField(default="R")
image_url = CharField(null=True, max_length=500)
class Meta: class Meta:
database = db database = db
@ -824,6 +826,7 @@ class PitchingCard(BaseModel):
batting = CharField(null=True) batting = CharField(null=True)
offense_col = IntegerField() offense_col = IntegerField()
hand = CharField(default="R") hand = CharField(default="R")
image_url = CharField(null=True, max_length=500)
class Meta: class Meta:
database = db database = db
@ -1232,6 +1235,7 @@ class RefractorCardState(BaseModel):
current_value = FloatField(default=0.0) current_value = FloatField(default=0.0)
fully_evolved = BooleanField(default=False) fully_evolved = BooleanField(default=False)
last_evaluated_at = DateTimeField(null=True) last_evaluated_at = DateTimeField(null=True)
variant = IntegerField(null=True)
class Meta: class Meta:
database = db database = db
@ -1245,47 +1249,37 @@ refractor_card_state_index = ModelIndex(
) )
RefractorCardState.add_index(refractor_card_state_index) RefractorCardState.add_index(refractor_card_state_index)
refractor_card_state_team_index = ModelIndex(
class RefractorTierBoost(BaseModel): RefractorCardState,
track = ForeignKeyField(RefractorTrack) (RefractorCardState.team,),
tier = IntegerField() # 1-4 unique=False,
boost_type = CharField() # e.g. 'rating', 'stat'
boost_target = CharField() # e.g. 'contact_vl', 'power_vr'
boost_value = FloatField(default=0.0)
class Meta:
database = db
table_name = "refractor_tier_boost"
refractor_tier_boost_index = ModelIndex(
RefractorTierBoost,
(
RefractorTierBoost.track,
RefractorTierBoost.tier,
RefractorTierBoost.boost_type,
RefractorTierBoost.boost_target,
),
unique=True,
) )
RefractorTierBoost.add_index(refractor_tier_boost_index) RefractorCardState.add_index(refractor_card_state_team_index)
class RefractorCosmetic(BaseModel): class RefractorBoostAudit(BaseModel):
name = CharField(unique=True) card_state = ForeignKeyField(RefractorCardState, on_delete="CASCADE")
tier_required = IntegerField(default=0) tier = IntegerField() # 1-4
cosmetic_type = CharField() # 'frame', 'badge', 'theme' battingcard = ForeignKeyField(BattingCard, null=True)
css_class = CharField(null=True) pitchingcard = ForeignKeyField(PitchingCard, null=True)
asset_url = CharField(null=True) variant_created = IntegerField()
boost_delta_json = (
TextField()
) # JSONB in PostgreSQL; TextField for SQLite test compat
applied_at = DateTimeField(default=datetime.now)
class Meta: class Meta:
database = db database = db
table_name = "refractor_cosmetic" table_name = "refractor_boost_audit"
if not SKIP_TABLE_CREATION: if not SKIP_TABLE_CREATION:
db.create_tables( db.create_tables(
[RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic], [
RefractorTrack,
RefractorCardState,
RefractorBoostAudit,
],
safe=True, safe=True,
) )

View File

@ -51,6 +51,7 @@ async def get_card_positions(
cardset_id: list = Query(default=None), cardset_id: list = Query(default=None),
short_output: Optional[bool] = False, short_output: Optional[bool] = False,
sort: Optional[str] = "innings-desc", sort: Optional[str] = "innings-desc",
limit: int = 100,
): ):
all_pos = ( all_pos = (
CardPosition.select() CardPosition.select()
@ -86,6 +87,9 @@ async def get_card_positions(
elif sort == "range-asc": elif sort == "range-asc":
all_pos = all_pos.order_by(CardPosition.range, CardPosition.id) all_pos = all_pos.order_by(CardPosition.range, CardPosition.id)
limit = max(0, min(limit, 500))
all_pos = all_pos.limit(limit)
return_val = { return_val = {
"count": all_pos.count(), "count": all_pos.count(),
"positions": [model_to_dict(x, recurse=not short_output) for x in all_pos], "positions": [model_to_dict(x, recurse=not short_output) for x in all_pos],

View File

@ -79,8 +79,8 @@ async def get_cards(
all_cards = all_cards.where(Card.pack == this_pack) all_cards = all_cards.where(Card.pack == this_pack)
if value is not None: if value is not None:
all_cards = all_cards.where(Card.value == value) all_cards = all_cards.where(Card.value == value)
# if variant is not None: if variant is not None:
# all_cards = all_cards.where(Card.variant == variant) all_cards = all_cards.where(Card.variant == variant)
if min_value is not None: if min_value is not None:
all_cards = all_cards.where(Card.value >= min_value) all_cards = all_cards.where(Card.value >= min_value)
if max_value is not None: if max_value is not None:
@ -114,8 +114,8 @@ async def get_cards(
if csv: if csv:
data_list = [ data_list = [
["id", "player", "cardset", "rarity", "team", "pack", "value"] ["id", "player", "cardset", "rarity", "team", "pack", "value", "variant"]
] # , 'variant']] ]
for line in all_cards: for line in all_cards:
data_list.append( data_list.append(
[ [
@ -125,7 +125,8 @@ async def get_cards(
line.player.rarity, line.player.rarity,
line.team.abbrev, line.team.abbrev,
line.pack, line.pack,
line.value, # line.variant line.value,
line.variant,
] ]
) )
return_val = DataFrame(data_list).to_csv(header=False, index=False) return_val = DataFrame(data_list).to_csv(header=False, index=False)

View File

@ -9,34 +9,36 @@ from ..db_engine import Paperdex, model_to_dict, Player, Cardset, Team, DoesNotE
from ..dependencies import oauth2_scheme, valid_token from ..dependencies import oauth2_scheme, valid_token
router = APIRouter( router = APIRouter(prefix="/api/v2/paperdex", tags=["paperdex"])
prefix='/api/v2/paperdex',
tags=['paperdex']
)
class PaperdexModel(pydantic.BaseModel): class PaperdexModel(pydantic.BaseModel):
team_id: int team_id: int
player_id: int player_id: int
created: Optional[int] = int(datetime.timestamp(datetime.now())*1000) created: Optional[int] = int(datetime.timestamp(datetime.now()) * 1000)
@router.get('') @router.get("")
async def get_paperdex( async def get_paperdex(
team_id: Optional[int] = None, player_id: Optional[int] = None, created_after: Optional[int] = None, team_id: Optional[int] = None,
cardset_id: Optional[int] = None, created_before: Optional[int] = None, flat: Optional[bool] = False, player_id: Optional[int] = None,
csv: Optional[bool] = None): created_after: Optional[int] = None,
cardset_id: Optional[int] = None,
created_before: Optional[int] = None,
flat: Optional[bool] = False,
csv: Optional[bool] = None,
limit: int = 100,
):
all_dex = Paperdex.select().join(Player).join(Cardset).order_by(Paperdex.id) all_dex = Paperdex.select().join(Player).join(Cardset).order_by(Paperdex.id)
if all_dex.count() == 0: if all_dex.count() == 0:
raise HTTPException(status_code=404, detail=f'There are no paperdex to filter') raise HTTPException(status_code=404, detail="There are no paperdex to filter")
if team_id is not None: if team_id is not None:
all_dex = all_dex.where(Paperdex.team_id == team_id) all_dex = all_dex.where(Paperdex.team_id == team_id)
if player_id is not None: if player_id is not None:
all_dex = all_dex.where(Paperdex.player_id == player_id) all_dex = all_dex.where(Paperdex.player_id == player_id)
if cardset_id is not None: if cardset_id is not None:
all_sets = Cardset.select().where(Cardset.id == cardset_id)
all_dex = all_dex.where(Paperdex.player.cardset.id == cardset_id) all_dex = all_dex.where(Paperdex.player.cardset.id == cardset_id)
if created_after is not None: if created_after is not None:
# Convert milliseconds timestamp to datetime for PostgreSQL comparison # Convert milliseconds timestamp to datetime for PostgreSQL comparison
@ -51,57 +53,62 @@ async def get_paperdex(
# db.close() # db.close()
# raise HTTPException(status_code=404, detail=f'No paperdex found') # raise HTTPException(status_code=404, detail=f'No paperdex found')
limit = max(0, min(limit, 500))
all_dex = all_dex.limit(limit)
if csv: if csv:
data_list = [['id', 'team_id', 'player_id', 'created']] data_list = [["id", "team_id", "player_id", "created"]]
for line in all_dex: for line in all_dex:
data_list.append( data_list.append(
[ [line.id, line.team.id, line.player.player_id, line.created]
line.id, line.team.id, line.player.player_id, line.created
]
) )
return_val = DataFrame(data_list).to_csv(header=False, index=False) return_val = DataFrame(data_list).to_csv(header=False, index=False)
return Response(content=return_val, media_type='text/csv') return Response(content=return_val, media_type="text/csv")
else: else:
return_val = {'count': all_dex.count(), 'paperdex': []} return_val = {"count": all_dex.count(), "paperdex": []}
for x in all_dex: for x in all_dex:
return_val['paperdex'].append(model_to_dict(x, recurse=not flat)) return_val["paperdex"].append(model_to_dict(x, recurse=not flat))
return return_val return return_val
@router.get('/{paperdex_id}') @router.get("/{paperdex_id}")
async def get_one_paperdex(paperdex_id, csv: Optional[bool] = False): async def get_one_paperdex(paperdex_id, csv: Optional[bool] = False):
try: try:
this_dex = Paperdex.get_by_id(paperdex_id) this_dex = Paperdex.get_by_id(paperdex_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') raise HTTPException(
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
)
if csv: if csv:
data_list = [ data_list = [
['id', 'team_id', 'player_id', 'created'], ["id", "team_id", "player_id", "created"],
[this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created] [this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created],
] ]
return_val = DataFrame(data_list).to_csv(header=False, index=False) return_val = DataFrame(data_list).to_csv(header=False, index=False)
return Response(content=return_val, media_type='text/csv') return Response(content=return_val, media_type="text/csv")
else: else:
return_val = model_to_dict(this_dex) return_val = model_to_dict(this_dex)
return return_val return return_val
@router.post('') @router.post("")
async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_scheme)): async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to post paperdex. This event has been logged.' detail="You are not authorized to post paperdex. This event has been logged.",
) )
dupe_dex = Paperdex.get_or_none(Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id) dupe_dex = Paperdex.get_or_none(
Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id
)
if dupe_dex: if dupe_dex:
return_val = model_to_dict(dupe_dex) return_val = model_to_dict(dupe_dex)
return return_val return return_val
@ -109,7 +116,7 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch
this_dex = Paperdex( this_dex = Paperdex(
team_id=paperdex.team_id, team_id=paperdex.team_id,
player_id=paperdex.player_id, player_id=paperdex.player_id,
created=datetime.fromtimestamp(paperdex.created / 1000) created=datetime.fromtimestamp(paperdex.created / 1000),
) )
saved = this_dex.save() saved = this_dex.save()
@ -119,24 +126,30 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch
else: else:
raise HTTPException( raise HTTPException(
status_code=418, status_code=418,
detail='Well slap my ass and call me a teapot; I could not save that dex' detail="Well slap my ass and call me a teapot; I could not save that dex",
) )
@router.patch('/{paperdex_id}') @router.patch("/{paperdex_id}")
async def patch_paperdex( async def patch_paperdex(
paperdex_id, team_id: Optional[int] = None, player_id: Optional[int] = None, created: Optional[int] = None, paperdex_id,
token: str = Depends(oauth2_scheme)): team_id: Optional[int] = None,
player_id: Optional[int] = None,
created: Optional[int] = None,
token: str = Depends(oauth2_scheme),
):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to patch paperdex. This event has been logged.' detail="You are not authorized to patch paperdex. This event has been logged.",
) )
try: try:
this_dex = Paperdex.get_by_id(paperdex_id) this_dex = Paperdex.get_by_id(paperdex_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') raise HTTPException(
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
)
if team_id is not None: if team_id is not None:
this_dex.team_id = team_id this_dex.team_id = team_id
@ -151,40 +164,43 @@ async def patch_paperdex(
else: else:
raise HTTPException( raise HTTPException(
status_code=418, status_code=418,
detail='Well slap my ass and call me a teapot; I could not save that rarity' detail="Well slap my ass and call me a teapot; I could not save that rarity",
) )
@router.delete('/{paperdex_id}') @router.delete("/{paperdex_id}")
async def delete_paperdex(paperdex_id, token: str = Depends(oauth2_scheme)): async def delete_paperdex(paperdex_id, token: str = Depends(oauth2_scheme)):
if not valid_token(token): if not valid_token(token):
logging.warning('Bad Token: [REDACTED]') logging.warning("Bad Token: [REDACTED]")
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail='You are not authorized to delete rewards. This event has been logged.' detail="You are not authorized to delete rewards. This event has been logged.",
) )
try: try:
this_dex = Paperdex.get_by_id(paperdex_id) this_dex = Paperdex.get_by_id(paperdex_id)
except DoesNotExist: except DoesNotExist:
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') raise HTTPException(
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
)
count = this_dex.delete_instance() count = this_dex.delete_instance()
if count == 1: if count == 1:
raise HTTPException(status_code=200, detail=f'Paperdex {this_dex} has been deleted')
else:
raise HTTPException(status_code=500, detail=f'Paperdex {this_dex} was not deleted')
@router.post('/wipe-ai')
async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)):
if not valid_token(token):
logging.warning('Bad Token: [REDACTED]')
raise HTTPException( raise HTTPException(
status_code=401, status_code=200, detail=f"Paperdex {this_dex} has been deleted"
detail='Unauthorized' )
else:
raise HTTPException(
status_code=500, detail=f"Paperdex {this_dex} was not deleted"
) )
g_teams = Team.select().where(Team.abbrev.contains('Gauntlet'))
@router.post("/wipe-ai")
async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)):
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
g_teams = Team.select().where(Team.abbrev.contains("Gauntlet"))
count = Paperdex.delete().where(Paperdex.team << g_teams).execute() count = Paperdex.delete().where(Paperdex.team << g_teams).execute()
return f'Deleted {count} records' return f"Deleted {count} records"

View File

@ -2,7 +2,15 @@ import datetime
import os.path import os.path
import pandas as pd import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Request,
Response,
Query,
)
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from typing import Optional, List, Literal from typing import Optional, List, Literal
@ -32,6 +40,9 @@ from ..db_engine import (
) )
from ..db_helpers import upsert_players from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token from ..dependencies import oauth2_scheme, valid_token
from ..services.card_storage import backfill_variant_image_url
from ..services.refractor_boost import compute_variant_hash
from ..services.apng_generator import apng_cache_path, generate_animated_card
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Persistent browser instance (WP-02) # Persistent browser instance (WP-02)
@ -132,6 +143,19 @@ def normalize_franchise(franchise: str) -> str:
return FRANCHISE_NORMALIZE.get(titled, titled) return FRANCHISE_NORMALIZE.get(titled, titled)
def resolve_refractor_tier(player_id: int, variant: int) -> int:
"""Determine the refractor tier (0-4) from a player's variant hash.
Pure math no DB query needed. Returns 0 for base cards or unknown variants.
"""
if variant == 0:
return 0
for tier in range(1, 5):
if compute_variant_hash(player_id, tier) == variant:
return tier
return 0
router = APIRouter(prefix="/api/v2/players", tags=["players"]) router = APIRouter(prefix="/api/v2/players", tags=["players"])
@ -713,16 +737,146 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False):
return return_val return return_val
@router.get("/{player_id}/{card_type}card/{d}/{variant}/animated")
async def get_animated_card(
request: Request,
player_id: int,
card_type: Literal["batting", "pitching"],
variant: int,
d: str,
tier: Optional[int] = Query(
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
),
):
try:
this_player = Player.get_by_id(player_id)
except DoesNotExist:
raise HTTPException(
status_code=404, detail=f"No player found with id {player_id}"
)
refractor_tier = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
if refractor_tier < 3:
raise HTTPException(
status_code=404,
detail=f"No animation for tier {refractor_tier}; animated cards require T3 or T4",
)
cache_path = apng_cache_path(
this_player.cardset.id, card_type, player_id, d, variant
)
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.isfile(cache_path) and tier is None:
return FileResponse(path=cache_path, media_type="image/apng", headers=headers)
all_pos = (
CardPosition.select()
.where(CardPosition.player == this_player)
.order_by(CardPosition.innings.desc())
)
if card_type == "batting":
this_bc = BattingCard.get_or_none(
BattingCard.player == this_player, BattingCard.variant == variant
)
if this_bc is None:
raise HTTPException(
status_code=404,
detail=f"Batting card not found for id {player_id}, variant {variant}",
)
rating_vl = BattingCardRatings.get_or_none(
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "L"
)
rating_vr = BattingCardRatings.get_or_none(
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "R"
)
if None in [rating_vr, rating_vl]:
raise HTTPException(
status_code=404,
detail=f"Ratings not found for batting card {this_bc.id}",
)
card_data = get_batter_card_data(
this_player, this_bc, rating_vl, rating_vr, all_pos
)
if (
this_player.description in this_player.cardset.name
and this_player.cardset.id not in [23]
):
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = refractor_tier
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
else:
this_pc = PitchingCard.get_or_none(
PitchingCard.player == this_player, PitchingCard.variant == variant
)
if this_pc is None:
raise HTTPException(
status_code=404,
detail=f"Pitching card not found for id {player_id}, variant {variant}",
)
rating_vl = PitchingCardRatings.get_or_none(
PitchingCardRatings.pitchingcard == this_pc,
PitchingCardRatings.vs_hand == "L",
)
rating_vr = PitchingCardRatings.get_or_none(
PitchingCardRatings.pitchingcard == this_pc,
PitchingCardRatings.vs_hand == "R",
)
if None in [rating_vr, rating_vl]:
raise HTTPException(
status_code=404,
detail=f"Ratings not found for pitching card {this_pc.id}",
)
card_data = get_pitcher_card_data(
this_player, this_pc, rating_vl, rating_vr, all_pos
)
if (
this_player.description in this_player.cardset.name
and this_player.cardset.id not in [23]
):
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = refractor_tier
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
browser = await get_browser()
page = await browser.new_page(viewport={"width": 1280, "height": 720})
try:
await generate_animated_card(
page,
html_response.body.decode("UTF-8"),
cache_path,
refractor_tier,
)
finally:
await page.close()
return FileResponse(path=cache_path, media_type="image/apng", headers=headers)
@router.get("/{player_id}/{card_type}card") @router.get("/{player_id}/{card_type}card")
@router.get("/{player_id}/{card_type}card/{d}") @router.get("/{player_id}/{card_type}card/{d}")
@router.get("/{player_id}/{card_type}card/{d}/{variant}") @router.get("/{player_id}/{card_type}card/{d}/{variant}")
async def get_batter_card( async def get_batter_card(
request: Request, request: Request,
background_tasks: BackgroundTasks,
player_id: int, player_id: int,
card_type: Literal["batting", "pitching"], card_type: Literal["batting", "pitching"],
variant: int = 0, variant: int = 0,
d: str = None, d: str = None,
html: Optional[bool] = False, html: Optional[bool] = False,
tier: Optional[int] = Query(
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
),
): ):
try: try:
this_player = Player.get_by_id(player_id) this_player = Player.get_by_id(player_id)
@ -740,6 +894,7 @@ async def get_batter_card(
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"
) )
and html is False and html is False
and tier is None
): ):
return FileResponse( return FileResponse(
path=f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png", path=f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png",
@ -786,6 +941,9 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name card_data["cardset_name"] = this_player.cardset.name
else: else:
card_data["cardset_name"] = this_player.description card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
card_data["request"] = request card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data) html_response = templates.TemplateResponse("player_card.html", card_data)
@ -823,6 +981,9 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name card_data["cardset_name"] = this_player.cardset.name
else: else:
card_data["cardset_name"] = this_player.description card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
card_data["request"] = request card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data) html_response = templates.TemplateResponse("player_card.html", card_data)
@ -882,6 +1043,27 @@ async def get_batter_card(
# save_as=f'{player_id}-{d}-v{variant}.png' # save_as=f'{player_id}-{d}-v{variant}.png'
# ) # )
# Schedule S3 upload for variant cards that don't have an image_url yet.
# Skip when tier is overridden (?tier= dev preview) — those renders don't
# correspond to real variant card rows.
if variant > 0 and tier is None:
CardModel = BattingCard if card_type == "batting" else PitchingCard
try:
card_row = CardModel.get(
(CardModel.player_id == player_id) & (CardModel.variant == variant)
)
if card_row.image_url is None:
background_tasks.add_task(
backfill_variant_image_url,
player_id=player_id,
variant=variant,
card_type=card_type,
cardset_id=this_player.cardset.id,
png_path=file_path,
)
except CardModel.DoesNotExist:
pass
return FileResponse(path=file_path, media_type="image/png", headers=headers) return FileResponse(path=file_path, media_type="image/png", headers=headers)

View File

@ -1,9 +1,13 @@
import os
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
import logging import logging
from typing import Optional from typing import Optional
from ..db_engine import model_to_dict from ..db_engine import model_to_dict, BattingCard, PitchingCard
from ..dependencies import oauth2_scheme, valid_token from ..dependencies import oauth2_scheme, valid_token
from ..services.refractor_init import initialize_card_refractor, _determine_card_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,15 +24,20 @@ _NEXT_THRESHOLD_ATTR = {
4: None, 4: None,
} }
# Sentinel used by _build_card_state_response to distinguish "caller did not
# pass image_url" (do the DB lookup) from "caller passed None" (use None).
_UNSET = object()
def _build_card_state_response(state) -> dict:
def _build_card_state_response(state, player_name=None, image_url=_UNSET) -> dict:
"""Serialise a RefractorCardState into the standard API response shape. """Serialise a RefractorCardState into the standard API response shape.
Produces a flat dict with player_id and team_id as plain integers, Produces a flat dict with player_id and team_id as plain integers,
a nested 'track' dict with all threshold fields, and a computed a nested 'track' dict with all threshold fields, and computed fields:
'next_threshold' field: - 'next_threshold': threshold for the tier immediately above (None when fully evolved).
- For tiers 0-3: the threshold value for the tier immediately above. - 'progress_pct': current_value / next_threshold * 100, rounded to 1 decimal
- For tier 4 (fully evolved): None. (None when fully evolved or next_threshold is zero).
- 'player_name': included when passed (e.g. from a list join); omitted otherwise.
Uses model_to_dict(recurse=False) internally so FK fields are returned Uses model_to_dict(recurse=False) internally so FK fields are returned
as IDs rather than nested objects, then promotes the needed IDs up to as IDs rather than nested objects, then promotes the needed IDs up to
@ -40,19 +49,54 @@ def _build_card_state_response(state) -> dict:
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier) next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
next_threshold = getattr(track, next_attr) if next_attr else None next_threshold = getattr(track, next_attr) if next_attr else None
return { progress_pct = None
if next_threshold is not None and next_threshold > 0:
progress_pct = round((state.current_value / next_threshold) * 100, 1)
result = {
"player_id": state.player_id, "player_id": state.player_id,
"team_id": state.team_id, "team_id": state.team_id,
"current_tier": state.current_tier, "current_tier": state.current_tier,
"current_value": state.current_value, "current_value": state.current_value,
"fully_evolved": state.fully_evolved, "fully_evolved": state.fully_evolved,
"last_evaluated_at": ( "last_evaluated_at": (
state.last_evaluated_at.isoformat() if state.last_evaluated_at else None state.last_evaluated_at.isoformat()
if hasattr(state.last_evaluated_at, "isoformat")
else state.last_evaluated_at or None
), ),
"track": track_dict, "track": track_dict,
"next_threshold": next_threshold, "next_threshold": next_threshold,
"progress_pct": progress_pct,
} }
if player_name is not None:
result["player_name"] = player_name
# Resolve image_url from the variant card row.
# When image_url is pre-fetched by the caller (batch list path), it is
# passed directly and the per-row DB query is skipped entirely.
if image_url is _UNSET:
image_url = None
if state.variant and state.variant > 0:
card_type = (
state.track.card_type
if hasattr(state, "track") and state.track
else None
)
if card_type:
CardModel = BattingCard if card_type == "batter" else PitchingCard
try:
variant_card = CardModel.get(
(CardModel.player_id == state.player_id)
& (CardModel.variant == state.variant)
)
image_url = variant_card.image_url
except CardModel.DoesNotExist:
pass
result["image_url"] = image_url
return result
@router.get("/tracks") @router.get("/tracks")
async def list_tracks( async def list_tracks(
@ -89,6 +133,154 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
return model_to_dict(track, recurse=False) return model_to_dict(track, recurse=False)
@router.get("/cards")
async def list_card_states(
team_id: int = Query(...),
card_type: Optional[str] = Query(default=None),
tier: Optional[int] = Query(default=None, ge=0, le=4),
season: Optional[int] = Query(default=None),
progress: Optional[str] = Query(default=None),
evaluated_only: bool = Query(default=True),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0),
token: str = Depends(oauth2_scheme),
):
"""List RefractorCardState rows for a team, with optional filters and pagination.
Required:
team_id -- filter to this team's cards; returns empty list if team has no states
Optional filters:
card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type
tier -- filter by current_tier (0-4)
season -- filter to players who have batting or pitching season stats in that
season (EXISTS subquery against batting/pitching_season_stats)
progress -- 'close' = only cards within 80% of their next tier threshold;
fully evolved cards are always excluded from this filter
evaluated_only -- default True; when True, excludes cards where last_evaluated_at
is NULL (cards created but never run through the evaluator).
Set to False to include all rows, including zero-value placeholders.
Pagination:
limit -- page size (1-100, default 10)
offset -- items to skip (default 0)
Response: {"count": N, "items": [...]}
count is the total matching rows before limit/offset.
Each item includes player_name and progress_pct in addition to the
standard single-card response fields.
Sort order: current_tier DESC, current_value DESC.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import (
RefractorCardState,
RefractorTrack,
Player,
BattingSeasonStats,
PitchingSeasonStats,
fn,
Case,
JOIN,
)
query = (
RefractorCardState.select(RefractorCardState, RefractorTrack, Player)
.join(RefractorTrack)
.switch(RefractorCardState)
.join(
Player, JOIN.LEFT_OUTER, on=(RefractorCardState.player == Player.player_id)
)
.where(RefractorCardState.team == team_id)
.order_by(
RefractorCardState.current_tier.desc(),
RefractorCardState.current_value.desc(),
)
)
if card_type is not None:
query = query.where(RefractorTrack.card_type == card_type)
if tier is not None:
query = query.where(RefractorCardState.current_tier == tier)
if season is not None:
batter_exists = BattingSeasonStats.select().where(
(BattingSeasonStats.player == RefractorCardState.player)
& (BattingSeasonStats.team == RefractorCardState.team)
& (BattingSeasonStats.season == season)
)
pitcher_exists = PitchingSeasonStats.select().where(
(PitchingSeasonStats.player == RefractorCardState.player)
& (PitchingSeasonStats.team == RefractorCardState.team)
& (PitchingSeasonStats.season == season)
)
query = query.where(fn.EXISTS(batter_exists) | fn.EXISTS(pitcher_exists))
if progress == "close":
next_threshold_expr = Case(
RefractorCardState.current_tier,
(
(0, RefractorTrack.t1_threshold),
(1, RefractorTrack.t2_threshold),
(2, RefractorTrack.t3_threshold),
(3, RefractorTrack.t4_threshold),
),
None,
)
query = query.where(
(RefractorCardState.fully_evolved == False) # noqa: E712
& (RefractorCardState.current_value >= next_threshold_expr * 0.8)
)
if evaluated_only:
query = query.where(RefractorCardState.last_evaluated_at.is_null(False))
total = query.count() or 0
states_page = list(query.offset(offset).limit(limit))
# Pre-fetch image_urls in at most 2 bulk queries (one per card table) so
# that _build_card_state_response never issues a per-row CardModel.get().
batter_pids: set[int] = set()
pitcher_pids: set[int] = set()
for state in states_page:
if state.variant and state.variant > 0:
card_type = state.track.card_type if state.track else None
if card_type == "batter":
batter_pids.add(state.player_id)
elif card_type in ("sp", "rp"):
pitcher_pids.add(state.player_id)
image_url_map: dict[tuple[int, int], str | None] = {}
if batter_pids:
for card in BattingCard.select().where(BattingCard.player_id.in_(batter_pids)):
image_url_map[(card.player_id, card.variant)] = card.image_url
if pitcher_pids:
for card in PitchingCard.select().where(
PitchingCard.player_id.in_(pitcher_pids)
):
image_url_map[(card.player_id, card.variant)] = card.image_url
items = []
for state in states_page:
player_name = None
try:
player_name = state.player.p_name
except Exception:
pass
img_url = image_url_map.get((state.player_id, state.variant))
items.append(
_build_card_state_response(
state, player_name=player_name, image_url=img_url
)
)
return {"count": total, "items": items}
@router.get("/cards/{card_id}") @router.get("/cards/{card_id}")
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
"""Return the RefractorCardState for a card identified by its Card.id. """Return the RefractorCardState for a card identified by its Card.id.
@ -168,14 +360,16 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
Finds all unique (player_id, team_id) pairs from the game's StratPlay rows, Finds all unique (player_id, team_id) pairs from the game's StratPlay rows,
then for each pair that has a RefractorCardState, re-computes the refractor then for each pair that has a RefractorCardState, re-computes the refractor
tier. Pairs without a state row are silently skipped. Per-player errors are tier. Pairs without a state row are auto-initialized on-the-fly via
logged but do not abort the batch. initialize_card_refractor (idempotent). Per-player errors are logged but
do not abort the batch.
""" """
if not valid_token(token): if not valid_token(token):
logging.warning("Bad Token: [REDACTED]") logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay from ..db_engine import RefractorCardState, Player, StratPlay
from ..services.refractor_boost import apply_tier_boost
from ..services.refractor_evaluator import evaluate_card from ..services.refractor_evaluator import evaluate_card
plays = list(StratPlay.select().where(StratPlay.game == game_id)) plays = list(StratPlay.select().where(StratPlay.game == game_id))
@ -190,21 +384,38 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
evaluated = 0 evaluated = 0
tier_ups = [] tier_ups = []
boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false"
for player_id, team_id in pairs: for player_id, team_id in pairs:
try: try:
state = RefractorCardState.get_or_none( state = RefractorCardState.get_or_none(
(RefractorCardState.player_id == player_id) (RefractorCardState.player_id == player_id)
& (RefractorCardState.team_id == team_id) & (RefractorCardState.team_id == team_id)
) )
if state is None:
try:
player = Player.get_by_id(player_id)
card_type = _determine_card_type(player)
state = initialize_card_refractor(player_id, team_id, card_type)
except Exception:
logger.warning(
f"Refractor auto-init failed for player={player_id} "
f"team={team_id} — skipping"
)
if state is None: if state is None:
continue continue
old_tier = state.current_tier old_tier = state.current_tier
result = evaluate_card(player_id, team_id) # Use dry_run=True so that current_tier is NOT written here.
# apply_tier_boost() writes current_tier + variant atomically on
# tier-up. If no tier-up occurs, apply_tier_boost is not called
# and the tier stays at old_tier (correct behaviour).
result = evaluate_card(player_id, team_id, dry_run=True)
evaluated += 1 evaluated += 1
new_tier = result.get("current_tier", old_tier) # Use computed_tier (what the formula says) to detect tier-ups.
if new_tier > old_tier: computed_tier = result.get("computed_tier", old_tier)
if computed_tier > old_tier:
player_name = "Unknown" player_name = "Unknown"
try: try:
p = Player.get_by_id(player_id) p = Player.get_by_id(player_id)
@ -212,17 +423,72 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
except Exception: except Exception:
pass pass
tier_ups.append( # Phase 2: Apply rating boosts for each tier gained.
{ # apply_tier_boost() writes current_tier + variant atomically.
# If it fails, current_tier stays at old_tier — automatic retry next game.
boost_result = None
if not boost_enabled:
# Boost disabled via REFRACTOR_BOOST_ENABLED=false.
# Skip notification — current_tier was not written (dry_run),
# so reporting a tier-up would be a false notification.
continue
card_type = state.track.card_type if state.track else None
if card_type:
last_successful_tier = old_tier
failing_tier = old_tier + 1
try:
for tier in range(old_tier + 1, computed_tier + 1):
failing_tier = tier
boost_result = apply_tier_boost(
player_id, team_id, tier, card_type
)
last_successful_tier = tier
except Exception as boost_exc:
logger.warning(
f"Refractor boost failed for player={player_id} "
f"team={team_id} tier={failing_tier}: {boost_exc}"
)
# Report only the tiers that actually succeeded.
# If none succeeded, skip the tier_up notification entirely.
if last_successful_tier == old_tier:
continue
# At least one intermediate tier was committed; report that.
computed_tier = last_successful_tier
else:
# No card_type means no track — skip boost and skip notification.
# A false tier-up notification must not be sent when the boost
# was never applied (current_tier was never written to DB).
logger.warning(
f"Refractor boost skipped for player={player_id} "
f"team={team_id}: no card_type on track"
)
continue
tier_up_entry = {
"player_id": player_id, "player_id": player_id,
"team_id": team_id, "team_id": team_id,
"player_name": player_name, "player_name": player_name,
"old_tier": old_tier, "old_tier": old_tier,
"new_tier": new_tier, "new_tier": computed_tier,
"current_value": result.get("current_value", 0), "current_value": result.get("current_value", 0),
"track_name": state.track.name if state.track else "Unknown", "track_name": state.track.name if state.track else "Unknown",
} }
# Non-breaking addition: include boost info when available.
if boost_result:
variant_num = boost_result.get("variant_created")
tier_up_entry["variant_created"] = variant_num
if computed_tier >= 3 and variant_num and card_type:
d = date.today().strftime("%Y-%m-%d")
api_base = os.environ.get("API_BASE_URL", "").rstrip("/")
tier_up_entry["animated_url"] = (
f"{api_base}/api/v2/players/{player_id}/{card_type}card"
f"/{d}/{variant_num}/animated"
) )
tier_ups.append(tier_up_entry)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
f"Refractor eval failed for player={player_id} team={team_id}: {exc}" f"Refractor eval failed for player={player_id} team={team_id}: {exc}"

View File

@ -0,0 +1,125 @@
"""
APNG animated card generation for T3 and T4 refractor tiers.
Captures animation frames by scrubbing CSS animations via Playwright each
frame is rendered with a negative animation-delay that freezes the render at a
specific point in the animation cycle. The captured PNGs are then assembled
into a looping APNG using the apng library.
Cache / S3 path convention:
Local: storage/cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
S3: cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
"""
import os
import tempfile
from apng import APNG
from playwright.async_api import Page
# ---------------------------------------------------------------------------
# Animation specs per tier
# Each entry: list of (css_selector, animation_duration_seconds) pairs that
# need to be scrubbed, plus the frame count and per-frame display time.
# ---------------------------------------------------------------------------
_T3_SPEC = {
"selectors_and_durations": [("#header::after", 2.5)],
"num_frames": 12,
"frame_delay_ms": 200,
}
_T4_SPEC = {
"selectors_and_durations": [
("#header::after", 6.0),
(".tier-diamond.diamond-glow", 2.0),
],
"num_frames": 24,
"frame_delay_ms": 250,
}
ANIM_SPECS = {3: _T3_SPEC, 4: _T4_SPEC}
def apng_cache_path(
cardset_id: int, card_type: str, player_id: int, d: str, variant: int
) -> str:
"""Return the local filesystem cache path for an animated card APNG."""
return f"storage/cards/cardset-{cardset_id}/{card_type}/{player_id}-{d}-v{variant}.apng"
async def generate_animated_card(
page: Page,
html_content: str,
output_path: str,
tier: int,
) -> None:
"""Generate an animated APNG for a T3 or T4 refractor card.
Scrubs each CSS animation by injecting an override <style> tag that sets
animation-play-state: running and a negative animation-delay, freezing the
render at evenly-spaced intervals across one animation cycle. The captured
frames are assembled into a looping APNG at output_path.
Args:
page: An open Playwright page (caller is responsible for lifecycle).
html_content: Rendered card HTML string (from TemplateResponse.body).
output_path: Destination path for the .apng file.
tier: Refractor tier must be 3 or 4.
Raises:
ValueError: If tier is not 3 or 4.
"""
spec = ANIM_SPECS.get(tier)
if spec is None:
raise ValueError(
f"No animation spec for tier {tier}; animated cards are T3 and T4 only"
)
num_frames = spec["num_frames"]
frame_delay_ms = spec["frame_delay_ms"]
selectors_and_durations = spec["selectors_and_durations"]
frame_paths: list[str] = []
try:
for i in range(num_frames):
progress = i / num_frames # 0.0 .. (N-1)/N, seamless loop
await page.set_content(html_content)
# Inject override CSS: unpauses animation and seeks to frame offset
css_parts = []
for selector, duration in selectors_and_durations:
delay_s = -progress * duration
css_parts.append(
f"{selector} {{"
f" animation-play-state: running !important;"
f" animation-delay: {delay_s:.4f}s !important;"
f" }}"
)
await page.add_style_tag(content="\n".join(css_parts))
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.close()
await page.screenshot(
path=tmp.name,
type="png",
clip={"x": 0.0, "y": 0, "width": 1200, "height": 600},
)
frame_paths.append(tmp.name)
dir_path = os.path.dirname(output_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
apng_obj = APNG()
for frame_path in frame_paths:
# delay/delay_den is the frame display time in seconds as a fraction
apng_obj.append_file(frame_path, delay=frame_delay_ms, delay_den=1000)
apng_obj.save(output_path)
finally:
for path in frame_paths:
try:
os.unlink(path)
except OSError:
pass

View File

@ -0,0 +1,198 @@
"""
card_storage.py S3 upload utility for variant card images.
Public API
----------
get_s3_client()
Create and return a boto3 S3 client using ambient AWS credentials
(environment variables or instance profile).
build_s3_key(cardset_id, player_id, variant, card_type)
Construct the S3 object key for a variant card image.
build_s3_url(s3_key, render_date)
Return the full HTTPS S3 URL with a cache-busting date query param.
upload_card_to_s3(s3_client, png_bytes, s3_key)
Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers.
backfill_variant_image_url(player_id, variant, card_type, cardset_id, png_path)
End-to-end: read PNG from disk, upload to S3, update BattingCard or
PitchingCard.image_url in the database. All exceptions are caught and
logged; this function never raises (safe to call as a background task).
Design notes
------------
- S3 credentials are resolved from the environment by boto3 at call time;
no credentials are hard-coded here.
- The cache-bust ?d= param matches the card-creation pipeline convention so
that clients can compare URLs across pipelines.
"""
import logging
import os
from datetime import date
import boto3
from app.db_engine import BattingCard, PitchingCard
logger = logging.getLogger(__name__)
S3_BUCKET = os.environ.get("S3_BUCKET", "paper-dynasty")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
def get_s3_client():
"""Create and return a boto3 S3 client for the configured region.
Credentials are resolved by boto3 from the standard chain:
environment variables ~/.aws/credentials instance profile.
Returns:
A boto3 S3 client instance.
"""
return boto3.client("s3", region_name=S3_REGION)
def build_s3_key(cardset_id: int, player_id: int, variant: int, card_type: str) -> str:
"""Construct the S3 object key for a variant card image.
Key format:
cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.png
Args:
cardset_id: Numeric cardset ID (zero-padded to 3 digits).
player_id: Player ID.
variant: Variant number (0 = base, 1-4 = refractor tiers).
card_type: Either "batting" or "pitching".
Returns:
The S3 object key string.
"""
return (
f"cards/cardset-{cardset_id:03d}/player-{player_id}"
f"/v{variant}/{card_type}card.png"
)
def build_s3_url(s3_key: str, render_date: date) -> str:
"""Return the full HTTPS S3 URL for a card image with a cache-bust param.
URL format:
https://{bucket}.s3.{region}.amazonaws.com/{key}?d={date}
The ?d= query param matches the card-creation pipeline convention so that
clients invalidate their cache after each re-render.
Args:
s3_key: S3 object key (from build_s3_key).
render_date: The date the card was rendered, used for cache-busting.
Returns:
Full HTTPS URL string.
"""
base_url = f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com"
date_str = render_date.strftime("%Y-%m-%d")
return f"{base_url}/{s3_key}?d={date_str}"
def upload_card_to_s3(s3_client, png_bytes: bytes, s3_key: str) -> None:
"""Upload raw PNG bytes to S3 with the standard card image headers.
Sets ContentType=image/png and CacheControl=public, max-age=300 (5 min)
so that CDN and browser caches are refreshed within a short window after
a re-render.
Args:
s3_client: A boto3 S3 client (from get_s3_client).
png_bytes: Raw PNG image bytes.
s3_key: S3 object key (from build_s3_key).
Returns:
None
"""
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=png_bytes,
ContentType="image/png",
CacheControl="public, max-age=300",
)
def backfill_variant_image_url(
player_id: int,
variant: int,
card_type: str,
cardset_id: int,
png_path: str,
) -> None:
"""Read a rendered PNG from disk, upload it to S3, and update the DB row.
Determines the correct card model (BattingCard or PitchingCard) from
card_type, then:
1. Reads PNG bytes from png_path.
2. Uploads to S3 via upload_card_to_s3.
3. Fetches the card row by (player_id, variant).
4. Sets image_url to the new S3 URL and calls save().
All exceptions are caught and logged this function is intended to be
called as a background task and must never propagate exceptions.
Args:
player_id: Player ID used to locate the card row.
variant: Variant number (matches the card row's variant field).
card_type: "batting" or "pitching" selects the model.
cardset_id: Cardset ID used for the S3 key.
png_path: Absolute path to the rendered PNG file on disk.
Returns:
None
"""
try:
# 1. Read PNG from disk
with open(png_path, "rb") as f:
png_bytes = f.read()
# 2. Build key and upload
s3_key = build_s3_key(
cardset_id=cardset_id,
player_id=player_id,
variant=variant,
card_type=card_type,
)
s3_client = get_s3_client()
upload_card_to_s3(s3_client, png_bytes, s3_key)
# 3. Build URL with today's date for cache-busting
image_url = build_s3_url(s3_key, render_date=date.today())
# 4. Locate the card row and update image_url
if card_type == "batting":
card = BattingCard.get(
BattingCard.player_id == player_id, BattingCard.variant == variant
)
else:
card = PitchingCard.get(
PitchingCard.player_id == player_id, PitchingCard.variant == variant
)
card.image_url = image_url
card.save()
logger.info(
"backfill_variant_image_url: updated %s card player=%s variant=%s url=%s",
card_type,
player_id,
variant,
image_url,
)
except Exception:
logger.exception(
"backfill_variant_image_url: failed for player=%s variant=%s card_type=%s",
player_id,
variant,
card_type,
)

View File

@ -0,0 +1,698 @@
"""Refractor rating boost service (Phase 2).
Pure functions for computing boosted card ratings when a player
reaches a new Refractor tier. The module-level 'db' variable is used by
apply_tier_boost() for atomic writes; tests patch this reference to redirect
writes to a shared-memory SQLite database.
Batter boost: fixed +0.5 to four offensive columns per tier.
Pitcher boost: 1.5 TB-budget priority algorithm per tier.
"""
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import json
import logging
# Module-level db reference imported lazily so that this module can be
# imported before app.db_engine is fully initialised (e.g. in tests that
# patch DATABASE_TYPE before importing db_engine).
# Tests that need to redirect DB writes should patch this attribute at module
# level: `import app.services.refractor_boost as m; m.db = test_db`.
db = None
def _get_db():
"""Return the module-level db, importing lazily on first use."""
global db
if db is None:
from app.db_engine import db as _db # noqa: PLC0415
db = _db
return db
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Batter constants
# ---------------------------------------------------------------------------
BATTER_POSITIVE_DELTAS: dict[str, Decimal] = {
"homerun": Decimal("0.50"),
"double_pull": Decimal("0.50"),
"single_one": Decimal("0.50"),
"walk": Decimal("0.50"),
}
BATTER_NEGATIVE_DELTAS: dict[str, Decimal] = {
"strikeout": Decimal("-1.50"),
"groundout_a": Decimal("-0.50"),
}
# All 22 outcome columns that must sum to 108.
BATTER_OUTCOME_COLUMNS: list[str] = [
"homerun",
"bp_homerun",
"triple",
"double_three",
"double_two",
"double_pull",
"single_two",
"single_one",
"single_center",
"bp_single",
"hbp",
"walk",
"strikeout",
"lineout",
"popout",
"flyout_a",
"flyout_bq",
"flyout_lf_b",
"flyout_rf_b",
"groundout_a",
"groundout_b",
"groundout_c",
]
# ---------------------------------------------------------------------------
# Pitcher constants
# ---------------------------------------------------------------------------
# (column, tb_cost) pairs in priority order.
PITCHER_PRIORITY: list[tuple[str, int]] = [
("double_cf", 2),
("double_three", 2),
("double_two", 2),
("single_center", 1),
("single_two", 1),
("single_one", 1),
("bp_single", 1),
("walk", 1),
("homerun", 4),
("bp_homerun", 4),
("triple", 3),
("hbp", 1),
]
# All 18 variable outcome columns (sum to 79; x-checks add 29 for 108 total).
PITCHER_OUTCOME_COLUMNS: list[str] = [
"homerun",
"bp_homerun",
"triple",
"double_three",
"double_two",
"double_cf",
"single_two",
"single_one",
"single_center",
"bp_single",
"hbp",
"walk",
"strikeout",
"flyout_lf_b",
"flyout_cf_b",
"flyout_rf_b",
"groundout_a",
"groundout_b",
]
# Cross-check columns that are NEVER modified by the boost algorithm.
PITCHER_XCHECK_COLUMNS: list[str] = [
"xcheck_p",
"xcheck_c",
"xcheck_1b",
"xcheck_2b",
"xcheck_3b",
"xcheck_ss",
"xcheck_lf",
"xcheck_cf",
"xcheck_rf",
]
PITCHER_TB_BUDGET = Decimal("1.5")
# ---------------------------------------------------------------------------
# Batter boost
# ---------------------------------------------------------------------------
def apply_batter_boost(ratings_dict: dict) -> dict:
"""Apply one Refractor tier boost to a batter's outcome ratings.
Adds fixed positive deltas to four offensive columns (homerun, double_pull,
single_one, walk) while funding that increase by reducing strikeout and
groundout_a. A 0-floor is enforced on negative columns: if the full
reduction cannot be taken, positive deltas are scaled proportionally so that
the invariant (22 columns sum to 108.0) is always preserved.
Args:
ratings_dict: Dict containing at minimum all 22 BATTER_OUTCOME_COLUMNS
as numeric (int or float) values.
Returns:
New dict with the same keys as ratings_dict, with boosted outcome column
values as floats. All other keys are passed through unchanged.
Raises:
KeyError: If any BATTER_OUTCOME_COLUMNS key is missing from ratings_dict.
"""
result = dict(ratings_dict)
# Step 1 — convert the 22 outcome columns to Decimal for precise arithmetic.
ratings: dict[str, Decimal] = {
col: Decimal(str(result[col])) for col in BATTER_OUTCOME_COLUMNS
}
# Step 2 — apply negative deltas with 0-floor, tracking how much was
# actually removed versus how much was requested.
total_requested_reduction = Decimal("0")
total_actually_reduced = Decimal("0")
for col, delta in BATTER_NEGATIVE_DELTAS.items():
requested = abs(delta)
total_requested_reduction += requested
actual = min(requested, ratings[col])
ratings[col] -= actual
total_actually_reduced += actual
# Step 3 — check whether any truncation occurred.
total_truncated = total_requested_reduction - total_actually_reduced
# Step 4 — scale positive deltas if we couldn't take the full reduction.
if total_truncated > Decimal("0"):
# Positive additions must equal what was actually reduced so the
# 108-sum is preserved.
total_requested_addition = sum(BATTER_POSITIVE_DELTAS.values())
if total_requested_addition > Decimal("0"):
scale = total_actually_reduced / total_requested_addition
else:
scale = Decimal("0")
logger.warning(
"refractor_boost: batter truncation occurred — "
"requested_reduction=%.4f actually_reduced=%.4f scale=%.6f",
float(total_requested_reduction),
float(total_actually_reduced),
float(scale),
)
# Quantize the first N-1 deltas independently, then assign the last
# delta as the remainder so the total addition equals
# total_actually_reduced exactly (no quantize drift across 4 ops).
pos_cols = list(BATTER_POSITIVE_DELTAS.keys())
positive_deltas = {}
running_sum = Decimal("0")
for col in pos_cols[:-1]:
scaled = (BATTER_POSITIVE_DELTAS[col] * scale).quantize(
Decimal("0.000001"), rounding=ROUND_HALF_UP
)
positive_deltas[col] = scaled
running_sum += scaled
last_delta = total_actually_reduced - running_sum
positive_deltas[pos_cols[-1]] = max(last_delta, Decimal("0"))
else:
positive_deltas = BATTER_POSITIVE_DELTAS
# Step 5 — apply (possibly scaled) positive deltas.
for col, delta in positive_deltas.items():
ratings[col] += delta
# Write boosted values back as floats.
for col in BATTER_OUTCOME_COLUMNS:
result[col] = float(ratings[col])
return result
# ---------------------------------------------------------------------------
# Pitcher boost
# ---------------------------------------------------------------------------
def apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict:
"""Apply one Refractor tier boost to a pitcher's outcome ratings.
Iterates through PITCHER_PRIORITY in order, converting as many outcome
chances as the TB budget allows into strikeouts. The TB cost per chance
varies by outcome type (e.g. a double costs 2 TB budget units, a single
costs 1). The strikeout column absorbs all converted chances.
X-check columns (xcheck_p through xcheck_rf) are never touched.
Args:
ratings_dict: Dict containing at minimum all 18 PITCHER_OUTCOME_COLUMNS
as numeric (int or float) values.
tb_budget: Total base budget available for this boost tier. Defaults
to 1.5 (PITCHER_TB_BUDGET).
Returns:
New dict with the same keys as ratings_dict, with boosted outcome column
values as floats. All other keys are passed through unchanged.
Raises:
KeyError: If any PITCHER_OUTCOME_COLUMNS key is missing from ratings_dict.
"""
result = dict(ratings_dict)
# Step 1 — convert outcome columns to Decimal, set remaining budget.
ratings: dict[str, Decimal] = {
col: Decimal(str(result[col])) for col in PITCHER_OUTCOME_COLUMNS
}
remaining = Decimal(str(tb_budget))
# Step 2 — iterate priority list, draining budget.
for col, tb_cost in PITCHER_PRIORITY:
if ratings[col] <= Decimal("0"):
continue
tb_cost_d = Decimal(str(tb_cost))
max_chances = remaining / tb_cost_d
chances_to_take = min(ratings[col], max_chances)
ratings[col] -= chances_to_take
ratings["strikeout"] += chances_to_take
remaining -= chances_to_take * tb_cost_d
if remaining <= Decimal("0"):
break
# Step 3 — warn if budget was not fully spent (rare, indicates all priority
# columns were already at zero).
if remaining > Decimal("0"):
logger.warning(
"refractor_boost: pitcher TB budget not fully spent — "
"remaining=%.4f of tb_budget=%.4f",
float(remaining),
tb_budget,
)
# Write boosted values back as floats.
for col in PITCHER_OUTCOME_COLUMNS:
result[col] = float(ratings[col])
return result
# ---------------------------------------------------------------------------
# Variant hash
# ---------------------------------------------------------------------------
def compute_variant_hash(
player_id: int,
refractor_tier: int,
cosmetics: list[str] | None = None,
) -> int:
"""Compute a stable, deterministic variant identifier for a boosted card.
Hashes the combination of player_id, refractor_tier, and an optional sorted
list of cosmetic identifiers to produce a compact integer suitable for use
as a database variant key. The result is derived from the first 8 hex
characters of a SHA-256 digest, so collisions are extremely unlikely in
practice.
variant=0 is reserved and will never be returned; any hash that resolves to
0 is remapped to 1.
Args:
player_id: Player primary key.
refractor_tier: Refractor tier (04) the card has reached.
cosmetics: Optional list of cosmetic tag strings (e.g. special art
identifiers). Order is normalised callers need not sort.
Returns:
A positive integer in the range [1, 2^32 - 1].
"""
inputs = {
"player_id": player_id,
"refractor_tier": refractor_tier,
"cosmetics": sorted(cosmetics or []),
}
raw = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
result = int(raw[:8], 16)
return result if result != 0 else 1 # variant=0 is reserved
# ---------------------------------------------------------------------------
# Display stat helpers
# ---------------------------------------------------------------------------
def compute_batter_display_stats(ratings: dict) -> dict:
"""Compute avg/obp/slg from batter outcome columns.
Uses the same formulas as the BattingCardRatingsModel Pydantic validator
so that variant card display stats are always consistent with the boosted
chance values. All denominators are 108 (the full card chance total).
Args:
ratings: Dict containing at minimum all BATTER_OUTCOME_COLUMNS as
numeric (int or float) values.
Returns:
Dict with keys 'avg', 'obp', 'slg' as floats.
"""
avg = (
ratings["homerun"]
+ ratings["bp_homerun"] / 2
+ ratings["triple"]
+ ratings["double_three"]
+ ratings["double_two"]
+ ratings["double_pull"]
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg
slg = (
ratings["homerun"] * 4
+ ratings["bp_homerun"] * 2
+ ratings["triple"] * 3
+ ratings["double_three"] * 2
+ ratings["double_two"] * 2
+ ratings["double_pull"] * 2
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
return {"avg": avg, "obp": obp, "slg": slg}
def compute_pitcher_display_stats(ratings: dict) -> dict:
"""Compute avg/obp/slg from pitcher outcome columns.
Uses the same formulas as the PitchingCardRatingsModel Pydantic validator
so that variant card display stats are always consistent with the boosted
chance values. All denominators are 108 (the full card chance total).
Args:
ratings: Dict containing at minimum all PITCHER_OUTCOME_COLUMNS as
numeric (int or float) values.
Returns:
Dict with keys 'avg', 'obp', 'slg' as floats.
"""
avg = (
ratings["homerun"]
+ ratings["bp_homerun"] / 2
+ ratings["triple"]
+ ratings["double_three"]
+ ratings["double_two"]
+ ratings["double_cf"]
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg
slg = (
ratings["homerun"] * 4
+ ratings["bp_homerun"] * 2
+ ratings["triple"] * 3
+ ratings["double_three"] * 2
+ ratings["double_two"] * 2
+ ratings["double_cf"] * 2
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
return {"avg": avg, "obp": obp, "slg": slg}
# ---------------------------------------------------------------------------
# Orchestration: apply_tier_boost
# ---------------------------------------------------------------------------
def apply_tier_boost(
player_id: int,
team_id: int,
new_tier: int,
card_type: str,
_batting_card_model=None,
_batting_ratings_model=None,
_pitching_card_model=None,
_pitching_ratings_model=None,
_card_model=None,
_state_model=None,
_audit_model=None,
) -> dict:
"""Create a boosted variant card for a tier-up.
IMPORTANT: This function is the SOLE writer of current_tier on
RefractorCardState when a tier-up occurs. The evaluator computes
the new tier but does NOT write it this function writes tier +
variant + audit atomically inside a single db.atomic() block.
If this function fails, the tier stays at its old value and will
be retried on the next game evaluation.
Orchestrates the full flow (card creation outside atomic; state
mutations inside db.atomic()):
1. Determine source variant (variant=0 for T1, previous tier's hash for T2+)
2. Fetch source card and ratings rows
3. Apply boost formula (batter or pitcher) per vs_hand split
4. Assert 108-sum after boost for both batters and pitchers
5. Compute new variant hash
6. Create new card row with new variant (idempotency: skip if exists)
7. Create new ratings rows for both vs_hand splits (idempotency: skip if exists)
8. Inside db.atomic():
a. Write RefractorBoostAudit record
b. Update RefractorCardState: current_tier, variant, fully_evolved
c. Propagate variant to all Card rows for (player_id, team_id)
Args:
player_id: Player primary key.
team_id: Team primary key.
new_tier: The tier being reached (1-4).
card_type: One of 'batter', 'sp', 'rp'.
_batting_card_model: Injectable stub for BattingCard (used in tests).
_batting_ratings_model: Injectable stub for BattingCardRatings.
_pitching_card_model: Injectable stub for PitchingCard.
_pitching_ratings_model: Injectable stub for PitchingCardRatings.
_card_model: Injectable stub for Card.
_state_model: Injectable stub for RefractorCardState.
_audit_model: Injectable stub for RefractorBoostAudit.
Returns:
Dict with 'variant_created' (int) and 'boost_deltas' (per-split dict).
Raises:
ValueError: If the source card or ratings are missing, or if
RefractorCardState is not found for (player_id, team_id).
"""
# Lazy model imports — same pattern as refractor_evaluator.py.
if _batting_card_model is None:
from app.db_engine import BattingCard as _batting_card_model # noqa: PLC0415
if _batting_ratings_model is None:
from app.db_engine import BattingCardRatings as _batting_ratings_model # noqa: PLC0415
if _pitching_card_model is None:
from app.db_engine import PitchingCard as _pitching_card_model # noqa: PLC0415
if _pitching_ratings_model is None:
from app.db_engine import PitchingCardRatings as _pitching_ratings_model # noqa: PLC0415
if _card_model is None:
from app.db_engine import Card as _card_model # noqa: PLC0415
if _state_model is None:
from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415
if _audit_model is None:
from app.db_engine import RefractorBoostAudit as _audit_model # noqa: PLC0415
_db = _get_db()
if card_type not in ("batter", "sp", "rp"):
raise ValueError(
f"Invalid card_type={card_type!r}; expected one of 'batter', 'sp', 'rp'"
)
is_batter = card_type == "batter"
CardModel = _batting_card_model if is_batter else _pitching_card_model
RatingsModel = _batting_ratings_model if is_batter else _pitching_ratings_model
fk_field = "battingcard" if is_batter else "pitchingcard"
# 1. Determine source variant.
if new_tier == 1:
source_variant = 0
else:
source_variant = compute_variant_hash(player_id, new_tier - 1)
# 2. Fetch source card and ratings rows.
source_card = CardModel.get_or_none(
(CardModel.player == player_id) & (CardModel.variant == source_variant)
)
if source_card is None:
raise ValueError(
f"No {'batting' if is_batter else 'pitching'}card for "
f"player={player_id} variant={source_variant}"
)
ratings_rows = list(
RatingsModel.select().where(getattr(RatingsModel, fk_field) == source_card.id)
)
if not ratings_rows:
raise ValueError(f"No ratings rows for card_id={source_card.id}")
# 3. Apply boost to each vs_hand split.
boost_fn = apply_batter_boost if is_batter else apply_pitcher_boost
outcome_cols = BATTER_OUTCOME_COLUMNS if is_batter else PITCHER_OUTCOME_COLUMNS
boosted_splits: dict[str, dict] = {}
for row in ratings_rows:
# Build the ratings dict: outcome columns + (pitcher) x-check columns.
ratings_dict: dict = {col: getattr(row, col) for col in outcome_cols}
if not is_batter:
for col in PITCHER_XCHECK_COLUMNS:
ratings_dict[col] = getattr(row, col)
boosted = boost_fn(ratings_dict)
# 4. Assert 108-sum invariant after boost (Peewee bypasses Pydantic validators).
if is_batter:
boosted_sum = sum(boosted[col] for col in BATTER_OUTCOME_COLUMNS)
else:
boosted_sum = sum(boosted[col] for col in PITCHER_OUTCOME_COLUMNS) + sum(
boosted[col] for col in PITCHER_XCHECK_COLUMNS
)
if abs(boosted_sum - 108.0) >= 0.01:
raise ValueError(
f"108-sum invariant violated after boost for player={player_id} "
f"vs_hand={row.vs_hand}: sum={boosted_sum:.6f}"
)
boosted_splits[row.vs_hand] = boosted
# 5. Compute new variant hash.
new_variant = compute_variant_hash(player_id, new_tier)
# 6. Create new card row (idempotency: skip if exists).
existing_card = CardModel.get_or_none(
(CardModel.player == player_id) & (CardModel.variant == new_variant)
)
if existing_card is not None:
new_card = existing_card
else:
if is_batter:
clone_fields = [
"steal_low",
"steal_high",
"steal_auto",
"steal_jump",
"bunting",
"hit_and_run",
"running",
"offense_col",
"hand",
]
else:
clone_fields = [
"balk",
"wild_pitch",
"hold",
"starter_rating",
"relief_rating",
"closer_rating",
"batting",
"offense_col",
"hand",
]
card_data: dict = {
"player": player_id,
"variant": new_variant,
"image_url": None, # No rendered image for variant cards yet.
}
for fname in clone_fields:
card_data[fname] = getattr(source_card, fname)
new_card = CardModel.create(**card_data)
# 7. Create new ratings rows for each split (idempotency: skip if exists).
display_stats_fn = (
compute_batter_display_stats if is_batter else compute_pitcher_display_stats
)
for vs_hand, boosted_ratings in boosted_splits.items():
existing_ratings = RatingsModel.get_or_none(
(getattr(RatingsModel, fk_field) == new_card.id)
& (RatingsModel.vs_hand == vs_hand)
)
if existing_ratings is not None:
continue # Idempotency: already written.
ratings_data: dict = {
fk_field: new_card.id,
"vs_hand": vs_hand,
}
# Outcome columns (boosted values).
ratings_data.update({col: boosted_ratings[col] for col in outcome_cols})
# X-check columns for pitchers (unchanged by boost, copy from boosted dict).
if not is_batter:
for col in PITCHER_XCHECK_COLUMNS:
ratings_data[col] = boosted_ratings[col]
# Direction rates for batters: copy from source row.
if is_batter:
source_row = next(r for r in ratings_rows if r.vs_hand == vs_hand)
for rate_col in ("pull_rate", "center_rate", "slap_rate"):
ratings_data[rate_col] = getattr(source_row, rate_col)
# Compute fresh display stats from boosted chance columns.
display_stats = display_stats_fn(boosted_ratings)
ratings_data.update(display_stats)
RatingsModel.create(**ratings_data)
# 8. Load card state — needed for atomic state mutations.
card_state = _state_model.get_or_none(
(_state_model.player == player_id) & (_state_model.team == team_id)
)
if card_state is None:
raise ValueError(
f"No refractor_card_state for player={player_id} team={team_id}"
)
# All state mutations in a single atomic block.
with _db.atomic():
# 8a. Write audit record.
# boost_delta_json stores per-split boosted values including x-check columns
# for pitchers so the full card can be reconstructed from the audit.
audit_data: dict = {
"card_state": card_state.id,
"tier": new_tier,
"variant_created": new_variant,
"boost_delta_json": json.dumps(boosted_splits, default=str),
}
if is_batter:
audit_data["battingcard"] = new_card.id
else:
audit_data["pitchingcard"] = new_card.id
existing_audit = _audit_model.get_or_none(
(_audit_model.card_state == card_state.id) & (_audit_model.tier == new_tier)
)
if existing_audit is None:
_audit_model.create(**audit_data)
# 8b. Update RefractorCardState — this is the SOLE tier write on tier-up.
card_state.current_tier = new_tier
card_state.fully_evolved = new_tier >= 4
card_state.variant = new_variant
card_state.save()
# 8c. Propagate variant to all Card rows for (player_id, team_id).
_card_model.update(variant=new_variant).where(
(_card_model.player == player_id) & (_card_model.team == team_id)
).execute()
logger.debug(
"refractor_boost: applied T%s boost for player=%s team=%s variant=%s",
new_tier,
player_id,
team_id,
new_variant,
)
return {
"variant_created": new_variant,
"boost_deltas": dict(boosted_splits),
}

View File

@ -9,9 +9,20 @@ evaluate_card() is the main entry point:
4. Compare value to track thresholds to determine new_tier 4. Compare value to track thresholds to determine new_tier
5. Update card_state.current_value = computed value 5. Update card_state.current_value = computed value
6. Update card_state.current_tier = max(current_tier, new_tier) no regression 6. Update card_state.current_tier = max(current_tier, new_tier) no regression
7. Update card_state.fully_evolved = (new_tier >= 4) (SKIPPED when dry_run=True)
7. Update card_state.fully_evolved = (current_tier >= 4)
(SKIPPED when dry_run=True)
8. Update card_state.last_evaluated_at = NOW() 8. Update card_state.last_evaluated_at = NOW()
When dry_run=True, only steps 5 and 8 are written (current_value and
last_evaluated_at). Steps 67 (current_tier and fully_evolved) are intentionally
skipped so that the evaluate-game endpoint can detect a pending tier-up and
delegate the tier write to apply_tier_boost(), which writes tier + variant
atomically. The return dict always includes both "computed_tier" (what the
formula says the tier should be) and "computed_fully_evolved" (whether the
computed tier implies full evolution) so callers can make decisions without
reading the database again.
Idempotent: calling multiple times with the same data produces the same result. Idempotent: calling multiple times with the same data produces the same result.
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats), Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
@ -47,6 +58,7 @@ class _CareerTotals:
def evaluate_card( def evaluate_card(
player_id: int, player_id: int,
team_id: int, team_id: int,
dry_run: bool = False,
_stats_model=None, _stats_model=None,
_state_model=None, _state_model=None,
_compute_value_fn=None, _compute_value_fn=None,
@ -56,15 +68,26 @@ def evaluate_card(
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
card_type) for (player_id, team_id) across all seasons, then delegates card_type) for (player_id, team_id) across all seasons, then delegates
formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and formula computation and tier classification to the formula engine. The
returned as a dict. result is written back to refractor_card_state and returned as a dict.
current_tier never decreases (no regression): current_tier never decreases (no regression):
card_state.current_tier = max(card_state.current_tier, new_tier) card_state.current_tier = max(card_state.current_tier, new_tier)
When dry_run=True, only current_value and last_evaluated_at are written
current_tier and fully_evolved are NOT updated. This allows the caller
(evaluate-game endpoint) to detect a tier-up and delegate the tier write
to apply_tier_boost(), which writes tier + variant atomically. The return
dict always includes "computed_tier" (what the formula says the tier should
be) in addition to "current_tier" (what is actually stored in the DB).
Args: Args:
player_id: Player primary key. player_id: Player primary key.
team_id: Team primary key. team_id: Team primary key.
dry_run: When True, skip writing current_tier and fully_evolved so
that apply_tier_boost() can write them atomically with variant
creation. Defaults to False (existing behaviour for the manual
/evaluate endpoint).
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats _stats_model: Override for BattingSeasonStats/PitchingSeasonStats
(used in tests to inject a stub model with all stat fields). (used in tests to inject a stub model with all stat fields).
_state_model: Override for RefractorCardState (used in tests to avoid _state_model: Override for RefractorCardState (used in tests to avoid
@ -75,8 +98,10 @@ def evaluate_card(
(used in tests). (used in tests).
Returns: Returns:
Dict with updated current_tier, current_value, fully_evolved, Dict with current_tier, computed_tier, current_value, fully_evolved,
last_evaluated_at (ISO-8601 string). last_evaluated_at (ISO-8601 string). "computed_tier" reflects what
the formula computed; "current_tier" reflects what is stored in the DB
(which may differ when dry_run=True and a tier-up is pending).
Raises: Raises:
ValueError: If no refractor_card_state row exists for (player_id, team_id). ValueError: If no refractor_card_state row exists for (player_id, team_id).
@ -169,21 +194,30 @@ def evaluate_card(
value = _compute_value_fn(track.card_type, totals) value = _compute_value_fn(track.card_type, totals)
new_tier = _tier_from_value_fn(value, track) new_tier = _tier_from_value_fn(value, track)
# 58. Update card state (no tier regression) # 58. Update card state.
now = datetime.now() now = datetime.now()
computed_tier = new_tier
computed_fully_evolved = computed_tier >= 4
# Always update value and timestamp; current_tier and fully_evolved are
# skipped when dry_run=True so that apply_tier_boost() can write them
# atomically with variant creation on tier-up.
card_state.current_value = value card_state.current_value = value
card_state.last_evaluated_at = now
if not dry_run:
card_state.current_tier = max(card_state.current_tier, new_tier) card_state.current_tier = max(card_state.current_tier, new_tier)
card_state.fully_evolved = card_state.current_tier >= 4 card_state.fully_evolved = card_state.current_tier >= 4
card_state.last_evaluated_at = now
card_state.save() card_state.save()
logging.debug( logging.debug(
"refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s", "refractor_eval: player=%s team=%s value=%.2f computed_tier=%s "
"stored_tier=%s dry_run=%s",
player_id, player_id,
team_id, team_id,
value, value,
computed_tier,
card_state.current_tier, card_state.current_tier,
card_state.fully_evolved, dry_run,
) )
return { return {
@ -191,6 +225,8 @@ def evaluate_card(
"team_id": team_id, "team_id": team_id,
"current_value": card_state.current_value, "current_value": card_state.current_value,
"current_tier": card_state.current_tier, "current_tier": card_state.current_tier,
"computed_tier": computed_tier,
"computed_fully_evolved": computed_fully_evolved,
"fully_evolved": card_state.fully_evolved, "fully_evolved": card_state.fully_evolved,
"last_evaluated_at": card_state.last_evaluated_at.isoformat(), "last_evaluated_at": card_state.last_evaluated_at.isoformat(),
} }

View File

@ -0,0 +1,19 @@
-- Migration: Add team_id index to refractor_card_state
-- Date: 2026-03-25
--
-- Adds a non-unique index on refractor_card_state.team_id to support the new
-- GET /api/v2/refractor/cards list endpoint, which filters by team as its
-- primary discriminator and is called on every /refractor status bot command.
--
-- The existing unique index is on (player_id, team_id) with player leading,
-- so team-only queries cannot use it efficiently.
BEGIN;
CREATE INDEX IF NOT EXISTS idx_refractor_card_state_team
ON refractor_card_state (team_id);
COMMIT;
-- Rollback:
-- DROP INDEX IF EXISTS idx_refractor_card_state_team;

View File

@ -0,0 +1,47 @@
-- Migration: Refractor Phase 2 — rating boost support
-- Date: 2026-03-28
-- Purpose: Extends the Refractor system to track and audit rating boosts
-- applied at each tier-up. Adds a variant column to
-- refractor_card_state (mirrors card.variant for promoted copies)
-- and creates the refractor_boost_audit table to record the
-- boost delta, source card, and variant assigned at each tier.
--
-- Tables affected:
-- refractor_card_state — new column: variant INTEGER
-- refractor_boost_audit — new table
--
-- Run on dev first, verify with:
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name = 'refractor_card_state'
-- AND column_name = 'variant';
-- SELECT count(*) FROM refractor_boost_audit;
--
-- Rollback: See DROP/ALTER statements at bottom of file
BEGIN;
-- Verify card.variant column exists (should be from Phase 1 migration).
-- If not present, uncomment:
-- ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL;
-- New columns on refractor_card_state (additive, no data migration needed)
ALTER TABLE refractor_card_state ADD COLUMN IF NOT EXISTS variant INTEGER;
-- Boost audit table: records what was applied at each tier-up
CREATE TABLE IF NOT EXISTS refractor_boost_audit (
id SERIAL PRIMARY KEY,
card_state_id INTEGER NOT NULL REFERENCES refractor_card_state(id) ON DELETE CASCADE,
tier SMALLINT NOT NULL,
battingcard_id INTEGER REFERENCES battingcard(id),
pitchingcard_id INTEGER REFERENCES pitchingcard(id),
variant_created INTEGER NOT NULL,
boost_delta_json JSONB NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(card_state_id, tier) -- Prevent duplicate audit records on retry
);
COMMIT;
-- Rollback:
-- DROP TABLE IF EXISTS refractor_boost_audit;
-- ALTER TABLE refractor_card_state DROP COLUMN IF EXISTS variant;

View File

@ -0,0 +1,7 @@
-- Drop orphaned RefractorTierBoost and RefractorCosmetic tables.
-- These were speculative schema from the initial Refractor design that were
-- never used — boosts are hardcoded in refractor_boost.py and tier visuals
-- are embedded in CSS templates. Both tables have zero rows on dev and prod.
DROP TABLE IF EXISTS refractor_tier_boost;
DROP TABLE IF EXISTS refractor_cosmetic;

View File

@ -12,3 +12,5 @@ requests==2.32.3
html2image==2.0.6 html2image==2.0.6
jinja2==3.1.4 jinja2==3.1.4
playwright==1.45.1 playwright==1.45.1
apng==0.3.4
boto3==1.42.65

132
run-local.sh Executable file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env bash
# run-local.sh — Spin up the Paper Dynasty Database API locally for testing.
#
# Connects to the dev PostgreSQL on the homelab (10.10.0.42) so you get real
# card data for rendering. Playwright Chromium must be installed locally
# (it already is on this workstation).
#
# Usage:
# ./run-local.sh # start on default port 8000
# ./run-local.sh 8001 # start on custom port
# ./run-local.sh --stop # kill a running instance
#
# Card rendering test URLs (after startup):
# HTML preview: http://localhost:8000/api/v2/players/{id}/battingcard/{date}/{variant}?html=True
# PNG render: http://localhost:8000/api/v2/players/{id}/battingcard/{date}/{variant}
# API docs: http://localhost:8000/api/docs
set -euo pipefail
cd "$(dirname "$0")"
PORT="${1:-8000}"
PIDFILE=".run-local.pid"
LOGFILE="logs/database/run-local.log"
# ── Stop mode ────────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--stop" ]]; then
if [[ -f "$PIDFILE" ]]; then
pid=$(cat "$PIDFILE")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "Stopped local API (PID $pid)"
else
echo "PID $pid not running (stale pidfile)"
fi
rm -f "$PIDFILE"
else
echo "No pidfile found — nothing to stop"
fi
exit 0
fi
# ── Pre-flight checks ───────────────────────────────────────────────────────
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Already running (PID $(cat "$PIDFILE")). Use './run-local.sh --stop' first."
exit 1
fi
# Check Python deps are importable
python -c "import fastapi, peewee, playwright" 2>/dev/null || {
echo "Missing Python dependencies. Install with: pip install -r requirements.txt"
exit 1
}
# Check Playwright Chromium is available
python -c "
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
print(p.chromium.executable_path)
p.stop()
" >/dev/null 2>&1 || {
echo "Playwright Chromium not installed. Run: playwright install chromium"
exit 1
}
# Check dev DB is reachable
DB_HOST="${POSTGRES_HOST_LOCAL:-10.10.0.42}"
python -c "
import socket, sys
s = socket.create_connection((sys.argv[1], 5432), timeout=3)
s.close()
" "$DB_HOST" 2>/dev/null || {
echo "Cannot reach dev PostgreSQL at ${DB_HOST}:5432 — is the homelab up?"
exit 1
}
# ── Ensure directories exist ────────────────────────────────────────────────
mkdir -p logs/database
mkdir -p storage/cards
# ── Launch ───────────────────────────────────────────────────────────────────
echo "Starting Paper Dynasty Database API on http://localhost:${PORT}"
echo " DB: paperdynasty_dev @ 10.10.0.42"
echo " Logs: ${LOGFILE}"
echo ""
# Load .env, then .env.local overrides (for passwords not in version control)
set -a
# shellcheck source=/dev/null
[[ -f .env ]] && source .env
[[ -f .env.local ]] && source .env.local
set +a
# Override DB host to point at the dev server's IP (not Docker network name)
export DATABASE_TYPE=postgresql
export POSTGRES_HOST="$DB_HOST"
export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
export POSTGRES_DB="${POSTGRES_DB:-paperdynasty_dev}"
export POSTGRES_USER="${POSTGRES_USER:-sba_admin}"
export LOG_LEVEL=INFO
export TESTING=True
if [[ -z "${POSTGRES_PASSWORD:-}" || "$POSTGRES_PASSWORD" == "your_production_password" ]]; then
echo "ERROR: POSTGRES_PASSWORD not set or is the placeholder value."
echo "Create .env.local with: POSTGRES_PASSWORD=<actual password>"
exit 1
fi
uvicorn app.main:app \
--host 0.0.0.0 \
--port "$PORT" \
--reload \
--reload-dir app \
--reload-dir storage/templates \
2>&1 | tee "$LOGFILE" &
echo $! >"$PIDFILE"
sleep 2
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo ""
echo "API running (PID $(cat "$PIDFILE"))."
echo ""
echo "Quick test URLs:"
echo " API docs: http://localhost:${PORT}/api/docs"
echo " Health: curl -s http://localhost:${PORT}/api/v2/players/1/battingcard?html=True"
echo ""
echo "Stop with: ./run-local.sh --stop"
else
echo "Failed to start — check ${LOGFILE}"
rm -f "$PIDFILE"
exit 1
fi

55
scripts/README.md Normal file
View File

@ -0,0 +1,55 @@
# Scripts
Operational scripts for the Paper Dynasty Database API.
## deploy.sh
Deploy the API by tagging a commit and triggering CI/CD.
```bash
./scripts/deploy.sh dev # Tag HEAD as 'dev', CI builds :dev image
./scripts/deploy.sh prod # Create CalVer tag + 'latest' + 'production'
./scripts/deploy.sh dev abc1234 # Tag a specific commit
./scripts/deploy.sh dev --sync-templates # Deploy + push changed templates to server
```
**Template drift check** runs automatically on every deploy. Compares local `storage/templates/*.html` against the target server via md5sum and warns if any files differ. Templates are volume-mounted (not baked into the Docker image), so code deploys alone won't update them.
**Cached image report** also runs automatically, showing PNG and APNG counts on the target server.
| Environment | SSH Host | Template Path |
|---|---|---|
| dev | `pd-database` | `/home/cal/container-data/dev-pd-database/storage/templates` |
| prod | `akamai` | `/root/container-data/paper-dynasty/storage/templates` |
## clear-card-cache.sh
Inspect or clear cached rendered card images inside the API container.
```bash
./scripts/clear-card-cache.sh dev # Report cache size (dry run)
./scripts/clear-card-cache.sh dev --apng-only # Delete animated card cache only
./scripts/clear-card-cache.sh dev --all # Delete all cached card images
```
Cached images regenerate on demand when next requested. APNG files (T3/T4 animated cards) are the most likely to go stale after template CSS changes. Both destructive modes prompt for confirmation before deleting.
| Environment | SSH Host | Container | Cache Path |
|---|---|---|---|
| dev | `pd-database` | `dev_pd_database` | `/app/storage/cards/` |
| prod | `akamai` | `pd_api` | `/app/storage/cards/` |
## Migration Scripts
| Script | Purpose |
|---|---|
| `migrate_to_postgres.py` | One-time SQLite to PostgreSQL migration |
| `migrate_missing_data.py` | Backfill missing data after migration |
| `db_migrations.py` (in repo root) | Schema migrations |
## Utility Scripts
| Script | Purpose |
|---|---|
| `wipe_gauntlet_team.py` | Reset a gauntlet team's state |
| `audit_sqlite.py` | Audit legacy SQLite database |

89
scripts/clear-card-cache.sh Executable file
View File

@ -0,0 +1,89 @@
#!/bin/bash
# Clear cached card images from the API container
# Usage: ./scripts/clear-card-cache.sh <dev|prod> [--apng-only|--all]
#
# With no flags: reports cache size only (dry run)
# --apng-only: delete only .apng files (animated cards)
# --all: delete all cached card images (.png + .apng)
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
declare -A DEPLOY_HOST=([dev]="pd-database" [prod]="akamai")
declare -A CONTAINER=([dev]="dev_pd_database" [prod]="pd_api")
usage() {
echo "Usage: $0 <dev|prod> [--apng-only|--all]"
echo ""
echo " No flag Report cache size (dry run)"
echo " --apng-only Delete only .apng files (animated cards)"
echo " --all Delete all cached card images"
exit 1
}
[[ $# -lt 1 ]] && usage
ENV="$1"
ACTION="${2:-report}"
if [[ "$ENV" != "dev" && "$ENV" != "prod" ]]; then
usage
fi
HOST="${DEPLOY_HOST[$ENV]}"
CTR="${CONTAINER[$ENV]}"
CACHE_PATH="/app/storage/cards"
report() {
echo -e "${CYAN}Card image cache on ${HOST} (${CTR}):${NC}"
ssh "$HOST" "
png_count=\$(docker exec $CTR find $CACHE_PATH -name '*.png' 2>/dev/null | wc -l)
apng_count=\$(docker exec $CTR find $CACHE_PATH -name '*.apng' 2>/dev/null | wc -l)
echo \" PNG: \${png_count} files\"
echo \" APNG: \${apng_count} files\"
echo \" Total: \$((\${png_count} + \${apng_count})) files\"
" 2>/dev/null || {
echo -e "${RED}Could not reach ${HOST}.${NC}"
exit 1
}
}
report
case "$ACTION" in
report)
echo -e "${GREEN}Dry run — no files deleted. Pass --apng-only or --all to clear.${NC}"
;;
--apng-only)
echo -e "${YELLOW}Deleting all .apng files from ${CTR}...${NC}"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
deleted=$(ssh "$HOST" "docker exec $CTR find $CACHE_PATH -name '*.apng' -delete -print 2>/dev/null | wc -l")
echo -e "${GREEN}Deleted ${deleted} .apng files.${NC}"
;;
--all)
echo -e "${RED}Deleting ALL cached card images from ${CTR}...${NC}"
read -rp "This will clear PNG and APNG caches. Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
deleted=$(ssh "$HOST" "docker exec $CTR find $CACHE_PATH -type f \( -name '*.png' -o -name '*.apng' \) -delete -print 2>/dev/null | wc -l")
echo -e "${GREEN}Deleted ${deleted} cached card images.${NC}"
;;
*)
usage
;;
esac

203
scripts/deploy.sh Executable file
View File

@ -0,0 +1,203 @@
#!/bin/bash
# Deploy Paper Dynasty Database API
# Usage: ./scripts/deploy.sh <dev|prod> [--sync-templates] [commit]
#
# Dev: Force-updates the "dev" git tag → CI builds :dev Docker image
# Prod: Creates CalVer tag + force-updates "latest" and "production" git tags
# → CI builds :<calver>, :latest, :production Docker images
#
# Options:
# --sync-templates Upload changed templates to the target server via scp
#
# Templates are volume-mounted (not in the Docker image). The script always
# checks for template drift and warns if local/remote differ. Pass
# --sync-templates to actually push the changed files.
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
REMOTE="origin"
SYNC_TEMPLATES=false
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../storage/templates"
# Server config
declare -A DEPLOY_HOST=([dev]="pd-database" [prod]="akamai")
declare -A TEMPLATE_PATH=(
[dev]="/home/cal/container-data/dev-pd-database/storage/templates"
[prod]="/root/container-data/paper-dynasty/storage/templates"
)
usage() {
echo "Usage: $0 <dev|prod> [--sync-templates] [commit]"
echo ""
echo " dev [commit] Force-update 'dev' tag on HEAD or specified commit"
echo " prod [commit] Create CalVer + 'latest' + 'production' tags on HEAD or specified commit"
echo ""
echo "Options:"
echo " --sync-templates Upload changed templates to the target server"
exit 1
}
[[ $# -lt 1 ]] && usage
ENV="$1"
shift
# Parse optional flags
COMMIT="HEAD"
while [[ $# -gt 0 ]]; do
case "$1" in
--sync-templates)
SYNC_TEMPLATES=true
shift
;;
--*)
echo -e "${RED}Unknown option: $1${NC}"
usage
;;
*)
COMMIT="$1"
shift
;;
esac
done
SHA=$(git rev-parse "$COMMIT" 2>/dev/null) || {
echo -e "${RED}Invalid commit: $COMMIT${NC}"
exit 1
}
SHA_SHORT="${SHA:0:7}"
git fetch --tags "$REMOTE"
if ! git branch -a --contains "$SHA" 2>/dev/null | grep -qE '(^|\s)(main|remotes/origin/main)$'; then
echo -e "${RED}Commit $SHA_SHORT is not on main. Aborting.${NC}"
exit 1
fi
# --- Template drift check ---
check_templates() {
local host="${DEPLOY_HOST[$ENV]}"
local remote_path="${TEMPLATE_PATH[$ENV]}"
echo -e "${CYAN}Checking templates against ${host}:${remote_path}...${NC}"
local local_hashes remote_hashes
local_hashes=$(cd "$TEMPLATE_DIR" && md5sum *.html 2>/dev/null | sort -k2)
remote_hashes=$(ssh "$host" "cd '$remote_path' && md5sum *.html 2>/dev/null | sort -k2" 2>/dev/null) || {
echo -e "${YELLOW} Could not reach ${host} — skipping template check.${NC}"
return 0
}
local changed=()
local missing_remote=()
while IFS= read -r line; do
local hash file remote_hash
hash=$(echo "$line" | awk '{print $1}')
file=$(echo "$line" | awk '{print $2}')
remote_hash=$(echo "$remote_hashes" | awk -v f="$file" '$2 == f {print $1}')
if [[ -z "$remote_hash" ]]; then
missing_remote+=("$file")
elif [[ "$hash" != "$remote_hash" ]]; then
changed+=("$file")
fi
done <<<"$local_hashes"
if [[ ${#changed[@]} -eq 0 && ${#missing_remote[@]} -eq 0 ]]; then
echo -e "${GREEN} Templates in sync.${NC}"
return 0
fi
echo -e "${YELLOW} Template drift detected:${NC}"
for f in "${changed[@]+"${changed[@]}"}"; do
[[ -n "$f" ]] && echo -e " ${YELLOW}CHANGED${NC} $f"
done
for f in "${missing_remote[@]+"${missing_remote[@]}"}"; do
[[ -n "$f" ]] && echo -e " ${YELLOW}MISSING${NC} $f (not on server)"
done
if [[ "$SYNC_TEMPLATES" == true ]]; then
echo -e "${CYAN} Syncing templates...${NC}"
for f in "${changed[@]+"${changed[@]}"}" "${missing_remote[@]+"${missing_remote[@]}"}"; do
[[ -n "$f" ]] && scp "$TEMPLATE_DIR/$f" "${host}:${remote_path}/$f"
done
echo -e "${GREEN} Templates synced to ${host}.${NC}"
else
echo -e "${YELLOW} Run with --sync-templates to push changes.${NC}"
fi
}
check_templates
# --- Cached image report ---
declare -A API_CONTAINER=([dev]="dev_pd_database" [prod]="pd_api")
report_cache() {
local host="${DEPLOY_HOST[$ENV]}"
local container="${API_CONTAINER[$ENV]}"
echo -e "${CYAN}Cached card images on ${host} (${container}):${NC}"
ssh "$host" "
png_count=\$(docker exec $container find /app/storage/cards -name '*.png' 2>/dev/null | wc -l)
apng_count=\$(docker exec $container find /app/storage/cards -name '*.apng' 2>/dev/null | wc -l)
echo \" PNG: \${png_count} files\"
echo \" APNG: \${apng_count} files\"
echo \" Total: \$((\${png_count} + \${apng_count})) files\"
" 2>/dev/null || echo -e "${YELLOW} Could not reach ${host} — skipping cache report.${NC}"
}
report_cache
case "$ENV" in
dev)
echo -e "${YELLOW}Deploying to dev...${NC}"
echo -e " Commit: ${SHA_SHORT}"
git tag -f dev "$SHA"
git push "$REMOTE" dev --force
echo -e "${GREEN}Tagged ${SHA_SHORT} as 'dev' and pushed. CI will build :dev image.${NC}"
;;
prod)
echo -e "${YELLOW}Deploying to prod...${NC}"
YEAR=$(date -u +%Y)
MONTH=$(date -u +%-m)
PREFIX="${YEAR}.${MONTH}."
LAST_BUILD=$(git tag -l "${PREFIX}*" | sed "s/^${PREFIX}//" | sort -n | tail -1)
BUILD=$((${LAST_BUILD:-0} + 1))
CALVER="${PREFIX}${BUILD}"
echo -e " Commit: ${SHA_SHORT}"
echo -e " Version: ${CALVER}"
echo -e " Tags: ${CALVER}, latest, production"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
git tag "$CALVER" "$SHA"
git tag -f latest "$SHA"
git tag -f production "$SHA"
git push "$REMOTE" "$CALVER"
git push "$REMOTE" latest --force
git push "$REMOTE" production --force
echo -e "${GREEN}Tagged ${SHA_SHORT} as '${CALVER}', 'latest', 'production' and pushed.${NC}"
echo -e "${GREEN}CI will build :${CALVER}, :latest, :production images.${NC}"
;;
*)
usage
;;
esac

View File

@ -2,9 +2,27 @@
<html lang="en"> <html lang="en">
<head> <head>
{% include 'style.html' %} {% include 'style.html' %}
{% include 'tier_style.html' %}
</head> </head>
<body> <body>
<div id="fullCard" style="width: 1200px; height: 600px;"> <div id="fullCard" style="width: 1200px; height: 600px;">
{% if refractor_tier is defined and refractor_tier > 0 %}
{%- set diamond_colors = {
1: {'color': '#1a6b1a', 'highlight': '#40b040'},
2: {'color': '#2070b0', 'highlight': '#50a0e8'},
3: {'color': '#a82020', 'highlight': '#e85050'},
4: {'color': '#6b2d8e', 'highlight': '#a060d0'},
} -%}
{%- set dc = diamond_colors[refractor_tier] -%}
{%- set filled_bg = 'linear-gradient(135deg, ' ~ dc.highlight ~ ' 0%, ' ~ dc.color ~ ' 50%, ' ~ dc.color ~ ' 100%)' -%}
<div class="tier-diamond-backing"></div>
<div class="tier-diamond{% if refractor_tier == 4 %} diamond-glow{% endif %}">
<div class="diamond-quad{% if refractor_tier >= 2 %} filled{% endif %}" {% if refractor_tier >= 2 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 1 %} filled{% endif %}" {% if refractor_tier >= 1 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 3 %} filled{% endif %}" {% if refractor_tier >= 3 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 4 %} filled{% endif %}" {% if refractor_tier >= 4 %}style="background: {{ filled_bg }};"{% endif %}></div>
</div>
{% endif %}
<div id="header" class="row-wrapper header-text border-bot" style="height: 65px"> <div id="header" class="row-wrapper header-text border-bot" style="height: 65px">
<!-- <div id="headerLeft" style="flex-grow: 3; height: auto">--> <!-- <div id="headerLeft" style="flex-grow: 3; height: auto">-->
<div id="headerLeft" style="width: 477px; height: auto"> <div id="headerLeft" style="width: 477px; height: auto">

View File

@ -0,0 +1,229 @@
<style>
#fullCard {
position: relative;
overflow: hidden;
}
</style>
{% if refractor_tier is defined and refractor_tier > 0 %}
<style>
.tier-diamond-backing,
.tier-diamond {
position: absolute;
left: 597px;
top: 78.5px;
transform: translate(-50%, -50%) rotate(45deg);
border-radius: 2px;
pointer-events: none;
}
.tier-diamond-backing {
width: 44px;
height: 44px;
background: rgba(200,210,220,0.9);
z-index: 19;
}
.tier-diamond {
display: grid;
grid-template: 1fr 1fr / 1fr 1fr;
gap: 2px;
z-index: 20;
pointer-events: none;
background: transparent;
border-radius: 2px;
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5);
}
.diamond-quad {
width: 19px;
height: 19px;
background: rgba(0,0,0,0.55);
}
.diamond-quad.filled {
box-shadow: inset 0 1px 2px rgba(255,255,255,0.45),
inset 0 -1px 2px rgba(0,0,0,0.35),
inset 1px 0 2px rgba(255,255,255,0.15);
}
{% if refractor_tier == 1 %}
/* T1 — Base Chrome */
#header {
background: linear-gradient(135deg, rgba(185,195,210,0.25) 0%, rgba(210,218,228,0.35) 50%, rgba(185,195,210,0.25) 100%), #ffffff;
}
.border-bot {
border-bottom-color: #8e9baf;
border-bottom-width: 4px;
}
#resultHeader.border-bot {
border-bottom-width: 3px;
}
.border-right-thick {
border-right-color: #8e9baf;
}
.border-right-thin {
border-right-color: #8e9baf;
}
.vline {
border-left-color: #8e9baf;
}
{% elif refractor_tier == 2 %}
/* T2 — Refractor */
#header {
background: linear-gradient(135deg, rgba(100,155,230,0.28) 0%, rgba(155,90,220,0.18) 25%, rgba(90,200,210,0.24) 50%, rgba(185,80,170,0.16) 75%, rgba(100,155,230,0.28) 100%), #ffffff;
}
#fullCard {
box-shadow: inset 0 0 14px 3px rgba(90,143,207,0.22);
}
.border-bot {
border-bottom-color: #7a9cc4;
border-bottom-width: 4px;
}
#resultHeader .border-right-thick {
border-right-width: 6px;
}
.border-right-thick {
border-right-color: #7a9cc4;
}
.border-right-thin {
border-right-color: #7a9cc4;
border-right-width: 3px;
}
.vline {
border-left-color: #7a9cc4;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(60,110,200,1), rgba(100,55,185,0.55), rgba(60,110,200,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(190,35,80,1), rgba(165,25,100,0.55), rgba(190,35,80,1));
}
{% elif refractor_tier == 3 %}
/* T3 — Gold Refractor */
#header {
background: linear-gradient(135deg, rgba(195,155,35,0.26) 0%, rgba(235,200,70,0.2) 50%, rgba(195,155,35,0.26) 100%), #ffffff;
overflow: hidden;
position: relative;
}
#fullCard {
box-shadow: inset 0 0 16px 4px rgba(200,165,48,0.22);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 4px;
}
.border-right-thick {
border-right-color: #c9a94e;
}
.border-right-thin {
border-right-color: #c9a94e;
border-right-width: 3px;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T3 shimmer animation — paused for static PNG capture */
@keyframes t3-shimmer {
0% { transform: translateX(-130%); }
100% { transform: translateX(230%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(
105deg,
transparent 38%,
rgba(255,240,140,0.18) 44%,
rgba(255,220,80,0.38) 50%,
rgba(255,200,60,0.30) 53%,
rgba(255,240,140,0.14) 58%,
transparent 64%
);
pointer-events: none;
z-index: 5;
animation: t3-shimmer 2.5s ease-in-out infinite;
animation-play-state: paused;
}
{% elif refractor_tier == 4 %}
/* T4 — Superfractor */
#header {
background: #ffffff;
overflow: hidden;
position: relative;
}
#fullCard {
box-shadow: inset 0 0 22px 6px rgba(45,212,191,0.28), inset 0 0 39px 9px rgba(200,165,48,0.15);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 4px;
}
.border-right-thick {
border-right-color: #c9a94e;
}
.border-right-thin {
border-right-color: #c9a94e;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T4 prismatic header sweep — paused for static PNG capture */
@keyframes t4-prismatic-sweep {
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 200%; height: 100%;
background: linear-gradient(135deg,
transparent 2%, rgba(255,100,100,0.28) 8%, rgba(255,200,50,0.32) 14%,
rgba(100,255,150,0.30) 20%, rgba(50,190,255,0.32) 26%, rgba(140,80,255,0.28) 32%,
rgba(255,100,180,0.24) 38%, transparent 44%,
transparent 52%, rgba(255,100,100,0.28) 58%, rgba(255,200,50,0.32) 64%,
rgba(100,255,150,0.30) 70%, rgba(50,190,255,0.32) 76%, rgba(140,80,255,0.28) 82%,
rgba(255,100,180,0.24) 88%, transparent 94%
);
z-index: 1;
pointer-events: none;
animation: t4-prismatic-sweep 6s linear infinite;
animation-play-state: paused;
}
#header > * { z-index: 2; }
/* T4 diamond glow pulse — paused for static PNG */
@keyframes diamond-glow-pulse {
0%, 100% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5),
0 0 8px 2px rgba(107,45,142,0.6); }
50% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3),
0 0 14px 5px rgba(107,45,142,0.8),
0 0 24px 8px rgba(107,45,142,0.3); }
}
.tier-diamond.diamond-glow {
animation: diamond-glow-pulse 2s ease-in-out infinite;
animation-play-state: paused;
}
{% endif %}
</style>
{% endif %}

View File

@ -44,10 +44,11 @@ from app.db_engine import (
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
ProcessedGame, ProcessedGame,
BattingCard,
PitchingCard,
RefractorTrack, RefractorTrack,
RefractorCardState, RefractorCardState,
RefractorTierBoost, RefractorBoostAudit,
RefractorCosmetic,
ScoutOpportunity, ScoutOpportunity,
ScoutClaim, ScoutClaim,
) )
@ -78,8 +79,9 @@ _TEST_MODELS = [
ScoutClaim, ScoutClaim,
RefractorTrack, RefractorTrack,
RefractorCardState, RefractorCardState,
RefractorTierBoost, BattingCard,
RefractorCosmetic, PitchingCard,
RefractorBoostAudit,
] ]

350
tests/test_card_storage.py Normal file
View File

@ -0,0 +1,350 @@
"""
Unit tests for app/services/card_storage.py S3 upload utility.
This module covers:
- S3 key construction for variant cards (batting, pitching, zero-padded cardset)
- Full S3 URL construction with cache-busting date param
- put_object call validation (correct params, return value)
- End-to-end backfill: read PNG from disk, upload to S3, update DB row
Why we test S3 key construction separately:
The key format is a contract used by both the renderer and the URL builder.
Validating it in isolation catches regressions before they corrupt stored URLs.
Why we test URL construction separately:
The cache-bust param (?d=...) must be appended consistently so that clients
invalidate cached images after a re-render. Testing it independently prevents
the formatter from silently changing.
Why we test upload params:
ContentType and CacheControl must be set exactly so that S3 serves images
with the correct headers. A missing header is a silent misconfiguration.
Why we test backfill error swallowing:
The backfill function is called as a background task it must never raise
exceptions that would abort a card render response. We verify that S3 failures
and missing files are both silently logged, not propagated.
Test isolation:
All tests use unittest.mock; no real S3 calls or DB connections are made.
The `backfill_variant_image_url` tests patch `get_s3_client` and the DB
model classes at the card_storage module level so lazy imports work correctly.
"""
import os
from datetime import date
from unittest.mock import MagicMock, patch
# Set env before importing module so db_engine doesn't try to connect
os.environ.setdefault("DATABASE_TYPE", "postgresql")
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
from app.services.card_storage import (
build_s3_key,
build_s3_url,
upload_card_to_s3,
backfill_variant_image_url,
S3_BUCKET,
S3_REGION,
)
# ---------------------------------------------------------------------------
# TestBuildS3Key
# ---------------------------------------------------------------------------
class TestBuildS3Key:
"""Tests for build_s3_key — S3 object key construction.
The key format must match the existing card-creation pipeline so that
the database API and card-creation tool write to the same S3 paths.
"""
def test_batting_card_key(self):
"""batting card type produces 'battingcard.png' in the key."""
key = build_s3_key(cardset_id=27, player_id=42, variant=1, card_type="batting")
assert key == "cards/cardset-027/player-42/v1/battingcard.png"
def test_pitching_card_key(self):
"""pitching card type produces 'pitchingcard.png' in the key."""
key = build_s3_key(cardset_id=27, player_id=99, variant=2, card_type="pitching")
assert key == "cards/cardset-027/player-99/v2/pitchingcard.png"
def test_cardset_zero_padded_to_three_digits(self):
"""Single-digit cardset IDs are zero-padded to three characters."""
key = build_s3_key(cardset_id=5, player_id=1, variant=0, card_type="batting")
assert "cardset-005" in key
def test_cardset_two_digit_zero_padded(self):
"""Two-digit cardset IDs are zero-padded correctly."""
key = build_s3_key(cardset_id=27, player_id=1, variant=0, card_type="batting")
assert "cardset-027" in key
def test_cardset_three_digit_no_padding(self):
"""Three-digit cardset IDs are not altered."""
key = build_s3_key(cardset_id=100, player_id=1, variant=0, card_type="batting")
assert "cardset-100" in key
def test_variant_included_in_key(self):
"""Variant number is included in the path so variants have distinct keys."""
key_v0 = build_s3_key(
cardset_id=27, player_id=1, variant=0, card_type="batting"
)
key_v3 = build_s3_key(
cardset_id=27, player_id=1, variant=3, card_type="batting"
)
assert "/v0/" in key_v0
assert "/v3/" in key_v3
assert key_v0 != key_v3
# ---------------------------------------------------------------------------
# TestBuildS3Url
# ---------------------------------------------------------------------------
class TestBuildS3Url:
"""Tests for build_s3_url — full URL construction with cache-bust param.
The URL format must be predictable so clients can construct and verify
image URLs without querying the database.
"""
def test_url_contains_bucket_and_region(self):
"""URL includes bucket name and region in the S3 hostname."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert S3_BUCKET in url
assert S3_REGION in url
def test_url_contains_s3_key(self):
"""URL path includes the full S3 key."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert key in url
def test_url_has_cache_bust_param(self):
"""URL ends with ?d=<render_date> for cache invalidation."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert "?d=2026-04-06" in url
def test_url_format_full(self):
"""Full URL matches expected S3 pattern exactly."""
key = "cards/cardset-027/player-1/v0/battingcard.png"
render_date = date(2025, 11, 8)
url = build_s3_url(key, render_date)
expected = (
f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}?d=2025-11-08"
)
assert url == expected
# ---------------------------------------------------------------------------
# TestUploadCardToS3
# ---------------------------------------------------------------------------
class TestUploadCardToS3:
"""Tests for upload_card_to_s3 — S3 put_object call validation.
We verify the exact parameters passed to put_object so that S3 serves
images with the correct Content-Type and Cache-Control headers.
"""
def test_put_object_called_with_correct_params(self):
"""put_object is called once with bucket, key, body, ContentType, CacheControl."""
mock_client = MagicMock()
png_bytes = b"\x89PNG\r\n\x1a\n"
s3_key = "cards/cardset-027/player-42/v1/battingcard.png"
upload_card_to_s3(mock_client, png_bytes, s3_key)
mock_client.put_object.assert_called_once_with(
Bucket=S3_BUCKET,
Key=s3_key,
Body=png_bytes,
ContentType="image/png",
CacheControl="public, max-age=300",
)
def test_upload_returns_none(self):
"""upload_card_to_s3 returns None (callers should not rely on a return value)."""
mock_client = MagicMock()
result = upload_card_to_s3(mock_client, b"PNG", "some/key.png")
assert result is None
# ---------------------------------------------------------------------------
# TestBackfillVariantImageUrl
# ---------------------------------------------------------------------------
class TestBackfillVariantImageUrl:
"""Tests for backfill_variant_image_url — end-to-end disk→S3→DB path.
The function is fire-and-forget: it reads a PNG from disk, uploads to S3,
then updates the appropriate card model's image_url. All errors are caught
and logged; the function must never raise.
Test strategy:
- Use tmp_path for temporary PNG files so no filesystem state leaks.
- Patch get_s3_client at the module level to intercept the S3 call.
- Patch BattingCard/PitchingCard at the module level (lazy import target).
"""
def test_batting_card_image_url_updated(self, tmp_path):
"""BattingCard.image_url is updated after a successful upload."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
mock_s3 = MagicMock()
mock_card = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = mock_card
backfill_variant_image_url(
player_id=42,
variant=1,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)
MockBatting.get.assert_called_once_with(
MockBatting.player_id == 42, MockBatting.variant == 1
)
assert mock_card.image_url is not None
mock_card.save.assert_called_once()
def test_pitching_card_image_url_updated(self, tmp_path):
"""PitchingCard.image_url is updated after a successful upload."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
mock_s3 = MagicMock()
mock_card = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.PitchingCard") as MockPitching,
):
MockPitching.get.return_value = mock_card
backfill_variant_image_url(
player_id=99,
variant=2,
card_type="pitching",
cardset_id=27,
png_path=str(png_path),
)
MockPitching.get.assert_called_once_with(
MockPitching.player_id == 99, MockPitching.variant == 2
)
assert mock_card.image_url is not None
mock_card.save.assert_called_once()
def test_s3_upload_called_with_png_bytes(self, tmp_path):
"""The PNG bytes read from disk are passed to put_object."""
png_bytes = b"\x89PNG\r\n\x1a\n real png content"
png_path = tmp_path / "card.png"
png_path.write_bytes(png_bytes)
mock_s3 = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = MagicMock()
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=5,
png_path=str(png_path),
)
mock_s3.put_object.assert_called_once()
call_kwargs = mock_s3.put_object.call_args.kwargs
assert call_kwargs["Body"] == png_bytes
def test_s3_error_is_swallowed(self, tmp_path):
"""If S3 raises an exception, backfill swallows it and returns normally.
The function is called as a background task it must never propagate
exceptions that would abort the calling request handler.
"""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"PNG data")
mock_s3 = MagicMock()
mock_s3.put_object.side_effect = Exception("S3 connection refused")
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard"),
):
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)
def test_missing_file_is_swallowed(self, tmp_path):
"""If the PNG file does not exist, backfill swallows the error and returns.
Render failures may leave no file on disk; the background task must
handle this gracefully rather than crashing the request.
"""
missing_path = str(tmp_path / "nonexistent.png")
with (
patch("app.services.card_storage.get_s3_client"),
patch("app.services.card_storage.BattingCard"),
):
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=missing_path,
)
def test_db_error_is_swallowed(self, tmp_path):
"""If the DB save raises, backfill swallows it and returns normally."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"PNG data")
mock_s3 = MagicMock()
mock_card = MagicMock()
mock_card.save.side_effect = Exception("DB connection lost")
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = mock_card
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)

View File

@ -204,3 +204,120 @@ def test_tier_t3_boundary():
def test_tier_accepts_namespace_track(): def test_tier_accepts_namespace_track():
"""tier_from_value must work with attribute-style track objects (Peewee models).""" """tier_from_value must work with attribute-style track objects (Peewee models)."""
assert tier_from_value(37, track_ns("batter")) == 1 assert tier_from_value(37, track_ns("batter")) == 1
# ---------------------------------------------------------------------------
# T1-1: Negative singles guard in compute_batter_value
# ---------------------------------------------------------------------------
def test_batter_negative_singles_component():
"""hits=1, doubles=1, triples=1, hr=0 produces singles=-1.
What: The formula computes singles = hits - doubles - triples - hr.
With hits=1, doubles=1, triples=1, hr=0 the result is singles = -1,
which is a physically impossible stat line but valid arithmetic input.
Why: Document the formula's actual behaviour when given an incoherent stat
line so that callers are aware that no clamping or guard exists. If a
guard is added in the future, this test will catch the change in behaviour.
singles = 1 - 1 - 1 - 0 = -1
tb = (-1)*1 + 1*2 + 1*3 + 0*4 = -1 + 2 + 3 = 4
value = pa + tb*2 = 0 + 4*2 = 8
"""
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
# singles will be -1; the formula does NOT clamp, so TB = 4 and value = 8.0
result = compute_batter_value(stats)
assert result == 8.0, (
f"Expected 8.0 (negative singles flows through unclamped), got {result}"
)
def test_batter_negative_singles_is_not_clamped():
"""A singles value below zero is NOT clamped to zero by the formula.
What: Confirms that singles < 0 propagates into TB rather than being
floored at 0. If clamping were added, tb would be 0*1 + 1*2 + 1*3 = 5
and value would be 10.0, not 8.0.
Why: Guards future refactors if someone adds `singles = max(0, ...)`,
this assertion will fail immediately, surfacing the behaviour change.
"""
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
unclamped_value = compute_batter_value(stats)
# If singles were clamped to 0: tb = 0+2+3 = 5, value = 10.0
clamped_value = 10.0
assert unclamped_value != clamped_value, (
"Formula appears to clamp negative singles — behaviour has changed"
)
# ---------------------------------------------------------------------------
# T1-2: Tier boundary precision with float SP values
# ---------------------------------------------------------------------------
def test_sp_tier_just_below_t1_outs29():
"""SP with outs=29 produces IP=9.666..., which is below T1 threshold (10) → T0.
What: 29 outs / 3 = 9.6666... IP + 0 K = 9.6666... value.
The SP T1 threshold is 10.0, so this value is strictly below T1.
Why: Floating-point IP values accumulate slowly for pitchers. A bug that
truncated or rounded IP upward could cause premature tier advancement.
Verify that tier_from_value uses a >= comparison (not >) and handles
non-integer values correctly.
"""
stats = pitcher_stats(outs=29, strikeouts=0)
value = compute_sp_value(stats)
assert value == pytest.approx(29 / 3) # 9.6666...
assert value < 10.0 # strictly below T1
assert tier_from_value(value, track_dict("sp")) == 0
def test_sp_tier_exactly_t1_outs30():
"""SP with outs=30 produces IP=10.0, exactly at T1 threshold → T1.
What: 30 outs / 3 = 10.0 IP + 0 K = 10.0 value.
The SP T1 threshold is 10.0, so value == t1 satisfies the >= condition.
Why: Off-by-one or strictly-greater-than comparisons would classify
this as T0 instead of T1. The boundary value must correctly promote
to the matching tier.
"""
stats = pitcher_stats(outs=30, strikeouts=0)
value = compute_sp_value(stats)
assert value == 10.0
assert tier_from_value(value, track_dict("sp")) == 1
def test_sp_float_value_at_exact_t2_boundary():
"""SP value exactly at T2 threshold (40.0) → T2.
What: outs=120 -> IP=40.0, strikeouts=0 -> value=40.0.
T2 threshold for SP is 40. The >= comparison must promote to T2.
Why: Validates that all four tier thresholds use inclusive lower-bound
comparisons for float values, not just T1.
"""
stats = pitcher_stats(outs=120, strikeouts=0)
value = compute_sp_value(stats)
assert value == 40.0
assert tier_from_value(value, track_dict("sp")) == 2
def test_sp_float_value_just_below_t2():
"""SP value just below T2 (39.999...) stays at T1.
What: outs=119 -> IP=39.6666..., strikeouts=0 -> value=39.666...
This is strictly less than T2=40, so tier should be 1 (already past T1=10).
Why: Confirms that sub-threshold float values are not prematurely promoted
due to floating-point comparison imprecision.
"""
stats = pitcher_stats(outs=119, strikeouts=0)
value = compute_sp_value(stats)
assert value == pytest.approx(119 / 3) # 39.666...
assert value < 40.0
assert tier_from_value(value, track_dict("sp")) == 1

View File

@ -48,35 +48,39 @@ import os
os.environ.setdefault("API_TOKEN", "test-token") os.environ.setdefault("API_TOKEN", "test-token")
import app.services.season_stats as _season_stats_module import app.services.season_stats as _season_stats_module
import app.services.refractor_boost as _refractor_boost_module
import pytest import pytest
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import SqliteDatabase from peewee import SqliteDatabase
from app.db_engine import ( from app.db_engine import (
BattingCard,
BattingCardRatings,
Cardset, Cardset,
RefractorCardState, Decision,
RefractorCosmetic, Event,
RefractorTierBoost,
RefractorTrack,
MlbPlayer, MlbPlayer,
Pack, Pack,
PackType, PackType,
PitchingCard,
PitchingCardRatings,
Player, Player,
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
ProcessedGame, ProcessedGame,
Rarity, Rarity,
RefractorBoostAudit,
RefractorCardState,
RefractorTrack,
Roster, Roster,
RosterSlot, RosterSlot,
ScoutClaim, ScoutClaim,
ScoutOpportunity, ScoutOpportunity,
StratGame, StratGame,
StratPlay, StratPlay,
Decision,
Team, Team,
Card, Card,
Event,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -111,15 +115,20 @@ _WP13_MODELS = [
BattingSeasonStats, BattingSeasonStats,
PitchingSeasonStats, PitchingSeasonStats,
ProcessedGame, ProcessedGame,
BattingCard,
BattingCardRatings,
PitchingCard,
PitchingCardRatings,
RefractorTrack, RefractorTrack,
RefractorCardState, RefractorCardState,
RefractorTierBoost, RefractorBoostAudit,
RefractorCosmetic,
] ]
# Patch the service-layer 'db' reference to use our shared test database so # Patch the service-layer 'db' references to use our shared test database so
# that db.atomic() in update_season_stats() operates on the same connection. # that db.atomic() in update_season_stats() and apply_tier_boost() operate on
# the same connection.
_season_stats_module.db = _wp13_db _season_stats_module.db = _wp13_db
_refractor_boost_module.db = _wp13_db
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Auth header used by every authenticated request # Auth header used by every authenticated request
@ -323,6 +332,65 @@ def _make_state(
) )
# Base batter ratings that sum to exactly 108 for use in tier advancement tests.
# apply_tier_boost() requires a base card (variant=0) with ratings rows to
# create boosted variant cards — tests that push past T1 must set this up.
_WP13_BASE_BATTER_RATINGS = {
"homerun": 3.0,
"bp_homerun": 1.0,
"triple": 0.5,
"double_three": 2.0,
"double_two": 2.0,
"double_pull": 6.0,
"single_two": 4.0,
"single_one": 12.0,
"single_center": 5.0,
"bp_single": 2.0,
"hbp": 3.0,
"walk": 7.0,
"strikeout": 15.0,
"lineout": 3.0,
"popout": 2.0,
"flyout_a": 5.0,
"flyout_bq": 4.0,
"flyout_lf_b": 3.0,
"flyout_rf_b": 9.0,
"groundout_a": 6.0,
"groundout_b": 8.0,
"groundout_c": 5.5,
}
def _make_base_batter_card(player):
"""Create a BattingCard (variant=0) with two ratings rows for apply_tier_boost()."""
card = BattingCard.create(
player=player,
variant=0,
steal_low=1,
steal_high=6,
steal_auto=False,
steal_jump=0.5,
bunting="C",
hit_and_run="B",
running=3,
offense_col=2,
hand="R",
)
for vs_hand in ("L", "R"):
BattingCardRatings.create(
battingcard=card,
vs_hand=vs_hand,
pull_rate=0.4,
center_rate=0.35,
slap_rate=0.25,
avg=0.300,
obp=0.370,
slg=0.450,
**_WP13_BASE_BATTER_RATINGS,
)
return card
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests: POST /api/v2/season-stats/update-game/{game_id} # Tests: POST /api/v2/season-stats/update-game/{game_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -486,6 +554,8 @@ def test_evaluate_game_tier_advancement(client):
game = _make_game(team_a, team_b) game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier Adv Track") track = _make_track(name="WP13 Tier Adv Track")
_make_state(batter, team_a, track, current_tier=0, current_value=34.0) _make_state(batter, team_a, track, current_tier=0, current_value=34.0)
# Phase 2: base card required so apply_tier_boost() can create a variant.
_make_base_batter_card(batter)
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37) # Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
BattingSeasonStats.create( BattingSeasonStats.create(
@ -567,6 +637,8 @@ def test_evaluate_game_tier_ups_in_response(client):
game = _make_game(team_a, team_b) game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier-Ups Track") track = _make_track(name="WP13 Tier-Ups Track")
_make_state(batter, team_a, track, current_tier=0) _make_state(batter, team_a, track, current_tier=0)
# Phase 2: base card required so apply_tier_boost() can create a variant.
_make_base_batter_card(batter)
# Seed prior stats below threshold # Seed prior stats below threshold
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
@ -665,3 +737,565 @@ def test_auth_required_evaluate_game(client):
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}") resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
assert resp.status_code == 401 assert resp.status_code == 401
# ---------------------------------------------------------------------------
# T1-3: evaluate-game with non-existent game_id
# ---------------------------------------------------------------------------
def test_evaluate_game_nonexistent_game_id(client):
"""POST /refractor/evaluate-game/99999 with a game_id that does not exist.
What: There is no StratGame row with id=99999. The endpoint queries
StratPlay for plays in that game, finds zero rows, builds an empty
pairs set, and returns without evaluating anyone.
Why: Documents the confirmed behaviour: 200 with {"evaluated": 0,
"tier_ups": []}. The endpoint does not treat a missing game as an
error because StratPlay.select().where(game_id=N) returning 0 rows is
a valid (if unusual) outcome there are simply no players to evaluate.
If the implementation is ever changed to return 404 for missing games,
this test will fail and alert the developer to update the contract.
"""
resp = client.post("/api/v2/refractor/evaluate-game/99999", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["evaluated"] == 0
assert data["tier_ups"] == []
# ---------------------------------------------------------------------------
# T2-3: evaluate-game with zero plays
# ---------------------------------------------------------------------------
def test_evaluate_game_zero_plays(client):
"""evaluate-game on a game with no StratPlay rows returns empty results.
What: Create a StratGame but insert zero StratPlay rows for it. POST
to evaluate-game for that game_id.
Why: The endpoint builds its player list from StratPlay rows. A game
with no plays has no players to evaluate. Verify the endpoint does not
crash and returns the expected empty-batch shape rather than raising a
KeyError or returning an unexpected structure.
"""
team_a = _make_team("ZP1", gmid=20101)
team_b = _make_team("ZP2", gmid=20102)
game = _make_game(team_a, team_b)
# Intentionally no plays created
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["evaluated"] == 0
assert data["tier_ups"] == []
# ---------------------------------------------------------------------------
# T2-9: Per-player error isolation in evaluate_game
# ---------------------------------------------------------------------------
def test_evaluate_game_error_isolation(client, monkeypatch):
"""An exception raised for one player does not abort the rest of the batch.
What: Create two batters in the same game. Both have RefractorCardState
rows. Patch evaluate_card in the refractor router to raise RuntimeError
on the first call and succeed on the second. Verify the endpoint returns
200, evaluated==1 (not 0 or 2), and no tier_ups from the failing player.
Why: The evaluate-game loop catches per-player exceptions and logs them.
If the isolation breaks, a single bad card would silently drop all
evaluations for the rest of the game. The 'evaluated' count is the
observable signal that error isolation is functioning.
Implementation note: we patch the evaluate_card function inside the
router module directly so that the test is independent of how the router
imports it. We use a counter to let the first call fail and the second
succeed.
"""
from app.services import refractor_evaluator
team_a = _make_team("EI1", gmid=20111)
team_b = _make_team("EI2", gmid=20112)
batter_fail = _make_player("WP13 Fail Batter", pos="1B")
batter_ok = _make_player("WP13 Ok Batter", pos="1B")
pitcher = _make_player("WP13 EI Pitcher", pos="SP")
game = _make_game(team_a, team_b)
# Both batters need season stats and a track/state so they are not
# skipped by the "no state" guard before evaluate_card is called.
track = _make_track(name="EI Batter Track")
_make_state(batter_fail, team_a, track)
_make_state(batter_ok, team_a, track)
_make_play(game, 1, batter_fail, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
_make_play(game, 2, batter_ok, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
# The real evaluate_card for batter_ok so we know what it returns
real_evaluate = refractor_evaluator.evaluate_card
call_count = {"n": 0}
fail_player_id = batter_fail.player_id
def patched_evaluate(player_id, team_id, **kwargs):
call_count["n"] += 1
if player_id == fail_player_id:
raise RuntimeError("simulated per-player error")
return real_evaluate(player_id, team_id, **kwargs)
# The router does `from ..services.refractor_evaluator import evaluate_card`
# inside the async function body, so the local import re-resolves on each
# call. Patching the function on its source module ensures the local `from`
# import picks up our patched version when the route handler executes.
monkeypatch.setattr(
"app.services.refractor_evaluator.evaluate_card", patched_evaluate
)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# One player succeeded; one was caught by the exception handler
assert data["evaluated"] == 1
# The failing player must not appear in tier_ups
failing_ids = [tu["player_id"] for tu in data["tier_ups"]]
assert fail_player_id not in failing_ids
# ---------------------------------------------------------------------------
# Base pitcher card ratings that sum to exactly 108 for use in pitcher tier
# advancement tests.
# Variable columns (18): sum to 79.
# X-check columns (9): sum to 29.
# Total: 108.
# ---------------------------------------------------------------------------
_WP13_BASE_PITCHER_RATINGS = {
# 18 variable outcome columns (sum = 79)
"homerun": 2.0,
"bp_homerun": 1.0,
"triple": 0.5,
"double_three": 1.5,
"double_two": 2.0,
"double_cf": 2.0,
"single_two": 3.0,
"single_one": 4.0,
"single_center": 3.0,
"bp_single": 2.0,
"hbp": 1.0,
"walk": 3.0,
"strikeout": 30.0,
"flyout_lf_b": 4.0,
"flyout_cf_b": 5.0,
"flyout_rf_b": 5.0,
"groundout_a": 5.0,
"groundout_b": 5.0,
# 9 x-check columns (sum = 29)
"xcheck_p": 4.0,
"xcheck_c": 3.0,
"xcheck_1b": 3.0,
"xcheck_2b": 3.0,
"xcheck_3b": 3.0,
"xcheck_ss": 3.0,
"xcheck_lf": 3.0,
"xcheck_cf": 3.0,
"xcheck_rf": 4.0,
}
def _make_base_pitcher_card(player):
"""Create a PitchingCard (variant=0) with two ratings rows for apply_tier_boost().
Analogous to _make_base_batter_card but for pitcher cards. Ratings are
seeded from _WP13_BASE_PITCHER_RATINGS which satisfies the 108-sum invariant
required by apply_tier_boost() (18 variable cols summing to 79 plus 9
x-check cols summing to 29 = 108 total).
"""
card = PitchingCard.create(
player=player,
variant=0,
balk=1,
wild_pitch=2,
hold=3,
starter_rating=7,
relief_rating=5,
closer_rating=None,
batting=None,
offense_col=1,
hand="R",
)
for vs_hand in ("L", "R"):
PitchingCardRatings.create(
pitchingcard=card,
vs_hand=vs_hand,
avg=0.250,
obp=0.310,
slg=0.360,
**_WP13_BASE_PITCHER_RATINGS,
)
return card
# ---------------------------------------------------------------------------
# Gap 1: REFRACTOR_BOOST_ENABLED=false kill switch
# ---------------------------------------------------------------------------
def test_evaluate_game_boost_disabled_skips_tier_up(client, monkeypatch):
"""When REFRACTOR_BOOST_ENABLED=false, tier-ups are not reported even if formula says tier-up.
What: Seed a batter at tier=0 with stats above T1 (pa=34 prior + 4-PA game
pushes total to 38 > T1 threshold of 37). Set REFRACTOR_BOOST_ENABLED=false
before calling evaluate-game.
Why: The kill switch must suppress all tier-up notifications and leave
current_tier unchanged so that no variant card is created and no Discord
announcement is sent. If the kill switch is ignored the bot will announce
tier-ups during maintenance windows when card creation is deliberately
disabled.
"""
monkeypatch.setenv("REFRACTOR_BOOST_ENABLED", "false")
team_a = _make_team("BD1", gmid=20201)
team_b = _make_team("BD2", gmid=20202)
batter = _make_player("WP13 KillSwitch Batter")
pitcher = _make_player("WP13 KillSwitch Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 KillSwitch Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Seed prior stats just below T1
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game adds 4 PA — total = 38 > T1 (37)
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# Kill switch: the boost block is bypassed so apply_tier_boost() is never
# called and current_tier must remain 0 in the DB.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 0
# No BattingCard variant must have been created (boost never ran).
from app.services.refractor_boost import compute_variant_hash
t1_hash = compute_variant_hash(batter.player_id, 1)
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
)
is None
), "Variant card must not be created when boost is disabled"
# When boost is disabled, no tier_up notification is sent — the router
# skips the append entirely to prevent false notifications to the bot.
assert len(data["tier_ups"]) == 0
# ---------------------------------------------------------------------------
# Gap 4: Multi-tier jump T0 -> T2 at HTTP layer
# ---------------------------------------------------------------------------
def test_evaluate_game_multi_tier_jump(client):
"""Player with stats above T2 threshold jumps from T0 to T2 in one game.
What: Seed a batter at tier=0 with no prior stats. The game itself
provides stats in range [T2=149, T3=448).
Using pa=50, hit=50 (all singles): value = 50 + 50*2 = 150.
Why: The evaluate-game loop must iterate through each tier from old+1 to
computed_tier, calling apply_tier_boost() once per tier. A multi-tier jump
must produce variant cards for every intermediate tier and report a single
tier_up entry whose new_tier equals the highest tier reached.
The variant_created in the response must match the T2 hash (not T1), because
the last apply_tier_boost() call returns the T2 variant.
"""
from app.services.refractor_boost import compute_variant_hash
team_a = _make_team("MJ1", gmid=20211)
team_b = _make_team("MJ2", gmid=20212)
batter = _make_player("WP13 MultiJump Batter")
pitcher = _make_player("WP13 MultiJump Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 MultiJump Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Target value in range [T2=149, T3=448).
# formula: pa + tb*2, tb = singles + 2*doubles + 3*triples + 4*HR.
# 50 PA, 50 hits (all singles): tb = 50; value = 50 + 50*2 = 150.
# 150 >= T2 (149) and < T3 (448) so tier lands exactly at 2.
for i in range(50):
_make_play(
game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0
)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# Must have exactly one tier_up entry for this player.
assert len(data["tier_ups"]) == 1
tu = data["tier_ups"][0]
assert tu["old_tier"] == 0
assert tu["new_tier"] == 2
# The variant_created must match T2 hash (last boost iteration).
expected_t2_hash = compute_variant_hash(batter.player_id, 2)
assert tu["variant_created"] == expected_t2_hash
# Both T1 and T2 variant BattingCard rows must exist.
t1_hash = compute_variant_hash(batter.player_id, 1)
t2_hash = compute_variant_hash(batter.player_id, 2)
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
)
is not None
), "T1 variant card missing"
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t2_hash)
)
is not None
), "T2 variant card missing"
# DB state must reflect T2.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 2
# ---------------------------------------------------------------------------
# Gap 5: Pitcher through evaluate-game
# ---------------------------------------------------------------------------
def test_evaluate_game_pitcher_tier_advancement(client):
"""Pitcher reaching T1 through evaluate-game creates a boosted PitchingCard variant.
What: Create a pitcher player with a PitchingCard + PitchingCardRatings
(variant=0) and a RefractorCardState on the 'sp' track. Seed
PitchingSeasonStats with outs and strikeouts just below T1 (prior season),
then add a game where the pitcher appears and records enough additional outs
to cross the threshold.
The pitcher formula is: outs/3 + strikeouts. Track thresholds are the same
(t1=37). Prior season: outs=60, strikeouts=16 -> value = 20 + 16 = 36.
Game adds 3 outs + 1 K -> career total outs=63, strikeouts=17 -> 21+17=38.
Why: Pitcher boost must follow the same evaluate-game flow as batter boost.
If card_type='sp' is not handled, the pitcher track silently skips the boost
and no tier_ups entry is emitted even when the threshold is passed.
"""
team_a = _make_team("PT1", gmid=20221)
team_b = _make_team("PT2", gmid=20222)
pitcher = _make_player("WP13 TierPitcher", pos="SP")
# We need a batter for the play records (pitcher is pitcher side).
batter = _make_player("WP13 PitcherTest Batter")
game = _make_game(team_a, team_b)
sp_track, _ = RefractorTrack.get_or_create(
name="WP13 SP Track",
defaults=dict(
card_type="sp",
formula="outs / 3 + strikeouts",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
_make_state(pitcher, team_a, sp_track, current_tier=0, current_value=0.0)
_make_base_pitcher_card(pitcher)
# Prior season: outs=60, K=16 -> 60/3 + 16 = 36 (below T1=37)
PitchingSeasonStats.create(
player=pitcher,
team=team_a,
season=10,
outs=60,
strikeouts=16,
)
# Game: pitcher records 3 outs (1 inning) and 1 K.
# Career after game: outs=63, K=17 -> 63/3 + 17 = 21 + 17 = 38 > T1=37.
_make_play(
game,
1,
batter,
team_b,
pitcher,
team_a,
pa=1,
ab=1,
outs=3,
so=1,
)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# The pitcher must appear in tier_ups.
pitcher_ups = [
tu for tu in data["tier_ups"] if tu["player_id"] == pitcher.player_id
]
assert len(pitcher_ups) == 1, (
f"Expected 1 tier_up for pitcher, got: {data['tier_ups']}"
)
tu = pitcher_ups[0]
assert tu["old_tier"] == 0
assert tu["new_tier"] >= 1
# A boosted PitchingCard variant must exist in the database.
from app.services.refractor_boost import compute_variant_hash
t1_hash = compute_variant_hash(pitcher.player_id, 1)
variant_card = PitchingCard.get_or_none(
(PitchingCard.player == pitcher) & (PitchingCard.variant == t1_hash)
)
assert variant_card is not None, "T1 PitchingCard variant was not created"
# ---------------------------------------------------------------------------
# Gap 7: variant_created field in tier_up response
# ---------------------------------------------------------------------------
def test_evaluate_game_tier_up_includes_variant_created(client):
"""Tier-up response includes variant_created with the correct hash.
What: Seed a batter at tier=0 with stats that push past T1. After
evaluate-game, the tier_ups entry must contain a 'variant_created' key
whose value matches compute_variant_hash(player_id, 1) and is a positive
non-zero integer.
Why: The bot reads variant_created to update the card image URL after a
tier-up. A missing or incorrect hash will point the bot at the wrong card
image (or no image at all), breaking the tier-up animation in Discord.
"""
from app.services.refractor_boost import compute_variant_hash
team_a = _make_team("VC1", gmid=20231)
team_b = _make_team("VC2", gmid=20232)
batter = _make_player("WP13 VariantCreated Batter")
pitcher = _make_player("WP13 VariantCreated Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 VariantCreated Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Prior season: pa=34, well below T1=37
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game: 4 PA -> total pa=38 > T1=37
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert len(data["tier_ups"]) == 1
tu = data["tier_ups"][0]
# variant_created must be present, non-zero, and match the T1 hash.
assert "variant_created" in tu, "variant_created key missing from tier_up entry"
assert isinstance(tu["variant_created"], int)
assert tu["variant_created"] != 0
expected_hash = compute_variant_hash(batter.player_id, 1)
assert tu["variant_created"] == expected_hash
# ---------------------------------------------------------------------------
# Gap 8: Empty card_type on track produces no tier-up
# ---------------------------------------------------------------------------
def test_evaluate_game_skips_boost_when_track_has_no_card_type(client):
"""Track with empty card_type produces no tier-up notification.
What: Create a RefractorTrack with card_type="" (empty string) and seed a
batter with stats above T1. Call evaluate-game.
Why: apply_tier_boost() requires a valid card_type to know which card model
to use. When card_type is empty or None the boost cannot run. The endpoint
must log a warning and skip the tier-up notification entirely it must NOT
report a tier-up that was never applied to the database. Reporting a phantom
tier-up would cause the bot to announce a card upgrade that does not exist.
"""
team_a = _make_team("NC1", gmid=20241)
team_b = _make_team("NC2", gmid=20242)
batter = _make_player("WP13 NoCardType Batter")
pitcher = _make_player("WP13 NoCardType Pitcher", pos="SP")
game = _make_game(team_a, team_b)
# Create track with card_type="" — an intentionally invalid/empty value.
empty_type_track, _ = RefractorTrack.get_or_create(
name="WP13 NoCardType Track",
defaults=dict(
card_type="",
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
_make_state(batter, team_a, empty_type_track, current_tier=0, current_value=0.0)
# Prior stats below T1; game pushes past T1.
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# No tier-up must be reported when card_type is empty.
assert data["tier_ups"] == []
# current_tier must remain 0 — boost was never applied.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 0

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -187,10 +187,11 @@ def _make_stats(player_id, team_id, season, **kwargs):
) )
def _eval(player_id, team_id): def _eval(player_id, team_id, dry_run: bool = False):
return evaluate_card( return evaluate_card(
player_id, player_id,
team_id, team_id,
dry_run=dry_run,
_stats_model=StatsStub, _stats_model=StatsStub,
_state_model=CardStateStub, _state_model=CardStateStub,
_compute_value_fn=_compute_value, _compute_value_fn=_compute_value,
@ -325,6 +326,59 @@ class TestCareerTotals:
assert result["current_value"] == 50.0 assert result["current_value"] == 50.0
class TestFullyEvolvedPersistence:
"""T2-1: fully_evolved=True is preserved even when stats drop or are absent."""
def test_fully_evolved_persists_when_stats_zeroed(self, batter_track):
"""Card at T4/fully_evolved=True stays fully_evolved after stats are removed.
What: Set up a RefractorCardState at tier=4 with fully_evolved=True.
Then call evaluate_card with no season stats rows (zero career totals).
The evaluator computes value=0 -> new_tier=0, but current_tier must
stay at 4 (no regression) and fully_evolved must remain True.
Why: fully_evolved is a permanent achievement flag it must not be
revoked if a team's stats are rolled back, corrected, or simply not
yet imported. The no-regression rule (max(current, new)) prevents
tier demotion; this test confirms that fully_evolved follows the same
protection.
"""
# Seed state at T4 fully_evolved
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
# No stats rows — career totals will be all zeros
# (no _make_stats call)
result = _eval(1, 1)
# The no-regression rule keeps tier at 4
assert result["current_tier"] == 4, (
f"Expected tier=4 (no regression), got {result['current_tier']}"
)
# fully_evolved must still be True since tier >= 4
assert result["fully_evolved"] is True, (
"fully_evolved was reset to False after re-evaluation with zero stats"
)
def test_fully_evolved_persists_with_partial_stats(self, batter_track):
"""Card at T4 stays fully_evolved even with stats below T1.
What: Same setup as above but with a season stats row giving value=30
(below T1=37). The computed tier would be 0, but current_tier must
not regress from 4.
Why: Validates that no-regression applies regardless of whether stats
are zero or merely insufficient for the achieved tier.
"""
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
# pa=30 -> value=30, which is below T1=37 -> computed tier=0
_make_stats(1, 1, 1, pa=30)
result = _eval(1, 1)
assert result["current_tier"] == 4
assert result["fully_evolved"] is True
class TestMissingState: class TestMissingState:
"""ValueError when no card state exists for (player_id, team_id).""" """ValueError when no card state exists for (player_id, team_id)."""
@ -339,13 +393,20 @@ class TestReturnShape:
"""Return dict has the expected keys and types.""" """Return dict has the expected keys and types."""
def test_return_keys(self, batter_track): def test_return_keys(self, batter_track):
"""Result dict contains all expected keys.""" """Result dict contains all expected keys.
Phase 2 addition: 'computed_tier' is included alongside 'current_tier'
so that evaluate-game can detect tier-ups without writing the tier
(dry_run=True path). Both keys must always be present.
"""
_make_state(1, 1, batter_track) _make_state(1, 1, batter_track)
result = _eval(1, 1) result = _eval(1, 1)
assert set(result.keys()) == { assert set(result.keys()) == {
"player_id", "player_id",
"team_id", "team_id",
"current_tier", "current_tier",
"computed_tier",
"computed_fully_evolved",
"current_value", "current_value",
"fully_evolved", "fully_evolved",
"last_evaluated_at", "last_evaluated_at",
@ -359,3 +420,385 @@ class TestReturnShape:
assert isinstance(ts, str) and len(ts) > 0 assert isinstance(ts, str) and len(ts) > 0
# Must be parseable as a datetime # Must be parseable as a datetime
datetime.fromisoformat(ts) datetime.fromisoformat(ts)
class TestFullyEvolvedFlagCorrection:
"""T3-7: fully_evolved/tier mismatch is corrected by evaluate_card.
A database corruption where fully_evolved=True but current_tier < 4 can
occur if the flag was set incorrectly by a migration or external script.
evaluate_card must re-derive fully_evolved from the freshly-computed tier
(after the no-regression max() is applied), not trust the stored flag.
"""
def test_fully_evolved_flag_corrected_when_tier_below_4(self, batter_track):
"""fully_evolved=True with current_tier=3 is corrected to False after evaluation.
What: Manually set database state to fully_evolved=True, current_tier=3
(a corruption scenario tier 3 cannot be "fully evolved" since T4 is
the maximum tier). Provide stats that compute to a value in the T3
range (value=500, which is >= T3=448 but < T4=896).
After evaluate_card:
- computed value = 500 new_tier = 3
- no-regression: max(current_tier=3, new_tier=3) = 3 tier stays 3
- fully_evolved = (3 >= 4) = False flag is corrected
Why: The evaluator always recomputes fully_evolved from the final
current_tier rather than preserving the stored flag. This ensures
that a corrupted fully_evolved=True at tier<4 is silently repaired
on the next evaluation without requiring a separate migration.
"""
# Inject corruption: fully_evolved=True but tier=3
state = CardStateStub.create(
player_id=1,
team_id=1,
track=batter_track,
current_tier=3,
current_value=500.0,
fully_evolved=True, # intentionally wrong
last_evaluated_at=None,
)
# Stats that compute to value=500: pa=500, no hits → value=500+0=500
# T3 threshold=448, T4 threshold=896 → tier=3, NOT 4
_make_stats(1, 1, 1, pa=500)
result = _eval(1, 1)
assert result["current_tier"] == 3, (
f"Expected tier=3 after evaluation with value=500, got {result['current_tier']}"
)
assert result["fully_evolved"] is False, (
"fully_evolved should have been corrected to False for tier=3, "
f"got {result['fully_evolved']}"
)
# Confirm the database row was updated (not just the return dict)
state_reloaded = CardStateStub.get_by_id(state.id)
assert state_reloaded.fully_evolved is False, (
"fully_evolved was not persisted as False after correction"
)
def test_fully_evolved_flag_preserved_when_tier_reaches_4(self, batter_track):
"""fully_evolved=True with current_tier=3 stays True when new stats push to T4.
What: Same corruption setup as above (fully_evolved=True, tier=3),
but now provide stats with value=900 (>= T4=896).
After evaluate_card:
- computed value = 900 new_tier = 4
- no-regression: max(current_tier=3, new_tier=4) = 4 advances to 4
- fully_evolved = (4 >= 4) = True flag stays True (correctly)
Why: Confirms the evaluator correctly sets fully_evolved=True when
the re-computed tier legitimately reaches T4 regardless of whether
the stored flag was already True before evaluation.
"""
CardStateStub.create(
player_id=1,
team_id=1,
track=batter_track,
current_tier=3,
current_value=500.0,
fully_evolved=True, # stored flag (will be re-derived)
last_evaluated_at=None,
)
# pa=900 → value=900 >= T4=896 → new_tier=4
_make_stats(1, 1, 1, pa=900)
result = _eval(1, 1)
assert result["current_tier"] == 4, (
f"Expected tier=4 for value=900, got {result['current_tier']}"
)
assert result["fully_evolved"] is True, (
f"Expected fully_evolved=True for tier=4, got {result['fully_evolved']}"
)
class TestMultiTeamStatIsolation:
"""T3-8: A player's refractor value is isolated to a specific team's stats.
The evaluator queries BattingSeasonStats WHERE player_id=? AND team_id=?.
When a player has stats on two different teams in the same season, each
team's RefractorCardState must reflect only that team's stats not a
combined total.
"""
def test_multi_team_same_season_stats_isolated(self, batter_track):
"""Each team's refractor value reflects only that team's stats, not combined.
What: Create one player with BattingSeasonStats on team_id=1 (pa=80)
and team_id=2 (pa=120) in the same season. Create a RefractorCardState
for each team. Evaluate each team's card separately and verify:
- Team 1 state: value = 80 tier = T1 (80 >= T1=37, < T2=149)
- Team 2 state: value = 120 tier = T1 (120 >= T1=37, < T2=149)
- Neither value equals the combined total (80+120=200 would be T2)
Why: Confirms the `WHERE player_id=? AND team_id=?` filter in the
evaluator is correctly applied. Without proper team isolation, the
combined total of 200 would cross the T2 threshold (149) and both
states would be incorrectly assigned to T2. This is a critical
correctness requirement: a player traded between teams should have
separate refractor progressions for their time with each franchise.
"""
# Stats on team 1: pa=80 → value=80 (T1: 37<=80<149)
_make_stats(player_id=1, team_id=1, season=11, pa=80)
# Stats on team 2: pa=120 → value=120 (T1: 37<=120<149)
_make_stats(player_id=1, team_id=2, season=11, pa=120)
# combined pa would be 200 → value=200 → T2 (149<=200<448)
# Each team must see only its own stats, not 200
_make_state(player_id=1, team_id=1, track=batter_track)
_make_state(player_id=1, team_id=2, track=batter_track)
result_team1 = _eval(player_id=1, team_id=1)
result_team2 = _eval(player_id=1, team_id=2)
# Team 1: only pa=80 counted → value=80 → T1
assert result_team1["current_value"] == 80.0, (
f"Team 1 value should be 80.0 (its own stats only), "
f"got {result_team1['current_value']}"
)
assert result_team1["current_tier"] == 1, (
f"Team 1 tier should be T1 for value=80, got {result_team1['current_tier']}"
)
# Team 2: only pa=120 counted → value=120 → T1
assert result_team2["current_value"] == 120.0, (
f"Team 2 value should be 120.0 (its own stats only), "
f"got {result_team2['current_value']}"
)
assert result_team2["current_tier"] == 1, (
f"Team 2 tier should be T1 for value=120, got {result_team2['current_tier']}"
)
# Sanity: neither team crossed T2 (which would happen if stats were combined)
assert (
result_team1["current_tier"] != 2 and result_team2["current_tier"] != 2
), (
"At least one team was incorrectly assigned T2 — stats may have been combined"
)
def test_multi_team_different_seasons_isolated(self, batter_track):
"""Stats for the same player across multiple seasons remain per-team isolated.
What: Same player with two seasons of stats for each of two teams:
- team_id=1: season 10 pa=90, season 11 pa=70 combined=160
- team_id=2: season 10 pa=100, season 11 pa=80 combined=180
After evaluation:
- Team 1: value=160 T2 (149<=160<448)
- Team 2: value=180 T2 (149<=180<448)
The test confirms that cross-team season aggregation does not bleed
stats from team 2 into team 1's calculation or vice versa.
Why: Multi-season aggregation and multi-team isolation must work
together. A bug that incorrectly sums all player stats regardless
of team would produce combined values of 340 T2, which coincidentally
passes, but the per-team values and tiers would be wrong.
This test uses values where cross-contamination would produce a
materially different value (340 vs 160/180), catching that class of bug.
"""
# Team 1 stats: total pa=160 → value=160 → T2
_make_stats(player_id=1, team_id=1, season=10, pa=90)
_make_stats(player_id=1, team_id=1, season=11, pa=70)
# Team 2 stats: total pa=180 → value=180 → T2
_make_stats(player_id=1, team_id=2, season=10, pa=100)
_make_stats(player_id=1, team_id=2, season=11, pa=80)
_make_state(player_id=1, team_id=1, track=batter_track)
_make_state(player_id=1, team_id=2, track=batter_track)
result_team1 = _eval(player_id=1, team_id=1)
result_team2 = _eval(player_id=1, team_id=2)
assert result_team1["current_value"] == 160.0, (
f"Team 1 multi-season value should be 160.0, got {result_team1['current_value']}"
)
assert result_team1["current_tier"] == 2, (
f"Team 1 tier should be T2 for value=160, got {result_team1['current_tier']}"
)
assert result_team2["current_value"] == 180.0, (
f"Team 2 multi-season value should be 180.0, got {result_team2['current_value']}"
)
assert result_team2["current_tier"] == 2, (
f"Team 2 tier should be T2 for value=180, got {result_team2['current_tier']}"
)
class TestDryRun:
"""dry_run=True writes current_value and last_evaluated_at but NOT current_tier
or fully_evolved, allowing apply_tier_boost() to write tier + variant atomically.
All tests use stats that would produce a tier-up (value=160 T2) on a card
seeded at tier=0, so the delta between dry and non-dry behaviour is obvious.
Stub thresholds (batter): T1=37, T2=149, T3=448, T4=896.
value=160 T2 (149 <= 160 < 448); starting current_tier=0 tier-up to T2.
"""
def test_dry_run_does_not_write_current_tier(self, batter_track):
"""dry_run=True leaves current_tier unchanged in the database.
What: Seed a card at tier=0. Provide stats that would advance to T2
(value=160). Call evaluate_card with dry_run=True. Re-read the DB row
and assert current_tier is still 0.
Why: The dry_run path must not persist the tier so that apply_tier_boost()
can write tier + variant atomically on the next step. If current_tier
were written here, a boost failure would leave the tier advanced with no
corresponding variant, causing an inconsistent state.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.current_tier == 0, (
f"dry_run should not write current_tier; expected 0, got {reloaded.current_tier}"
)
def test_dry_run_does_not_write_fully_evolved(self, batter_track):
"""dry_run=True leaves fully_evolved=False unchanged in the database.
What: Seed a card at tier=0 with fully_evolved=False. Provide stats that
would push to T4 (value=900). Call evaluate_card with dry_run=True.
Re-read the DB row and assert fully_evolved is still False.
Why: fully_evolved follows current_tier and must be written atomically
by apply_tier_boost(). Writing it here would let the flag get out of
sync with the tier if the boost subsequently fails.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=900) # value=900 → T4 → fully_evolved=True normally
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.fully_evolved is False, (
"dry_run should not write fully_evolved; expected False, "
f"got {reloaded.fully_evolved}"
)
def test_dry_run_writes_current_value(self, batter_track):
"""dry_run=True DOES update current_value in the database.
What: Seed a card with current_value=0. Provide stats giving value=160.
Call evaluate_card with dry_run=True. Re-read the DB row and assert
current_value has been updated to 160.0.
Why: current_value tracks formula progress and is safe to write
at any time it does not affect game logic atomicity, so it is
always persisted regardless of dry_run.
"""
_make_state(1, 1, batter_track, current_value=0.0)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.current_value == 160.0, (
f"dry_run should still write current_value; expected 160.0, "
f"got {reloaded.current_value}"
)
def test_dry_run_writes_last_evaluated_at(self, batter_track):
"""dry_run=True DOES update last_evaluated_at in the database.
What: Seed a card with last_evaluated_at=None. Call evaluate_card with
dry_run=True. Re-read the DB row and assert last_evaluated_at is now a
non-None datetime.
Why: last_evaluated_at is a bookkeeping field used for scheduling and
audit purposes. It is safe to update independently of tier writes
and should always reflect the most recent evaluation attempt.
"""
_make_state(1, 1, batter_track)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.last_evaluated_at is not None, (
"dry_run should still write last_evaluated_at; got None"
)
def test_dry_run_returns_computed_tier(self, batter_track):
"""dry_run=True return dict has computed_tier=T2 while current_tier stays 0.
What: Seed at tier=0. Stats value=160 T2. Call dry_run=True.
Assert:
- result["computed_tier"] == 2 (what the formula says)
- result["current_tier"] == 0 (what is stored; unchanged)
Why: Callers use the divergence between computed_tier and current_tier
to detect a pending tier-up. Both keys must be present and correct for
the evaluate-game endpoint to gate apply_tier_boost() correctly.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
result = _eval(1, 1, dry_run=True)
assert result["computed_tier"] == 2, (
f"computed_tier should reflect formula result T2; got {result['computed_tier']}"
)
assert result["current_tier"] == 0, (
f"current_tier should reflect unchanged DB value 0; got {result['current_tier']}"
)
def test_dry_run_returns_computed_fully_evolved(self, batter_track):
"""dry_run=True sets computed_fully_evolved correctly in the return dict.
What: Two sub-cases:
- Stats value=160 T2: computed_fully_evolved should be False.
- Stats value=900 T4: computed_fully_evolved should be True.
In both cases fully_evolved in the DB remains False (tier not written).
Why: computed_fully_evolved lets callers know whether the pending tier-up
will result in a fully-evolved card without having to re-query the DB
or recalculate the tier themselves. It must match (computed_tier >= 4),
not the stored fully_evolved value.
"""
# Sub-case 1: computed T2 → computed_fully_evolved=False
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
result = _eval(1, 1, dry_run=True)
assert result["computed_fully_evolved"] is False, (
f"computed_fully_evolved should be False for T2; got {result['computed_fully_evolved']}"
)
assert result["fully_evolved"] is False, (
"stored fully_evolved should remain False after dry_run"
)
# Reset for sub-case 2: computed T4 → computed_fully_evolved=True
CardStateStub.delete().execute()
StatsStub.delete().execute()
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=900) # value=900 → T4
result2 = _eval(1, 1, dry_run=True)
assert result2["computed_fully_evolved"] is True, (
f"computed_fully_evolved should be True for T4; got {result2['computed_fully_evolved']}"
)
assert result2["fully_evolved"] is False, (
"stored fully_evolved should remain False after dry_run even at T4"
)

View File

@ -0,0 +1,311 @@
"""Tests for image_url field in refractor cards API response.
What: Verifies that GET /api/v2/refractor/cards includes image_url in each card state
item, pulling the URL from the variant BattingCard or PitchingCard row.
Why: The refractor card art pipeline stores rendered card image URLs in the
BattingCard/PitchingCard rows. The Discord bot and website need image_url in
the /refractor/cards response so they can display variant art without a separate
lookup. These tests guard against regressions where image_url is accidentally
dropped from the response serialization.
Test cases:
test_cards_response_includes_image_url -- BattingCard with image_url set; verify
the value appears in the /cards response.
test_cards_response_image_url_null_when_not_set -- BattingCard with image_url=None;
verify null is returned (not omitted).
Uses the shared-memory SQLite TestClient pattern from test_refractor_state_api.py
so no PostgreSQL connection is required.
"""
import os
os.environ.setdefault("API_TOKEN", "test")
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingCard,
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingCard,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
AUTH_HEADER = {"Authorization": "Bearer test"}
# ---------------------------------------------------------------------------
# SQLite database + model list
# ---------------------------------------------------------------------------
_img_url_db = SqliteDatabase(
"file:imgurlapitest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
# Full model list matching the existing state API tests — needed so all FK
# constraints resolve in SQLite.
_IMG_URL_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
BattingCard,
PitchingCard,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
@pytest.fixture(autouse=False)
def setup_img_url_db():
"""Bind image-url test models to shared-memory SQLite and create tables.
What: Initialises the in-process SQLite database before each test and drops
all tables afterwards to ensure test isolation.
Why: SQLite shared-memory databases persist between tests in the same
process unless tables are dropped. Creating and dropping around each test
guarantees a clean state without requiring a real PostgreSQL instance.
"""
_img_url_db.bind(_IMG_URL_MODELS)
_img_url_db.connect(reuse_if_open=True)
_img_url_db.create_tables(_IMG_URL_MODELS)
yield _img_url_db
_img_url_db.drop_tables(list(reversed(_IMG_URL_MODELS)), safe=True)
def _build_image_url_app() -> FastAPI:
"""Minimal FastAPI app with refractor router for image_url tests."""
from app.routers_v2.refractor import router as refractor_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_img_url_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(refractor_router)
return app
@pytest.fixture
def img_url_client(setup_img_url_db):
"""FastAPI TestClient backed by shared-memory SQLite for image_url tests."""
with TestClient(_build_image_url_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Seed helpers
# ---------------------------------------------------------------------------
def _make_rarity():
r, _ = Rarity.get_or_create(
value=10, name="IU_Common", defaults={"color": "#ffffff"}
)
return r
def _make_cardset():
cs, _ = Cardset.get_or_create(
name="IU Test Set",
defaults={"description": "image url test cardset", "total_cards": 1},
)
return cs
def _make_player(name: str = "Test Player") -> Player:
return Player.create(
p_name=name,
rarity=_make_rarity(),
cardset=_make_cardset(),
set_num=1,
pos_1="CF",
image="https://example.com/img.png",
mlbclub="TST",
franchise="TST",
description="image url test",
)
def _make_team(suffix: str = "IU") -> Team:
return Team.create(
abbrev=suffix,
sname=suffix,
lname=f"Team {suffix}",
gmid=99900 + len(suffix),
gmname=f"gm_{suffix.lower()}",
gsheet="https://docs.google.com/iu_test",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _make_track(card_type: str = "batter") -> RefractorTrack:
track, _ = RefractorTrack.get_or_create(
name=f"IU {card_type} Track",
defaults=dict(
card_type=card_type,
formula="pa",
t1_threshold=100,
t2_threshold=300,
t3_threshold=700,
t4_threshold=1200,
),
)
return track
def _make_batting_card(player: Player, variant: int, image_url=None) -> BattingCard:
return BattingCard.create(
player=player,
variant=variant,
steal_low=1,
steal_high=3,
steal_auto=False,
steal_jump=1.0,
bunting="N",
hit_and_run="N",
running=5,
offense_col=1,
hand="R",
image_url=image_url,
)
def _make_card_state(
player: Player,
team: Team,
track: RefractorTrack,
variant: int,
current_tier: int = 1,
current_value: float = 150.0,
) -> RefractorCardState:
import datetime
return RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=datetime.datetime(2026, 4, 1, 12, 0, 0),
variant=variant,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_cards_response_includes_image_url(setup_img_url_db, img_url_client):
"""GET /api/v2/refractor/cards includes image_url when the variant BattingCard has one.
What: Seeds a RefractorCardState at variant=1 and a matching BattingCard with
image_url set. Calls the /cards endpoint and asserts that image_url in the
response matches the seeded URL.
Why: This is the primary happy-path test for the image_url feature. If the
DB lookup in _build_card_state_response fails or the field is accidentally
omitted from the response dict, this test will catch it.
"""
player = _make_player("Homer Simpson")
team = _make_team("IU1")
track = _make_track("batter")
expected_url = (
"https://s3.example.com/cards/cardset-001/player-1/v1/battingcard.png"
)
_make_batting_card(player, variant=1, image_url=expected_url)
_make_card_state(player, team, track, variant=1)
resp = img_url_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["count"] == 1
item = data["items"][0]
assert "image_url" in item, "image_url key missing from response"
assert item["image_url"] == expected_url
def test_cards_response_image_url_null_when_not_set(setup_img_url_db, img_url_client):
"""GET /api/v2/refractor/cards returns image_url: null when BattingCard.image_url is None.
What: Seeds a BattingCard with image_url=None and a RefractorCardState at
variant=1. Verifies the response contains image_url with a null value.
Why: The image_url field must always be present in the response (even when
null) so API consumers can rely on its presence. Returning null rather than
omitting the key is the correct contract omitting it would break consumers
that check for the key's presence to determine upload status.
"""
player = _make_player("Bart Simpson")
team = _make_team("IU2")
track = _make_track("batter")
_make_batting_card(player, variant=1, image_url=None)
_make_card_state(player, team, track, variant=1)
resp = img_url_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["count"] == 1
item = data["items"][0]
assert "image_url" in item, "image_url key missing from response"
assert item["image_url"] is None

View File

@ -158,6 +158,50 @@ class TestDetermineCardType:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDetermineCardTypeEdgeCases:
"""T2-2: Parametrized edge cases for _determine_card_type.
Covers all the boundary inputs identified in the PO review:
DH, C, 2B (batters), empty string, None, and the compound 'SP/RP'
which contains both 'SP' and 'RP' substrings.
The function checks 'SP' before 'RP'/'CP', so 'SP/RP' resolves to 'sp'.
"""
@pytest.mark.parametrize(
"pos_1, expected",
[
# Plain batter positions
("DH", "batter"),
("C", "batter"),
("2B", "batter"),
# Empty / None — fall through to batter default
("", "batter"),
(None, "batter"),
# Compound string containing 'SP' first — must resolve to 'sp'
# because _determine_card_type checks "SP" in pos.upper() before RP/CP
("SP/RP", "sp"),
],
)
def test_position_mapping(self, pos_1, expected):
"""_determine_card_type maps each pos_1 value to the expected card_type.
What: Directly exercises _determine_card_type with the given pos_1 string.
None is handled by the `(player.pos_1 or "").upper()` guard in the
implementation, so it falls through to 'batter'.
Why: The card_type string is the key used to look up a RefractorTrack.
An incorrect mapping silently assigns the wrong thresholds to a player's
entire refractor journey. Parametrized so each edge case is a
distinct, independently reported test failure.
"""
player = _FakePlayer(pos_1)
assert _determine_card_type(player) == expected, (
f"pos_1={pos_1!r}: expected {expected!r}, "
f"got {_determine_card_type(player)!r}"
)
class TestInitializeCardEvolution: class TestInitializeCardEvolution:
"""Integration tests for initialize_card_refractor against in-memory SQLite. """Integration tests for initialize_card_refractor against in-memory SQLite.

View File

@ -5,8 +5,6 @@ Covers WP-01 acceptance criteria:
- RefractorTrack: CRUD and unique-name constraint - RefractorTrack: CRUD and unique-name constraint
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint, - RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
and FK resolution back to RefractorTrack and FK resolution back to RefractorTrack
- RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
- RefractorCosmetic: CRUD and unique-name constraint
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season), - BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
and in-place stat accumulation and in-place stat accumulation
@ -22,8 +20,6 @@ from playhouse.shortcuts import model_to_dict
from app.db_engine import ( from app.db_engine import (
BattingSeasonStats, BattingSeasonStats,
RefractorCardState, RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack, RefractorTrack,
) )
@ -134,115 +130,6 @@ class TestRefractorCardState:
assert resolved_track.name == "Batter Track" assert resolved_track.name == "Batter Track"
# ---------------------------------------------------------------------------
# RefractorTierBoost
# ---------------------------------------------------------------------------
class TestRefractorTierBoost:
"""Tests for RefractorTierBoost, the per-tier stat/rating bonus table.
Each row maps a (track, tier) combination to a single boost the
specific stat or rating column to buff and by how much. The four-
column unique constraint prevents double-booking the same boost slot.
"""
def test_create_tier_boost(self, track):
"""Creating a boost row persists all fields accurately.
Verifies boost_type, boost_target, and boost_value are stored
and retrieved without modification.
"""
boost = RefractorTierBoost.create(
track=track,
tier=1,
boost_type="rating",
boost_target="contact_vl",
boost_value=1.5,
)
fetched = RefractorTierBoost.get_by_id(boost.id)
assert fetched.track_id == track.id
assert fetched.tier == 1
assert fetched.boost_type == "rating"
assert fetched.boost_target == "contact_vl"
assert fetched.boost_value == 1.5
def test_tier_boost_unique_constraint(self, track):
"""Duplicate (track, tier, boost_type, boost_target) raises IntegrityError.
The four-column unique index ensures that a single boost slot
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the
same track, which would create ambiguity during evolution evaluation.
"""
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
boost_target="power_vr",
boost_value=2.0,
)
with pytest.raises(IntegrityError):
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
boost_target="power_vr",
boost_value=3.0, # different value, same identity columns
)
# ---------------------------------------------------------------------------
# RefractorCosmetic
# ---------------------------------------------------------------------------
class TestRefractorCosmetic:
"""Tests for RefractorCosmetic, decorative unlocks tied to evolution tiers.
Cosmetics are purely visual rewards (frames, badges, themes) that a
card unlocks when it reaches a required tier. The name column is
the stable identifier and carries a UNIQUE constraint.
"""
def test_create_cosmetic(self):
"""Creating a cosmetic persists all fields correctly.
Verifies all columns including optional ones (css_class, asset_url)
are stored and retrieved.
"""
cosmetic = RefractorCosmetic.create(
name="Gold Frame",
tier_required=2,
cosmetic_type="frame",
css_class="evo-frame-gold",
asset_url="https://cdn.example.com/frames/gold.png",
)
fetched = RefractorCosmetic.get_by_id(cosmetic.id)
assert fetched.name == "Gold Frame"
assert fetched.tier_required == 2
assert fetched.cosmetic_type == "frame"
assert fetched.css_class == "evo-frame-gold"
assert fetched.asset_url == "https://cdn.example.com/frames/gold.png"
def test_cosmetic_unique_name(self):
"""Inserting a second cosmetic with the same name raises IntegrityError.
The UNIQUE constraint on RefractorCosmetic.name prevents duplicate
cosmetic definitions that could cause ambiguous tier unlock lookups.
"""
RefractorCosmetic.create(
name="Silver Badge",
tier_required=1,
cosmetic_type="badge",
)
with pytest.raises(IntegrityError):
RefractorCosmetic.create(
name="Silver Badge", # duplicate
tier_required=3,
cosmetic_type="badge",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# BattingSeasonStats # BattingSeasonStats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -124,6 +124,89 @@ def test_seed_idempotent():
assert RefractorTrack.select().count() == 3 assert RefractorTrack.select().count() == 3
# ---------------------------------------------------------------------------
# T1-4: Seed threshold ordering invariant (t1 < t2 < t3 < t4 + all positive)
# ---------------------------------------------------------------------------
def test_seed_all_thresholds_strictly_ascending_after_seed():
"""After seeding, every track satisfies t1 < t2 < t3 < t4.
What: Call seed_refractor_tracks(), then assert the full ordering chain
t1 < t2 < t3 < t4 for every row in the database. Also assert that all
four thresholds are strictly positive (> 0).
Why: The refractor tier engine uses these thresholds as exclusive partition
points. If any threshold is out-of-order or zero the tier assignment
becomes incorrect or undefined. This test is the authoritative invariant
guard; if a JSON edit accidentally violates the ordering this test fails
loudly before any cards are affected.
Separate from test_seed_thresholds_ascending which was written earlier
this test combines ordering + positivity into a single explicit assertion
block and uses more descriptive messages to aid debugging.
"""
seed_refractor_tracks()
for track in RefractorTrack.select():
assert track.t1_threshold > 0, (
f"{track.name}: t1_threshold={track.t1_threshold} is not positive"
)
assert track.t2_threshold > 0, (
f"{track.name}: t2_threshold={track.t2_threshold} is not positive"
)
assert track.t3_threshold > 0, (
f"{track.name}: t3_threshold={track.t3_threshold} is not positive"
)
assert track.t4_threshold > 0, (
f"{track.name}: t4_threshold={track.t4_threshold} is not positive"
)
assert (
track.t1_threshold
< track.t2_threshold
< track.t3_threshold
< track.t4_threshold
), (
f"{track.name}: thresholds are not strictly ascending: "
f"t1={track.t1_threshold}, t2={track.t2_threshold}, "
f"t3={track.t3_threshold}, t4={track.t4_threshold}"
)
# ---------------------------------------------------------------------------
# T2-10: Duplicate card_type tracks guard
# ---------------------------------------------------------------------------
def test_seed_each_card_type_has_exactly_one_track():
"""Each card_type must appear exactly once across all RefractorTrack rows.
What: After seeding, group the rows by card_type and assert that every
card_type has a count of exactly 1.
Why: RefractorTrack rows are looked up by card_type (e.g.
RefractorTrack.get(card_type='batter')). If a card_type appears more
than once, Peewee's .get() raises MultipleObjectsReturned, crashing
every pack opening and card evaluation for that type. This test acts as
a uniqueness contract so that seed bugs or accidental DB drift surface
immediately.
"""
seed_refractor_tracks()
from peewee import fn as peewee_fn
# Group by card_type and count occurrences
query = (
RefractorTrack.select(
RefractorTrack.card_type, peewee_fn.COUNT(RefractorTrack.id).alias("cnt")
)
.group_by(RefractorTrack.card_type)
.tuples()
)
for card_type, count in query:
assert count == 1, (
f"card_type={card_type!r} has {count} tracks; expected exactly 1"
)
def test_seed_updates_on_rerun(json_tracks): def test_seed_updates_on_rerun(json_tracks):
"""A second seed call must restore any manually changed threshold to the JSON value. """A second seed call must restore any manually changed threshold to the JSON value.

View File

@ -34,12 +34,45 @@ Test matrix
test_get_card_404_no_state -- card with no RefractorCardState returns 404 test_get_card_404_no_state -- card with no RefractorCardState returns 404
test_duplicate_cards_share_state -- two cards same player+team return the same state row test_duplicate_cards_share_state -- two cards same player+team return the same state row
test_auth_required -- missing token returns 401 on both endpoints test_auth_required -- missing token returns 401 on both endpoints
Tier 3 tests (T3-6) use a SQLite-backed TestClient and run without a PostgreSQL
connection. They test GET /api/v2/refractor/cards/{card_id} when the state row
has last_evaluated_at=None (card initialised but never evaluated).
test_get_card_state_last_evaluated_at_null -- last_evaluated_at: null in response
""" """
import os import os
os.environ.setdefault("API_TOKEN", "test-token")
import pytest import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorCardState,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
POSTGRES_HOST = os.environ.get("POSTGRES_HOST") POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
_skip_no_pg = pytest.mark.skipif( _skip_no_pg = pytest.mark.skipif(
@ -607,3 +640,395 @@ def test_auth_required(client, seeded_data):
resp_card = client.get(f"/api/v2/refractor/cards/{card_id}") resp_card = client.get(f"/api/v2/refractor/cards/{card_id}")
assert resp_card.status_code == 401 assert resp_card.status_code == 401
# ===========================================================================
# SQLite-backed tests for T2-4, T2-5, T2-6, T3-6
#
# These tests use the same shared-memory SQLite pattern as test_postgame_refractor
# so they run without a PostgreSQL connection. They test the
# GET /api/v2/teams/{team_id}/refractors, POST /refractor/cards/{card_id}/evaluate,
# and GET /api/v2/refractor/cards/{card_id} endpoints in isolation.
# ===========================================================================
_state_api_db = SqliteDatabase(
"file:stateapitest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
_STATE_API_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
RefractorTrack,
RefractorCardState,
]
@pytest.fixture(autouse=False)
def setup_state_api_db():
"""Bind state-api test models to shared-memory SQLite and create tables.
Not autouse only the SQLite-backed tests in this section depend on it.
"""
_state_api_db.bind(_STATE_API_MODELS)
_state_api_db.connect(reuse_if_open=True)
_state_api_db.create_tables(_STATE_API_MODELS)
yield _state_api_db
_state_api_db.drop_tables(list(reversed(_STATE_API_MODELS)), safe=True)
def _build_state_api_app() -> FastAPI:
"""Minimal FastAPI app with teams + refractor routers for SQLite tests."""
from app.routers_v2.teams import router as teams_router
from app.routers_v2.refractor import router as refractor_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_state_api_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(teams_router)
app.include_router(refractor_router)
return app
@pytest.fixture
def state_api_client(setup_state_api_db):
"""FastAPI TestClient for the SQLite-backed state API tests."""
with TestClient(_build_state_api_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Helper factories for SQLite-backed tests
# ---------------------------------------------------------------------------
def _sa_make_rarity():
r, _ = Rarity.get_or_create(
value=50, name="SA_Common", defaults={"color": "#aabbcc"}
)
return r
def _sa_make_cardset():
cs, _ = Cardset.get_or_create(
name="SA Test Set",
defaults={"description": "state api test", "total_cards": 10},
)
return cs
def _sa_make_team(abbrev: str, gmid: int) -> Team:
return Team.create(
abbrev=abbrev,
sname=abbrev,
lname=f"Team {abbrev}",
gmid=gmid,
gmname=f"gm_{abbrev.lower()}",
gsheet="https://docs.google.com/sa_test",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _sa_make_player(name: str, pos: str = "1B") -> Player:
return Player.create(
p_name=name,
rarity=_sa_make_rarity(),
cardset=_sa_make_cardset(),
set_num=1,
pos_1=pos,
image="https://example.com/sa.png",
mlbclub="TST",
franchise="TST",
description=f"sa test: {name}",
)
def _sa_make_track(card_type: str = "batter") -> RefractorTrack:
track, _ = RefractorTrack.get_or_create(
name=f"SA {card_type} Track",
defaults=dict(
card_type=card_type,
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
return track
def _sa_make_pack(team: Team) -> Pack:
pt, _ = PackType.get_or_create(
name="SA PackType",
defaults={"cost": 100, "card_count": 5, "description": "sa test pack type"},
)
return Pack.create(team=team, pack_type=pt)
def _sa_make_card(player: Player, team: Team) -> Card:
pack = _sa_make_pack(team)
return Card.create(player=player, team=team, pack=pack, value=0)
def _sa_make_state(player, team, track, current_tier=0, current_value=0.0):
return RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=None,
)
# ---------------------------------------------------------------------------
# T2-4: GET /teams/{valid_team_id}/refractors — team exists, zero states
# ---------------------------------------------------------------------------
def test_team_refractors_zero_states(setup_state_api_db, state_api_client):
"""GET /teams/{id}/refractors for a team with no RefractorCardState rows.
What: Create a Team with no associated RefractorCardState rows.
Call the endpoint and verify the response is {"count": 0, "items": []}.
Why: The endpoint uses a JOIN from RefractorCardState to RefractorTrack
filtered by team_id. If the WHERE produces no rows, the correct response
is an empty list with count=0, not a 404 or 500. This is the base-case
for a newly-created team that hasn't opened any packs yet.
"""
team = _sa_make_team("SA4", gmid=30041)
resp = state_api_client.get(
f"/api/v2/teams/{team.id}/refractors", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["items"] == []
# ---------------------------------------------------------------------------
# T2-5: GET /teams/99999/refractors — non-existent team
# ---------------------------------------------------------------------------
def test_team_refractors_nonexistent_team(setup_state_api_db, state_api_client):
"""GET /teams/99999/refractors where team_id 99999 does not exist.
What: Call the endpoint with a team_id that has no Team row and no
RefractorCardState rows.
Why: Documents the confirmed behaviour: 200 with {"count": 0, "items": []}.
The endpoint queries RefractorCardState WHERE team_id=99999. Because no
state rows reference that team, the result is an empty list. The endpoint
does NOT validate that the Team row itself exists, so it does not return 404.
If the implementation is ever changed to validate team existence and return
404 for missing teams, this test will fail and surface the contract change.
"""
resp = state_api_client.get("/api/v2/teams/99999/refractors", headers=AUTH_HEADER)
assert resp.status_code == 200
data = resp.json()
# No state rows reference team 99999 — empty list with count=0
assert data["count"] == 0
assert data["items"] == []
# ---------------------------------------------------------------------------
# T2-6: POST /refractor/cards/{card_id}/evaluate — zero season stats → T0
# ---------------------------------------------------------------------------
def test_evaluate_card_zero_stats_stays_t0(setup_state_api_db, state_api_client):
"""POST /cards/{card_id}/evaluate for a card with no season stats stays at T0.
What: Create a Player, Team, Card, and RefractorCardState. Do NOT create
any BattingSeasonStats rows for this player+team. Call the evaluate
endpoint. The response must show current_tier=0 and current_value=0.0.
Why: A player who has never appeared in a game has zero career stats.
The evaluator sums all stats rows (none) -> all-zero totals ->
compute_batter_value(zeros) = 0.0 -> tier_from_value(0.0) = T0.
Verifies the happy-path zero-stats case returns a valid response rather
than crashing on an empty aggregation.
"""
team = _sa_make_team("SA6", gmid=30061)
player = _sa_make_player("SA6 Batter", pos="1B")
track = _sa_make_track("batter")
card = _sa_make_card(player, team)
_sa_make_state(player, team, track, current_tier=0, current_value=0.0)
# No BattingSeasonStats rows — intentionally empty
resp = state_api_client.post(
f"/api/v2/refractor/cards/{card.id}/evaluate", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["current_tier"] == 0
assert data["current_value"] == 0.0
# ---------------------------------------------------------------------------
# T3-6: GET /refractor/cards/{card_id} — last_evaluated_at is None
# ---------------------------------------------------------------------------
def test_get_card_state_last_evaluated_at_null(setup_state_api_db, state_api_client):
"""GET /refractor/cards/{card_id} returns last_evaluated_at: null for un-evaluated card.
What: Create a Player, Team, Card, and RefractorCardState where
last_evaluated_at is explicitly None (the state was initialised via a
pack-open hook but has never been through the evaluator). Call
GET /api/v2/refractor/cards/{card_id} and verify:
- The response status is 200 (not a 500 crash from calling .isoformat() on None).
- The response body contains the key 'last_evaluated_at'.
- The value of 'last_evaluated_at' is JSON null (Python None after parsing).
Why: The _build_card_state_response helper serialises last_evaluated_at
with `state.last_evaluated_at.isoformat() if state.last_evaluated_at else None`.
This test confirms that the None branch is exercised and the field is always
present in the response envelope. Callers must be able to distinguish
"never evaluated" (null) from a real ISO-8601 timestamp, and the API must
not crash on a newly-created card that has not yet been evaluated.
"""
team = _sa_make_team("SA_T36", gmid=30360)
player = _sa_make_player("T36 Batter", pos="1B")
track = _sa_make_track("batter")
card = _sa_make_card(player, team)
# Create state with last_evaluated_at=None — simulates a freshly initialised
# card that has not yet been through the evaluator
RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=0,
current_value=0.0,
fully_evolved=False,
last_evaluated_at=None,
)
resp = state_api_client.get(
f"/api/v2/refractor/cards/{card.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
data = resp.json()
# 'last_evaluated_at' must be present as a key even when the value is null
assert "last_evaluated_at" in data, (
"Response is missing the 'last_evaluated_at' key"
)
assert data["last_evaluated_at"] is None, (
f"Expected last_evaluated_at=null for un-evaluated card, "
f"got {data['last_evaluated_at']!r}"
)
# ---------------------------------------------------------------------------
# T3-8: GET /refractor/cards?team_id=X&evaluated_only=false includes un-evaluated
# ---------------------------------------------------------------------------
def test_list_cards_evaluated_only_false_includes_unevaluated(
setup_state_api_db, state_api_client
):
"""GET /refractor/cards?team_id=X&evaluated_only=false returns cards with last_evaluated_at=NULL.
What: Create two RefractorCardState rows for the same team one with
last_evaluated_at=None (never evaluated) and one with last_evaluated_at set
to a timestamp (has been evaluated). Call the endpoint twice:
1. evaluated_only=true (default) only the evaluated card appears.
2. evaluated_only=false both cards appear.
Why: The default evaluated_only=True filter uses
`last_evaluated_at IS NOT NULL` to exclude placeholder rows created at
pack-open time but never run through the evaluator. At team scale (2753
rows, ~14 evaluated) this filter is critical for bot performance.
This test verifies the opt-out path (evaluated_only=false) exposes all rows,
which is needed for admin/pipeline use cases.
"""
from datetime import datetime, timezone
team = _sa_make_team("SA_T38", gmid=30380)
track = _sa_make_track("batter")
# Card 1: never evaluated — last_evaluated_at is NULL
player_unevaluated = _sa_make_player("T38 Unevaluated", pos="1B")
card_unevaluated = _sa_make_card(player_unevaluated, team) # noqa: F841
RefractorCardState.create(
player=player_unevaluated,
team=team,
track=track,
current_tier=0,
current_value=0.0,
fully_evolved=False,
last_evaluated_at=None,
)
# Card 2: has been evaluated — last_evaluated_at is a timestamp
player_evaluated = _sa_make_player("T38 Evaluated", pos="RF")
card_evaluated = _sa_make_card(player_evaluated, team) # noqa: F841
RefractorCardState.create(
player=player_evaluated,
team=team,
track=track,
current_tier=1,
current_value=5.0,
fully_evolved=False,
last_evaluated_at=datetime(2025, 4, 1, tzinfo=timezone.utc),
)
# Default (evaluated_only=true) — only the evaluated card should appear
resp_default = state_api_client.get(
f"/api/v2/refractor/cards?team_id={team.id}", headers=AUTH_HEADER
)
assert resp_default.status_code == 200
data_default = resp_default.json()
assert data_default["count"] == 1, (
f"evaluated_only=true should return 1 card, got {data_default['count']}"
)
assert data_default["items"][0]["player_name"] == "T38 Evaluated"
# evaluated_only=false — both cards should appear
resp_all = state_api_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp_all.status_code == 200
data_all = resp_all.json()
assert data_all["count"] == 2, (
f"evaluated_only=false should return 2 cards, got {data_all['count']}"
)
names = {item["player_name"] for item in data_all["items"]}
assert "T38 Unevaluated" in names
assert "T38 Evaluated" in names

View File

@ -11,12 +11,45 @@ Tests auto-skip when POSTGRES_HOST is not set.
Test data is inserted via psycopg2 before the test module runs and deleted Test data is inserted via psycopg2 before the test module runs and deleted
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
even if a previous run did not complete teardown. even if a previous run did not complete teardown.
Tier 3 tests (T3-1) in this file use a SQLite-backed TestClient so they run
without a PostgreSQL connection. They test the card_type filter edge cases:
an unrecognised card_type string and an empty string should both return an
empty list (200 with count=0) rather than an error.
""" """
import os import os
import pytest import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import SqliteDatabase
os.environ.setdefault("API_TOKEN", "test-token")
from app.db_engine import ( # noqa: E402
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorCardState,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
POSTGRES_HOST = os.environ.get("POSTGRES_HOST") POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
_skip_no_pg = pytest.mark.skipif( _skip_no_pg = pytest.mark.skipif(
@ -130,3 +163,170 @@ def test_auth_required(client, seeded_tracks):
track_id = seeded_tracks[0] track_id = seeded_tracks[0]
resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}") resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}")
assert resp_single.status_code == 401 assert resp_single.status_code == 401
# ===========================================================================
# SQLite-backed tests for T3-1: invalid card_type query parameter
#
# These tests run without a PostgreSQL connection. They verify that the
# card_type filter on GET /api/v2/refractor/tracks handles values that match
# no known track (an unrecognised string, an empty string) gracefully: the
# endpoint must return 200 with {"count": 0, "items": []}, not a 4xx/5xx.
# ===========================================================================
_track_api_db = SqliteDatabase(
"file:trackapitest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
_TRACK_API_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
RefractorTrack,
RefractorCardState,
]
@pytest.fixture(autouse=False)
def setup_track_api_db():
"""Bind track-API test models to shared-memory SQLite and create tables.
Inserts exactly two tracks (batter, sp) so the filter tests have a
non-empty table to query against confirming that the WHERE predicate
excludes them rather than the table simply being empty.
"""
_track_api_db.bind(_TRACK_API_MODELS)
_track_api_db.connect(reuse_if_open=True)
_track_api_db.create_tables(_TRACK_API_MODELS)
# Seed two real tracks so the table is not empty
RefractorTrack.get_or_create(
name="T3-1 Batter Track",
defaults=dict(
card_type="batter",
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
RefractorTrack.get_or_create(
name="T3-1 SP Track",
defaults=dict(
card_type="sp",
formula="ip + k",
t1_threshold=10,
t2_threshold=40,
t3_threshold=120,
t4_threshold=240,
),
)
yield _track_api_db
_track_api_db.drop_tables(list(reversed(_TRACK_API_MODELS)), safe=True)
def _build_track_api_app() -> FastAPI:
"""Minimal FastAPI app containing only the refractor router for T3-1 tests."""
from app.routers_v2.refractor import router as refractor_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_track_api_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(refractor_router)
return app
@pytest.fixture
def track_api_client(setup_track_api_db):
"""FastAPI TestClient for the SQLite-backed T3-1 track filter tests."""
with TestClient(_build_track_api_app()) as c:
yield c
# ---------------------------------------------------------------------------
# T3-1a: card_type=foo (unrecognised value) returns empty list
# ---------------------------------------------------------------------------
def test_invalid_card_type_returns_empty_list(setup_track_api_db, track_api_client):
"""GET /tracks?card_type=foo returns 200 with count=0, not a 4xx/5xx.
What: Query the track list with a card_type value ('foo') that matches
no row in refractor_track. The table contains batter and sp tracks so
the result must be an empty list rather than a full list (which would
indicate the filter was ignored).
Why: The endpoint applies `WHERE card_type == card_type` when the
parameter is not None. An unrecognised value is a valid no-match query
the contract is an empty list, not a validation error. Returning
a 422 Unprocessable Entity or 500 here would break clients that probe
for tracks by card type before knowing which types are registered.
"""
resp = track_api_client.get(
"/api/v2/refractor/tracks?card_type=foo", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0, (
f"Expected count=0 for unknown card_type 'foo', got {data['count']}"
)
assert data["items"] == [], (
f"Expected empty items list for unknown card_type 'foo', got {data['items']}"
)
# ---------------------------------------------------------------------------
# T3-1b: card_type= (empty string) returns empty list
# ---------------------------------------------------------------------------
def test_empty_string_card_type_returns_empty_list(
setup_track_api_db, track_api_client
):
"""GET /tracks?card_type= (empty string) returns 200 with count=0.
What: Pass an empty string as the card_type query parameter. No track
has card_type='' so the response must be an empty list with count=0.
Why: An empty string is not None FastAPI will pass it through as ''
rather than treating it as an absent parameter. The WHERE predicate
`card_type == ''` produces no matches, which is the correct silent
no-results behaviour. This guards against regressions where an empty
string might be mishandled as a None/absent value and accidentally return
all tracks, or raise a server error.
"""
resp = track_api_client.get(
"/api/v2/refractor/tracks?card_type=", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0, (
f"Expected count=0 for empty card_type string, got {data['count']}"
)
assert data["items"] == [], (
f"Expected empty items list for empty card_type string, got {data['items']}"
)