From 9a121d370f3781ac32e27a527f290a18c0e5c511 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 22 Jan 2026 10:56:55 -0600 Subject: [PATCH] Add card builder architecture redesign documentation Documents proposed architecture for moving card-building logic upstream to Python, including: - Executive summary with problem statement and migration path - CardBuilder sketch with contract system for pluggable placement strategies - Support for different card "personalities" (Standard, Clutch, Power Heavy, etc.) Co-Authored-By: Claude Opus 4.5 --- docs/architecture/CARD_BUILDER_REDESIGN.md | 209 +++ docs/architecture/card_builder_sketch.py | 1589 ++++++++++++++++++++ docs/architecture/contracts.py | 589 ++++++++ 3 files changed, 2387 insertions(+) create mode 100644 docs/architecture/CARD_BUILDER_REDESIGN.md create mode 100644 docs/architecture/card_builder_sketch.py create mode 100644 docs/architecture/contracts.py diff --git a/docs/architecture/CARD_BUILDER_REDESIGN.md b/docs/architecture/CARD_BUILDER_REDESIGN.md new file mode 100644 index 0000000..e976f11 --- /dev/null +++ b/docs/architecture/CARD_BUILDER_REDESIGN.md @@ -0,0 +1,209 @@ +# Card Creation Architecture Redesign - Executive Summary + +**Date:** 2026-01-22 +**Project:** Paper Dynasty Card Generation System +**Author:** Cal Corum + Claude (Jarvis) + +--- + +## Problem Statement + +The current card creation pipeline has an architectural inconsistency: + +1. **Python generates continuous chance values** (e.g., 4.78 out of 108) +2. **Database fits to discrete card mechanics** (2d6 × d20 combinations) +3. **Database saves fitted values back** - what you send ≠ what gets stored + +This results in subtle discrepancies (e.g., sending 4.75, card shows 4.65) because the card mechanics only support specific discrete probability values. + +### Root Cause + +The card uses 2d6 (rows 2-12) combined with d20 splits. Valid chance values are constrained by: +- Row frequencies: 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1 (totaling 36) +- D20 subdivisions: 1-20 range creates fractional chances + +Python generates values ignorant of these constraints; the database must "round" to fit. + +--- + +## Proposed Solution + +**Move the card-building logic upstream to Python**, making it the single source of truth. + +### New Architecture + +``` +BEFORE: +Python: stats → continuous chances → POST raw values +Database: receives → fits to card → saves DIFFERENT values → renders + +AFTER: +Python: stats → continuous chances → CardBuilder → discrete card structure → POST +Database: receives → stores → renders (no fitting needed) +``` + +### Key Components + +| Component | Responsibility | +|-----------|----------------| +| `CardBuilder` | Fits continuous chances to discrete card structure | +| `CardContract` | Defines placement strategy (which rows for which plays) | +| `BuiltCard` | Structured output ready for storage/rendering | +| `RawBattingChances` / `RawPitchingChances` | Input from existing stat calculations | + +--- + +## Contract System + +A major enhancement: **pluggable placement strategies** that give cards different "personalities" using the same raw stats. + +### Available Contracts + +| Contract | Behavior | Use Case | +|----------|----------|----------| +| **Standard** | On-base results on middle rows (6-8) | Default behavior | +| **Clutch** | On-base results on edge rows (2-4, 10-12) | Role players, postseason heroes | +| **Power Heavy** | HR/3B/2B on prime rows, singles on edges | HR leaders, sluggers | +| **Contact First** | Singles on prime rows, power on edges | Leadoff hitters, high-AVG players | +| **Groundball Pitcher** | Groundouts on middle rows | Sinkerballers | +| **Flyball Pitcher** | Strikeouts/flyouts on middle rows | Power pitchers | + +### Example: Same Stats, Different Cards + +``` +Power Heavy Contract: + Row 7 (most likely): HOMERUN + Row 12 (least likely): SINGLE + +Contact First Contract: + Row 7 (most likely): SINGLE + Row 12 (least likely): HOMERUN +``` + +### Contract Interface + +```python +class CardContract: + def get_row_preference(self, category: PlayCategory) -> List[int] + def get_play_priority(self) -> List[PlayCategory] + def should_use_splits(self, category: PlayCategory) -> bool + def max_splits_per_column(self) -> int +``` + +--- + +## Benefits + +### Immediate + +1. **Deterministic output** - What Python calculates is exactly what appears on the card +2. **No step 7** - Database doesn't modify/re-save values +3. **Local preview** - Validate cards without hitting the API +4. **Single source of truth** - Fitting logic in one place + +### Strategic + +1. **Card personalities** - Same player can have different "feels" via contracts +2. **Testable** - Unit test card building without database +3. **Extensible** - Add new contracts without touching core logic +4. **Data-driven** - Store contract name in DB, apply at generation time + +--- + +## Migration Path + +### Phase 1: Extract & Validate +- Extract current database fitting logic to shared module +- Run old and new in parallel, compare outputs +- Fix discrepancies + +### Phase 2: Python Adoption +- Modify Python card-creation to use `CardBuilder` +- POST fitted card structures instead of raw chances +- Keep raw chances in separate field for debugging + +### Phase 3: Database Simplification +- Database receives pre-fitted card structures +- Remove fitting logic from database +- Eliminate step 7 (no re-saving) + +### Phase 4: Enhancements +- Add preview endpoint (returns structure without saving) +- Implement contract selection in card creation workflow +- Build custom contract UI + +--- + +## Technical Details + +### Discrete Probability Space + +Valid chance values are defined by `EXACT_CHANCES`: +```python +[5.7, 5.4, 5.1, 4.8, 4.75, 4.5, 4.25, 4.2, 3.9, 3.8, 3.75, ...] +``` + +Plus whole numbers 1-6 (full row allocations). + +### Pitching Card Specifics + +- **X-checks**: Redirect to batter's card (e.g., `GB (ss) X`) +- **Batter Power**: `bp_homerun`, `bp_single` redirect to batter +- **Secondary plays**: D20 splits pair primary with complementary outcome +- **Bolding**: Strikeouts bolded (good for pitcher), hits not bolded + +### Row Frequencies + +``` +Row: 2 3 4 5 6 7 8 9 10 11 12 +Freq: 1 2 3 4 5 6 5 4 3 2 1 +``` + +Row 7 is most valuable (6 chances = 16.67% of outcomes). + +--- + +## Files Produced + +| File | Description | +|------|-------------| +| `card_builder_sketch.py` | Main module: CardBuilder, contracts, data structures | +| `contracts.py` | Standalone contracts reference (superseded by integration) | +| `EXECUTIVE_SUMMARY.md` | This document | + +--- + +## Open Questions + +1. **Shared package or copy?** Extract to pip-installable package both repos import, or maintain synced copies? + +2. **Keep raw chances?** Store both raw (for analysis) and fitted (for rendering), or just fitted? + +3. **Contract storage** - Where to store contract selection? Player level? Cardset level? Both? + +4. **Custom contracts** - Allow users to create custom contracts via UI? + +--- + +## Recommendation + +**Proceed with the shared card-building module approach.** + +The contract system adds significant value by enabling card personalities without changing the underlying statistics. This addresses the original consistency problem while opening new design possibilities. + +**Suggested next step:** Create a proof-of-concept by implementing `CardBuilder` against a single real player's stats and comparing output to current database behavior. + +--- + +## Appendix: Code Location + +All sketch files are in: +``` +~/.claude/scratchpad/2026-01-22-card-builder-sketch/ +``` + +To test: +```bash +cd ~/.claude/scratchpad/2026-01-22-card-builder-sketch +python card_builder_sketch.py +``` diff --git a/docs/architecture/card_builder_sketch.py b/docs/architecture/card_builder_sketch.py new file mode 100644 index 0000000..1087783 --- /dev/null +++ b/docs/architecture/card_builder_sketch.py @@ -0,0 +1,1589 @@ +""" +Shared Card Builder Module - Architecture Sketch + +This module would be the single source of truth for converting raw chance +calculations into discrete card structures that honor the 2d6 × d20 mechanics. + +Could be packaged as: +- A shared Python package both card-creation and database import +- Or extracted to a standalone service + +Key insight: The fitting algorithm must know the full card context (which rows +are available) to make optimal placement decisions. So we need to build the +entire card in one pass, not quantize individual values independently. + +CONTRACT SYSTEM: +Contracts define placement strategies - which rows to prefer for different +play categories. This enables different card "personalities" while using +the same core fitting engine. +""" + +from __future__ import annotations +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum +from typing import Optional, List, Dict, Tuple, TYPE_CHECKING +import math + +# Import contracts (will be defined below or in separate module) +# For now, we define a minimal contract interface inline + + +# ============================================================================= +# CONSTANTS: The discrete probability space +# ============================================================================= + +# Valid fractional chances achievable via d20 splits on single 2d6 rows +EXACT_CHANCES = [ + Decimal('5.7'), Decimal('5.4'), Decimal('5.1'), Decimal('4.8'), Decimal('4.75'), + Decimal('4.5'), Decimal('4.25'), Decimal('4.2'), Decimal('3.9'), Decimal('3.8'), + Decimal('3.75'), Decimal('3.6'), Decimal('3.5'), Decimal('3.4'), Decimal('3.3'), + Decimal('3.25'), Decimal('3.2'), Decimal('2.85'), Decimal('2.8'), Decimal('2.75'), + Decimal('2.7'), Decimal('2.6'), Decimal('2.55'), Decimal('2.5'), Decimal('2.4'), + Decimal('2.25'), Decimal('2.2'), Decimal('2.1'), Decimal('1.95'), Decimal('1.9'), + Decimal('1.8'), Decimal('1.75'), Decimal('1.7'), Decimal('1.65'), Decimal('1.6'), + Decimal('1.5'), Decimal('1.4'), Decimal('1.35'), Decimal('1.3'), Decimal('1.25'), + Decimal('1.2'), Decimal('1.1'), Decimal('1.05'), Decimal('1.0'), Decimal('0.95'), + Decimal('0.9'), Decimal('0.85'), Decimal('0.8'), Decimal('0.75'), Decimal('0.7'), + Decimal('0.65'), Decimal('0.6'), Decimal('0.55'), Decimal('0.5'), Decimal('0.45'), + Decimal('0.4'), Decimal('0.35'), Decimal('0.3'), Decimal('0.25'), Decimal('0.2'), + Decimal('0.15'), Decimal('0.1'), Decimal('0.05'), +] + +# 2d6 row frequencies (how many times each result occurs out of 36 rolls) +ROW_FREQUENCIES = { + 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 5, 9: 4, 10: 3, 11: 2, 12: 1 +} + +# Maximum splits allowed per column (d20 subdivisions) +MAX_SPLITS_PER_COLUMN = 4 + + +# ============================================================================= +# CONTRACT SYSTEM: Pluggable Placement Strategies +# ============================================================================= + +class RowPreference(Enum): + """Built-in row ordering strategies.""" + MIDDLE_FIRST = "middle_first" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 + EDGES_FIRST = "edges_first" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 + HIGH_TO_LOW = "high_to_low" # 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 + LOW_TO_HIGH = "low_to_high" # 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 + BY_FREQUENCY_DESC = "freq_desc" # Same as middle_first + BY_FREQUENCY_ASC = "freq_asc" # Same as edges_first + + +ROW_ORDERINGS = { + RowPreference.MIDDLE_FIRST: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], + RowPreference.EDGES_FIRST: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], + RowPreference.HIGH_TO_LOW: [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2], + RowPreference.LOW_TO_HIGH: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + RowPreference.BY_FREQUENCY_DESC: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], + RowPreference.BY_FREQUENCY_ASC: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], +} + + +class PlayCategory(Enum): + """Categories of plays for contract rules.""" + ON_BASE = "on_base" # Walks, HBP, singles, doubles, triples, HR + POWER = "power" # HR, triples, doubles + CONTACT = "contact" # Singles + PATIENCE = "patience" # Walks, HBP + STRIKEOUT = "strikeout" + GROUNDOUT = "groundout" + FLYOUT = "flyout" + XCHECK = "xcheck" # Pitcher X-checks + BATTER_POWER = "batter_power" # bp_homerun, bp_single + + +class CardContract: + """ + Base class for card building contracts. + + A contract defines the "personality" of a card - how plays are distributed + across the 2d6 probability space. Subclass this to create custom contracts. + """ + + @property + def name(self) -> str: + return "Base Contract" + + @property + def description(self) -> str: + return "Override this in subclasses" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + """Return ordered list of preferred rows for this play category.""" + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + """Return categories in priority order (placed first to last).""" + return [ + PlayCategory.BATTER_POWER, + PlayCategory.PATIENCE, + PlayCategory.XCHECK, + PlayCategory.POWER, + PlayCategory.CONTACT, + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + def get_secondary_pool(self, category: PlayCategory) -> List[str]: + """Return play names that can fill d20 split remainders.""" + return ['flyout_cf', 'groundout_a', 'strikeout'] + + def should_use_splits(self, category: PlayCategory) -> bool: + """Whether this category should use d20 splits.""" + return True + + def max_splits_per_column(self) -> int: + """Maximum d20 splits allowed per column.""" + return MAX_SPLITS_PER_COLUMN + + +class StandardContract(CardContract): + """Default contract - on-base results favor middle rows (most likely).""" + + @property + def name(self) -> str: + return "Standard" + + @property + def description(self) -> str: + return "Default card layout - on-base results on middle rows (6-8)" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.ON_BASE, PlayCategory.POWER, + PlayCategory.CONTACT, PlayCategory.PATIENCE]: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + +class ClutchContract(CardContract): + """Clutch card - on-base results on edge rows (less frequent but memorable).""" + + @property + def name(self) -> str: + return "Clutch" + + @property + def description(self) -> str: + return "On-base results on edge rows (2-4, 10-12) - clutch performer" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.ON_BASE, PlayCategory.POWER, + PlayCategory.CONTACT, PlayCategory.PATIENCE]: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + else: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + + +class PowerHeavyContract(CardContract): + """Power-focused - HR/3B/2B get prime rows, singles on edges.""" + + @property + def name(self) -> str: + return "Power Heavy" + + @property + def description(self) -> str: + return "Power hits on prime rows (6-8), singles pushed to edges" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.POWER: + return [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12] + elif category == PlayCategory.CONTACT: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.PATIENCE: + return [5, 9, 4, 10, 6, 8, 7, 3, 11, 2, 12] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.POWER, # Power BEFORE patience + PlayCategory.PATIENCE, + PlayCategory.XCHECK, + PlayCategory.CONTACT, + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + +class ContactFirstContract(CardContract): + """Contact-focused - singles get prime rows, power on edges.""" + + @property + def name(self) -> str: + return "Contact First" + + @property + def description(self) -> str: + return "Singles on prime rows, power on edges - high average hitter" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.CONTACT: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.POWER: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.PATIENCE: + return [5, 9, 4, 10, 3, 11, 2, 12, 6, 8, 7] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.CONTACT, # Contact BEFORE power + PlayCategory.PATIENCE, + PlayCategory.POWER, + PlayCategory.XCHECK, + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + +class GroundballPitcherContract(CardContract): + """Groundball pitcher - groundouts on prime rows.""" + + @property + def name(self) -> str: + return "Groundball Pitcher" + + @property + def description(self) -> str: + return "Groundouts on likely rows - sinkerball specialist" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.GROUNDOUT: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.FLYOUT: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.STRIKEOUT: + return [5, 9, 4, 10, 3, 11, 2, 12, 6, 8, 7] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.PATIENCE, + PlayCategory.XCHECK, + PlayCategory.GROUNDOUT, # Groundouts prioritized + PlayCategory.STRIKEOUT, + PlayCategory.POWER, + PlayCategory.CONTACT, + PlayCategory.FLYOUT, + ] + + +class FlyballPitcherContract(CardContract): + """Flyball pitcher - strikeouts and flyouts on prime rows.""" + + @property + def name(self) -> str: + return "Flyball Pitcher" + + @property + def description(self) -> str: + return "Strikeouts and flyouts on likely rows - power pitcher" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.FLYOUT, PlayCategory.STRIKEOUT]: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.GROUNDOUT: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.PATIENCE, + PlayCategory.STRIKEOUT, # Strikeouts prioritized + PlayCategory.FLYOUT, + PlayCategory.XCHECK, + PlayCategory.POWER, + PlayCategory.CONTACT, + PlayCategory.GROUNDOUT, + ] + + +# Contract registry for easy lookup +CONTRACT_REGISTRY: Dict[str, CardContract] = { + 'standard': StandardContract(), + 'clutch': ClutchContract(), + 'power_heavy': PowerHeavyContract(), + 'contact_first': ContactFirstContract(), + 'groundball_pitcher': GroundballPitcherContract(), + 'flyball_pitcher': FlyballPitcherContract(), +} + + +def get_contract(name: str) -> CardContract: + """Get a contract by name, or return Standard if not found.""" + return CONTRACT_REGISTRY.get(name.lower(), StandardContract()) + + +# ============================================================================= +# DATA STRUCTURES: The card model +# ============================================================================= + +class PlayType(Enum): + """Categories of play results for priority ordering.""" + # Common to both batting and pitching + WALK = "walk" + HBP = "hbp" + STRIKEOUT = "strikeout" + HOMERUN = "homerun" + TRIPLE = "triple" + DOUBLE = "double" + SINGLE = "single" + GROUNDOUT = "groundout" + FLYOUT = "flyout" + LINEOUT = "lineout" + + # Pitcher-specific + BP_HOMERUN = "bp_homerun" # Batter Power HR - redirects to batter card + BP_SINGLE = "bp_single" # Batter Power Single - redirects to batter card + XCHECK = "xcheck" # X-check - redirects to batter card at position + WILD_PITCH = "wild_pitch" + BALK = "balk" + + +@dataclass +class PlayResult: + """A single play outcome to place on the card.""" + play_type: PlayType + full_name: str # e.g., "SINGLE(rf)+" + short_name: str # e.g., "SI(rf)+" for split rows + is_offense: bool # Bold on card if True + direction: Optional[str] = None # lf, cf, rf, etc. + + +@dataclass +class CardSlot: + """A single result slot on a 2d6 row (may have d20 split).""" + result_one: Optional[PlayResult] = None + result_two: Optional[PlayResult] = None # Only if d20 split + d20_split: Optional[int] = None # e.g., 13 means "1-13" and "14-20" + + @property + def is_empty(self) -> bool: + return self.result_one is None + + @property + def is_split(self) -> bool: + return self.result_two is not None + + def to_dict(self) -> dict: + """Serialize for API/storage.""" + return { + 'result_one': self.result_one.full_name if self.result_one else None, + 'result_two': self.result_two.short_name if self.result_two else None, + 'd20_one': f'1-{self.d20_split}' if self.d20_split else None, + 'd20_two': f'{self.d20_split + 1}-20' if self.d20_split and self.d20_split < 20 else None, + 'bold_one': self.result_one.is_offense if self.result_one else False, + 'bold_two': self.result_two.is_offense if self.result_two else False, + } + + +@dataclass +class CardColumn: + """A single column (vL or vR) with 11 2d6 result rows.""" + rows: Dict[int, CardSlot] = field(default_factory=lambda: {i: CardSlot() for i in range(2, 13)}) + num_splits: int = 0 + + def get_available_rows(self, min_chances: int = 1) -> List[int]: + """Return 2d6 values with empty slots that have >= min_chances frequency.""" + return [ + row for row, slot in self.rows.items() + if slot.is_empty and ROW_FREQUENCIES[row] >= min_chances + ] + + def total_chances_used(self) -> int: + """Sum of frequencies for all filled rows.""" + return sum( + ROW_FREQUENCIES[row] for row, slot in self.rows.items() + if not slot.is_empty + ) + + def is_full(self) -> bool: + return all(not slot.is_empty for slot in self.rows.values()) + + def to_dict(self) -> dict: + return {str(row): slot.to_dict() for row, slot in self.rows.items()} + + +@dataclass +class BuiltCard: + """The fully-built card structure ready for storage/rendering.""" + column_vl: CardColumn + column_vr: CardColumn + + # Store the fitted chances for each play type (what actually got placed) + fitted_chances_vl: Dict[str, Decimal] = field(default_factory=dict) + fitted_chances_vr: Dict[str, Decimal] = field(default_factory=dict) + + # Store deltas from requested chances (for debugging/logging) + deltas_vl: Dict[str, Decimal] = field(default_factory=dict) + deltas_vr: Dict[str, Decimal] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + 'column_vl': self.column_vl.to_dict(), + 'column_vr': self.column_vr.to_dict(), + 'fitted_chances_vl': {k: float(v) for k, v in self.fitted_chances_vl.items()}, + 'fitted_chances_vr': {k: float(v) for k, v in self.fitted_chances_vr.items()}, + 'deltas_vl': {k: float(v) for k, v in self.deltas_vl.items()}, + 'deltas_vr': {k: float(v) for k, v in self.deltas_vr.items()}, + } + + +# ============================================================================= +# INPUT STRUCTURES: What Python card-creation provides +# ============================================================================= + +@dataclass +class RawBattingChances: + """ + Raw calculated chances (out of 108) for a batting card. + This is what the Python card-creation code produces today. + """ + walk: Decimal = Decimal(0) + hbp: Decimal = Decimal(0) + strikeout: Decimal = Decimal(0) + homerun: Decimal = Decimal(0) + triple: Decimal = Decimal(0) + double_pull: Decimal = Decimal(0) + double_straight: Decimal = Decimal(0) + double_oppo: Decimal = Decimal(0) + single_pull: Decimal = Decimal(0) + single_straight: Decimal = Decimal(0) + single_oppo: Decimal = Decimal(0) + groundout_pull: Decimal = Decimal(0) + groundout_straight: Decimal = Decimal(0) + groundout_oppo: Decimal = Decimal(0) + flyout_pull: Decimal = Decimal(0) + flyout_straight: Decimal = Decimal(0) + flyout_oppo: Decimal = Decimal(0) + lineout: Decimal = Decimal(0) + + +# Alias for backwards compatibility +RawChances = RawBattingChances + + +@dataclass +class RawPitchingChances: + """ + Raw calculated chances (out of 108) for a pitching card. + + Key differences from batting: + - bp_homerun/bp_single: "Batter Power" outcomes redirect to batter's card + - xcheck_*: X-check outcomes redirect to batter's card at that position + - Hits allowed are NOT bolded (bad for pitcher) + - Strikeouts ARE bolded (good for pitcher) + """ + # Batter Power outcomes (redirect to batter card for power hitters) + bp_homerun: Decimal = Decimal(0) + bp_single: Decimal = Decimal(0) + + # Standard outcomes + hbp: Decimal = Decimal(0) + walk: Decimal = Decimal(0) + strikeout: Decimal = Decimal(0) + + # Hits allowed (NOT offense - bad for pitcher) + homerun: Decimal = Decimal(0) + triple: Decimal = Decimal(0) + double_cf: Decimal = Decimal(0) # Center field double + double_two: Decimal = Decimal(0) # Two-base double (standard) + double_three: Decimal = Decimal(0) # Three-base double (gap shot) + single_one: Decimal = Decimal(0) # One-base single + single_two: Decimal = Decimal(0) # Two-base single (extra base potential) + single_center: Decimal = Decimal(0) # Center field single + + # Outs + flyout_lf: Decimal = Decimal(0) + flyout_cf: Decimal = Decimal(0) + flyout_rf: Decimal = Decimal(0) + groundout_a: Decimal = Decimal(0) # Groundout type A (e.g., to 1B side) + groundout_b: Decimal = Decimal(0) # Groundout type B (e.g., to 3B side) + + # X-checks: redirect to batter's card at specified position + # These appear as "GB (p) X", "CATCH X", "FLY (cf) X", etc. + xcheck_p: Decimal = Decimal(0) # Pitcher + xcheck_c: Decimal = Decimal(0) # Catcher + xcheck_1b: Decimal = Decimal(0) # First base + xcheck_2b: Decimal = Decimal(0) # Second base + xcheck_3b: Decimal = Decimal(0) # Third base + xcheck_ss: Decimal = Decimal(0) # Shortstop + xcheck_lf: Decimal = Decimal(0) # Left field + xcheck_cf: Decimal = Decimal(0) # Center field + xcheck_rf: Decimal = Decimal(0) # Right field + + # Special outcomes + wild_pitch: Decimal = Decimal(0) + balk: Decimal = Decimal(0) + + +# ============================================================================= +# THE FITTING ALGORITHM +# ============================================================================= + +class CardBuilder: + """ + Converts raw continuous chances into a discrete card structure. + + This is the single source of truth for the fitting algorithm. + Both Python card-creation and the database API would use this. + + The contract parameter controls placement strategy: + - Which rows to prefer for different play categories + - Priority ordering of plays + - Secondary play selection rules + """ + + def __init__( + self, + contract: CardContract = None, + max_splits: int = None + ): + self.contract = contract or StandardContract() + self.max_splits = max_splits or self.contract.max_splits_per_column() + + def build_batting_card( + self, + chances_vl: RawBattingChances, + chances_vr: RawBattingChances, + batter_hand: str, # 'L' or 'R' - affects hit directions + contract: CardContract = None # Override instance contract + ) -> BuiltCard: + """ + Build a complete batting card from raw chances. + + The order of play placement matters - high-value outcomes first, + then fill remaining rows with outs. The contract controls which + rows are preferred for each play category. + """ + active_contract = contract or self.contract + card = BuiltCard( + column_vl=CardColumn(), + column_vr=CardColumn() + ) + + # Build each column + for column, chances, side in [ + (card.column_vl, chances_vl, 'vL'), + (card.column_vr, chances_vr, 'vR') + ]: + fitted = {} + deltas = {} + + # Get play order from contract's priority, mapped to actual plays + play_order = self._get_batting_play_order(chances, batter_hand, active_contract) + + for play_result, raw_chance, category in play_order: + if raw_chance <= 0: + continue + + # Use contract's row preference for this category + actual_placed = self._place_play_with_contract( + column, play_result, raw_chance, category, active_contract + ) + fitted[play_result.full_name] = actual_placed + deltas[play_result.full_name] = actual_placed - raw_chance + + # Store results + if side == 'vL': + card.fitted_chances_vl = fitted + card.deltas_vl = deltas + else: + card.fitted_chances_vr = fitted + card.deltas_vr = deltas + + return card + + def build_pitching_card( + self, + chances_vl: RawPitchingChances, + chances_vr: RawPitchingChances, + pitcher_hand: str, # 'L' or 'R' - affects middle infield preference + contract: CardContract = None # Override instance contract + ) -> BuiltCard: + """ + Build a complete pitching card from raw chances. + + Key differences from batting: + - X-checks and BP outcomes redirect to batter's card + - Strikeouts are bolded (good for pitcher) + - Hits allowed are NOT bolded (bad for pitcher) + - Secondary play selection uses remaining chances from other categories + - Contract controls row preferences and play priority + """ + active_contract = contract or self.contract + card = BuiltCard( + column_vl=CardColumn(), + column_vr=CardColumn() + ) + + for column, chances, side in [ + (card.column_vl, chances_vl, 'vL'), + (card.column_vr, chances_vr, 'vR') + ]: + fitted = {} + deltas = {} + + # Create mutable copy of chances for secondary play tracking + remaining = self._chances_to_dict(chances) + + # Get play order - X-checks and BP first, then standard outcomes + play_order = self._get_pitching_play_order(chances, pitcher_hand, side) + + for play_result, raw_chance, secondary_pool in play_order: + if raw_chance <= 0: + continue + + # For pitching, we need to track secondary plays used + actual_placed, secondary_used = self._place_play_with_secondary( + column, play_result, raw_chance, remaining, secondary_pool + ) + + fitted[play_result.full_name] = actual_placed + deltas[play_result.full_name] = actual_placed - raw_chance + + # Update remaining chances for secondaries used + for sec_name, sec_amount in secondary_used.items(): + if sec_name in remaining: + remaining[sec_name] -= sec_amount + if sec_name not in fitted: + fitted[sec_name] = sec_amount + else: + fitted[sec_name] += sec_amount + + if side == 'vL': + card.fitted_chances_vl = fitted + card.deltas_vl = deltas + else: + card.fitted_chances_vr = fitted + card.deltas_vr = deltas + + return card + + def _chances_to_dict(self, chances: RawPitchingChances) -> Dict[str, Decimal]: + """Convert chances dataclass to mutable dict for tracking.""" + return { + 'strikeout': chances.strikeout, + 'flyout_cf': chances.flyout_cf, + 'flyout_lf': chances.flyout_lf, + 'flyout_rf': chances.flyout_rf, + 'groundout_a': chances.groundout_a, + 'groundout_b': chances.groundout_b, + 'double_cf': chances.double_cf, + 'double_two': chances.double_two, + 'double_three': chances.double_three, + 'triple': chances.triple, + 'single_two': chances.single_two, + 'single_center': chances.single_center, + 'single_one': chances.single_one, + } + + def _get_pitching_play_order( + self, + chances: RawPitchingChances, + pitcher_hand: str, + vs_hand: str + ) -> List[Tuple[PlayResult, Decimal, List[str]]]: + """ + Return pitching plays in priority order with secondary play pools. + + Order based on database logic: + 1. Batter Power HR (bp_homerun) + 2. HBP + 3. X-checks (p, c, 1b, 3b, rf, lf, 2b, cf, ss) + 4. Walks (secondary: strikeouts) + 5. Home runs (secondary: flyouts, doubles) + 6. Triples (secondary: singles, flyouts, doubles) + 7. Doubles (secondary: singles, flyouts) + 8. Singles (secondary: flyouts, groundouts) + 9. Strikeouts + 10. Groundouts + 11. Flyouts + 12. Batter Power Single + + Each entry is (PlayResult, raw_chances, secondary_pool) + Secondary pool defines what plays can fill the d20 split remainder. + """ + # Determine preferred middle infielder based on handedness matchup + if pitcher_hand == 'L' and vs_hand == 'L': + pref_mif = 'ss' + elif pitcher_hand == 'L' or (pitcher_hand == 'R' and vs_hand == 'R'): + pref_mif = '2b' + else: + pref_mif = 'ss' + + return [ + # Batter Power HR - no secondary (full row) + (PlayResult(PlayType.BP_HOMERUN, 'HOMERUN*', 'HR*', False), chances.bp_homerun, []), + + # HBP - no secondary typically + (PlayResult(PlayType.HBP, 'HBP', 'HBP', False), chances.hbp, []), + + # X-checks - redirect to batter's card + (PlayResult(PlayType.XCHECK, 'GB (p) X', 'GB (p) X', False), chances.xcheck_p, []), + (PlayResult(PlayType.XCHECK, 'CATCH X', 'CATCH X', False), chances.xcheck_c, []), + (PlayResult(PlayType.XCHECK, 'GB (1b) X', 'GB (1b) X', False), chances.xcheck_1b, []), + (PlayResult(PlayType.XCHECK, 'GB (3b) X', 'GB (3b) X', False), chances.xcheck_3b, []), + (PlayResult(PlayType.XCHECK, 'FLY (rf) X', 'FLY (rf) X', False), chances.xcheck_rf, []), + (PlayResult(PlayType.XCHECK, 'FLY (lf) X', 'FLY (lf) X', False), chances.xcheck_lf, []), + (PlayResult(PlayType.XCHECK, f'GB ({pref_mif}) X', f'GB ({pref_mif}) X', False), + chances.xcheck_2b if pref_mif == '2b' else chances.xcheck_ss, []), + (PlayResult(PlayType.XCHECK, 'FLY (cf) X', 'FLY (cf) X', False), chances.xcheck_cf, []), + (PlayResult(PlayType.XCHECK, f'GB ({"ss" if pref_mif == "2b" else "2b"}) X', + f'GB ({"ss" if pref_mif == "2b" else "2b"}) X', False), + chances.xcheck_ss if pref_mif == '2b' else chances.xcheck_2b, []), + + # Walks - secondary is strikeout (good pairing: offense vs defense) + (PlayResult(PlayType.WALK, 'WALK', 'BB', False), chances.walk, ['strikeout']), + + # Home runs - secondary is flyouts or doubles + (PlayResult(PlayType.HOMERUN, 'HOMERUN', 'HR', False), chances.homerun, + ['double_cf', 'flyout_cf', 'flyout_lf', 'flyout_rf', 'double_three', 'double_two', 'triple']), + + # Triples - secondary is singles or flyouts + (PlayResult(PlayType.TRIPLE, 'TRIPLE', '3B', False), chances.triple, + ['single_two', 'flyout_cf', 'flyout_lf', 'flyout_rf', 'double_cf', 'double_three', 'double_two']), + + # Doubles + (PlayResult(PlayType.DOUBLE, 'DOUBLE (cf)', 'DO (cf)', False), chances.double_cf, + ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two', 'single_center']), + (PlayResult(PlayType.DOUBLE, 'DOUBLE***', 'DO***', False), chances.double_three, + ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two']), + (PlayResult(PlayType.DOUBLE, 'DOUBLE**', 'DO**', False), chances.double_two, + ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two']), + + # Singles + (PlayResult(PlayType.SINGLE, 'SINGLE**', 'SI**', False), chances.single_two, + ['flyout_cf', 'flyout_lf', 'flyout_rf', 'groundout_a', 'groundout_b']), + (PlayResult(PlayType.SINGLE, 'SINGLE (cf)', 'SI (cf)', False), chances.single_center, + ['flyout_cf', 'groundout_a', 'groundout_b']), + (PlayResult(PlayType.SINGLE, 'SINGLE*', 'SI*', False), chances.single_one, + ['groundout_a', 'groundout_b', 'flyout_lf', 'flyout_rf']), + + # Strikeouts - BOLDED (good for pitcher!) + (PlayResult(PlayType.STRIKEOUT, 'strikeout', 'K', True), chances.strikeout, []), + + # Groundouts + (PlayResult(PlayType.GROUNDOUT, 'GO (a)', 'GO (a)', False), chances.groundout_a, []), + (PlayResult(PlayType.GROUNDOUT, 'GO (b)', 'GO (b)', False), chances.groundout_b, []), + + # Flyouts + (PlayResult(PlayType.FLYOUT, 'FO (lf)', 'FO (lf)', False), chances.flyout_lf, []), + (PlayResult(PlayType.FLYOUT, 'FO (cf)', 'FO (cf)', False), chances.flyout_cf, []), + (PlayResult(PlayType.FLYOUT, 'FO (rf)', 'FO (rf)', False), chances.flyout_rf, []), + + # Batter Power Single - last (redirect to batter) + (PlayResult(PlayType.BP_SINGLE, 'SINGLE*', 'SI*', False), chances.bp_single, []), + ] + + def _place_play_with_secondary( + self, + column: CardColumn, + play: PlayResult, + target_chances: Decimal, + remaining: Dict[str, Decimal], + secondary_pool: List[str] + ) -> Tuple[Decimal, Dict[str, Decimal]]: + """ + Place a play with potential secondary (d20 split). + + Returns (actual_placed, {secondary_name: amount_used}) + """ + if target_chances <= 0: + return Decimal(0), {} + + placed = Decimal(0) + remaining_target = target_chances + secondary_used: Dict[str, Decimal] = {} + + while remaining_target > Decimal('0.05') and not column.is_full(): + # Find best secondary from pool (one with most remaining chances) + secondary = self._select_secondary(remaining, secondary_pool, remaining_target) + + chunk_placed, sec_chunk = self._place_chunk_with_secondary( + column, play, remaining_target, secondary + ) + + if chunk_placed == 0: + break + + placed += chunk_placed + remaining_target -= chunk_placed + + if sec_chunk and secondary: + sec_name = secondary.full_name + if sec_name not in secondary_used: + secondary_used[sec_name] = Decimal(0) + secondary_used[sec_name] += sec_chunk + + return placed, secondary_used + + def _select_secondary( + self, + remaining: Dict[str, Decimal], + pool: List[str], + min_needed: Decimal + ) -> Optional[PlayResult]: + """Select best secondary play from pool based on remaining chances.""" + if not pool: + return None + + # Find pool entry with most remaining chances that exceeds min_needed + best_name = None + best_amount = Decimal(0) + + for name in pool: + amount = remaining.get(name, Decimal(0)) + if amount > best_amount and amount > (1 - min_needed): + best_amount = amount + best_name = name + + if best_name is None: + return None + + # Create appropriate PlayResult for the secondary + return self._create_secondary_play(best_name) + + def _create_secondary_play(self, name: str) -> PlayResult: + """Create a PlayResult for a secondary outcome.""" + mapping = { + 'strikeout': PlayResult(PlayType.STRIKEOUT, 'strikeout', 'so', True), + 'flyout_cf': PlayResult(PlayType.FLYOUT, 'FO (cf)', 'FO (cf)', False), + 'flyout_lf': PlayResult(PlayType.FLYOUT, 'FO (lf)', 'FO (lf)', False), + 'flyout_rf': PlayResult(PlayType.FLYOUT, 'FO (rf)', 'FO (rf)', False), + 'groundout_a': PlayResult(PlayType.GROUNDOUT, 'GO (a)', 'GO (a)', False), + 'groundout_b': PlayResult(PlayType.GROUNDOUT, 'GO (b)', 'GO (b)', False), + 'double_cf': PlayResult(PlayType.DOUBLE, 'DO (cf)', 'DO (cf)', False), + 'double_two': PlayResult(PlayType.DOUBLE, 'DO**', 'DO**', False), + 'double_three': PlayResult(PlayType.DOUBLE, 'DO***', 'DO***', False), + 'triple': PlayResult(PlayType.TRIPLE, 'TR', 'TR', False), + 'single_two': PlayResult(PlayType.SINGLE, 'SI**', 'SI**', False), + 'single_center': PlayResult(PlayType.SINGLE, 'SI (cf)', 'SI (cf)', False), + 'single_one': PlayResult(PlayType.SINGLE, 'SI*', 'SI*', False), + } + return mapping.get(name) + + def _place_chunk_with_secondary( + self, + column: CardColumn, + play: PlayResult, + target: Decimal, + secondary: Optional[PlayResult] + ) -> Tuple[Decimal, Optional[Decimal]]: + """ + Place a chunk with optional secondary. + Returns (primary_placed, secondary_placed or None). + """ + quantized = self._quantize(target) + if quantized <= 0: + return Decimal(0), None + + available = column.get_available_rows() + if not available: + return Decimal(0), None + + # Whole number placement (no secondary needed) + if quantized == quantized.to_integral_value(): + int_target = int(quantized) + placement = self._find_whole_placement(available, int_target) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)), None + + # Fractional placement with secondary + if column.num_splits < self.max_splits and secondary: + result = self._find_split_placement_with_secondary( + column, quantized, play, secondary + ) + if result: + return result + + # Fall back to floor + floor_val = int(math.floor(float(quantized))) + if floor_val > 0: + placement = self._find_whole_placement(available, floor_val) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)), None + + return Decimal(0), None + + def _find_split_placement_with_secondary( + self, + column: CardColumn, + target: Decimal, + primary: PlayResult, + secondary: PlayResult + ) -> Optional[Tuple[Decimal, Decimal]]: + """ + Place fractional value with d20 split, filling remainder with secondary. + Returns (primary_placed, secondary_placed) or None. + """ + for row in column.get_available_rows(): + freq = ROW_FREQUENCIES[row] + d20_needed = float(target) * 20 / freq + + if 1 <= d20_needed <= 19 and d20_needed == int(d20_needed): + d20_int = int(d20_needed) + primary_placed = Decimal(freq * d20_int) / Decimal(20) + secondary_placed = Decimal(freq * (20 - d20_int)) / Decimal(20) + + # Create the split + column.rows[row].result_one = primary + column.rows[row].result_two = secondary + column.rows[row].d20_split = d20_int + column.num_splits += 1 + + return primary_placed, secondary_placed + + return None + + def _get_batting_play_order( + self, + chances: RawBattingChances, + batter_hand: str, + contract: CardContract + ) -> List[Tuple[PlayResult, Decimal, PlayCategory]]: + """ + Return plays in priority order for placement, based on contract. + + Order matters because earlier plays get first pick of optimal rows. + Returns tuples of (PlayResult, chance_amount, PlayCategory). + """ + # Determine pull/oppo directions based on batter hand + pull_dir = 'rf' if batter_hand == 'L' else 'lf' + oppo_dir = 'lf' if batter_hand == 'L' else 'rf' + + # Define all plays with their categories + all_plays = { + PlayCategory.PATIENCE: [ + (PlayResult(PlayType.WALK, 'WALK', 'BB', True), chances.walk), + (PlayResult(PlayType.HBP, 'HBP', 'HBP', True), chances.hbp), + ], + PlayCategory.POWER: [ + (PlayResult(PlayType.HOMERUN, 'HOMERUN', 'HR', True), chances.homerun), + (PlayResult(PlayType.TRIPLE, 'TRIPLE', '3B', True), chances.triple), + (PlayResult(PlayType.DOUBLE, f'DOUBLE({pull_dir})', f'2B({pull_dir})', True, pull_dir), chances.double_pull), + (PlayResult(PlayType.DOUBLE, 'DOUBLE(cf)', '2B(cf)', True, 'cf'), chances.double_straight), + (PlayResult(PlayType.DOUBLE, f'DOUBLE({oppo_dir})', f'2B({oppo_dir})', True, oppo_dir), chances.double_oppo), + ], + PlayCategory.CONTACT: [ + (PlayResult(PlayType.SINGLE, f'SINGLE({pull_dir})+', f'SI({pull_dir})+', True, pull_dir), chances.single_pull), + (PlayResult(PlayType.SINGLE, 'SINGLE(cf)+', 'SI(cf)+', True, 'cf'), chances.single_straight), + (PlayResult(PlayType.SINGLE, f'SINGLE({oppo_dir})+', f'SI({oppo_dir})+', True, oppo_dir), chances.single_oppo), + ], + PlayCategory.STRIKEOUT: [ + (PlayResult(PlayType.STRIKEOUT, 'strikeout', 'K', False), chances.strikeout), + ], + PlayCategory.GROUNDOUT: [ + (PlayResult(PlayType.GROUNDOUT, f'GO({pull_dir})', f'GO({pull_dir})', False, pull_dir), chances.groundout_pull), + (PlayResult(PlayType.GROUNDOUT, 'GO(ss/2b)', 'GO(ss/2b)', False, 'middle'), chances.groundout_straight), + (PlayResult(PlayType.GROUNDOUT, f'GO({oppo_dir})', f'GO({oppo_dir})', False, oppo_dir), chances.groundout_oppo), + ], + PlayCategory.FLYOUT: [ + (PlayResult(PlayType.FLYOUT, f'FO({pull_dir})', f'FO({pull_dir})', False, pull_dir), chances.flyout_pull), + (PlayResult(PlayType.FLYOUT, 'FO(cf)', 'FO(cf)', False, 'cf'), chances.flyout_straight), + (PlayResult(PlayType.FLYOUT, f'FO({oppo_dir})', f'FO({oppo_dir})', False, oppo_dir), chances.flyout_oppo), + (PlayResult(PlayType.LINEOUT, 'lineout', 'LO', False), chances.lineout), + ], + } + + # Build ordered list based on contract's priority + result = [] + for category in contract.get_play_priority(): + if category in all_plays: + for play, chance in all_plays[category]: + result.append((play, chance, category)) + + return result + + def _place_play_with_contract( + self, + column: CardColumn, + play: PlayResult, + target_chances: Decimal, + category: PlayCategory, + contract: CardContract + ) -> Decimal: + """ + Place a play using the contract's row preferences. + """ + if target_chances <= 0: + return Decimal(0) + + placed = Decimal(0) + remaining = target_chances + + # Get preferred row order from contract + preferred_rows = contract.get_row_preference(category) + + while remaining > Decimal('0.05') and not column.is_full(): + chunk_placed = self._place_chunk_with_preference( + column, play, remaining, preferred_rows, contract + ) + if chunk_placed == 0: + break + placed += chunk_placed + remaining -= chunk_placed + + return placed + + def _place_chunk_with_preference( + self, + column: CardColumn, + play: PlayResult, + target: Decimal, + preferred_rows: List[int], + contract: CardContract + ) -> Decimal: + """ + Place a chunk using contract's preferred row order. + """ + quantized = self._quantize(target) + if quantized <= 0: + return Decimal(0) + + # Filter to available rows, maintaining preference order + available = [r for r in preferred_rows if column.rows[r].is_empty] + if not available: + return Decimal(0) + + # Whole number: find row(s) that sum to quantized + if quantized == quantized.to_integral_value(): + int_target = int(quantized) + placement = self._find_whole_placement_preferred(available, int_target) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) + + # Fractional: need d20 split (if contract allows) + if column.num_splits < self.max_splits and contract.should_use_splits( + self._play_to_category(play) + ): + placement = self._find_split_placement(column, quantized, play) + if placement: + return placement + + # Fall back to floor + floor_val = int(math.floor(float(quantized))) + if floor_val > 0: + placement = self._find_whole_placement_preferred(available, floor_val) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) + + return Decimal(0) + + def _find_whole_placement_preferred( + self, + available: List[int], + target: int + ) -> Optional[List[int]]: + """ + Find combination of available rows that sum to target. + Respects the order of available (which is already preference-sorted). + """ + # Single row solutions (prefer earlier in list) + for row in available: + if ROW_FREQUENCIES[row] == target: + return [row] + + # Two row solutions + for i, row1 in enumerate(available): + for row2 in available[i+1:]: + if ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] == target: + return [row1, row2] + + # Three row solutions + for i, row1 in enumerate(available): + for j, row2 in enumerate(available[i+1:], i+1): + for row3 in available[j+1:]: + total = ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] + ROW_FREQUENCIES[row3] + if total == target: + return [row1, row2, row3] + + return None + + def _play_to_category(self, play: PlayResult) -> PlayCategory: + """Map a PlayResult to its PlayCategory.""" + mapping = { + PlayType.WALK: PlayCategory.PATIENCE, + PlayType.HBP: PlayCategory.PATIENCE, + PlayType.HOMERUN: PlayCategory.POWER, + PlayType.TRIPLE: PlayCategory.POWER, + PlayType.DOUBLE: PlayCategory.POWER, + PlayType.SINGLE: PlayCategory.CONTACT, + PlayType.STRIKEOUT: PlayCategory.STRIKEOUT, + PlayType.GROUNDOUT: PlayCategory.GROUNDOUT, + PlayType.FLYOUT: PlayCategory.FLYOUT, + PlayType.LINEOUT: PlayCategory.FLYOUT, + PlayType.BP_HOMERUN: PlayCategory.BATTER_POWER, + PlayType.BP_SINGLE: PlayCategory.BATTER_POWER, + PlayType.XCHECK: PlayCategory.XCHECK, + } + return mapping.get(play.play_type, PlayCategory.FLYOUT) + + def _place_play( + self, + column: CardColumn, + play: PlayResult, + target_chances: Decimal + ) -> Decimal: + """ + Place a play on the card, returning actual chances placed. + + This is the core fitting algorithm - finding the best combination + of available rows to approximate the target chances. + """ + if target_chances <= 0: + return Decimal(0) + + placed = Decimal(0) + remaining = target_chances + + while remaining > Decimal('0.05') and not column.is_full(): + # Try to place a chunk + chunk_placed = self._place_chunk(column, play, remaining) + if chunk_placed == 0: + break + placed += chunk_placed + remaining -= chunk_placed + + return placed + + def _place_chunk( + self, + column: CardColumn, + play: PlayResult, + target: Decimal + ) -> Decimal: + """ + Place a single chunk of chances, returning amount placed. + + Strategy: + 1. If target is a whole number, find best row combination + 2. If target is fractional and in EXACT_CHANCES, use d20 split + 3. Otherwise, quantize to nearest valid value + """ + # Quantize to nearest valid discrete value + quantized = self._quantize(target) + + if quantized <= 0: + return Decimal(0) + + # Find best available placement + available = column.get_available_rows() + if not available: + return Decimal(0) + + # Whole number: find row(s) that sum to quantized + if quantized == quantized.to_integral_value(): + int_target = int(quantized) + placement = self._find_whole_placement(available, int_target) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) + + # Fractional: need d20 split + if column.num_splits < self.max_splits: + placement = self._find_split_placement(column, quantized, play) + if placement: + return placement + + # Fall back to floor + floor_val = int(math.floor(float(quantized))) + if floor_val > 0: + placement = self._find_whole_placement(available, floor_val) + if placement: + for row in placement: + column.rows[row].result_one = play + return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) + + return Decimal(0) + + def _quantize(self, value: Decimal) -> Decimal: + """Snap to nearest valid discrete chance value.""" + if value <= 0: + return Decimal(0) + if value > 6: + return Decimal(6) + + # Check whole numbers first + rounded = round(float(value)) + if abs(value - rounded) < Decimal('0.025'): + return Decimal(rounded) + + # Find nearest EXACT_CHANCE + valid = EXACT_CHANCES + [Decimal(i) for i in range(1, 7)] + return min(valid, key=lambda x: abs(x - value)) + + def _find_whole_placement( + self, + available: List[int], + target: int + ) -> Optional[List[int]]: + """ + Find combination of available rows that sum to target frequency. + Prefers fewer rows (simpler card layout). + """ + # Single row solutions + for row in available: + if ROW_FREQUENCIES[row] == target: + return [row] + + # Two row solutions + for i, row1 in enumerate(available): + for row2 in available[i+1:]: + if ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] == target: + return [row1, row2] + + # Three row solutions (common for 6 = 3+2+1) + for i, row1 in enumerate(available): + for j, row2 in enumerate(available[i+1:], i+1): + for row3 in available[j+1:]: + total = ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] + ROW_FREQUENCIES[row3] + if total == target: + return [row1, row2, row3] + + return None + + def _find_split_placement( + self, + column: CardColumn, + target: Decimal, + play: PlayResult + ) -> Optional[Decimal]: + """ + Place a fractional value using d20 split on a single row. + Returns actual chances placed, or None if not possible. + + A d20 split on a row with frequency F can produce: + F * (d20_value / 20) chances + + For example, row 7 (freq=6) with d20 1-15: + 6 * (15/20) = 4.5 chances + """ + for row in column.get_available_rows(): + freq = ROW_FREQUENCIES[row] + # What d20 split would give us target? + # target = freq * (d20 / 20) + # d20 = target * 20 / freq + d20_needed = float(target) * 20 / freq + + if 1 <= d20_needed <= 19 and d20_needed == int(d20_needed): + d20_int = int(d20_needed) + actual = Decimal(freq * d20_int) / Decimal(20) + + # Create the split + column.rows[row].result_one = play + column.rows[row].d20_split = d20_int + # Note: result_two would be a "secondary" play (typically an out) + # For now, leaving it to be filled by the next pass + column.num_splits += 1 + + return actual + + return None + + +# ============================================================================= +# USAGE EXAMPLES +# ============================================================================= + +def example_batting_card(): + """ + How Python card-creation would use this module for a batting card. + Demonstrates using different contracts for different card personalities. + """ + # Raw chances (same for all examples) + raw_vl = RawBattingChances( + walk=Decimal('8.5'), + strikeout=Decimal('22.3'), + homerun=Decimal('4.78'), + triple=Decimal('1.2'), + double_pull=Decimal('3.5'), + single_pull=Decimal('12.1'), + groundout_straight=Decimal('15.6'), + flyout_straight=Decimal('18.2'), + ) + + raw_vr = RawBattingChances( + walk=Decimal('7.2'), + strikeout=Decimal('24.1'), + homerun=Decimal('3.95'), + triple=Decimal('0.9'), + double_pull=Decimal('2.8'), + single_pull=Decimal('10.8'), + groundout_straight=Decimal('17.3'), + flyout_straight=Decimal('20.1'), + ) + + # Build the SAME player with DIFFERENT contracts + print("=" * 60) + print("SAME STATS, DIFFERENT CONTRACTS") + print("=" * 60) + + contracts_to_test = [ + ('standard', StandardContract()), + ('clutch', ClutchContract()), + ('power_heavy', PowerHeavyContract()), + ('contact_first', ContactFirstContract()), + ] + + cards = {} + for name, contract in contracts_to_test: + builder = CardBuilder(contract=contract) + card = builder.build_batting_card(raw_vl, raw_vr, batter_hand='R') + cards[name] = card + + print(f"\n--- {contract.name} Contract ---") + print(f"Description: {contract.description}") + print("vL Column row assignments:") + for row_num in [7, 6, 8, 2, 12]: # Show key rows + slot = card.column_vl.rows[row_num] + if slot.result_one: + result = slot.result_one.short_name + if slot.is_split: + result += f" / {slot.result_two.short_name}" + print(f" Row {row_num:2d} ({ROW_FREQUENCIES[row_num]} chances): {result}") + + return cards + + +def example_contract_comparison(): + """ + Show how the same player looks different with different contracts. + """ + raw_vl = RawBattingChances( + walk=Decimal('6'), + homerun=Decimal('5'), + single_pull=Decimal('6'), + strikeout=Decimal('10'), + flyout_straight=Decimal('9'), + ) + + raw_vr = raw_vl # Same for simplicity + + print("\n" + "=" * 60) + print("CONTRACT COMPARISON: Row 7 (6 chances - most likely)") + print("=" * 60) + + for name, contract in CONTRACT_REGISTRY.items(): + builder = CardBuilder(contract=contract) + card = builder.build_batting_card(raw_vl, raw_vr, batter_hand='R') + slot = card.column_vl.rows[7] + result = slot.result_one.full_name if slot.result_one else "" + print(f" {name:20s}: {result}") + + +def example_pitching_card(): + """ + How Python card-creation would use this module for a pitching card. + + Key differences from batting: + - X-checks redirect to batter's card at specific positions + - Batter Power (bp_*) outcomes redirect to batter's card for power hitters + - Secondary plays fill d20 split remainders intelligently + - Strikeouts are bolded (good for pitcher), hits are not + """ + # 1. Calculate raw chances (existing logic) + raw_vl = RawPitchingChances( + # Batter power - redirects to batter card + bp_homerun=Decimal('1.5'), + bp_single=Decimal('2.0'), + + # Standard outcomes + hbp=Decimal('1.2'), + walk=Decimal('7.8'), + strikeout=Decimal('24.5'), + + # Hits allowed + homerun=Decimal('2.3'), + triple=Decimal('0.8'), + double_cf=Decimal('1.5'), + double_two=Decimal('3.2'), + double_three=Decimal('1.1'), + single_one=Decimal('4.5'), + single_two=Decimal('5.8'), + single_center=Decimal('3.2'), + + # Outs + flyout_lf=Decimal('8.5'), + flyout_cf=Decimal('12.3'), + flyout_rf=Decimal('7.8'), + groundout_a=Decimal('10.2'), + groundout_b=Decimal('8.5'), + + # X-checks - redirect to batter's card + xcheck_p=Decimal('1.5'), + xcheck_c=Decimal('0.8'), + xcheck_1b=Decimal('2.3'), + xcheck_2b=Decimal('3.5'), + xcheck_3b=Decimal('2.1'), + xcheck_ss=Decimal('3.2'), + xcheck_lf=Decimal('1.8'), + xcheck_cf=Decimal('2.5'), + xcheck_rf=Decimal('1.5'), + ) + + raw_vr = RawPitchingChances( + bp_homerun=Decimal('2.0'), + bp_single=Decimal('2.5'), + hbp=Decimal('1.0'), + walk=Decimal('6.5'), + strikeout=Decimal('28.2'), + homerun=Decimal('1.8'), + triple=Decimal('0.5'), + double_cf=Decimal('1.2'), + double_two=Decimal('2.8'), + double_three=Decimal('0.9'), + single_one=Decimal('3.8'), + single_two=Decimal('4.5'), + single_center=Decimal('2.8'), + flyout_lf=Decimal('9.2'), + flyout_cf=Decimal('14.5'), + flyout_rf=Decimal('8.5'), + groundout_a=Decimal('11.5'), + groundout_b=Decimal('9.2'), + xcheck_p=Decimal('1.2'), + xcheck_c=Decimal('0.6'), + xcheck_1b=Decimal('2.0'), + xcheck_2b=Decimal('3.0'), + xcheck_3b=Decimal('1.8'), + xcheck_ss=Decimal('2.8'), + xcheck_lf=Decimal('1.5'), + xcheck_cf=Decimal('2.2'), + xcheck_rf=Decimal('1.2'), + ) + + # 2. Build the card (fitting happens here) + builder = CardBuilder() + card = builder.build_pitching_card(raw_vl, raw_vr, pitcher_hand='R') + + # 3. Inspect what actually got placed + print("\n=== PITCHING CARD ===") + print("Fitted chances vL (with deltas):") + for play, chance in card.fitted_chances_vl.items(): + delta = card.deltas_vl.get(play, Decimal(0)) + if delta != 0: + print(f" {play}: {chance} (delta: {delta:+.2f})") + + # 4. Show a sample row with d20 split + print("\nSample card structure (vL column):") + for row_num in [6, 7, 8]: # Common split rows + slot = card.column_vl.rows[row_num] + if slot.result_one: + if slot.is_split: + print(f" Row {row_num}: {slot.result_one.short_name} (1-{slot.d20_split}) / " + f"{slot.result_two.short_name} ({slot.d20_split+1}-20)") + else: + print(f" Row {row_num}: {slot.result_one.full_name}") + + return card + + +def example_usage(): + """Combined example showing contracts in action.""" + # Show batting cards with different contracts + batting_cards = example_batting_card() + + # Show contract comparison on row 7 + example_contract_comparison() + + # Show pitching card + pitching_card = example_pitching_card() + + # Return the standard batting card and pitching card for further inspection + return batting_cards.get('standard'), pitching_card + + +# ============================================================================= +# MIGRATION PATH +# ============================================================================= + +""" +Migration strategy to adopt this architecture: + +PHASE 1: Extract & Validate +- Extract current database fitting logic to this shared module +- Run both old and new in parallel, compare outputs +- Fix any discrepancies + +PHASE 2: Python Adoption +- Modify Python card-creation to use CardBuilder +- POST fitted card structures instead of raw chances +- Keep raw chances in separate field for debugging/analysis + +PHASE 3: Database Simplification +- Database receives pre-fitted card structures +- Remove fitting logic from database (just store and render) +- Remove step 7 (no more re-saving modified chances) + +PHASE 4: API Enhancement +- Add preview endpoint that returns card structure without saving +- Enable local card preview in Python scripts + +Benefits achieved: +- Deterministic: Python knows exactly what card will look like +- Testable: Can validate cards without database +- Single source of truth: Fitting logic in one place +- Cleaner separation: Database is just storage + rendering +""" + + +if __name__ == '__main__': + import json + + batting_card, pitching_card = example_usage() + + print("\n" + "="*60) + print("BATTING CARD JSON STRUCTURE (abbreviated)") + print("="*60) + batting_dict = batting_card.to_dict() + print(f"Keys: {list(batting_dict.keys())}") + print(f"Fitted vL plays: {len(batting_dict['fitted_chances_vl'])}") + print(f"Deltas with non-zero: {sum(1 for d in batting_dict['deltas_vl'].values() if d != 0)}") + + print("\n" + "="*60) + print("PITCHING CARD JSON STRUCTURE (abbreviated)") + print("="*60) + pitching_dict = pitching_card.to_dict() + print(f"Keys: {list(pitching_dict.keys())}") + print(f"Fitted vL plays: {len(pitching_dict['fitted_chances_vl'])}") + print(f"Deltas with non-zero: {sum(1 for d in pitching_dict['deltas_vl'].values() if d != 0)}") + + # Show one full column structure + print("\n" + "="*60) + print("SAMPLE: Pitching card vL column structure") + print("="*60) + for row_num in range(2, 13): + slot = pitching_card.column_vl.rows[row_num] + if slot.result_one: + if slot.is_split: + print(f" {row_num:2d}: {slot.result_one.short_name:12s} (1-{slot.d20_split:2d}) | " + f"{slot.result_two.short_name:12s} ({slot.d20_split+1:2d}-20)") + else: + print(f" {row_num:2d}: {slot.result_one.full_name}") + else: + print(f" {row_num:2d}: ") diff --git a/docs/architecture/contracts.py b/docs/architecture/contracts.py new file mode 100644 index 0000000..33c05e0 --- /dev/null +++ b/docs/architecture/contracts.py @@ -0,0 +1,589 @@ +""" +Card Building Contracts - Pluggable Placement Strategies + +A "contract" defines the rules for how plays are placed on a card: +- Which rows to prefer for different play types +- Priority ordering of plays +- Secondary play pairing rules +- Split vs whole number preferences +- etc. + +This enables different card "personalities" while using the same fitting engine. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum +from typing import List, Dict, Optional, Callable, Tuple + + +# ============================================================================= +# ROW PREFERENCE STRATEGIES +# ============================================================================= + +class RowPreference(Enum): + """Built-in row ordering strategies.""" + MIDDLE_FIRST = "middle_first" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 + EDGES_FIRST = "edges_first" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 + HIGH_TO_LOW = "high_to_low" # 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 + LOW_TO_HIGH = "low_to_high" # 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 + SYMMETRIC_OUT = "symmetric_out" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (same as middle) + SYMMETRIC_IN = "symmetric_in" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 (same as edges) + BY_FREQUENCY_DESC = "freq_desc" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (by chance count) + BY_FREQUENCY_ASC = "freq_asc" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 + + +ROW_ORDERINGS = { + RowPreference.MIDDLE_FIRST: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], + RowPreference.EDGES_FIRST: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], + RowPreference.HIGH_TO_LOW: [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2], + RowPreference.LOW_TO_HIGH: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + RowPreference.SYMMETRIC_OUT: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], + RowPreference.SYMMETRIC_IN: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], + RowPreference.BY_FREQUENCY_DESC: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], + RowPreference.BY_FREQUENCY_ASC: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], +} + + +# ============================================================================= +# PLAY CATEGORY DEFINITIONS +# ============================================================================= + +class PlayCategory(Enum): + """Categories of plays for contract rules.""" + ON_BASE = "on_base" # Walks, HBP, singles, doubles, triples, HR + POWER = "power" # HR, triples, doubles + CONTACT = "contact" # Singles + PATIENCE = "patience" # Walks, HBP + STRIKEOUT = "strikeout" + GROUNDOUT = "groundout" + FLYOUT = "flyout" + XCHECK = "xcheck" # Pitcher X-checks + BATTER_POWER = "batter_power" # bp_homerun, bp_single + + +# ============================================================================= +# THE CONTRACT INTERFACE +# ============================================================================= + +@dataclass +class PlacementRule: + """A single rule for how to place a category of plays.""" + category: PlayCategory + row_preference: RowPreference + priority: int = 50 # Lower = placed earlier (0-100) + prefer_splits: bool = True # Use d20 splits when possible? + max_per_column: Optional[int] = None # Limit instances per column + secondary_pool: List[str] = field(default_factory=list) # What can fill split remainder + + +class CardContract(ABC): + """ + Abstract base class for card building contracts. + + A contract defines the "personality" of a card - how plays are distributed + across the 2d6 probability space. + """ + + @property + @abstractmethod + def name(self) -> str: + """Human-readable contract name.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Describe what this contract does.""" + pass + + @abstractmethod + def get_row_preference(self, category: PlayCategory) -> List[int]: + """ + Return ordered list of preferred rows for this play category. + First row in list is most preferred. + """ + pass + + @abstractmethod + def get_play_priority(self) -> List[PlayCategory]: + """ + Return categories in priority order (placed first to last). + """ + pass + + def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]: + """ + Return categories that can fill d20 split remainders for this category. + Default: flyouts and groundouts. + """ + return [PlayCategory.FLYOUT, PlayCategory.GROUNDOUT] + + def should_use_splits(self, category: PlayCategory) -> bool: + """Whether this category should use d20 splits. Default: True.""" + return True + + def max_splits_per_column(self) -> int: + """Maximum d20 splits allowed per column. Default: 4.""" + return 4 + + def validate(self, card_data: dict) -> List[str]: + """ + Validate a built card against this contract's rules. + Returns list of violations (empty if valid). + """ + return [] + + +# ============================================================================= +# CONCRETE CONTRACT IMPLEMENTATIONS +# ============================================================================= + +class StandardContract(CardContract): + """ + The default contract - matches current database behavior. + + - On-base results favor middle rows (more likely to occur) + - Outs fill remaining rows + - Standard priority ordering + """ + + @property + def name(self) -> str: + return "Standard" + + @property + def description(self) -> str: + return "Default card layout - on-base results on middle rows (6-8)" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.ON_BASE, PlayCategory.POWER, + PlayCategory.CONTACT, PlayCategory.PATIENCE]: + # Good outcomes on likely rows + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + else: + # Outs on whatever's left (edges tend to be available) + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, # bp_hr, bp_single first + PlayCategory.PATIENCE, # Walks, HBP + PlayCategory.XCHECK, # X-checks + PlayCategory.POWER, # HR, 3B, 2B + PlayCategory.CONTACT, # Singles + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + +class ClutchContract(CardContract): + """ + "Clutch" card personality - on-base results on EDGE rows. + + This makes hits less likely overall, but when they happen, + the player "came through in the clutch" (rare but impactful). + + Good for: Role players, clutch hitters, postseason heroes + """ + + @property + def name(self) -> str: + return "Clutch" + + @property + def description(self) -> str: + return "On-base results on edge rows (2-4, 10-12) - less frequent but memorable" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.ON_BASE, PlayCategory.POWER, + PlayCategory.CONTACT, PlayCategory.PATIENCE]: + # Good outcomes on unlikely rows + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + else: + # Outs on middle rows (more common) + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + # Same priority, different placement + return StandardContract().get_play_priority() + + +class ConsistentContract(CardContract): + """ + "Consistent" card personality - spread results evenly. + + Distributes on-base results across all rows proportionally, + making the player more predictable/reliable. + + Good for: Contact hitters, batting average leaders + """ + + @property + def name(self) -> str: + return "Consistent" + + @property + def description(self) -> str: + return "Spread results evenly across all rows - predictable performance" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + # Alternate between middle and edges to spread out + return [7, 2, 8, 12, 6, 3, 9, 11, 5, 4, 10] + + def get_play_priority(self) -> List[PlayCategory]: + return StandardContract().get_play_priority() + + +class PowerHeavyContract(CardContract): + """ + Power-focused card - extra-base hits get prime real estate. + + HR and triples on row 7 (most likely), doubles on 6/8, + singles pushed to edges. + + Good for: Power hitters, HR leaders + """ + + @property + def name(self) -> str: + return "Power Heavy" + + @property + def description(self) -> str: + return "Power hits (HR, 3B, 2B) on prime rows, singles on edges" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.POWER: + # Power on the most likely rows + return [7, 6, 8, 5, 9] + elif category == PlayCategory.CONTACT: + # Singles pushed to edges + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.PATIENCE: + # Walks in the middle-ish + return [5, 9, 4, 10, 6, 8] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + # Power comes before patience + return [ + PlayCategory.BATTER_POWER, + PlayCategory.POWER, # HR, 3B, 2B FIRST + PlayCategory.PATIENCE, # Then walks + PlayCategory.XCHECK, + PlayCategory.CONTACT, + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + +class ContactFirstContract(CardContract): + """ + Contact-focused card - singles get prime placement. + + Singles on middle rows, power hits on edges. + More base hits, but less extra-base damage. + + Good for: Contact hitters, leadoff types, high-average players + """ + + @property + def name(self) -> str: + return "Contact First" + + @property + def description(self) -> str: + return "Singles on prime rows, power on edges - consistent base hits" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.CONTACT: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.POWER: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.PATIENCE: + return [5, 9, 4, 10] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.CONTACT, # Singles FIRST + PlayCategory.PATIENCE, + PlayCategory.POWER, + PlayCategory.XCHECK, + PlayCategory.STRIKEOUT, + PlayCategory.GROUNDOUT, + PlayCategory.FLYOUT, + ] + + +class GroundballPitcherContract(CardContract): + """ + Groundball pitcher personality. + + Groundouts on middle rows, flyouts on edges. + X-checks to corner infielders get priority. + + Good for: Sinkerballers, ground ball specialists + """ + + @property + def name(self) -> str: + return "Groundball Pitcher" + + @property + def description(self) -> str: + return "Groundouts on likely rows, X-checks favor corner infielders" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category == PlayCategory.GROUNDOUT: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.FLYOUT: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + elif category == PlayCategory.STRIKEOUT: + return [5, 9, 4, 10] # Mid-edges + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.PATIENCE, # Walks/HBP + PlayCategory.XCHECK, # Corner IF X-checks + PlayCategory.GROUNDOUT, # Groundouts prioritized + PlayCategory.STRIKEOUT, + PlayCategory.POWER, + PlayCategory.CONTACT, + PlayCategory.FLYOUT, + ] + + +class FlyballPitcherContract(CardContract): + """ + Flyball pitcher personality. + + Flyouts and strikeouts on middle rows. + X-checks to outfielders get priority. + + Good for: Power pitchers, high-K guys + """ + + @property + def name(self) -> str: + return "Flyball Pitcher" + + @property + def description(self) -> str: + return "Flyouts and strikeouts on likely rows, X-checks favor outfielders" + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in [PlayCategory.FLYOUT, PlayCategory.STRIKEOUT]: + return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] + elif category == PlayCategory.GROUNDOUT: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + else: + return ROW_ORDERINGS[RowPreference.EDGES_FIRST] + + def get_play_priority(self) -> List[PlayCategory]: + return [ + PlayCategory.BATTER_POWER, + PlayCategory.PATIENCE, + PlayCategory.STRIKEOUT, # K's prioritized + PlayCategory.FLYOUT, # Flyouts prioritized + PlayCategory.XCHECK, + PlayCategory.POWER, + PlayCategory.CONTACT, + PlayCategory.GROUNDOUT, + ] + + +# ============================================================================= +# CUSTOM CONTRACT BUILDER +# ============================================================================= + +@dataclass +class CustomContract(CardContract): + """ + Build a custom contract from individual rules. + + Example: + contract = CustomContract( + name="My Custom Card", + description="Power on edges, walks in middle", + rules={ + PlayCategory.POWER: PlacementRule( + category=PlayCategory.POWER, + row_preference=RowPreference.EDGES_FIRST, + priority=10 + ), + PlayCategory.PATIENCE: PlacementRule( + category=PlayCategory.PATIENCE, + row_preference=RowPreference.MIDDLE_FIRST, + priority=5 + ), + } + ) + """ + _name: str + _description: str + rules: Dict[PlayCategory, PlacementRule] = field(default_factory=dict) + default_row_preference: RowPreference = RowPreference.MIDDLE_FIRST + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + def get_row_preference(self, category: PlayCategory) -> List[int]: + if category in self.rules: + return ROW_ORDERINGS[self.rules[category].row_preference] + return ROW_ORDERINGS[self.default_row_preference] + + def get_play_priority(self) -> List[PlayCategory]: + # Sort by priority value (lower = first) + sorted_rules = sorted( + self.rules.items(), + key=lambda x: x[1].priority + ) + return [cat for cat, _ in sorted_rules] + + def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]: + if category in self.rules and self.rules[category].secondary_pool: + # Convert strings to PlayCategory if needed + return [PlayCategory(s) if isinstance(s, str) else s + for s in self.rules[category].secondary_pool] + return super().get_secondary_pool(category) + + def should_use_splits(self, category: PlayCategory) -> bool: + if category in self.rules: + return self.rules[category].prefer_splits + return True + + +# ============================================================================= +# CONTRACT REGISTRY +# ============================================================================= + +class ContractRegistry: + """Registry of available contracts.""" + + _contracts: Dict[str, CardContract] = {} + + @classmethod + def register(cls, contract: CardContract): + """Register a contract by name.""" + cls._contracts[contract.name.lower()] = contract + + @classmethod + def get(cls, name: str) -> Optional[CardContract]: + """Get a contract by name (case-insensitive).""" + return cls._contracts.get(name.lower()) + + @classmethod + def list_all(cls) -> List[str]: + """List all registered contract names.""" + return list(cls._contracts.keys()) + + @classmethod + def get_default(cls) -> CardContract: + """Get the default contract.""" + return StandardContract() + + +# Register built-in contracts +ContractRegistry.register(StandardContract()) +ContractRegistry.register(ClutchContract()) +ContractRegistry.register(ConsistentContract()) +ContractRegistry.register(PowerHeavyContract()) +ContractRegistry.register(ContactFirstContract()) +ContractRegistry.register(GroundballPitcherContract()) +ContractRegistry.register(FlyballPitcherContract()) + + +# ============================================================================= +# USAGE EXAMPLE +# ============================================================================= + +def example_contract_usage(): + """ + How contracts would integrate with the CardBuilder. + """ + from card_builder_sketch import CardBuilder, RawBattingChances + + # Get a contract + standard = ContractRegistry.get("standard") + clutch = ContractRegistry.get("clutch") + + print("Available contracts:") + for name in ContractRegistry.list_all(): + contract = ContractRegistry.get(name) + print(f" - {contract.name}: {contract.description}") + + print("\nRow preferences for ON_BASE category:") + print(f" Standard: {standard.get_row_preference(PlayCategory.ON_BASE)[:5]}...") + print(f" Clutch: {clutch.get_row_preference(PlayCategory.ON_BASE)[:5]}...") + + # Build a custom contract + custom = CustomContract( + _name="My Custom", + _description="Walks on edges, power in middle", + rules={ + PlayCategory.PATIENCE: PlacementRule( + category=PlayCategory.PATIENCE, + row_preference=RowPreference.EDGES_FIRST, + priority=1 + ), + PlayCategory.POWER: PlacementRule( + category=PlayCategory.POWER, + row_preference=RowPreference.MIDDLE_FIRST, + priority=2 + ), + } + ) + + print(f"\nCustom contract rows for PATIENCE: {custom.get_row_preference(PlayCategory.PATIENCE)[:5]}...") + print(f"Custom contract rows for POWER: {custom.get_row_preference(PlayCategory.POWER)[:5]}...") + + +# ============================================================================= +# INTEGRATION WITH CARD BUILDER +# ============================================================================= + +""" +To integrate contracts with CardBuilder, modify the builder to accept a contract: + +class CardBuilder: + def __init__(self, contract: CardContract = None): + self.contract = contract or StandardContract() + self.max_splits = self.contract.max_splits_per_column() + + def _get_available_rows(self, column, category: PlayCategory) -> List[int]: + # Instead of returning rows in default order, + # use the contract's preference for this category + preferred_order = self.contract.get_row_preference(category) + return [r for r in preferred_order if column.rows[r].is_empty] + + def build_batting_card(self, ...): + # Use contract's priority order + for category in self.contract.get_play_priority(): + plays = self._get_plays_for_category(category, chances) + for play, raw_chance in plays: + # Use contract's row preference when placing + available = self._get_available_rows(column, category) + ... + +This makes the fitting algorithm contract-aware without changing its core logic. +""" + + +if __name__ == '__main__': + example_contract_usage()