docs: add Major Domo and Paper Dynasty release notes and card evolution PRD
All checks were successful
Reindex Knowledge Base / reindex (push) Successful in 5s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 22:29:18 -05:00
parent f2258dfade
commit aafe527d51
20 changed files with 2494 additions and 0 deletions

View File

@ -0,0 +1,55 @@
---
title: Major Domo Database Release — 2026.3.17
type: reference
domain: server-configs
tags: [major-domo, deployment, release-notes, database]
---
# Major Domo Database Release — 2026.3.17
**Date:** 2026-03-17
**Branch:** next-release → main (PR #64, 11 commits)
**Repo:** cal/major-domo-database
**Server:** akamai (`sba_db_api` container, port 801)
## Release Summary
11 commits from `next-release` rebased onto `main`. Mix of query performance improvements, bug fixes, and code quality cleanup. No schema migrations — safe drop-in deploy.
## Performance
- **Push limit/offset to DB in `PlayerService.get_players`** (#37): Previously fetched all rows then sliced in Python. Now passes `LIMIT`/`OFFSET` directly to the Peewee query, eliminating full-table reads on paginated requests.
- **Eliminate N+1 queries in batch POST endpoints** (#25): Replaced per-row `get_or_none()` loops in `battingstats`, `results`, `schedules`, and `transactions` batch insert endpoints with a single bulk set-lookup before the write loop.
## Bug Fixes
- **Fix `lob_2outs` and `rbipercent` calculation in `SeasonPitchingStats`** (#28): Both fields were returning incorrect values due to wrong formula inputs. Now computed correctly from the underlying stat columns.
- **Invalidate cache after `PlayerService` write operations** (#32): Cache was being populated on reads but never invalidated on writes, causing stale player data to persist until the next restart.
- **Validate `sort_by` parameter with `Literal` type in `views.py`** (#36): Unvalidated `sort_by` could cause silent no-ops or unexpected ordering. Now enforced with a `Literal` type annotation, raising a 422 on invalid values.
## Code Quality
- **Replace bare `except:` with `except Exception:`** (#29): Silencing `KeyboardInterrupt`, `SystemExit`, and `GeneratorExit` is an antipattern. All 6 instances updated.
- **Remove unused imports in `standings.py` and `pitchingstats.py`** (#30): Dead imports removed (`pydantic` in `standings.py`).
- **Replace `print(req.scope)` with `logger.debug` in `/api/docs`** (#21): Debug print statement that logged full request scope on every docs page load replaced with a conditional logger call.
- **Remove empty `WEEK_NUMS` dict from `db_engine.py`** (#34): Unused stub dict removed.
- **Remove unimplementable skipped caching tests** (#33): Tests marked `@pytest.mark.skip` for a caching interface that was never built. Removed rather than leaving permanently skipped.
## Conflict Resolution
Branch was rebased (not squash-merged) onto main. Six files had textual conflicts from two main-branch fixes that landed after `next-release` diverged:
**Trailing slash regression (6 router files)**
- PR #61 on main fixed a production bug: `aiohttp` converts POST→GET on 307 redirects, silently dropping request bodies. The fix changed all `@router.post("")``@router.post("/")`.
- next-release's batch N+1 and bare-except PRs all touched the same files with the old `""` signature.
- Resolution: kept `@router.post("/")` from main, applied next-release's functional changes on top.
**Case-insensitive `team_abbrev` filter (`transactions.py`)**
- PR #60 on main fixed affiliate roster transactions being silently dropped due to case mismatch (input uppercased, DB column mixed-case). Fix used `fn.UPPER(Team.abbrev)`.
- next-release's version of `transactions.py` predated this fix.
- Resolution: preserved `fn.UPPER(Team.abbrev)` from main alongside next-release's N+1 fix.
**`requirements.txt`**
- Main has fully pinned deps including `starlette==0.52.1` (from 2026-03-09 outage) and a new `requirements-dev.txt`.
- next-release had loose pins and no `requirements-dev.txt`.
- Resolution: took main's fully-pinned version entirely.

View File

@ -0,0 +1,105 @@
---
title: Major Domo v2 Release — 2026.3.17
type: reference
domain: server-configs
tags: [discord, major-domo, deployment, release-notes]
---
# Major Domo v2 Release — 2026.3.17
**Date:** 2026-03-17
**Branch:** next-release → main (PR #81, 33 commits)
**Image:** `manticorum67/major-domo-discordapp:latest`
**Server:** akamai (`/root/container-data/major-domo`)
**Deploy script:** `/mnt/NV2/Development/major-domo/discord-app-v2/.scripts/deploy.sh`
## Release Summary
Merged 33 commits from `next-release` into `main`. This was the first release cut since the next-release branch workflow was adopted. A merge conflict in `views/trade_embed.py` required manual resolution.
## Merge Conflict Resolution
**File:** `views/trade_embed.py`
**Cause:** Two divergent changes since the branch point:
- **main hotfix** (`e98a658`): Stripped all emoji from trade embed UI, added inline validation error details to Quick Status
- **next-release refactor** (`858663c`): Moved 42 lazy imports to top-level, which also touched this file and had emoji additions
**Resolution:** Kept main's hotfix (no emoji, detailed validation errors) as the authoritative version, then applied only the import refactor from next-release (hoisted lazy imports to top-level). Merged main into next-release first to resolve, then PR #81 merged cleanly.
## Key Changes in This Release
### Trades & Transactions
- Trade validation now checks projected rosters (next week + pending transactions)
- Player roster auto-detection in `/trade add-player` and `/trade supplementary` (was hardcoded to Major League, caused cascading validation errors)
- Team-prefixed validation errors: `[BSG] Too many ML players`
- Stale transaction cache fixed — TransactionBuilder re-fetches on each validation
- Stale roster cache fixed — refreshed before validation, invalidated after submission
### Scorecard Submission
- All spreadsheet data read before any DB writes (prevents partial state on formula errors)
- Detailed error messages shown to users instead of generic failure
### Commands & Permissions
- `/injury set-new` and `/injury clear` verify team ownership (admins bypass)
- `/admin-maintenance` maintenance mode flag implemented (but see Post-Deploy Issues below)
- Roster labels updated: Active Roster / Minor League / Injured List sections
### Bug Fixes
- Key plays score text: tied scores now show correctly
- `is_admin()` helper prevents AttributeError in DM contexts
### Performance & Code Quality
- Persistent aiohttp session for Giphy (eliminates per-call TCP/DNS overhead)
- 42 lazy imports moved to top-level across codebase
- Deduplicated command hash logic
### CI/CD
- `next-release` branch now triggers Docker builds via shared `docker-tags` composite action
## Post-Deploy Issues
### 1. Maintenance mode interaction_check was broken (FIXED)
**Discovered immediately after deploy.** RuntimeWarning in logs:
```
RuntimeWarning: coroutine 'CommandTree.interaction_check' was never awaited
```
**Root cause:** `@self.tree.interaction_check` is not a decorator — it's an async method meant to be overridden via subclassing. The check was never registered; maintenance mode was a complete no-op.
**Fix:** PR #83 — Created `MaintenanceAwareTree(CommandTree)` subclass, passed via `tree_cls=` to Bot constructor. Merged and redeployed same session.
**Issue:** #82 (closed)
### 2. Stale scorecards causing repeated channel updates (MITIGATED)
**Discovered after deploy.** The `#live-sba-scores` channel kept being cleared/hidden every 3 minutes despite no active games.
**Root cause:** `scorecards.json` on akamai contained 16 stale entries from March 9-17. The `data/` volume is mounted `:ro` in docker-compose (to protect Google Sheets credentials), which silently prevents `ScorecardTracker.save_data()` from persisting scorecard removals. Completed games accumulate indefinitely.
**Immediate fix:** Cleared the file on akamai:
```bash
ssh akamai
echo '{"scorecards": {}}' > /root/container-data/major-domo/storage/scorecards.json
```
**Permanent fix needed:** Split volume mount — credentials read-only, state files writable. Also add logging to the bare `except` in `save_data()`.
**Issue:** #85 (open)
## Rollback
Previous image digest (pre-release):
```
manticorum67/major-domo-discordapp@sha256:8e01dd6ee78442c1bbf06f3c291c7e8694e472683defb403b70f6d1e1a5facf1
```
Rollback command:
```bash
ssh akamai "cd /root/container-data/major-domo && \
docker pull manticorum67/major-domo-discordapp@sha256:8e01dd6ee78442c1bbf06f3c291c7e8694e472683defb403b70f6d1e1a5facf1 && \
docker tag manticorum67/major-domo-discordapp@sha256:8e01dd6ee78442c1bbf06f3c291c7e8694e472683defb403b70f6d1e1a5facf1 manticorum67/major-domo-discordapp:latest && \
docker compose up -d discord-app"
```
**Note:** Rolling back would reintroduce the broken `@self.tree.interaction_check` (no-op, not harmful) but would also lose all the trade validation fixes which are actively used by league GMs.

View File

@ -0,0 +1,26 @@
# 1. Business Objectives and Success Metrics
[< Back to Index](README.md) | [Next: Architecture >](02-architecture.md)
---
## Business Objectives
| Objective | Description |
|-----------|-------------|
| Increase session depth | Give players a persistent reason to play games beyond pack acquisition |
| Create card attachment | Players build emotional investment in specific cards through accumulated play |
| Reward active play | Evolution milestones reward game activity, not currency spending |
| Differentiate the meta | Evolved cards create unique roster compositions that are hard to replicate |
| Cosmetic revenue | Premium cosmetics create a voluntary, desirable currency sink |
| Introduce healthy grind | Controlled progression loops keep engaged players busy between major events |
## Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Games played per active user per week | +25% within 60 days of launch | stratgame records |
| Average evolution tier completions per active user per month | >= 1 full track | evolution_card_state table |
| Player retention at 30 days | +15% vs pre-evolution cohort | team active flag / login frequency |
| Currency spent on cosmetics | >= 15% of total currency activity | wallet transaction logs |
| Cards with at least one milestone completed | >= 75% of all owned cards with 10+ games played | evolution_card_state |

View File

@ -0,0 +1,319 @@
# 2. System Architecture Overview
[< Back to Index](README.md) | [Next: Tracks >](03-tracks.md)
---
## Stats Tracking Approach: Lazy Evaluation Against Existing Data
Evolution progress is computed on-demand by querying the existing `stratplay`, `decision`, and
`stratgame` tables at the `player_id` level. There are no new event tables or pipeline changes
required.
The `stratplay` table already records `player_id` for every plate appearance, with full counting
stats (hits, HR, BB, SO, etc.). The `decision` table already records pitcher wins, saves, holds.
The `stratgame` table records game outcomes by team. All milestone types can be satisfied by
querying this existing data.
**Progress is team-scoped.** Milestones count stats accumulated by `player_id` in games played
by the *owning team*, starting from when the team acquired the card (`progress_since`). This means
evolution rewards your team's history with a player. If a card is traded (when trading ships),
progress resets for the new team.
A card that has been on a roster for months before this system launches gets credit for all past
games played by that team. This is intentional — it gives the feature immediate retroactive depth
rather than a cold start.
**What this means in practice:**
- "Hit 10 HRs" → `SELECT SUM(homerun) FROM stratplay sp JOIN stratgame sg ON sp.game_id = sg.id WHERE sp.batter_id = {player_id} AND (sg.away_team = {team_id} OR sg.home_team = {team_id}) AND sg.created_at >= {progress_since}`
- "Win 5 games" → `SELECT COUNT(*) FROM stratgame WHERE (away_team = {team_id} AND away_score > home_score OR home_team = {team_id} AND home_score > away_score) AND created_at >= {progress_since}`
- "Record 8 saves" → `SELECT COUNT(*) FROM decision d JOIN stratgame sg ON d.game_id = sg.id WHERE d.pitcher = {player_id} AND d.is_save = true AND (sg.away_team = {team_id} OR sg.home_team = {team_id})`
### Materialized Aggregate Stats Table
Evolution milestone evaluation requires counting stats like hits, HRs, saves, etc. for a
`(player_id, team_id)` pair. Currently, all stat aggregation is done on-demand via
`GROUP BY + SUM()` against raw `stratplay` rows (one row per plate appearance). This gets
progressively slower as the table grows.
A **`player_season_stats`** table should be created as an early deliverable to provide
pre-computed season totals. This table is updated incrementally in the post-game callback
(add the game's deltas to running totals) rather than recomputed from scratch. Milestone
evaluation then becomes a single row lookup instead of a table scan.
**This pattern is already implemented in the Major Domo project** (SBA fantasy baseball) for
the same purpose — quick lookup of large sample size stats. Reference that implementation for
schema design and the incremental update logic.
The aggregate table benefits the entire system beyond evolution: leaderboards, scouting,
player comparison, and any future feature that needs season totals.
See [06-database.md](06-database.md) for the `player_season_stats` schema.
**The `evolution_card_state` table** is a cached materialization of the current tier per
`(player_id, team_id)` pair. It is the source of display truth for Discord embeds and the card
API. The source of truth for progress is always the raw game data tables. The cached state is
refreshed after each game completes (post-game callback) and can be invalidated on-demand via
the evaluate endpoint.
**Duplicate cards of the same player_id** on the same team are identical for evolution purposes.
They share the same `evolution_card_state`, the same tier, the same boosts, the same cosmetics,
and the same variant hash. The evolution system sees `(player_id, team_id)` as its fundamental
unit — individual `card_id` instances are not differentiated.
**Different-year versions of the same real-world player** (e.g., 2019 Mike Trout vs 2024 Mike
Trout) have different `player_id` values and evolve independently. They are completely separate
players in the database with separate stats, ratings, and evolution tracks.
## Component Map
```
Discord Bot (game engine)
|
|-- [existing] stratplay pipeline (player_id level, UNCHANGED)
|
|-- [NEW] post-game evolution evaluator
|
+-- queries stratplay/decision/stratgame for milestone progress
+-- updates evolution_card_state table (cached tier per card)
+-- sends Discord notifications on milestone/tier completion
+-- applies auto-detected boost profile and cosmetics
Paper Dynasty API (database service)
|
+-- [NEW] /v2/evolution/* endpoints
+-- [NEW] /v2/evolution/evaluate/{card_id} -- recalculate tier from source tables
+-- [MODIFIED] /v2/players/{id}/battingcard -- aware of evolution variant
+-- [MODIFIED] /v2/players/{id}/pitchingcard -- aware of evolution variant
Card Creation System (this repo)
|
+-- [MODIFIED] variant=0 -> variant=1+ for evolved card ratings
+-- [NEW] apply_evolution_boosts() utility
+-- [NEW] APNG render pipeline for animated cosmetics
+-- [MODIFIED] concurrent upload pipeline with semaphore-bounded asyncio.gather
```
---
## Card Render Pipeline Optimization
### Current State (Pre-Optimization)
The existing card image render pipeline in `database/app/routers_v2/players.py` launches a
**new Chromium process for every single card render**:
```python
# Current: ~3 seconds per card
async with async_playwright() as p:
browser = await p.chromium.launch() # ~1.0-1.5s: spawn Chromium
page = await browser.new_page()
await page.set_content(html_string) # ~0.3-0.5s: parse HTML + fetch Google Fonts from CDN
await page.screenshot(path=..., clip=...) # ~0.5-0.8s: layout + paint + PNG encode
await browser.close() # teardown
```
The upload pipeline in `pd_cards/core/upload.py` is fully sequential — one card at a time in a
`for` loop, no concurrency. A typical 800-card run takes **~40 minutes**.
**Breakdown of the ~3 seconds per card:**
| Step | Time | Notes |
|---|---|---|
| Chromium launch | ~1.0-1.5s | New OS process per card |
| Google Fonts CDN fetch | ~0.3-0.5s | Network round-trip during `set_content()` |
| HTML parse + layout + paint | ~0.5-0.8s | Skia software rasterizer, 1200x600 |
| PNG screenshot encode | ~0.3-0.5s | PNG compression of framebuffer |
| DB rating write-back + disk write | ~0.2-0.3s | Two UPDATE statements + file I/O |
### Optimization 1: Persistent Browser (Priority: Immediate)
Launch Chromium **once** at API startup and reuse it across all render requests. This eliminates
the ~1.0-1.5 second launch/teardown overhead per card.
**Implementation in `database/app/routers_v2/players.py`:**
```python
# Module-level: persistent browser instance
_browser: Browser | None = None
_playwright: Playwright | None = None
async def get_browser() -> Browser:
"""Get or create persistent Chromium browser instance."""
global _browser, _playwright
if _browser is None or not _browser.is_connected():
_playwright = await async_playwright().start()
_browser = await _playwright.chromium.launch(
args=["--no-sandbox", "--disable-dev-shm-usage"]
)
return _browser
async def shutdown_browser():
"""Clean shutdown on API exit. Register with FastAPI lifespan."""
global _browser, _playwright
if _browser:
await _browser.close()
if _playwright:
await _playwright.stop()
```
**Per-render request (replaces current `async with async_playwright()` block):**
```python
browser = await get_browser()
page = await browser.new_page(viewport={"width": 1280, "height": 720})
try:
await page.set_content(html_response.body.decode("UTF-8"))
await page.screenshot(
path=file_path,
type="png",
clip={"x": 0, "y": 0, "width": 1200, "height": 600},
)
finally:
await page.close() # ~2ms vs ~1500ms for full browser close+relaunch
```
**FastAPI lifespan integration:**
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: warm up browser
await get_browser()
yield
# Shutdown: clean up
await shutdown_browser()
app = FastAPI(lifespan=lifespan)
```
**Expected result:** ~3.0s → ~1.0-1.5s per card (browser overhead eliminated).
**Safety:** If the browser crashes or disconnects, `get_browser()` detects via
`_browser.is_connected()` and relaunches automatically. Page-level errors are isolated — a
crashed page does not affect other pages in the same browser.
### Optimization 2: Self-Hosted Fonts (Priority: Immediate)
The card HTML template loads Google Fonts via CDN during every `set_content()` call:
```css
/* Current: network round-trip on every render */
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;700&display=swap');
```
Self-hosting eliminates the ~0.3-0.5s network dependency. Two approaches:
**Option A: Base64-embed in template (simplest, no infrastructure change):**
```css
@font-face {
font-family: 'Source Sans 3';
font-weight: 400;
src: url(data:font/woff2;base64,<base64_data>) format('woff2');
}
@font-face {
font-family: 'Source Sans 3';
font-weight: 700;
src: url(data:font/woff2;base64,<base64_data>) format('woff2');
}
```
**Option B: Local filesystem served via Playwright's `route` API:**
```python
await page.route("**/fonts.googleapis.com/**", lambda route: route.fulfill(
path="/app/storage/fonts/source-sans-3.css"
))
await page.route("**/fonts.gstatic.com/**", lambda route: route.fulfill(
path=f"/app/storage/fonts/{route.request.url.split('/')[-1]}"
))
```
Option A is preferred — it makes the template fully self-contained with zero external
dependencies, which also makes it work in airgapped/offline environments.
**Expected result:** Additional ~0.3-0.5s saved. Combined with persistent browser: **~0.6-1.0s per card**.
### Optimization 3: Concurrent Upload Pipeline (Priority: High)
The upload loop in `pd_cards/core/upload.py` processes cards sequentially. Adding
semaphore-bounded concurrency allows multiple cards to render and upload in parallel:
```python
import asyncio
async def upload_cardset(all_players, session, concurrency=8):
"""Upload cards with bounded concurrency."""
sem = asyncio.Semaphore(concurrency)
results = []
async def process_card(player):
async with sem:
image_bytes = await fetch_card_image(session, card_url, timeout=10)
if image_bytes:
s3_url = await upload_card_to_s3(image_bytes, ...)
await update_player_url(session, player_id, s3_url)
return player_id
return None
results = await asyncio.gather(
*[process_card(p) for p in all_players],
return_exceptions=True
)
successes = [r for r in results if r is not None and not isinstance(r, Exception)]
failures = [r for r in results if isinstance(r, Exception)]
return successes, failures
```
**Concurrency tuning:** The API server's Chromium instance handles multiple pages concurrently
within a single browser process. Each page consumes ~50-100 MB RAM. With 16 GB server RAM:
| Concurrency | RAM Usage | Cards/Minute | 800 Cards |
|---|---|---|---|
| 1 (current) | ~200 MB | ~60-100 | ~8-13 min |
| 4 | ~600 MB | ~240 | ~3-4 min |
| 8 | ~1 GB | ~480 | ~2 min |
| 16 | ~2 GB | ~800+ | ~1 min |
Start with `concurrency=8` and tune based on server load. The `fetch_card_image` timeout should
increase from 6s to 10s to account for render queue contention.
**Expected result:** Combined with persistent browser + local fonts, an 800-card run goes from
**~40 minutes to ~2-4 minutes**.
### Optimization 4: GPU Acceleration (Priority: Skip)
Chromium supports `--enable-gpu` and `--use-gl=egl` for hardware-accelerated rendering. However:
- Card images are flat 2D layout — Skia's software rasterizer is already fast for this
- GPU passthrough in Docker requires NVIDIA container toolkit + EGL libraries
- Expected gain for 2D card images: **~5-10%** — not worth the infrastructure complexity
- GPU becomes relevant if we add WebGL effects or complex CSS 3D transforms in the future
**Recommendation:** Skip GPU acceleration. The persistent browser + concurrency changes deliver
a **20-40x total speedup** without touching infrastructure.
### Summary: Expected Performance After Optimization
| Metric | Before | After | Improvement |
|---|---|---|---|
| Per-card render time | ~3.0s | ~0.6-1.0s | ~3-5x |
| 800-card sequential run | ~40 min | ~8-13 min | ~3-5x |
| 800-card concurrent run (8x) | N/A | ~2-4 min | ~10-20x vs current |
| External dependencies | Google Fonts CDN | None | Offline-capable |
| Chromium processes spawned | 800 | 1 | 800x fewer |
### Implementation Files
| Change | File | Scope |
|---|---|---|
| Persistent browser | `database/app/routers_v2/players.py` | Replace `async with async_playwright()` block |
| FastAPI lifespan | `database/app/main.py` (or equivalent) | Add startup/shutdown hooks |
| Self-hosted fonts | `database/storage/templates/style.html` | Replace `@import` with `@font-face` |
| Font files | `database/storage/fonts/` | Download Source Sans 3 + Open Sans WOFF2 files |
| Concurrent upload | `pd_cards/core/upload.py` | Replace sequential loop with `asyncio.gather` |
| Timeout increase | `pd_cards/core/upload.py` | `fetch_card_image` timeout: 6s → 10s |

View File

@ -0,0 +1,69 @@
# 3. Evolution Track Design
[< Back to Index](README.md) | [Next: Milestones >](04-milestones.md)
---
## 3.1 Track Structure
Each evolution track has **four tiers** (T1 through T4). Each tier contains **two to three
milestones**. Completing all milestones in a tier unlocks the tier's rating boost and advances
the card to the next tier automatically. Completing all four tiers achieves **Full Evolution**.
```
[Card Acquired]
|
[T1: Initiate] 2 milestones --> +small rating boost + visual badge
|
[T2: Rising] 2 milestones --> +medium rating boost + embed accent
|
[T3: Ascendant] 3 milestones --> +large rating boost + rarity border glow
|
[T4: Evolved] 2 milestones --> +capstone boost + full visual rework
--> rarity tier upgrade (Standard track, Sta and below)
--> card name becomes "[Team]'s Evolved [Player]"
```
## 3.2 Track Parameterization
Track definitions are stored in the database, not hardcoded. Each track record specifies:
- Which card type it applies to (batter, starting pitcher, relief pitcher)
- Rating boost amounts per tier (flat probability point deltas, same regardless of rarity)
- Milestone thresholds per tier (admin-tunable)
**Tracks are universal — not rarity-gated.** A Replacement-level card and a Hall of Fame card
follow the same track with the same flat rating deltas. This is intentional:
- **Rarity fluctuates during live series.** Cards update every 2 weeks during the MLB season.
A Reserve card in April could become All-Star by June. Rarity-gated tracks would cause
mid-season track jumps, resetting or invalidating progress.
- **Flat deltas intentionally favor lower-rarity cards.** A +0.5 chances boost to a Replacement
card's homerun probability is a larger relative improvement than the same boost to a HoF card.
This gives lower-tier cards a reason to exist beyond crafting fodder and avoids making elite
cards overpowered.
- **Existing rating caps prevent runaway stats.** The D20 system has hard caps on individual
stat columns (e.g., hold rating caps at -5). Evolution boosts that would exceed a cap are
truncated, providing a natural ceiling that prevents creating artificial super-cards.
**Three tracks exist at launch (one per card type):**
| Track | Card Type | T4 Outcome |
|---|---|---|
| Batter | All batters | Rarity upgrade (if below HoF) + full visual rework |
| Starting Pitcher | SP | Rarity upgrade (if below HoF) + full visual rework |
| Relief Pitcher | RP/CP | Rarity upgrade (if below HoF) + full visual rework |
**Subtracks are on the table.** The schema supports multiple tracks per card type. Before launch,
we may add subtracks within each category — e.g., power batter vs contact batter tracks with
different milestone compositions, or a closer-specific RP track. The `evolution_track` table
is designed to accommodate this without schema changes; adding a subtrack is just inserting new
rows with different milestone sets. The decision on how many subtracks to ship with is deferred
until milestone design is finalized.
## 3.3 Evolution Eligibility
Every card is always eligible. There is no enrollment step, no activation cost, and no slot limit.
A card that has not yet reached Full Evolution is always progressing. A fully evolved card
(`current_tier = 4`, all milestones complete) cannot progress further — it has reached its peak
state. It can still be crafted (#49) or traded.

View File

@ -0,0 +1,78 @@
# 4. Milestone Challenge System
[< Back to Index](README.md) | [Next: Rating Boosts >](05-rating-boosts.md)
---
## 4.1 Milestone Design Philosophy
Milestones are evaluated by querying existing `stratplay`, `decision`, and `stratgame` tables at
the `player_id` level, filtered to games played after the card's `created_at` date. No new event
tables or pipeline changes needed.
**Performance Milestones** (looked up from `player_season_stats`):
- Record N hits
- Hit N home runs
- Reach base N times (H + BB + HBP)
- Strike out N batters (pitcher)
- Record N scoreless appearances (pitcher)
**Game Outcome Milestones** (looked up from `player_season_stats`):
- Win N games (any mode, with card in the rostered team)
- Win N games in a specific mode (campaign, gauntlet, unlimited)
- Record N saves or holds (pitcher)
- Record N quality starts (pitcher, from `decision` where is_start = true + derived ERA check)
Most milestone types resolve to a single column lookup against `player_season_stats` rather than
a full `stratplay` table scan. See [06-database.md](06-database.md) for the aggregate table
schema and [02-architecture.md](02-architecture.md) for the incremental update design.
**Event Milestones** (tracked via post-game callbacks):
- Complete a gauntlet event (any placement)
- Place top 3 in a gauntlet event
- Win a gauntlet event
- Complete a campaign stage with this card in the active roster
## 4.2 Milestone Quantity Targets
Milestones are calibrated so an average active player (4-6 games per week) completes a full track
in 4-8 weeks. This avoids both instant gratification and grind fatigue.
**Batter example milestones by tier:**
| Tier | Milestone A | Milestone B | Milestone C |
|------|-------------|-------------|-------------|
| T1 | Win 3 games with card rostered | Record 15 hits | — |
| T2 | Win 8 games with card in active lineup | Hit 5 HR | Reach base 30 times |
| T3 | Win 15 games with card in active lineup | Hit 12 HR | Record 60 hits |
| T4 | Complete a gauntlet with card rostered | Hit 20 HR | — |
**Starting pitcher example milestones by tier:**
| Tier | Milestone A | Milestone B | Milestone C |
|------|-------------|-------------|-------------|
| T1 | Win 3 games with card rostered | Record 10 strikeouts | — |
| T2 | Win 8 games as starter | Record 3 quality starts (6+ innings, 3 or fewer ER equiv) | Record 25 strikeouts |
| T3 | Win 15 games as starter | Record 8 quality starts | Record 60 strikeouts |
| T4 | Complete a gauntlet with card rostered | Record 12 quality starts | — |
**Relief pitcher example milestones by tier:**
| Tier | Milestone A | Milestone B | Milestone C |
|------|-------------|-------------|-------------|
| T1 | Win 3 games with card rostered | Record 5 holds or saves | — |
| T2 | Win 8 games with card active | Record 12 holds or saves | Record 25 strikeouts |
| T3 | Win 15 games with card active | Record 20 holds or saves | Record 3 scoreless appearances |
| T4 | Complete a gauntlet with card rostered | Record 30 holds or saves | — |
## 4.3 Milestone Completion Logic
Milestones are evaluated after each game completes. The post-game callback queries
`stratplay`/`decision`/`stratgame` for all cards on the team that participated, computes
accumulated progress against each milestone threshold, and updates `evolution_card_state.current_tier`
and `evolution_progress.current_count` as needed. Tier completion is evaluated after all milestones
in a tier are marked complete. The bot sends a Discord notification to the team channel when a
tier is completed and a rating boost is applied.
Progress can also be recomputed on-demand via the evaluate endpoint (useful for admin correction
or cache invalidation after a data fix).

View File

@ -0,0 +1,171 @@
# 5. Rating Boost Mechanics
[< Back to Index](README.md) | [Next: Database Schema >](06-database.md)
---
## 5.1 Rating Model Overview
The card rating system is built on the `battingcardratings` and `pitchingcardratings` models.
Each model defines outcome columns whose values represent chances out of a **108-chance total**
(derived from the D20 probability system: 2d6 × 3 columns × 6 rows = 108 total chances).
**Batter ratings** have **22 outcome columns** summing to 108:
| Category | Columns |
|---|---|
| Hits | `homerun`, `bp_homerun`, `triple`, `double_three`, `double_two`, `double_pull`, `single_two`, `single_one`, `single_center`, `bp_single` |
| On-base | `hbp`, `walk` |
| Outs | `strikeout`, `lineout`, `popout`, `flyout_a`, `flyout_bq`, `flyout_lf_b`, `flyout_rf_b`, `groundout_a`, `groundout_b`, `groundout_c` |
**Pitcher ratings** have **18 outcome columns + 9 x-check fields** summing to 108:
| Category | Columns |
|---|---|
| Hits allowed | `homerun`, `bp_homerun`, `triple`, `double_three`, `double_two`, `double_cf`, `single_two`, `single_one`, `single_center`, `bp_single` |
| On-base | `hbp`, `walk` |
| Outs | `strikeout`, `flyout_lf_b`, `flyout_cf_b`, `flyout_rf_b`, `groundout_a`, `groundout_b` |
| X-checks | `xcheck_p` (1), `xcheck_c` (3), `xcheck_1b` (2), `xcheck_2b` (6), `xcheck_3b` (3), `xcheck_ss` (7), `xcheck_lf` (2), `xcheck_cf` (3), `xcheck_rf` (2) — always sum to 29 |
**Key differences:** Batters have `double_pull`, pitchers have `double_cf`. Batters have
`lineout`, `popout`, `flyout_a`, `flyout_bq`, `groundout_c` — pitchers do not. Pitchers have
`flyout_cf_b` and x-check fields — batters do not.
Evolution boosts apply **flat deltas to individual result columns** within these models. The
108-sum constraint must be maintained: any increase to a positive outcome column requires an
equal decrease to a negative outcome column.
### Rating Cap Enforcement
All boosts are subject to the existing hard caps on individual stat columns. If applying a delta
would push a value past its cap, the delta is **truncated** to the cap value.
**Key caps (from existing card creation system):**
| Stat | Cap | Direction | Example |
|---|---|---|---|
| Hold rating (pitcher) | -5 | Lower is better | A pitcher at -4 hold can only receive -1 more |
| Result columns | 0 floor | Cannot go negative | A 0.1 strikeout column can only lose 0.1 |
**Truncated points are lost, not redistributed.** If a boost would push a stat past its cap, the
delta is truncated and the excess is simply discarded. This is an intentional soft penalty for
cards that are already near their ceiling — they're being penalized because they're already that
good. Lower-rated cards have more headroom and benefit more from the same flat delta.
## 5.2 Boost Budgets Per Tier
Rating boosts are defined as **flat deltas to specific result columns** within the 108-sum model.
The budget per tier is the total number of chances that can be shifted from negative outcomes
(outs) to positive outcomes (hits, on-base).
| Tier | Budget (chances/108) | Approx Impact |
|------|---------------------|---------------|
| T1 | 1.0 | A full chance moved from outs to positive outcomes |
| T2 | 1.0 | Same — consistent per-tier reward |
| T3 | 1.0 | Same — consistent per-tier reward |
| T4 | 1.0 | Same — plus rarity upgrade and name change |
| **Total** | **4.0** | **~3.7% of total chances shifted from outs to positive outcomes** |
Every tier provides the same 1.0-chance budget. This keeps the system simple and predictable —
each tier completion feels equally rewarding in raw stats. T4 is distinguished not by a larger
delta but by the rarity upgrade and evolved card name, which are the real capstone rewards.
**Flat delta design rationale:** All cards receive the same absolute budget regardless of rarity.
A 4.0-chance shift on a Replacement card (where `homerun` might be 0.3) is a huge relative
improvement. The same shift on a Hall of Fame card (where `homerun` might be 5.0) is marginal.
This intentionally incentivizes using lower-rated cards and prevents elite cards from becoming
god-tier. Cards already near column caps receive even less due to truncation.
**Example — T1 power batter boost (1.0 budget):**
```
homerun: +0.50 (from 2.0 → 2.50)
double_pull: +0.50 (from 3.5 → 4.00)
strikeout: -0.70 (from 15.0 → 14.30)
groundout_a: -0.30 (from 8.0 → 7.70)
Net: +1.0 / -1.0 = 0, sum stays at 108
```
## 5.3 Default Boost Distribution Rules
By default, the boost is distributed to result columns based on the player's characteristic
style, auto-detected from their existing ratings:
**Power hitter profile** (high homerun relative to singles): budget added to `homerun`,
`double_three`, `double_two`, `double_pull`; subtracted from `strikeout`, `groundout_a`.
**Contact hitter profile** (high singles relative to HR): budget added to `single_one`,
`single_center`, `single_two`; subtracted from `strikeout`, `lineout`.
**Patient hitter profile** (high walk): budget added to `walk`, `hbp`; subtracted from
`strikeout`, `popout`.
**Starting pitcher** (reduce hits allowed): budget subtracted from `homerun`, `walk` (positive
for the pitcher); added to `strikeout`, `groundout_a`.
**Relief pitcher**: Same as SP but with larger strikeout bias and tighter HR reduction.
The `apply_evolution_boosts(card_ratings, boost_tier, player_profile)` function (card-creation
repo) handles this distribution deterministically. It ensures the 108-sum invariant is maintained
after each boost application. Columns are modified in the `battingcardratings` /
`pitchingcardratings` rows directly — the same model objects used by the game engine.
## 5.4 Rarity Upgrade at T4
When a card completes T4, the card's rarity is upgraded by one tier (if below HoF):
- The `player.rarity_id` field is incremented by one step (e.g., Sta -> All)
- The card's base rating recalculation is skipped; only the T4 boost deltas are applied on top of the
accumulated evolved ratings
- The card cost field is NOT automatically recalculated (rarity upgrade is a gameplay reward, not
a market event; admin can manually adjust if needed)
- The rarity change is recorded in `evolution_card_state.final_rarity_id` for audit purposes
- **HoF cards cannot upgrade further** — they receive the T4 boost deltas but no rarity change
**Live series interaction:** If a card's rarity changes due to a live series update (e.g.,
Reserve → All-Star after a hot streak), the evolution rarity upgrade stacks on top of the
*current* rarity at the time T4 completes. The evolution system does not track or care about
historical rarity — it simply increments whatever the current rarity is by one step.
## 5.5 Variant System Usage (Hash-Based)
The existing `battingcard.variant` and `pitchingcard.variant` fields (integer, UNIQUE with player)
are currently always 0. The evolution system uses variant to store evolved versions, with the
variant number derived from a **deterministic hash** of all inputs that affect the card:
```python
import hashlib
def compute_variant_hash(player_id: int, evolution_tier: int,
cosmetics: list[str] | None) -> int:
"""Compute a stable variant number from evolution + cosmetic state."""
inputs = {
"player_id": player_id,
"evolution_tier": evolution_tier,
"cosmetics": sorted(cosmetics or []),
}
raw = hashlib.sha256(str(inputs).encode()).hexdigest()
return int(raw[:8], 16) # 32-bit unsigned integer from first 8 hex chars
```
- `variant = 0`: Base card (standard, shared across all teams)
- `variant = <hash>`: Evolution/cosmetic-specific card with boosted ratings and custom image
**Key property: two teams with the same player_id, same evolution tier, and same cosmetics
produce the same variant hash.** This means they share the same ratings rows and the same
rendered S3 image — no duplication. If either team changes any input (buys a cosmetic), the
hash changes, creating a new variant.
Each tier completion or cosmetic change computes the new variant hash, checks if a `battingcard`
row with that variant exists (reuse if so), and creates one if not. The `card` table instance
points to its current variant via `card.variant`.
Evolved rating rows coexist with the base card in the same `battingcardratings`/`pitchingcardratings`
tables, keyed by `(battingcard_id, vs_hand)` where `battingcard_id` points to the variant row.
No new columns needed on the ratings table itself.
**Image storage:** Each variant's rendered card image URL is stored on `battingcard.image_url`
and `pitchingcard.image_url` (new nullable columns). The bot's display logic checks `card.variant`:
if set, look up the variant's `battingcard.image_url`; if null, fall back to `player.image`.
Images are rendered once via the existing Playwright pipeline (with cosmetic CSS applied) and
uploaded to S3 at a predictable path: `cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png`.
The 5-6 second render cost is paid once per variant creation, not on every display.

View File

@ -0,0 +1,272 @@
# 6. Database Schema
[< Back to Index](README.md) | [Next: Variant System >](07-variant-system.md)
---
## 6.1 New Tables
### `player_season_stats` (Early Deliverable — Phase 1)
Pre-computed season totals per `(player_id, team_id, season)`. Updated incrementally in the
post-game callback. Used by the evolution milestone evaluator for fast lookups instead of
scanning `stratplay`. Also benefits leaderboards, scouting, and any future feature needing
season totals.
**Reference implementation exists in the Major Domo project** (SBA fantasy baseball) — use
that as the basis for schema design and incremental update logic.
```sql
CREATE TABLE player_season_stats (
id SERIAL PRIMARY KEY,
player_id INTEGER NOT NULL REFERENCES player(id),
team_id INTEGER NOT NULL REFERENCES team(id),
season INTEGER NOT NULL,
-- batting totals
games_batting INTEGER NOT NULL DEFAULT 0,
pa INTEGER NOT NULL DEFAULT 0,
ab INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0,
hr INTEGER NOT NULL DEFAULT 0,
doubles INTEGER NOT NULL DEFAULT 0,
triples INTEGER NOT NULL DEFAULT 0,
bb INTEGER NOT NULL DEFAULT 0,
hbp INTEGER NOT NULL DEFAULT 0,
so INTEGER NOT NULL DEFAULT 0,
rbi INTEGER NOT NULL DEFAULT 0,
runs INTEGER NOT NULL DEFAULT 0,
sb INTEGER NOT NULL DEFAULT 0,
cs INTEGER NOT NULL DEFAULT 0,
-- pitching totals
games_pitching INTEGER NOT NULL DEFAULT 0,
innings NUMERIC(5,1) NOT NULL DEFAULT 0,
k INTEGER NOT NULL DEFAULT 0,
bb_allowed INTEGER NOT NULL DEFAULT 0,
hits_allowed INTEGER NOT NULL DEFAULT 0,
hr_allowed INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
saves INTEGER NOT NULL DEFAULT 0,
holds INTEGER NOT NULL DEFAULT 0,
blown_saves INTEGER NOT NULL DEFAULT 0,
-- metadata
last_game_id INTEGER REFERENCES stratgame(id), -- last game included in totals
last_updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_player_season UNIQUE (player_id, team_id, season)
);
CREATE INDEX idx_player_season_stats_team ON player_season_stats(team_id, season);
CREATE INDEX idx_player_season_stats_player ON player_season_stats(player_id, season);
```
**Update pattern:** After each game completes, the post-game callback computes deltas from the
game's `stratplay`/`decision` rows and adds them to the running totals via
`UPDATE ... SET hits = hits + delta_hits, ...`. The `last_game_id` field provides an audit trail
and prevents double-counting if the callback retries.
**Backfill:** A one-time backfill script aggregates all existing `stratplay`/`decision` rows
into this table, grouped by `(player_id, team_id, season)`.
---
### `evolution_track`
Defines available evolution tracks. Populated by admin at launch and expandable over time.
```sql
CREATE TABLE evolution_track (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL, -- e.g. "Batter", "Starting Pitcher", "Relief Pitcher"
card_type VARCHAR(20) NOT NULL, -- 'batter', 'sp', 'rp'
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
Tracks are universal per card type — not rarity-gated. All batters follow the same track
regardless of whether they are Replacement or Hall of Fame. Rarity upgrade at T4 is universal
(capped at HoF). See [03-tracks.md](03-tracks.md) for design rationale.
### `evolution_milestone`
Defines individual milestones within a track. Each track has 9-10 milestones across 4 tiers.
```sql
CREATE TABLE evolution_milestone (
id SERIAL PRIMARY KEY,
track_id INTEGER NOT NULL REFERENCES evolution_track(id),
tier SMALLINT NOT NULL CHECK (tier BETWEEN 1 AND 4),
name VARCHAR(200) NOT NULL, -- e.g. "First Steps: Win 3 games"
description TEXT NOT NULL, -- Player-facing description
challenge_type VARCHAR(50) NOT NULL, -- 'win_games', 'record_hits', 'record_hr',
-- 'record_k', 'record_sv_hd', 'record_qs',
-- 'complete_gauntlet', 'win_gauntlet',
-- 'complete_campaign_stage'
threshold INTEGER NOT NULL, -- Quantity required (e.g. 15 for 15 hits)
game_mode VARCHAR(30), -- NULL = any mode, or 'campaign', 'gauntlet', etc.
sort_order SMALLINT NOT NULL DEFAULT 0
);
```
### `evolution_card_state`
Cached evolution tier and metadata per `(player_id, team_id)` pair. This is the fundamental
evolution unit — all card instances sharing the same `player_id` on the same team share this
single record. Duplicate cards are not differentiated.
This is a cache — authoritative data lives in the game tables (stratplay, decision, stratgame).
Recomputable via evaluate endpoint.
```sql
CREATE TABLE evolution_card_state (
id SERIAL PRIMARY KEY,
player_id INTEGER NOT NULL REFERENCES player(id),
team_id INTEGER NOT NULL REFERENCES team(id),
track_id INTEGER NOT NULL REFERENCES evolution_track(id),
current_tier SMALLINT NOT NULL DEFAULT 0, -- 0 = no tier complete yet
variant INTEGER, -- current variant hash (shared by all card instances)
progress_since TIMESTAMP NOT NULL, -- earliest card.created_at for this player+team
last_evaluated_at TIMESTAMP NOT NULL DEFAULT NOW(),
fully_evolved BOOLEAN NOT NULL DEFAULT FALSE,
initial_rarity_id INTEGER REFERENCES rarity(id),
final_rarity_id INTEGER REFERENCES rarity(id), -- set on T4 completion
CONSTRAINT uq_evolution_player_team UNIQUE (player_id, team_id)
);
CREATE INDEX idx_evolution_card_state_team ON evolution_card_state(team_id);
CREATE INDEX idx_evolution_card_state_player ON evolution_card_state(player_id);
```
**Key design points:**
- `card_id` is intentionally absent — the state belongs to `(player_id, team_id)`, not to a
card instance. All card instances with the same player_id on the same team read from this record.
- `variant` is stored here (the single source of truth) and propagated to all matching
`card.variant` fields when it changes.
- `progress_since` uses the earliest `card.created_at` among all cards of this player on this team.
### `evolution_progress`
Tracks per-milestone completion state for each `(player_id, team_id)`. One row per (card_state, milestone).
```sql
CREATE TABLE evolution_progress (
id SERIAL PRIMARY KEY,
card_state_id INTEGER NOT NULL REFERENCES evolution_card_state(id),
milestone_id INTEGER NOT NULL REFERENCES evolution_milestone(id),
current_count INTEGER NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
completed_at TIMESTAMP,
CONSTRAINT uq_card_milestone UNIQUE (card_state_id, milestone_id)
);
CREATE INDEX idx_evolution_progress_card_state ON evolution_progress(card_state_id);
```
### `evolution_tier_boost`
Audit record of rating changes applied when a tier is completed. Allows rollback and inspection.
```sql
CREATE TABLE evolution_tier_boost (
id SERIAL PRIMARY KEY,
card_state_id INTEGER NOT NULL REFERENCES evolution_card_state(id),
tier SMALLINT NOT NULL,
battingcard_id INTEGER REFERENCES battingcard(id), -- NULL if pitcher
pitchingcard_id INTEGER REFERENCES pitchingcard(id), -- NULL if batter
variant_created INTEGER NOT NULL, -- the variant hash written
boost_delta_json JSONB NOT NULL, -- column deltas applied, keyed by vs_hand
profile_used VARCHAR(50) NOT NULL, -- 'auto_power', 'auto_contact', 'override_contact', etc.
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```
### `evolution_cosmetic`
Tracks cosmetic purchases per `(player_id, team_id)` via the evolution_card_state FK. All
duplicate card instances of the same player on the same team share cosmetics. This is the
primary revenue table for the evolution system.
```sql
CREATE TABLE evolution_cosmetic (
id SERIAL PRIMARY KEY,
card_state_id INTEGER NOT NULL REFERENCES evolution_card_state(id),
cosmetic_type VARCHAR(50) NOT NULL, -- 'frame_gold', 'frame_animated', 'border_glow',
-- 'shimmer_effect', 'tier_badge_premium', etc.
cosmetic_key VARCHAR(100) NOT NULL, -- specific variant/skin identifier
cost_paid INTEGER NOT NULL, -- manticoins spent
purchased_at TIMESTAMP NOT NULL DEFAULT NOW(),
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_evolution_cosmetic_card ON evolution_cosmetic(card_state_id);
```
**Note:** `team_id` is not stored directly — it is available via `card_state_id → evolution_card_state.team_id`.
This avoids redundancy and ensures cosmetics are always consistent with the owning team.
## 6.2 Modified Tables
### `battingcard` / `pitchingcard` — Add `image_url` and `image_format` columns
```sql
ALTER TABLE battingcard ADD COLUMN image_url VARCHAR(500) NULL;
ALTER TABLE battingcard ADD COLUMN image_format VARCHAR(10) NULL DEFAULT 'png';
ALTER TABLE pitchingcard ADD COLUMN image_url VARCHAR(500) NULL;
ALTER TABLE pitchingcard ADD COLUMN image_format VARCHAR(10) NULL DEFAULT 'png';
```
For `variant = 0` rows, `image_url` remains NULL (use `player.image` / `player.image2` as before).
For evolution/cosmetic variant rows, `image_url` stores the S3 URL for the rendered card with
boosts and cosmetics baked in. `image_format` indicates whether the image is `'png'` (static) or
`'apng'` (animated). This keeps the image co-located with the ratings data it was rendered from.
The `battingcard`/`pitchingcard` tables are not auth-gated (unlike the ratings tables), so the
bot can always fetch the image URL.
The existing `UNIQUE(player_id, variant)` constraint enforces one set of ratings and one image
per player per variant hash.
### `card` — Add `variant` column
```sql
ALTER TABLE card ADD COLUMN variant INTEGER NULL DEFAULT NULL;
```
`variant` defaults to NULL, meaning the card uses `variant = 0` (base card, `player.image`).
When evolution state changes (tier completion or cosmetic purchase), the new
variant hash is computed in `evolution_card_state.variant` and propagated to `card.variant` on
**all** card instances matching that `(player_id, team_id)` pair. The bot's image lookup:
```python
if card.variant is not None:
batting_card = BattingCard.get(player_id=card.player_id, variant=card.variant)
image_url = batting_card.image_url # S3 URL with cosmetics/boosts baked in
else:
image_url = card.player.image # standard base card image
```
**Variant propagation:** When `evolution_card_state.variant` changes, all card instances with
the matching `player_id` owned by the matching `team_id` are updated:
```python
Card.update(variant=new_variant).where(
Card.player_id == player_id,
Card.team_id == team_id,
).execute()
```
Thousands of existing cards will have `variant = NULL` — no backfill needed for the column itself.
The `evolution_card_state` backfill job handles computing variant hashes for cards with evolution
progress.
## 6.3 Schema Relationships Diagram (text)
```
team ──< evolution_card_state(player_id, team_id) > player
| |
| (variant propagated to all matching ├──< battingcard(variant=0..<hash>)
| card instances) │ ├── battingcardratings
| │ ├── image_url (S3, nullable)
| card.variant ← evo_state.variant │ └── image_format ('png' | 'apng')
| │
├──< evolution_progress >── evolution_milestone >── evolution_track
|
├──< evolution_tier_boost
|
└──< evolution_cosmetic
```

View File

@ -0,0 +1,69 @@
# 7. Card Model Changes and Variant System
[< Back to Index](README.md) | [Next: Display and Visual Identity >](08-display.md)
---
## 7.1 How the Game Engine Resolves Ratings and Images for a Card
When the bot builds a game lineup or displays a card, it checks `card.variant`:
**For ratings (game engine):**
1. Read `card.variant` from the card instance
2. Call `GET battingcardratings/player/{player_id}?variant={card.variant}` — the API filters by
`(player_id, variant)` and returns the matching `battingcard` row with its nested vL/vR ratings
3. Each variant has its own distinct `battingcard` row (with `battingcard.variant` matching
`card.variant`). The ratings rows themselves don't carry a variant — they belong to a specific
`battingcard`, and the variant distinguishes which `battingcard` to use
4. `variant = 0` is the base card (unchanged behavior); evolved/cosmetic variants use higher values
> **Current implementation:** `card.variant` does not exist, yet, but `battingcard.variant` column does already exist
> (default 0). The API endpoint and game engine query path (`gameplay_queries.py →
> get_batter_scouting_or_none`) already pass variant through. The local Postgres cache
> (`batterscouting`) keys on `battingcard_id`, which is inherently variant-specific since each
> variant produces a separate `battingcard` row. Same pattern for `pitchingcard`/`pitcherscouting`.
**For images (card display):**
1. Read `card.variant` from the card instance
2. If `variant` is not 0: fetch `battingcard.image_url` for that variant — the pre-rendered
image (PNG or APNG) with cosmetics and evolution visuals baked in
3. If `variant` is 0: use `player.image` / `player.image2` (base card, unchanged behavior)
> **Migration required:** `battingcard.image_url` and `pitchingcard.image_url` columns do not
> exist yet — they must be added (nullable varchar). Current image resolution uses `player.image`
> and `player.image2` fields exclusively (checked via `Player.batter_card_url` /
> `Player.pitcher_card_url` properties in `gameplay_models.py`, and `helpers.player_bcard` /
> `helpers.player_pcard` in the legacy cog system). Bot display logic must be updated to check
> `battingcard.image_url` first when `card.variant != 0`, falling back to `player.image`.
The variant field on the card instance acts as a simple pointer. The bot does not need to know
about evolution tiers, cosmetics, or boost profiles — it just reads the variant and gets the
right ratings and image. All complexity is in the variant creation/update path, not the read path.
**All card instances of the same `player_id` on the same team share the same variant.** The
variant is stored authoritatively on `evolution_card_state` and propagated to `card.variant` on
all matching instances when it changes. Duplicate cards are not differentiated.
## 7.2 Evolved Card Naming
When `evolution_card_state.fully_evolved = true`, the card display name is modified:
- Base name: `"Mike Trout"`
- Evolved name: `"[TeamName]'s Evolved Mike Trout"`
The `player.p_name` field is NOT modified (it is a shared blueprint). The evolved name is
computed dynamically from the card state record at display time. This preserves data integrity
and allows future re-display if the naming convention changes.
## 7.3 Card Uniqueness
**Within a team:** All copies of the same `player_id` are identical — same evolution state, same
variant, same boosts, same cosmetics. Duplicates are not differentiated in any way.
**Across teams:** Two teams that evolve the same `player_id` with the same boost profile and
same cosmetics will share the same variant hash — and therefore the same ratings rows and S3
image. This is by design: shared variants avoid redundant storage and rendering.
Uniqueness diverges when teams purchase different cosmetics — each distinct combination produces
a different variant hash, creating a genuinely different card with its own rendered image.
Premium cosmetic customization is the path to a truly unique card. Rating boosts are determined
by auto-detected card profile and are not customizable.

View File

@ -0,0 +1,402 @@
# 8. Discord Display, Visual Identity, and Animated Cards
[< Back to Index](README.md) | [Next: Integrations >](09-integrations.md)
---
## 8.1 Tier Visual Indicators
Evolved cards display visual indicators in Discord embeds that signal evolution status without
requiring image regeneration (which is expensive and slow):
| Evolution State | Embed Change |
|---|---|
| Not evolved (T0) | Standard embed, no change |
| T1 complete | Small badge emoji appended to card name field: `Mike Trout [T1]` |
| T2 complete | Badge updates: `Mike Trout [T2]` + embed accent color changes to gold |
| T3 complete | Badge: `Mike Trout [T3]` + embed color changes to purple |
| T4 full evolved | Name changes to `[Team]'s Evolved Mike Trout` + embed color changes to teal |
> **TBD:** Tier badge icons are placeholders and likely to change. Current candidates:
> T1 = `:seedling:`, T2 = `:star:`, T3 = `:gem:`, T4 = `:crown:`. Final icons will be
> chosen during implementation based on Discord rendering quality and visual distinctiveness.
## 8.2 Evolution Status Embed
A dedicated `!evo status` command (or equivalent slash command) displays evolution status for
all cards on the roster (or a specific card):
```
[Team Name]'s Evolution Progress
================================
Mike Trout (All-Star) — T2 in progress
Standard Batter Track
Tier 2 milestones:
[x] Win 8 games with card in lineup (8/8)
[x] Hit 5 HR (5/5)
[ ] Reach base 30 times (22/30) <-- in progress
Tier 2 unlocks: +1.0 budget (contact profile)
Boost profile: auto-contact
Babe Ruth (HoF) — T1 complete, T2 in progress
Legend Batter Track
...
Type !evo card <card_id> for detailed milestone breakdown.
Type !evo cosmetics <card_id> to browse premium visual upgrades.
```
## 8.3 Milestone Completion Notification
When a milestone is completed, the bot sends a notification in the team's designated channel:
```
[Seedling] Evolution milestone completed!
Mike Trout — T1, Milestone 2: "Record 15 Hits" COMPLETE
Tier 1 is now fully complete! Rating boost applied: +1.0 budget (contact profile)
[Mike Trout's card is now Tier 1 Evolved]
```
When a full tier is completed and the rarity upgrade fires:
```
[Crown] FULL EVOLUTION COMPLETE!
"[TeamName]'s Evolved Mike Trout" — Starter -> All-Star Upgrade Applied!
Rating boost: +1.0 budget capstone applied (4.0 total across all tiers).
This card is now permanently upgraded on your roster.
```
## 8.4 Premium Cosmetics Display
Premium cosmetics purchased via the currency store apply visual upgrades to the card's Discord
embed beyond the standard tier badge system:
For the full cosmetics catalog with pricing, see [10-economy.md § 10.3](10-economy.md#103-cosmetics-pricing).
| Cosmetic | Effect | Image Format |
|---|---|---|
| Gold Frame | Card embed styled with gold border accent | Static PNG |
| Diamond Frame | Diamond shimmer gradient border | Static PNG |
| Team Color Frame | Solid team-color border | Static PNG |
| Holographic Frame | Animated rainbow gradient border shimmer | **Animated APNG** |
| Dark Mode Theme | Charcoal header + dark column backgrounds | Static PNG |
| Midnight Theme | Deep navy/black column backgrounds | Static PNG |
| Prestige Badge | T4 badge upgraded from `:crown:` to `:crown::sparkles:` with custom label | Static PNG |
| Rarity Glow (Subtle) | Subtle pulsing glow matching rarity color | **Animated APNG** |
| Rarity Glow (Strong) | Intense pulsing glow matching rarity color | **Animated APNG** |
| Full Art Reveal | Custom card art background | Static PNG |
Cosmetics are purchased per `player_id` for a specific team. Since `player_id` maps 1:many with
`card_id`, purchasing a cosmetic for a player applies to all duplicate cards of that player on
the team — the variant hash is recomputed for all of them. Cosmetics are attached to the
`evolution_card_state` record (keyed by `player_id + team_id`), so duplicates always share the
same visual treatment.
When a card is traded, the new owner does **not** inherit purchased cosmetics — those remain
with the selling team's evolution state. The traded card's cosmetic display reverts to the
standard tier badge for the new owner.
This creates a flex incentive: players invest in cosmetics for cards they intend to keep and show
off. It also means a card with premium cosmetics is visually distinctive in gauntlet brackets
and trading discussions, providing social proof of investment.
## 8.5 Card Image Generation for Variants (Static)
When a new variant is created (tier completion or cosmetic purchase), the system:
1. Computes the variant hash from `(player_id, evolution_tier, cosmetics)`
2. Checks if a `battingcard` row with that hash already exists — if so, reuses it
3. If not, creates the variant's rating rows (base + evolution boosts applied)
4. Determines whether the variant requires animation (see § 8.6) or is static PNG
5. For **static variants**: renders the card via the existing Playwright pipeline, with cosmetic
CSS injected into the HTML template (frame overlays, background gradients, evolution badge, etc.)
6. Uploads the PNG to S3 at `cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png`
7. Stores the S3 URL in `battingcard.image_url`
8. Updates `card.variant` on all card instances that should use this variant
The image format (PNG vs APNG) is determined by the file extension in the S3 URL stored in
`battingcard.image_url` — no separate format column is needed. The bot and Discord both handle
`.png` and `.apng` URLs identically.
The ~5 second render + upload cost is paid **once per variant creation**, not on every display.
Subsequent displays are instant — the bot reads `battingcard.image_url` and puts it in the embed
just like it reads `player.image` today.
**Cosmetic CSS injection** is handled by passing cosmetic parameters to the card rendering
endpoint. The HTML template conditionally applies CSS classes based on cosmetic flags:
- Frame overlays → CSS border/outline/box-shadow on the card container
- Background themes → replace hardcoded `#ACE6FF`/`#EAA49C` with themed colors
- Evolution badge → additional `<img>` element alongside the rarity badge
- Header gradient → custom gradient colors replacing the standard blue/red
See [14-risks.md § Open Question 9](14-risks.md) for CSS implementation approach discussion.
---
## 8.6 Animated Card Pipeline
### 8.6.1 Why APNG
Several cosmetic effects benefit from animation: holographic frame shimmer, rarity glow pulses,
evolution badge sparkle effects. The format choice matters:
| Format | Discord Embed | Browser `<img>` | Color Depth | File Size |
|---|---|---|---|---|
| **APNG** | Auto-plays, full support | Native, all browsers | Full 24-bit + alpha | ~1-4 MB for 8-12 frames |
| GIF | Auto-plays | Native | **256 colors** — visible banding on gradients | ~0.5-2 MB |
| Animated WebP | Works in most clients | Native, modern browsers | Full color | ~0.8-3 MB |
| MP4/WebM | **Not supported** in `set_image` | Requires `<video>` tag | Full color | Smallest |
**APNG wins** because it preserves the full PNG color depth our cards rely on (subtle gradients
in the blue/red columns, rarity badge details) while working natively in Discord embeds and
all modern browsers. GIF's 256-color limit would visibly degrade card quality. MP4/WebM cannot
be used in Discord embed images at all.
### 8.6.2 Which Cosmetics Trigger Animation
Not all cosmetics need animation. Only cosmetics that involve visible motion produce APNG output.
All others remain static PNG (smaller file, faster to render).
| Cosmetic | Animated? | Effect Description |
|---|---|---|
| Holographic Frame | Yes | Rainbow gradient shifts position across frames |
| Rarity Glow (Subtle) | Yes | Border glow opacity pulses 0.3 → 0.6 → 0.3 over ~2 seconds |
| Rarity Glow (Strong) | Yes | Border glow opacity pulses 0.5 → 1.0 → 0.5, larger spread |
| T4 Crown Badge sparkle | Yes | Small sparkle particles rotate around the crown icon |
| Gold Frame | No | Static gold border + subtle shadow |
| Diamond Frame | No | Static diamond-gradient border |
| Dark/Midnight themes | No | Static background color swap |
| Team Color Frame | No | Static solid border |
When a variant includes **any** animated cosmetic, the entire card is rendered as APNG.
When all cosmetics are static, the card stays PNG.
### 8.6.3 Render Pipeline
The animated card pipeline builds on the **persistent browser** optimization described in
[02-architecture.md](02-architecture.md#card-render-pipeline-optimization). Rather than launching
a new Chromium process, frames are captured using a page from the shared browser instance.
The pipeline uses **deterministic frame control** via a CSS custom property (`--anim-progress`)
rather than relying on CSS animation timing. This guarantees identical output across renders and
eliminates timing jitter.
```
1. Get page from persistent browser (shared instance, ~2ms)
2. Load HTML template with cosmetic CSS + animation custom properties
3. For each frame i in range(N):
a. Set --anim-progress = i / N (0.0 → 0.9)
b. CSS calc() expressions update all animated properties from progress value
c. page.screenshot() captures 1200x600 PNG frame buffer
4. Close page (~2ms)
5. Assemble frames into APNG using Python apng library
- Frame delay: 166ms (6 FPS) for smooth loop without excessive file size
- Loop count: 0 (infinite)
6. Optional: pngquant-compress each frame before assembly if over file size budget
7. Upload APNG to S3 with content-type image/apng
8. Store URL in battingcard.image_url (format inferred from .apng extension)
```
**Frame count targets by effect:**
| Effect | Frames | Duration | Rationale |
|---|---|---|---|
| Holographic shimmer | 12 | 2.0s | Full gradient cycle needs more frames for smoothness |
| Rarity glow pulse | 8 | 1.3s | Simple opacity fade needs fewer frames |
| Badge sparkle | 10 | 1.7s | Particle rotation at medium smoothness |
**Performance (with persistent browser):** Each screenshot takes ~0.4s with the persistent
browser (no launch overhead). Assembly adds ~1s.
| Variant Type | Frames | Capture Time | Assembly | Total |
|---|---|---|---|---|
| Static PNG | 1 | ~0.4s | — | ~0.6s |
| 8-frame APNG | 8 | ~3.2s | ~1.0s | ~4.5s |
| 12-frame APNG | 12 | ~4.8s | ~1.0s | ~6.0s |
Since animated cosmetics are premium purchases (not bulk operations), the volume is low —
estimated <50 animated variants per month at steady state.
### 8.6.4 Deterministic Frame Control
Animated card rendering uses a **custom CSS property** (`--anim-progress`) to drive all
animation state, rather than relying on CSS `animation` timing. This ensures:
- **Reproducible output:** Same inputs always produce identical frames
- **No timing jitter:** No dependency on Playwright's internal clock or screenshot timing
- **Simpler debugging:** Each frame's visual state is fully determined by a single `0.0 → 1.0` value
**Python frame capture loop:**
```python
async def capture_animated_frames(
page: Page,
html_content: str,
frame_count: int = 10,
) -> list[bytes]:
"""Capture N deterministic animation frames from a card template.
Each frame sets --anim-progress to i/frame_count, which CSS calc()
expressions use to compute all animated property values.
"""
await page.set_content(html_content)
frames = []
for i in range(frame_count):
progress = i / frame_count # 0.0, 0.1, 0.2, ... 0.9
await page.evaluate(
f"document.documentElement.style.setProperty('--anim-progress', '{progress}')"
)
frame = await page.screenshot(
type="png",
clip={"x": 0, "y": 0, "width": 1200, "height": 600},
)
frames.append(frame)
return frames
```
**CSS using `--anim-progress` (replaces @keyframes):**
```css
:root {
--anim-progress: 0; /* set by Python per frame: 0.0 → 0.9 */
}
/* Holographic frame shimmer — gradient position driven by progress */
.frame-holographic {
border: 4px solid transparent;
background: linear-gradient(
135deg,
#ff0000, #ff8800, #ffff00, #00ff00,
#0088ff, #8800ff, #ff0088, #ff0000
) border-box;
background-size: 400% 400%;
background-position: calc(var(--anim-progress) * 400%) 50%;
}
/* Rarity glow pulse — opacity follows a sine-like curve over progress
Approximation: triangle wave via abs(progress - 0.5) * 2
At progress=0: opacity=0.3, progress=0.5: opacity=0.8, progress=1: opacity=0.3 */
.glow-subtle {
--glow-intensity: calc(0.3 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
box-shadow: 0 0 calc(8px + 12px * var(--glow-intensity)) calc(2px + 4px * var(--glow-intensity)) var(--rarity-color);
opacity: var(--glow-intensity);
}
.glow-strong {
--glow-intensity: calc(0.5 + 0.5 * (1 - abs(var(--anim-progress) - 0.5) * 2));
box-shadow: 0 0 calc(12px + 20px * var(--glow-intensity)) calc(4px + 8px * var(--glow-intensity)) var(--rarity-color);
opacity: var(--glow-intensity);
}
/* T4 Crown badge sparkle — rotation driven by progress */
.badge-sparkle {
transform: rotate(calc(var(--anim-progress) * 360deg));
}
```
**Why not CSS @keyframes?** CSS animation timing in headless Chromium depends on internal clock
ticks between screenshots. Two renders of the same card could produce slightly different frames
if the timing drifts. The `--anim-progress` approach makes each frame's visual state a pure
function of its index — deterministic and reproducible.
### 8.6.5 APNG Assembly
Python assembly using the `apng` library:
```python
from apng import APNG, PNG
def assemble_apng(frame_buffers: list[bytes], delay_ms: int = 166) -> bytes:
"""Assemble PNG frame buffers into an APNG.
Args:
frame_buffers: List of PNG image bytes (one per frame).
delay_ms: Delay between frames in milliseconds.
Returns:
APNG file bytes ready for S3 upload.
"""
apng = APNG()
for buf in frame_buffers:
png = PNG.from_bytes(buf)
apng.append(png, delay=delay_ms, delay_den=1000)
output = io.BytesIO()
apng.save(output)
return output.getvalue()
```
**Dependencies:** `pip install apng` (pure Python, no native deps). The `apng` library handles
APNG chunk encoding per the PNG spec extension. No need for external tools like `apngasm`.
### 8.6.6 S3 Storage and Content-Type
Animated cards use `.apng` extension and `image/apng` content type:
```python
s3_key = f"cards/cardset-{cardset_id:03d}/player-{player_id}/v{variant}/battingcard.apng"
await s3_client.put_object(
Bucket=bucket,
Key=s3_key,
Body=apng_bytes,
ContentType="image/apng",
CacheControl="public, max-age=31536000", # immutable variant images
)
```
Discord and browsers handle `image/apng` identically to `image/png` for display purposes — the
APNG spec is a backward-compatible extension of PNG. A client that doesn't support APNG will
display the first frame as a static PNG, which is an acceptable graceful degradation.
### 8.6.7 Bot Display Logic Update
The bot's image resolution needs a minor update to handle both formats:
```python
if card.variant != 0:
bc = BattingCard.get(player_id=card.player_id, variant=card.variant)
image_url = bc.image_url # could be .png or .apng — Discord handles both
else:
image_url = card.player.image # always static PNG
embed.set_image(url=image_url) # Discord auto-plays APNG in embeds
```
No format-specific logic needed in the bot — Discord's embed renderer handles APNG auto-play
transparently. The image format is inferred from the URL extension (`.png` vs `.apng`) when
the render pipeline needs to decide whether to re-render as animated.
### 8.6.8 File Size Budget
Target file sizes to keep Discord embeds responsive:
| Type | Target | Max |
|---|---|---|
| Static PNG | ~150-250 KB | 500 KB |
| Animated APNG (8 frames) | ~1.0-2.0 MB | 4 MB |
| Animated APNG (12 frames) | ~1.5-3.0 MB | 5 MB |
Discord's embed image limit is 25 MB, so we have significant headroom. However, large images
slow down embed rendering on mobile clients. The targets above keep load times under 1 second
on typical connections.
**Optimization options if sizes run high:**
- Reduce frame count (8 instead of 12 — less smooth but much smaller)
- Use `pngquant` to lossy-compress each frame before assembly (can cut 50-70% with minimal
visual impact on card images)
- Only animate the border/glow region and keep the card body static (composite optimization)
### 8.6.9 Animated Card Cosmetics Pricing
Animated cosmetics carry a premium over static equivalents, reflecting both the visual impact
and the higher render cost:
| Cosmetic | Static Equivalent | Animated Price | Premium Over Static |
|---|---|---|---|
| Holographic Frame | Gold Frame (800₼) | 2,000₼ | +150% |
| Rarity Glow (Subtle) | — (no static equiv) | 1,200₼ | — |
| Rarity Glow (Strong) | Rarity Glow Subtle (1,200₼) | 1,800₼ | +50% |
The higher price point makes animated cards a genuine flex — visible rarity in gauntlet brackets
and trading negotiations.

View File

@ -0,0 +1,102 @@
# 9. System Integrations
[< Back to Index](README.md) | [Next: Economy >](10-economy.md)
---
## 9.1 Crafting System (#49) — Future Integration
The crafting system (Gitea #49) is out of scope for this PRD and has not been fully designed.
When crafting ships, the evolution system will need to define interaction rules (e.g., what
happens to evolution state when a card is used as a crafting ingredient, whether evolution tier
affects crafting output). Those rules will be specified in the crafting PRD.
---
## 9.2 Campaign Mode
Evolution milestones of the `complete_campaign_stage` type are met when the team completes a
campaign stage. The bot already records campaign stage completions; the evolution subsystem hooks
into the post-game callback.
Evolved cards used in campaign games contribute normally to game mechanics. No special
campaign-mode rules for evolved cards.
## 9.3 Gauntlet Mode
Gauntlet is the highest-stakes game mode. Two milestone types reference it directly:
`complete_gauntlet` (finish any gauntlet) and `win_gauntlet` (place first). These are evaluated
via the `gauntletreward` table's post-event callback, which triggers milestone evaluation for all
active teams.
Fully evolved cards used in gauntlet display their evolved name in the gauntlet bracket display,
providing social prestige incentive for completing evolutions before entering gauntlet events.
## 9.4 Unlimited / Ranked PvP
Evolved cards are legal in all game modes. Their higher ratings reflect the investment of gameplay
time, not additional spending. This positions evolved cards as earned rather than bought power,
which is important for PvP mode balance.
There is no separate evolved card bracket or ladder. Evolved cards compete in the same pool.
## 9.5 Scouting System
The scouting reports (`pd-cards scouting all`) aggregate stats at the `player_id` level across
all cardsets. Evolution creates `variant`-specific rating rows, which the scouting report
generator should ignore (it reads `variant = 0` base rows). No changes needed to scouting scripts.
A future enhancement could flag evolved cards in scouting output with a visual marker.
## 9.6 Trading (Not Yet Implemented — Roadmap)
Trading is not yet implemented but is on the roadmap. When it ships, the following rules apply:
**Evolution resets on trade.** The traded card's `card.variant` is set to `0`, and it is
disassociated from the selling team's `evolution_card_state` record. If the new team already has
an `evolution_card_state` for this `player_id`, the traded card inherits that state (and its
variant). If the new team has no prior state for this player, a fresh `evolution_card_state` is
created with `current_tier = 0` and `progress_since` set to the trade date. The new team starts
fresh — only stats accumulated in games played by the *new* team count toward milestones.
**The selling team's state is unaffected.** If Oakland still has other copies of the same
player_id, their `evolution_card_state` remains intact. The trade only affects the specific
card instance being transferred.
Example: If Mark McGwire evolves to T2 on the Oakland Athletics and that card gets traded to the
St. Louis Cardinals, the Cardinals' McGwire card starts at T0 (assuming they had no prior McGwire
cards). Only stats from Cardinals games count toward the new evolution progress.
**The card's variant resets.** `card.variant` is set to `0` on the traded card instance, meaning
it falls back to `player.image` and base ratings. The old variant's
`battingcard`/`pitchingcard` rows and S3 images are NOT deleted — they may still be shared by
other cards with the same hash.
**The evolved card name resets.** Since the card is at T0 on the new team, no evolved name is
displayed.
**Purchased cosmetics do not transfer.** Cosmetics are attached to the selling team's
`evolution_card_state`. The new team does not inherit the previous owner's premium visuals.
**Design rationale:** This creates a loyalty incentive — the longer you keep a card, the more
evolved it becomes. Trading away a fully evolved card is a real sacrifice, creating meaningful
tension when trading eventually ships.
## 9.7 Pack Opening and paperdex
When cards are acquired (pack opening, reward, etc.), the system resolves evolution state using
a **batched eager** approach so that card flair is visible immediately during the opening:
1. Collect all `player_id` values from the pack's cards
2. Single batch query: `SELECT player_id, current_tier, variant FROM evolution_card_state WHERE team_id = ? AND player_id IN (?...)`
3. For cards with an existing state: set `card.variant` to match (inherits tier, cosmetics, flair)
4. For cards without a state: batch `INSERT` new `evolution_card_state` rows at `current_tier = 0`, `progress_since = card.created_at`
5. Batch `UPDATE` card variants for all cards that inherited existing state
This reduces evolution resolution to ~3 queries total regardless of pack size, avoiding per-card
round trips. The eager approach ensures evolved card visuals (and any future random cosmetic
effects like holo pulls) display immediately during the pack opening experience rather than
loading lazily on first view.
paperdex tracks ownership booleans at the team+player level and does not need modification for
evolution.

View File

@ -0,0 +1,57 @@
# 10. Economy Design and Currency Sinks
[< Back to Index](README.md) | [Next: Safeguards >](11-safeguards.md)
---
## 10.1 Design Principle: Free Progression, Paid Expression
Evolution as a stat progression system is entirely free. No currency is required to evolve a card
from T0 to T4. The sole currency sink in this system is:
1. **Premium cosmetics** — Visual upgrades that signal investment and create prestige
Cosmetics are not required to compete. A player who never spends currency still gets the full
rating boost from evolution. Paying currency gets you a better-looking card.
Boost direction is determined automatically by the card's detected profile (see
[05-rating-boosts.md](05-rating-boosts.md)) and cannot be overridden. This keeps the system
simple and avoids a niche min-max feature that adds UI, economy, and state tracking complexity
for minimal player engagement.
## 10.2 Cosmetics Pricing
| Cosmetic | Cost | Format |
|---|---|---|
| Gold Frame | 800₼ | Static PNG |
| Diamond Frame | 1,000₼ | Static PNG |
| Team Color Frame | 600₼ | Static PNG |
| Holographic Frame | 2,000₼ | Animated APNG |
| Dark Mode Theme | 800₼ | Static PNG |
| Midnight Theme | 1,000₼ | Static PNG |
| Prestige Badge | 600₼ | Static PNG |
| Rarity Glow (Subtle) | 1,200₼ | Animated APNG |
| Rarity Glow (Strong) | 1,800₼ | Animated APNG |
| Full Art Reveal | 2,000₼ | Static PNG |
A player who fully decks out a single card (all cosmetics) spends approximately 10,200₼. This
is the maximum per-card investment and represents a significant commitment to a single card —
intentionally aspirational.
Animated cosmetics carry a premium over static equivalents (see [08-display.md § 8.6.9](08-display.md#869-animated-card-cosmetics-pricing)).
## 10.3 Economy Projections
At steady state with 20 active players:
- Assume 20% of active players purchase at least one cosmetic per month: ~4 players x avg 1,000₼ = 4,000₼/month from cosmetics
- Assume 10% of active players purchase an animated cosmetic: ~2 players x avg 1,700₼ = 3,400₼/month from animated
- Total estimated monthly currency sink: ~7,400₼
This is a meaningful sink driven by voluntary cosmetic purchases that players make by choice,
not tollbooths they must pass through to play.
## 10.4 No Paywall
Evolution is funded entirely by earned currency. There is no mechanic that requires purchasing
premium currency or special items to complete milestones or receive rating boosts. Challenge
types do not include "spend N manticoins" as a milestone.

View File

@ -0,0 +1,53 @@
# 11. Anti-Abuse Safeguards
[< Back to Index](README.md) | [Next: API Endpoints >](12-api.md)
---
## 11.1 Milestone Threshold Tuning
The primary anti-abuse lever is milestone threshold configuration. Thresholds defined in
`evolution_milestone.threshold` are tunable by admin without code changes. If data shows
milestones being completed too quickly (e.g., full T4 in under a week), admin raises thresholds.
If players stall at a specific milestone, admin can lower the threshold. Already-credited
completions are not retroactively revoked.
## 11.2 Game Mode Diversity Requirements
"Win games" milestones do not specify a mode and can be completed in Exhibition (vs AI). This is
intentional — exhibition games are still real games with real D20 outcomes. Gauntlet and campaign
milestones specifically cannot be completed in exhibition, creating incentive to engage with
competitive and narrative modes.
If exploitation is observed (e.g., players spinning up exhibition games specifically to complete
milestones without meaningful play), a safeguard can restrict "win games" milestones to campaign,
gauntlet, or unlimited modes via the `game_mode` field on `evolution_milestone`.
## 11.3 Evaluation Deduplication
The post-game evaluator must be idempotent. If the game pipeline retries a post-game callback,
progress counts should not double. The evaluator recalculates current counts by re-querying the
source tables (not by incrementing cached values) and compares against the stored `current_count`.
Progress is updated only when the newly computed count exceeds the stored count.
## 11.4 Rarity Cap Enforcement
A fully evolved card receiving a rarity upgrade cannot exceed HoF (the maximum rarity). If a Sta
card completes T4, it becomes All-Star. If a card is already HoF, it receives the T4 boost deltas
but no rarity change. This is enforced at application layer by checking `player.rarity_id`
against the HoF ceiling before incrementing.
**Rating cap enforcement:** Individual stat columns have hard caps (e.g., hold rating at -5).
The `apply_evolution_boosts()` function truncates any delta that would exceed a cap and
discards the excess (no redistribution — see [05-rating-boosts.md](05-rating-boosts.md)).
This is an intentional soft penalty for cards already near their ceiling.
## 11.5 Evaluation Performance
Milestone progress is resolved via `player_season_stats` (a materialized view of aggregate
stats), not raw `stratplay` table scans. Each post-game evaluation is a simple `SELECT` keyed
on `(team_id, player_id)` — lightweight and independent per team. Concurrent games across
different teams do not contend since evolution state is keyed by `(player_id, team_id)`.
The post-game callback should only evaluate cards that participated in the just-completed game,
not the entire roster.

View File

@ -0,0 +1,99 @@
# 12. API Endpoints
[< Back to Index](README.md) | [Next: Implementation >](13-implementation.md)
---
All endpoints are under `/v2/` and require bearer token authentication.
## 12.1 Evolution Track Endpoints
```
GET /v2/evolution/tracks
Query params: card_type, track_family
Returns: list of evolution_track records with milestones nested
GET /v2/evolution/tracks/{track_id}
Returns: single track with all milestones
GET /v2/evolution/tracks/{track_id}/milestones
Returns: all milestones for track, grouped by tier
```
## 12.2 Card State Endpoints
> **Note:** Endpoints accept `card_id` for convenience since the bot always has it available.
> The server resolves `card_id` to `(player_id, team_id)` internally via a single join — no
> extra round trip. Evolution state is keyed by `(player_id, team_id)` in the database.
```
GET /v2/teams/{team_id}/evolutions
Returns: all evolution_card_state records for team, with nested progress
GET /v2/evolution/cards/{card_id}
Returns: evolution_card_state for a specific card, with nested progress
POST /v2/evolution/cards/{card_id}/evaluate
Action: Force re-evaluation of this card's evolution state from player_season_stats
Use cases: admin correction, cache invalidation after data fix, on-demand status refresh
Returns: updated evolution_card_state with new current_tier and progress counts
```
## 12.3 Cosmetics Endpoints
```
GET /v2/evolution/cosmetics
Returns: catalog of available cosmetic types with descriptions and costs
GET /v2/evolution/cards/{card_id}/cosmetics
Returns: all cosmetics purchased for this card by the requesting team
POST /v2/evolution/cards/{card_id}/cosmetics
Body: { cosmetic_type, cosmetic_key }
Validates: card ownership, sufficient wallet balance, cosmetic not already active
Action: Deducts currency, creates evolution_cosmetic record, triggers variant render
(static PNG or animated APNG depending on cosmetic type)
Returns: created cosmetic record + new image_url
DELETE /v2/evolution/cards/{card_id}/cosmetics/{cosmetic_id}
Action: Deactivates cosmetic (no refund). Cosmetic record preserved for audit.
Returns: updated cosmetic record
```
## 12.4 Modified Card Endpoints
```
GET /v2/players/{player_id}/battingcard
New optional query param: variant (integer)
If variant provided: returns ratings for that variant (used by game engine for evolved cards)
If not provided: returns variant=0 (base card) as before
GET /v2/players/{player_id}/pitchingcard
Same modification as battingcard
GET /v2/players/{player_id}/battingcard?variant={hash}&render=true&cosmetics=frame_gold,bg_dark
Triggers Playwright render with cosmetic CSS applied, uploads to S3, stores URL on
battingcard.image_url for that variant. If any cosmetic is animated, renders APNG instead of PNG.
Used by the evolution pipeline, not by the bot at display time.
Returns: the rendered image (PNG or APNG).
```
## 12.5 Admin Endpoints
```
POST /v2/admin/evolution/tracks
Body: full evolution_track definition with milestones
Creates new track (admin only)
PATCH /v2/admin/evolution/milestones/{milestone_id}
Body: { threshold, description }
Updates threshold or description (admin only, does not retroactively revoke progress)
POST /v2/admin/evolution/cards/{card_id}/apply-boost
Action: Manually trigger tier boost application (for recovery from failed boost events)
Admin only
POST /v2/admin/evolution/cards/{card_id}/initialize
Action: Create evolution_card_state for a card that was acquired before the system launched
Admin only; used for backfill of existing card inventory
```

View File

@ -0,0 +1,266 @@
# 13. Implementation Phases and Checklists
[< Back to Index](README.md) | [Next: Risks and Open Questions >](14-risks.md)
---
## Phase 0 — Render Pipeline Optimization (Week 0, Pre-Requisite)
Focus: Persistent browser, self-hosted fonts, concurrent upload pipeline. This phase is
**independent of evolution** and benefits all existing card generation workflows immediately.
Should be implemented before any evolution work begins.
**Deliverables:**
- Persistent Chromium browser instance in API server (replaces per-request browser launch)
- FastAPI lifespan hooks for browser startup/shutdown with auto-reconnect
- Self-hosted fonts: download Source Sans 3 + Open Sans WOFF2 files, base64-embed in template
- Remove Google Fonts CDN `@import` from `style.html`
- Concurrent upload pipeline in `pd_cards/core/upload.py` using `asyncio.Semaphore(8)` + `asyncio.gather`
- Increase `fetch_card_image` timeout from 6s to 10s to account for render queue contention
- Benchmark: measure per-card render time before/after, total run time for full cardset
**Implementation details:** See [02-architecture.md § Card Render Pipeline Optimization](02-architecture.md#card-render-pipeline-optimization)
**Success Criteria:** Per-card render time drops from ~3s to ~0.6-1.0s. Full 800-card upload
run completes in under 5 minutes (down from ~40 minutes). No external font CDN dependency.
**Files changed:**
| File | Change |
|---|---|
| `database/app/routers_v2/players.py` | Persistent browser, page-per-request pattern |
| `database/app/main.py` | FastAPI lifespan hooks |
| `database/storage/templates/style.html` | `@font-face` with base64 WOFF2 |
| `database/storage/fonts/` | Font files (new directory) |
| `pd_cards/core/upload.py` | `asyncio.gather` with semaphore |
| `check_cards_and_upload.py` | Same concurrency pattern (legacy script) |
---
## 13.1 Phase 1 — Foundation (Weeks 1-3)
Focus: Database schema, lazy evaluation engine, tier badge display, basic Discord commands.
**Deliverables:**
- `player_season_stats` materialized aggregate table created (reference Major Domo implementation)
- Incremental update logic in post-game callback for `player_season_stats`
- Backfill script to populate `player_season_stats` from existing `stratplay`/`decision` data
- All evolution database tables created with migrations
- `evolution_card_state` initialization on card acquisition (pack opening hook): create if no `(player_id, team_id)` state exists, otherwise inherit existing state
- API endpoints: track catalog, card state GET, evaluate POST
- Post-game callback integration: evaluate participating cards after each game
- Milestone evaluation engine for all stratplay/decision/stratgame milestone types
- Bot command: `!evo status` (roster-wide view and per-card view)
- Basic Discord embed: tier badge on card name
- No rating boosts yet — tier advances are tracked but boosts deferred to Phase 2
- Three universal evolution tracks seeded in database (Batter, Starting Pitcher, Relief Pitcher)
- Backfill job: initialize `evolution_card_state` for all unique `(player_id, team_id)` pairs with `progress_since = earliest card.created_at`
**Success Criteria:** All cards have evolution state. Players can see tier progress. Milestones
complete automatically after games.
## 13.2 Phase 2 — Rating Boosts (Weeks 4-6)
Focus: Apply rating boosts on tier completion, variant card API.
**Deliverables:**
- `apply_evolution_boosts()` function in card-creation repo
- Batter and pitcher player profile detection from existing ratings
- Tier boost application on tier completion: writes new variant rows to battingcardratings/pitchingcardratings
- `evolution_tier_boost` audit table populated
- Updated card API: `card_id` param for variant resolution
- Discord notification on milestone and tier completion (with boost summary)
- T4 rarity upgrade (universal, capped at HoF)
- Evolved card display name (`[Team]'s Evolved [Player]`)
**Success Criteria:** Tier completion triggers rating boost. Evolved cards show higher ratings
in card embed.
## 13.3 Phase 3 — Static Cosmetics (Weeks 7-10)
Focus: Premium static cosmetics, visual differentiation, economy tuning.
**Deliverables:**
- Cosmetics catalog seeded in database (static cosmetics only: frames, themes, badges)
- Cosmetics purchase and display endpoints
- Cosmetic CSS injection in card HTML template (frame overlays, background colors, badges)
- Variant render pipeline: Playwright renders cards with cosmetic CSS, uploads to S3
- `battingcard.image_url` column populated for variant rows
- Bot image resolution updated: variant → `battingcard.image_url` fallback to `player.image`
- Verify all three tracks work correctly across all rarity levels (Rep through HoF)
- Discord embed accent colors per tier (T2 gold, T3 purple, T4 teal)
- Analytics query for admin: evolution completion rates, milestone drop-off, cosmetic revenue
- Economy review: check cosmetics pricing against actual currency circulation data
**Success Criteria:** Static cosmetics purchasable and visually distinct. Full T0-T4 evolution
journey works end to end including cosmetics.
## 13.4 Phase 4 — Animated Cosmetics (Weeks 11-13)
Focus: APNG render pipeline, animated cosmetic effects.
**Prerequisite:** Phase 0 (persistent browser) must be complete. The APNG pipeline depends on
the persistent browser for efficient multi-frame capture.
**Deliverables:**
- Deterministic multi-frame capture using `--anim-progress` CSS custom property
- `capture_animated_frames()` function using persistent browser page pool
- APNG assembly from frame buffers using `apng` Python library (`pip install apng`)
- CSS custom property-driven animations in card HTML template (no `@keyframes` for animated cosmetics)
- Animated cosmetics added to catalog: Holographic Frame, Rarity Glow (Subtle/Strong), T4 Badge Sparkle
- S3 upload with `.apng` extension and `image/apng` content type and `CacheControl: public, max-age=31536000`
- File size validation (target <4 MB per animated card)
- Bot display verified: APNG auto-plays in Discord embeds (desktop + mobile)
- Performance benchmarking: target ~4.5s for 8-frame, ~6s for 12-frame animated variant
- pngquant integration for frame compression if file sizes exceed budget
**Success Criteria:** Animated cosmetics render correctly as APNG, auto-play in Discord embeds,
file sizes within budget, render time <8 seconds per animated variant.
---
## 13.5 Render Pipeline Optimization Checklist (Phase 0)
- [ ] Download Source Sans 3 (400, 700) and Open Sans (300, 400, 700) WOFF2 files
- [ ] Base64-encode font files and create `@font-face` declarations
- [ ] Replace `@import url('fonts.googleapis.com/...')` in `style.html` with local `@font-face` — PR #96 (next-release)
- [ ] Verify card renders identically with self-hosted fonts (visual diff)
- [x] Create `get_browser()` / `shutdown_browser()` functions in players.py — merged 2026.3.17
- [x] Add FastAPI lifespan hooks for browser startup/shutdown — merged 2026.3.17
- [x] Replace `async with async_playwright()` block with persistent browser + page-per-request — merged 2026.3.17
- [x] Add `is_connected()` check with automatic browser reconnect — implemented via `asyncio.Lock`, merged 2026.3.17
- [ ] Test: render 10 cards sequentially, verify no browser leaks (page count stays at 0 between renders)
- [ ] Test: concurrent renders (4 simultaneous requests) complete without errors
- [ ] Benchmark: measure per-card render time (target: <1.0s, down from ~3.0s) script in PR #95 (next-release)
- [ ] Refactor `pd_cards/core/upload.py` loop to use `asyncio.Semaphore(8)` + `asyncio.gather`
- [ ] Add error handling: individual card failures don't abort the batch
- [ ] Add progress reporting: log every N completions (not every 20 starts)
- [ ] Increase `fetch_card_image` timeout from 6s to 10s
- [ ] Benchmark: full 800-card upload run (target: <5 minutes)
- [ ] Update `check_cards_and_upload.py` legacy script with same concurrency pattern
- [ ] Deploy to dev, run full cardset upload, verify all cards render correctly
- [x] Deploy to production — persistent browser deployed 2026.3.17
## 13.6 Database Checklist
- [ ] Write Peewee model for `evolution_track` — pending PR #84 (card-evolution)
- [ ] Write Peewee model for `evolution_milestone` — pending PR #84 (card-evolution)
- [ ] Write Peewee model for `evolution_card_state` — pending PR #84 (card-evolution)
- [ ] Write Peewee model for `evolution_progress`
- [ ] Write Peewee model for `evolution_tier_boost` — stub schema in PR #84
- [ ] Write Peewee model for `evolution_cosmetic` — stub schema in PR #84
- [ ] Write migration scripts for all new tables (idempotent) — pending PR #84 (card-evolution)
- [x] Write Peewee model for `player_season_stats` — split into `BattingSeasonStats` + `PitchingSeasonStats` in `app/models/season_stats.py`, merged 2026.3.17
- [ ] Implement incremental update in post-game callback (delta-based, not full recompute)
- [ ] Add `last_game_id` check to prevent double-counting on callback retries
- [ ] Write backfill script to populate from existing `stratplay`/`decision` rows
- [ ] Verify backfill totals match on-demand `GROUP BY` queries for a sample of players
- [ ] Reference Major Domo implementation for patterns and edge cases
- [x] Seed three universal evolution tracks (Batter, SP, RP) — seed data + JSON in `app/seed/evolution_tracks.py`, merged 2026.3.17; runnable once migration (PR #84) lands
- [ ] Add `image_url` nullable column to `battingcard` and `pitchingcard` — in PR #84 migration
- [ ] Add `variant` column to `card` table (integer, default 0) — in PR #84 migration
- [ ] Verify `battingcard.variant` UNIQUE constraint behavior with hash-based integers
- [ ] Index all FK columns and query-hot columns on new tables — indexes in PR #84 migration
- [ ] Ensure `player_season_stats` has appropriate indexes for `(team_id, player_id)` lookups — indexes in PR #84 migration
- [ ] Test schema in dev environment before production migration
- [ ] Write and test backfill script: create `evolution_card_state` for each unique `(player_id, team_id)` pair, propagate variant to all matching card instances
## 13.7 Card Creation System Checklist
- [ ] Implement `apply_evolution_boosts(card_ratings_df, boost_tier, player_profile)` in `creation_helpers.py` or `batters/calcs_batter.py`
- [ ] Implement batter player profile detection (power, contact, patient) from existing ratings
- [ ] Implement pitcher player profile detection (SP power, SP control, RP) from existing ratings
- [ ] Write unit tests for boost distribution: verify column sums remain valid post-boost
- [ ] Write unit tests for rating cap enforcement: verify boosts truncate at caps (e.g., hold at -5)
- [ ] Write unit tests for cap truncation: verify excess budget is discarded (not redistributed) when a stat hits its cap
- [ ] Write unit test for rarity upgrade eligibility check (HoF ceiling)
- [ ] Integrate `apply_evolution_boosts()` with API endpoint trigger (or standalone script callable by API service)
- [ ] Implement `compute_variant_hash()` function for deterministic variant numbering
- [ ] Handle variant field correctly: check for existing variant row before creating new one
- [ ] Implement cosmetic CSS injection in card HTML template (frames, backgrounds, badges)
- [ ] Implement CSS `--anim-progress` custom property animations for animated cosmetics (holographic, glow pulse, sparkle)
- [ ] Implement multi-frame Playwright capture for animated variants
- [ ] Implement APNG assembly from frame buffers
- [ ] Add file size validation for animated output
- [ ] Add CLI command: `pd-cards evolution apply-boost --card-id <id> --tier <n>` for admin use
- [ ] Add CLI command: `pd-cards evolution render-variant --player-id <id> --variant <hash>` for manual re-renders
## 13.8 API Development Checklist
- [x] Implement track catalog endpoints (GET list, GET single) — `GET /api/v2/evolution/tracks` and `/tracks/{id}` in `app/routers_v2/evolution.py`, merged 2026.3.17; returns 500 until PR #84 (EvolutionTrack model) lands
- [ ] Implement track milestones endpoint (GET milestones for track)
- [x] Implement season stats CRUD — full REST endpoints in `app/routers_v2/season_stats.py`, merged 2026.3.17
- [x] Formula engine — `app/services/formula_engine.py`, batter OPS + pitcher ERA/WHIP/K formulas, merged 2026.3.17
- [ ] Implement card state endpoints (GET by team, GET by card)
- [ ] Implement POST evaluate endpoint — in PR #98 (card-evolution); `POST /api/v2/evolution/cards/{card_id}/evaluate` with evaluator service
- [ ] Implement cosmetics endpoints (GET catalog, GET card cosmetics, POST purchase, DELETE deactivate)
- [ ] Modify battingcard/pitchingcard endpoints to accept optional `card_id` param
- [ ] Implement variant resolution logic in card endpoints using `evolution_card_state`
- [ ] Implement admin endpoints for track/milestone management and manual boost application
- [ ] Implement idempotent milestone evaluator (recalculate from source, not increment)
- [ ] Render endpoint: detect animated vs static cosmetics, route to correct pipeline
- [ ] Write API integration tests for full T0-T4 evolution lifecycle
- [ ] Write API integration tests for cosmetics purchase flow (static + animated)
- [ ] Document all new endpoints in API reference
## 13.9 Bot Integration Checklist
- [ ] Add `!evo status` command with roster-wide formatted embed
- [ ] Add `!evo card <card_id>` command for detailed single-card milestone breakdown
- [ ] Add `!evo cosmetics <card_id>` command for premium cosmetics browsing
- [ ] Integrate post-game evolution evaluator in post-game processing pipeline
- [ ] Verify evaluator fires after game resolution in all modes (campaign, gauntlet, exhibition, unlimited)
- [ ] Add milestone completion notification to team channel
- [ ] Add tier completion notification with rating boost summary
- [ ] Add Full Evolution notification with rarity upgrade announcement
- [ ] Update card display embed to show tier badge from `evolution_card_state.current_tier`
- [ ] Update card display embed to show evolved name for fully evolved cards
- [ ] Update card display embed to apply active cosmetic styling
- [ ] Verify APNG auto-plays correctly in Discord embeds across desktop and mobile
- [ ] Update trade processing: set traded card's variant to 0, check for existing state on new team's `(player_id, team_id)`, create fresh state if none exists
## 13.10 Testing Checklist
- [ ] Unit tests: `apply_evolution_boosts()` for all player profiles and tiers
- [ ] Unit tests: milestone evaluation logic for all challenge types
- [ ] Unit tests: variant resolution in card endpoint
- [ ] Unit tests: idempotent evaluator (calling twice yields same result)
- [ ] Unit tests: APNG assembly produces valid APNG output
- [ ] Integration tests: game -> post-game callback -> milestone completion -> tier boost full flow
- [ ] Integration tests: T4 rarity upgrade across all rarity levels (Rep→Res, Sta→All, MVP→HoF, HoF stays HoF)
- [ ] Integration tests: evolved card state and name update correctly after trade
- [ ] Integration tests: cosmetics remain on selling team's `(player_id, team_id)` state after trade
- [ ] Integration tests: animated cosmetic purchase triggers APNG render pipeline
- [ ] Load test: milestone query latency against player_season_stats with large dataset
- [ ] Regression test: base card ratings unaffected by evolution variant writes
- [ ] Regression test: scouting report generation ignores variant rows
- [ ] Visual test: animated APNG renders correctly in Discord (desktop + mobile)
- [ ] Visual test: APNG graceful degradation (first frame displays if client doesn't support animation)
## 13.11 Security and Balance Checklist
- [ ] Verify cosmetic purchase currency deducted atomically (wallet check + deduction in single transaction)
- [ ] Verify card ownership validated before cosmetic purchase (card.team_id == requesting team_id)
- [ ] Verify cosmetics are non-transferable on trade (team_id check in display layer)
- [ ] Review milestone thresholds against expected games-per-week before launch
- [ ] Verify rarity cap enforcement (no evolution beyond HoF)
- [ ] Verify individual stat column caps enforced during boost application (e.g., hold ≥ -5)
- [ ] Verify evolved card stats cannot exceed designed boost budget (4.0 chances/108 total) via any combination of track + boost
## 13.12 Deployment Checklist
- [ ] Run schema migration in dev, verify all tables and indexes created correctly — blocked on PR #84
- [ ] Seed evolution tracks and milestones in dev — seed script ready, blocked on schema
- [ ] Run backfill job on dev copy of card inventory, verify all cards get state records
- [ ] Run full Phase 1 test suite in dev environment
- [ ] Deploy API changes to dev, smoke test all new endpoints — season stats + formula engine deployed to prod 2026.3.17; track catalog deployed but non-functional (missing model)
- [ ] Deploy bot changes to dev instance, run a game to verify post-game evaluator fires
- [ ] Demo full T0 -> T1 progression flow in dev
- [ ] Cal approves dev demo
- [ ] Run schema migration in production during low-traffic window
- [ ] Run backfill job in production for existing card inventory
- [ ] Deploy API changes to production
- [ ] Deploy bot changes to production
- [ ] Monitor milestone query latency for first 48 hours
- [ ] Monitor tier completion rate and cosmetic purchase rate after first week
- [ ] Schedule economy review at Day 30

View File

@ -0,0 +1,86 @@
# 14. Risk Register and Open Questions
[< Back to Index](README.md)
---
## Risk Register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Milestone queries become slow at scale | Low | Medium | Queries go through `player_season_stats` materialized view keyed on `(team_id, player_id)`, not raw stratplay scans. Ensure appropriate indexes on the materialized view. |
| Milestone thresholds too easy, Full Evolution trivial | Medium | Medium | Admin-tunable thresholds; monitor completion rate and adjust; launch conservatively |
| Milestone thresholds too hard, players don't notice evolution is happening | Medium | Medium | Lower thresholds; T1 should complete for any card played in ~2 weeks of casual play |
| Retroactive backfill gives some cards immediate T4 on launch | Low | Medium | Evaluate whether progress_since should be launch date rather than card.created_at; see Open Question 2 |
| Cosmetic pricing too low, not a meaningful sink | Low | Low | Monitor purchase rates; adjust cosmetic catalog pricing post-launch |
| Cosmetic pricing too high, nobody buys them | Medium | Medium | Start at lower price points; increase for new cosmetics based on demand data |
| Rating boosts break 108-sum column invariant | Low | High | Unit test suite for `apply_evolution_boosts()`; validation on every boost application |
| Variant rows conflict with existing `UNIQUE(player_id, variant)` constraint | Low | High | Validate constraint behavior in dev before migration; existing data has variant=0 only |
| API variant resolution adds latency to card embed | Low | Low | Single additional DB lookup; index on card_state by card_id |
| Trade cosmetic-stripping creates player frustration | Low | Low | Clear in-game warning before trade completes; cosmetics are team purchases, not card upgrades |
| Player collusion to farm milestones via exhibition | Low | Low | Future safeguard: mode restrictions via game_mode field; low priority since exhibition outcomes are real D20 games |
| APNG file sizes too large for mobile Discord clients | Medium | Medium | pngquant compression, frame count reduction, file size budget enforcement (see [08-display.md § 8.6.8](08-display.md#868-file-size-budget)) |
| Animated render pipeline too slow for batch operations | Low | Low | Animated variants are rare (premium purchases only); batch re-renders can run in background |
| APNG not supported on older Discord mobile clients | Low | Low | Graceful degradation: first frame renders as static PNG; bot can check URL extension to warn user |
---
## Open Questions
1. **Progress start date — all-time vs acquisition date vs launch date:** Currently specified as
`card.created_at`. Three options exist: (a) all-time for that player_id on that team (most
rewarding, may cause immediate tier completions on launch), (b) card acquisition date (natural,
but retroactive depth varies by card age), (c) system launch date (clean cold start, no
retroactive credit). Which is preferred for the launch experience?
2. **Immediate Full Evolution on backfill:** Some veteran players may have cards where enough
historical games exist to hit T4 immediately on launch day. Is this a good thing (reward
loyal players) or a bad thing (deflates the progression experience)? One mitigation: cap
retroactive credit at T2 on launch, let players play their way to T3-T4.
3. ~~**Boost allocation player choice (Phase 4):**~~ **RESOLVED — CUT.** Boost direction override
was removed entirely. Auto-detected profile determines distribution; no player customization.
Cosmetics are the sole currency sink.
4. **Maximum evolved card count visibility:** With all cards evolving simultaneously, the
`!evo status` embed could become overwhelming for large rosters. Should it default to showing
only cards close to a tier milestone (within 20% of threshold)? Or only T3+ cards?
5. **Season-specific evolution tracks:** Time-limited evolution tracks tied to current season
events are a strong engagement feature. Should these be a separate PRD, or designed into
the track schema from the start?
6. ~~**HoF cards on Legend track:**~~ **RESOLVED in v4.0.** Tracks are now universal (not
rarity-gated). HoF cards follow the same track as all other cards, receive the same flat rating
boosts, and get the full visual rework at T4. They do not receive a rarity upgrade (already
at ceiling). The flat delta naturally provides diminishing returns for elite cards.
7. **Quality start definition:** The D20 system does not natively track innings pitched per
appearance. "6+ innings, 3 or fewer ER" requires a proxy calculation based on the outcome
simulation. How is this currently exposed from the game engine, if at all? If not available,
this milestone type may need to be replaced with a simpler proxy (e.g., "pitcher records a win
with fewer than N walks allowed").
8. **Cosmetic catalog scope:** The cosmetics listed in [08-display.md](08-display.md) are
illustrative. Should cosmetics be designed in detail before Phase 3 begins, or is it
acceptable to ship with one or two cosmetics and expand the catalog over time?
9. **Cosmetic CSS implementation detail:** The card HTML template currently uses hardcoded inline
styles for backgrounds (`#ACE6FF`, `#EAA49C`) and gradients (`blue-gradient`, `red-gradient`).
Cosmetics need to override these. Options: (a) pass cosmetic flags as Jinja2 template variables
and use conditionals in the template, (b) inject a cosmetic CSS override block after `style.html`,
(c) build a cosmetic theme system with named CSS classes. Which approach best balances flexibility
with maintainability?
10. ~~**Variant hash collision handling:**~~ **RESOLVED.** The `UNIQUE(player_id, variant)`
constraint catches collisions at the DB level. No application-level check needed. With 32-bit
hashes (~4 billion values) and realistic catalog sizes, collision probability is negligible.
11. ~~**APNG render determinism:**~~ **RESOLVED in v4.0.** The render pipeline uses the
`--anim-progress` CSS custom property for deterministic per-frame control. CSS `@keyframes`
are not used for animated cosmetics. See [08-display.md § 8.6.4](08-display.md#864-deterministic-frame-control).
---
*End of PRD: Card Evolution System v4.1*
*Status: Full review with Cal complete (2026-03-11). Next step: Confirm Phase 1 scope, create Gitea milestone and issue breakdown.*

View File

@ -0,0 +1,26 @@
# PRD: Card Evolution System
**This document has been refactored into chapters. See [prd-evolution/README.md](prd-evolution/README.md).**
The monolithic PRD was split into 14 chapter files at v4.0 for maintainability.
All content is preserved — nothing was removed, only reorganized.
## Quick Links
| Chapter | File |
|---------|------|
| Index + Executive Summary | [README.md](prd-evolution/README.md) |
| Business Objectives | [01-objectives.md](prd-evolution/01-objectives.md) |
| Architecture | [02-architecture.md](prd-evolution/02-architecture.md) |
| Evolution Tracks | [03-tracks.md](prd-evolution/03-tracks.md) |
| Milestones | [04-milestones.md](prd-evolution/04-milestones.md) |
| Rating Boosts | [05-rating-boosts.md](prd-evolution/05-rating-boosts.md) |
| Database Schema | [06-database.md](prd-evolution/06-database.md) |
| Variant System | [07-variant-system.md](prd-evolution/07-variant-system.md) |
| Display, Visuals, Animated Cards | [08-display.md](prd-evolution/08-display.md) |
| Integrations | [09-integrations.md](prd-evolution/09-integrations.md) |
| Economy | [10-economy.md](prd-evolution/10-economy.md) |
| Safeguards | [11-safeguards.md](prd-evolution/11-safeguards.md) |
| API Endpoints | [12-api.md](prd-evolution/12-api.md) |
| Implementation + Checklists | [13-implementation.md](prd-evolution/13-implementation.md) |
| Risks + Open Questions | [14-risks.md](prd-evolution/14-risks.md) |

View File

@ -0,0 +1,116 @@
# PRD: Card Evolution System
**Document Status:** Draft v4.0 — Phase 0 complete, Phase 1 in progress
**Author:** Atlas (Principal Software Architect)
**Date:** 2026-03-10 (last updated 2026-03-17)
**Issue Reference:** Gitea #46 (Evolution), #49 (Crafting)
**Stakeholder:** Cal
## Implementation Status
| Phase | Status | Notes |
|-------|--------|-------|
| Phase 0 — Render Pipeline | ✅ Mostly complete | Browser persistence + lifespan hooks in main (2026.3.17). Self-hosted fonts (PR #96) and benchmark script (PR #95) still in next-release. |
| Phase 1 — Foundation | 🔄 In progress | Season stats models, formula engine, track catalog API, seed data all in main. SQL migration + Peewee models (PR #84) and evaluate endpoint (PR #98) pending on `card-evolution` branch. |
| Phase 2 — Rating Boosts | ⬜ Not started | |
| Phase 3 — Static Cosmetics | ⬜ Not started | |
| Phase 4 — Animated Cosmetics | ⬜ Not started | |
**Active branch:** `card-evolution` (paper-dynasty-database repo)
**Open PRs targeting card-evolution:** #84 (SQL migration + models), #98 (evaluate endpoint)
---
## Revision History
> **v4.0 (2026-03-11):** Added animated card support (APNG) and render pipeline optimization.
> Persistent browser instance replaces per-request Chromium launch (~3x per-card speedup).
> Concurrent upload pipeline with `asyncio.gather` (~10-20x total run speedup). Self-hosted
> fonts eliminate CDN dependency. APNG pipeline uses deterministic `--anim-progress` CSS custom
> property for reproducible frame capture. New Phase 0 (render optimization) added as pre-requisite.
> See [02-architecture.md](02-architecture.md#card-render-pipeline-optimization) and
> [08-display.md § 8.6](08-display.md#86-animated-card-pipeline).
>
> **v3.0 (2026-03-10):** Variant system redesigned to use deterministic hash-based variant
> numbering. `card.variant` (nullable) added to card table. `image_url` added to
> `battingcard`/`pitchingcard` for variant-specific rendered images with cosmetics baked in.
> Trading resets evolution (team-scoped progress). Card images rendered once per variant creation.
>
> **v2.0 (2026-03-10):** Major design pivot from opt-in enrollment model to passive, automatic,
> free evolution. Every card always evolves; currency sinks are cosmetics
> choice, not activation fees. Slot limits and abandonment mechanics removed.
---
## Table of Contents
| Chapter | Section | File |
|---------|---------|------|
| — | Executive Summary | [README.md](#executive-summary) (below) |
| 1 | Business Objectives and Success Metrics | [01-objectives.md](01-objectives.md) |
| 2 | System Architecture + Render Pipeline Optimization | [02-architecture.md](02-architecture.md) |
| 3 | Evolution Track Design | [03-tracks.md](03-tracks.md) |
| 4 | Milestone Challenge System | [04-milestones.md](04-milestones.md) |
| 5 | Rating Boost Mechanics | [05-rating-boosts.md](05-rating-boosts.md) |
| 6 | Database Schema | [06-database.md](06-database.md) |
| 7 | Card Model Changes and Variant System | [07-variant-system.md](07-variant-system.md) |
| 8 | Discord Display, Visual Identity, and Animated Cards | [08-display.md](08-display.md) |
| 9 | System Integrations (Crafting, Existing Systems) | [09-integrations.md](09-integrations.md) |
| 10 | Economy Design and Currency Sinks | [10-economy.md](10-economy.md) |
| 11 | Anti-Abuse Safeguards | [11-safeguards.md](11-safeguards.md) |
| 12 | API Endpoints | [12-api.md](12-api.md) |
| 13 | Implementation Phases and Checklists | [13-implementation.md](13-implementation.md) |
| 14 | Risk Register and Open Questions | [14-risks.md](14-risks.md) |
**Related files:**
- [Card Cosmetics Playground](../card-cosmetics-playground.html) — Interactive visual explorer for cosmetic options
---
## Executive Summary
### Project Overview
Card Evolution is a passive, automatic progression system that permanently upgrades cards as
players use them in games. Every card a team owns is always evolving — there is no opt-in,
no activation cost, and no slot limit. Players build attachment to specific cards by simply
playing games with them. When enough milestones accumulate, the card tiers up automatically
and its ratings improve.
The system takes direct inspiration from EA FC's Evolutions mechanic and adapts it to Paper
Dynasty's D20 probability engine, Discord-native UX, and PostgreSQL/Peewee architecture. The
v2.0 pivot strips away the management overhead (slots, activation fees, abandonment) that would
have made evolution feel like a second job and instead makes it feel like a natural reward for
playing the game.
### Core Design Philosophy
- **Passive and automatic**: Evolution happens in the background. Players do not enroll cards or
manage slots. They play games; cards evolve.
- **Free to evolve**: Rating boosts cost nothing. Currency is spent on customization and cosmetics,
not on unlocking basic progression.
- **Visible progress**: Players can always check any card's current evolution tier and milestone
progress. The system rewards curiosity.
- **Premium expression layer**: Players who want to customize their card's visuals (frames,
themes, animated effects) spend currency to do so. Flex culture is the monetization hook,
not gatekeeping.
### Scope
This PRD covers:
- Passive evolution track structure and milestone types
- Lazy evaluation architecture against existing game data tables
- D20-safe rating boost calculations
- Premium cosmetic system
- Animated card rendering pipeline (APNG) for premium cosmetics
- All new database tables and changes to existing tables
- Discord embed design for evolved cards
- Interplay with the planned Crafting (#49) system
- Economy balance (cosmetics-based, not activation-based)
- A phased implementation plan
### Out of Scope
- The Crafting / Duplicate Merging system itself (covered in its own PRD, #49)
- Ranked mode (separate project)
- Mobile or web frontend

View File

@ -0,0 +1,74 @@
---
title: Paper Dynasty Database Release — 2026.3.17
type: reference
domain: paper-dynasty
tags: [paper-dynasty, deployment, release-notes, database]
---
# Paper Dynasty Database Release — 2026.3.17
**Date:** 2026-03-17
**Branch:** next-release → main (PR #100, 27 commits)
**Repo:** cal/paper-dynasty-database
**Server:** pddev.manticorum.com:816
## Release Summary
Major release adding the evolution system foundation, render pipeline improvements, query performance fixes, and infrastructure hardening. This is the first release cut from the next-release branch for this repo.
## Evolution System (Card Evolution Phase 1)
The core backend for the card evolution feature. Cards can now progress through tiers based on accumulated player stats across seasons.
- **Season stats model** (`app/models/season_stats.py`): `BattingSeasonStats` and `PitchingSeasonStats` Peewee models for per-player, per-team, per-season stat accumulators. Originally a single `PlayerSeasonStats` model, refactored mid-cycle to split batting/pitching and rename `so`→`so_batter`, `k`→`so_pitcher` for clarity.
- **Season stats CRUD** (`app/routers_v2/season_stats.py`): Full REST endpoints for reading/writing season stats.
- **Track catalog API** (`app/routers_v2/evolution.py`, WP-06): `GET /api/v2/evolution/tracks` and `GET /api/v2/evolution/tracks/{id}` endpoints serving the three universal tracks (Batter, SP, RP) with tier thresholds.
- **Formula engine** (`app/services/formula_engine.py`, WP-09): Computes evolution value from career stats. Handles batter OPS-based formula and pitcher ERA/WHIP/K-based formula with split weighting.
- **Evolution seed data** (`app/seed/evolution_tracks.py`, WP-03): Seed script and JSON for the three base tracks with t1-t4 thresholds.
### Tests Added
- `tests/test_formula_engine.py` — 188 lines covering batter/pitcher value computation
- `tests/test_evolution_seed.py` — 119 lines validating seed data structure and idempotency
- `tests/test_evolution_track_api.py` — 132 lines for track catalog endpoints
- `tests/test_season_stats_model.py` — 451 lines for the stats model CRUD
## Render Pipeline
- **Persistent browser instance** (WP-02, `#89`): Playwright Chromium browser is now launched once at startup via FastAPI lifespan hooks and reused across all card render requests. Eliminates ~1-1.5s per-request launch/teardown overhead. Uses `asyncio.Lock` to prevent concurrent launch races and error-tolerant shutdown.
- **Dockerfile optimization**: Multi-stage build, switched to `python:3.11-slim-bookworm` for Playwright compatibility, pinned base image.
## Query Performance
- **Batch Paperdex lookups** (`#17`): Replaced N+1 per-player Paperdex queries with a single batch `WHERE player_id IN (...)` query in both `get_players` and `get_random_player` endpoints.
- **Materialize queryset** (`get_random_player`): Added `list()` call before double-iteration to prevent queryset exhaustion.
## Bug Fixes
- **Pitcher OPS split weighting** (`#6`): Use `max()` instead of incorrect weighting for pitcher OPS formula
- **Remove stub endpoint** (`#11`): Deleted unimplemented `live_update_pitching` endpoint that returned 501
- **Docker build fixes**: Removed Docker Hub registry cache (blob limits), pinned base image for Playwright compatibility
## Infrastructure
- `pyproject.toml` added for ruff configuration
- `requirements.txt` pinned all dependency versions (`#64`)
## Merge Conflict Resolution
**Files:** `app/main.py`, `app/routers_v2/players.py`
**Cause:** Main had the Phase 0 render pipeline optimization (PR #94 — asyncio.Lock, `--no-sandbox` args, error-tolerant shutdown). Next-release had an earlier version of the same persistent browser feature (`#89` — simpler, no lock, no error handling).
**Resolution:** Kept main's version (the more robust implementation) for all browser-related code. Added the evolution router import/inclusion from next-release.
## Open PRs Still Targeting next-release
After this release, 5 PRs remain open against next-release:
| PR | Title | Status |
|----|-------|--------|
| #84 | SQL migration for evolution tables (WP-00) | Conflict |
| #95 | Phase 0 benchmark script | Mergeable |
| #96 | Self-hosted WOFF2 fonts | Mergeable |
| #98 | WP-08 evaluate endpoint | Conflict |
| #99 | Lifespan hooks for browser pre-warm | Conflict |
PRs #98 and #99 conflict because they contain earlier versions of code already in main (evaluate endpoint and browser lifespan). PR #84 conflicts on shared test infrastructure files. These should be rebased or have conflicts resolved before merging into next-release.

View File

@ -0,0 +1,49 @@
---
title: "Fix: Scout Token Purchase Not Deducting Currency"
description: "Changelog for PR #90 — scout token buy flow was silently failing to deduct 200₼ due to using db_patch instead of the dedicated money endpoint."
domain: development
type: troubleshooting
tags:
- paper-dynasty
- discord
- troubleshooting
- python
---
# Fix: Scout Token Purchase Not Deducting Currency
**PR**: #90`fix/scout-token-deduction`
**Merged**: 2026-03-16
**File**: `discord_ui/scout_view.py`
## Bug
When a player exhausted their 2 free daily scout tokens and purchased an extra one for 200₼, the token was granted and the card was scouted, but the wallet balance was never reduced. Players could buy unlimited scout tokens for free.
## Root Cause
`BuyScoutTokenView.buy_button` used `db_patch("teams", params=[("wallet", new_wallet)])` to set the wallet directly. The Paper Dynasty API does not support mutating the `wallet` field via generic PATCH query params — the request returned 200 but silently ignored the parameter.
## Fix
Replaced with the dedicated money endpoint used by all other wallet mutations:
```python
# Before (broken — silently ignored by API)
await db_patch("teams", object_id=team["id"], params=[("wallet", new_wallet)])
# After (correct)
await db_post(f'teams/{team["id"]}/money/-{SCOUT_TOKEN_COST}')
```
Removed unused `db_patch` import from the file.
## Key Pattern: Wallet Mutations
The Paper Dynasty API requires dedicated endpoints for wallet changes. Never use `db_patch` with a `wallet` param.
| Operation | Pattern | Example |
|-----------|---------|---------|
| Add money | `db_post(f'teams/{id}/money/{amount}')` | Daily check-in (`packs.py:218`) |
| Deduct money | `db_post(f'teams/{id}/money/-{amount}')` | Card purchases (`selectors.py:372`) |
| Atomic buy | `db_get(f'teams/{id}/buy/players', params=[...])` | Marketplace (`marketplace.py:82`) |