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 <noreply@anthropic.com>
1590 lines
59 KiB
Python
1590 lines
59 KiB
Python
"""
|
||
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 "<empty>"
|
||
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}: <empty>")
|