diff --git a/major-domo/database-release-2026.3.17.md b/major-domo/database-release-2026.3.17.md new file mode 100644 index 0000000..f610f9b --- /dev/null +++ b/major-domo/database-release-2026.3.17.md @@ -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. diff --git a/major-domo/release-2026.3.17.md b/major-domo/release-2026.3.17.md new file mode 100644 index 0000000..d8594c4 --- /dev/null +++ b/major-domo/release-2026.3.17.md @@ -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. diff --git a/paper-dynasty/card-evolution-prd/01-objectives.md b/paper-dynasty/card-evolution-prd/01-objectives.md new file mode 100644 index 0000000..83639cd --- /dev/null +++ b/paper-dynasty/card-evolution-prd/01-objectives.md @@ -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 | diff --git a/paper-dynasty/card-evolution-prd/02-architecture.md b/paper-dynasty/card-evolution-prd/02-architecture.md new file mode 100644 index 0000000..2eaa676 --- /dev/null +++ b/paper-dynasty/card-evolution-prd/02-architecture.md @@ -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,) format('woff2'); +} +@font-face { + font-family: 'Source Sans 3'; + font-weight: 700; + src: url(data:font/woff2;base64,) 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 | diff --git a/paper-dynasty/card-evolution-prd/03-tracks.md b/paper-dynasty/card-evolution-prd/03-tracks.md new file mode 100644 index 0000000..56e4215 --- /dev/null +++ b/paper-dynasty/card-evolution-prd/03-tracks.md @@ -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. diff --git a/paper-dynasty/card-evolution-prd/04-milestones.md b/paper-dynasty/card-evolution-prd/04-milestones.md new file mode 100644 index 0000000..48d8651 --- /dev/null +++ b/paper-dynasty/card-evolution-prd/04-milestones.md @@ -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). diff --git a/paper-dynasty/card-evolution-prd/05-rating-boosts.md b/paper-dynasty/card-evolution-prd/05-rating-boosts.md new file mode 100644 index 0000000..351fc37 --- /dev/null +++ b/paper-dynasty/card-evolution-prd/05-rating-boosts.md @@ -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 = `: 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. diff --git a/paper-dynasty/card-evolution-prd/06-database.md b/paper-dynasty/card-evolution-prd/06-database.md new file mode 100644 index 0000000..a2b1def --- /dev/null +++ b/paper-dynasty/card-evolution-prd/06-database.md @@ -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..) + | 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 +``` diff --git a/paper-dynasty/card-evolution-prd/07-variant-system.md b/paper-dynasty/card-evolution-prd/07-variant-system.md new file mode 100644 index 0000000..132dd1e --- /dev/null +++ b/paper-dynasty/card-evolution-prd/07-variant-system.md @@ -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. diff --git a/paper-dynasty/card-evolution-prd/08-display.md b/paper-dynasty/card-evolution-prd/08-display.md new file mode 100644 index 0000000..82395ee --- /dev/null +++ b/paper-dynasty/card-evolution-prd/08-display.md @@ -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 for detailed milestone breakdown. +Type !evo cosmetics 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 `` 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 `` | 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 `