Standardize formatting with black and apply ruff auto-fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1849 lines
63 KiB
Python
1849 lines
63 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
|
||
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__":
|
||
|
||
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>")
|