paper-dynasty-card-creation/docs/architecture/contracts.py
Cal Corum 9a121d370f Add card builder architecture redesign documentation
Documents proposed architecture for moving card-building logic upstream
to Python, including:
- Executive summary with problem statement and migration path
- CardBuilder sketch with contract system for pluggable placement strategies
- Support for different card "personalities" (Standard, Clutch, Power Heavy, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:56:55 -06:00

590 lines
20 KiB
Python

"""
Card Building Contracts - Pluggable Placement Strategies
A "contract" defines the rules for how plays are placed on a card:
- Which rows to prefer for different play types
- Priority ordering of plays
- Secondary play pairing rules
- Split vs whole number preferences
- etc.
This enables different card "personalities" while using the same fitting engine.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal
from enum import Enum
from typing import List, Dict, Optional, Callable, Tuple
# =============================================================================
# ROW PREFERENCE STRATEGIES
# =============================================================================
class RowPreference(Enum):
"""Built-in row ordering strategies."""
MIDDLE_FIRST = "middle_first" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12
EDGES_FIRST = "edges_first" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7
HIGH_TO_LOW = "high_to_low" # 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2
LOW_TO_HIGH = "low_to_high" # 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
SYMMETRIC_OUT = "symmetric_out" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (same as middle)
SYMMETRIC_IN = "symmetric_in" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 (same as edges)
BY_FREQUENCY_DESC = "freq_desc" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (by chance count)
BY_FREQUENCY_ASC = "freq_asc" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7
ROW_ORDERINGS = {
RowPreference.MIDDLE_FIRST: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12],
RowPreference.EDGES_FIRST: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7],
RowPreference.HIGH_TO_LOW: [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2],
RowPreference.LOW_TO_HIGH: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
RowPreference.SYMMETRIC_OUT: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12],
RowPreference.SYMMETRIC_IN: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7],
RowPreference.BY_FREQUENCY_DESC: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12],
RowPreference.BY_FREQUENCY_ASC: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7],
}
# =============================================================================
# PLAY CATEGORY DEFINITIONS
# =============================================================================
class PlayCategory(Enum):
"""Categories of plays for contract rules."""
ON_BASE = "on_base" # Walks, HBP, singles, doubles, triples, HR
POWER = "power" # HR, triples, doubles
CONTACT = "contact" # Singles
PATIENCE = "patience" # Walks, HBP
STRIKEOUT = "strikeout"
GROUNDOUT = "groundout"
FLYOUT = "flyout"
XCHECK = "xcheck" # Pitcher X-checks
BATTER_POWER = "batter_power" # bp_homerun, bp_single
# =============================================================================
# THE CONTRACT INTERFACE
# =============================================================================
@dataclass
class PlacementRule:
"""A single rule for how to place a category of plays."""
category: PlayCategory
row_preference: RowPreference
priority: int = 50 # Lower = placed earlier (0-100)
prefer_splits: bool = True # Use d20 splits when possible?
max_per_column: Optional[int] = None # Limit instances per column
secondary_pool: List[str] = field(default_factory=list) # What can fill split remainder
class CardContract(ABC):
"""
Abstract base class for card building contracts.
A contract defines the "personality" of a card - how plays are distributed
across the 2d6 probability space.
"""
@property
@abstractmethod
def name(self) -> str:
"""Human-readable contract name."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Describe what this contract does."""
pass
@abstractmethod
def get_row_preference(self, category: PlayCategory) -> List[int]:
"""
Return ordered list of preferred rows for this play category.
First row in list is most preferred.
"""
pass
@abstractmethod
def get_play_priority(self) -> List[PlayCategory]:
"""
Return categories in priority order (placed first to last).
"""
pass
def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]:
"""
Return categories that can fill d20 split remainders for this category.
Default: flyouts and groundouts.
"""
return [PlayCategory.FLYOUT, PlayCategory.GROUNDOUT]
def should_use_splits(self, category: PlayCategory) -> bool:
"""Whether this category should use d20 splits. Default: True."""
return True
def max_splits_per_column(self) -> int:
"""Maximum d20 splits allowed per column. Default: 4."""
return 4
def validate(self, card_data: dict) -> List[str]:
"""
Validate a built card against this contract's rules.
Returns list of violations (empty if valid).
"""
return []
# =============================================================================
# CONCRETE CONTRACT IMPLEMENTATIONS
# =============================================================================
class StandardContract(CardContract):
"""
The default contract - matches current database behavior.
- On-base results favor middle rows (more likely to occur)
- Outs fill remaining rows
- Standard priority ordering
"""
@property
def name(self) -> str:
return "Standard"
@property
def description(self) -> str:
return "Default card layout - on-base results on middle rows (6-8)"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category in [PlayCategory.ON_BASE, PlayCategory.POWER,
PlayCategory.CONTACT, PlayCategory.PATIENCE]:
# Good outcomes on likely rows
return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST]
else:
# Outs on whatever's left (edges tend to be available)
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
return [
PlayCategory.BATTER_POWER, # bp_hr, bp_single first
PlayCategory.PATIENCE, # Walks, HBP
PlayCategory.XCHECK, # X-checks
PlayCategory.POWER, # HR, 3B, 2B
PlayCategory.CONTACT, # Singles
PlayCategory.STRIKEOUT,
PlayCategory.GROUNDOUT,
PlayCategory.FLYOUT,
]
class ClutchContract(CardContract):
"""
"Clutch" card personality - on-base results on EDGE rows.
This makes hits less likely overall, but when they happen,
the player "came through in the clutch" (rare but impactful).
Good for: Role players, clutch hitters, postseason heroes
"""
@property
def name(self) -> str:
return "Clutch"
@property
def description(self) -> str:
return "On-base results on edge rows (2-4, 10-12) - less frequent but memorable"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category in [PlayCategory.ON_BASE, PlayCategory.POWER,
PlayCategory.CONTACT, PlayCategory.PATIENCE]:
# Good outcomes on unlikely rows
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
else:
# Outs on middle rows (more common)
return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
# Same priority, different placement
return StandardContract().get_play_priority()
class ConsistentContract(CardContract):
"""
"Consistent" card personality - spread results evenly.
Distributes on-base results across all rows proportionally,
making the player more predictable/reliable.
Good for: Contact hitters, batting average leaders
"""
@property
def name(self) -> str:
return "Consistent"
@property
def description(self) -> str:
return "Spread results evenly across all rows - predictable performance"
def get_row_preference(self, category: PlayCategory) -> List[int]:
# Alternate between middle and edges to spread out
return [7, 2, 8, 12, 6, 3, 9, 11, 5, 4, 10]
def get_play_priority(self) -> List[PlayCategory]:
return StandardContract().get_play_priority()
class PowerHeavyContract(CardContract):
"""
Power-focused card - extra-base hits get prime real estate.
HR and triples on row 7 (most likely), doubles on 6/8,
singles pushed to edges.
Good for: Power hitters, HR leaders
"""
@property
def name(self) -> str:
return "Power Heavy"
@property
def description(self) -> str:
return "Power hits (HR, 3B, 2B) on prime rows, singles on edges"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category == PlayCategory.POWER:
# Power on the most likely rows
return [7, 6, 8, 5, 9]
elif category == PlayCategory.CONTACT:
# Singles pushed to edges
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
elif category == PlayCategory.PATIENCE:
# Walks in the middle-ish
return [5, 9, 4, 10, 6, 8]
else:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
# Power comes before patience
return [
PlayCategory.BATTER_POWER,
PlayCategory.POWER, # HR, 3B, 2B FIRST
PlayCategory.PATIENCE, # Then walks
PlayCategory.XCHECK,
PlayCategory.CONTACT,
PlayCategory.STRIKEOUT,
PlayCategory.GROUNDOUT,
PlayCategory.FLYOUT,
]
class ContactFirstContract(CardContract):
"""
Contact-focused card - singles get prime placement.
Singles on middle rows, power hits on edges.
More base hits, but less extra-base damage.
Good for: Contact hitters, leadoff types, high-average players
"""
@property
def name(self) -> str:
return "Contact First"
@property
def description(self) -> str:
return "Singles on prime rows, power on edges - consistent base hits"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category == PlayCategory.CONTACT:
return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST]
elif category == PlayCategory.POWER:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
elif category == PlayCategory.PATIENCE:
return [5, 9, 4, 10]
else:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
return [
PlayCategory.BATTER_POWER,
PlayCategory.CONTACT, # Singles FIRST
PlayCategory.PATIENCE,
PlayCategory.POWER,
PlayCategory.XCHECK,
PlayCategory.STRIKEOUT,
PlayCategory.GROUNDOUT,
PlayCategory.FLYOUT,
]
class GroundballPitcherContract(CardContract):
"""
Groundball pitcher personality.
Groundouts on middle rows, flyouts on edges.
X-checks to corner infielders get priority.
Good for: Sinkerballers, ground ball specialists
"""
@property
def name(self) -> str:
return "Groundball Pitcher"
@property
def description(self) -> str:
return "Groundouts on likely rows, X-checks favor corner infielders"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category == PlayCategory.GROUNDOUT:
return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST]
elif category == PlayCategory.FLYOUT:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
elif category == PlayCategory.STRIKEOUT:
return [5, 9, 4, 10] # Mid-edges
else:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
return [
PlayCategory.BATTER_POWER,
PlayCategory.PATIENCE, # Walks/HBP
PlayCategory.XCHECK, # Corner IF X-checks
PlayCategory.GROUNDOUT, # Groundouts prioritized
PlayCategory.STRIKEOUT,
PlayCategory.POWER,
PlayCategory.CONTACT,
PlayCategory.FLYOUT,
]
class FlyballPitcherContract(CardContract):
"""
Flyball pitcher personality.
Flyouts and strikeouts on middle rows.
X-checks to outfielders get priority.
Good for: Power pitchers, high-K guys
"""
@property
def name(self) -> str:
return "Flyball Pitcher"
@property
def description(self) -> str:
return "Flyouts and strikeouts on likely rows, X-checks favor outfielders"
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category in [PlayCategory.FLYOUT, PlayCategory.STRIKEOUT]:
return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST]
elif category == PlayCategory.GROUNDOUT:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
else:
return ROW_ORDERINGS[RowPreference.EDGES_FIRST]
def get_play_priority(self) -> List[PlayCategory]:
return [
PlayCategory.BATTER_POWER,
PlayCategory.PATIENCE,
PlayCategory.STRIKEOUT, # K's prioritized
PlayCategory.FLYOUT, # Flyouts prioritized
PlayCategory.XCHECK,
PlayCategory.POWER,
PlayCategory.CONTACT,
PlayCategory.GROUNDOUT,
]
# =============================================================================
# CUSTOM CONTRACT BUILDER
# =============================================================================
@dataclass
class CustomContract(CardContract):
"""
Build a custom contract from individual rules.
Example:
contract = CustomContract(
name="My Custom Card",
description="Power on edges, walks in middle",
rules={
PlayCategory.POWER: PlacementRule(
category=PlayCategory.POWER,
row_preference=RowPreference.EDGES_FIRST,
priority=10
),
PlayCategory.PATIENCE: PlacementRule(
category=PlayCategory.PATIENCE,
row_preference=RowPreference.MIDDLE_FIRST,
priority=5
),
}
)
"""
_name: str
_description: str
rules: Dict[PlayCategory, PlacementRule] = field(default_factory=dict)
default_row_preference: RowPreference = RowPreference.MIDDLE_FIRST
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
def get_row_preference(self, category: PlayCategory) -> List[int]:
if category in self.rules:
return ROW_ORDERINGS[self.rules[category].row_preference]
return ROW_ORDERINGS[self.default_row_preference]
def get_play_priority(self) -> List[PlayCategory]:
# Sort by priority value (lower = first)
sorted_rules = sorted(
self.rules.items(),
key=lambda x: x[1].priority
)
return [cat for cat, _ in sorted_rules]
def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]:
if category in self.rules and self.rules[category].secondary_pool:
# Convert strings to PlayCategory if needed
return [PlayCategory(s) if isinstance(s, str) else s
for s in self.rules[category].secondary_pool]
return super().get_secondary_pool(category)
def should_use_splits(self, category: PlayCategory) -> bool:
if category in self.rules:
return self.rules[category].prefer_splits
return True
# =============================================================================
# CONTRACT REGISTRY
# =============================================================================
class ContractRegistry:
"""Registry of available contracts."""
_contracts: Dict[str, CardContract] = {}
@classmethod
def register(cls, contract: CardContract):
"""Register a contract by name."""
cls._contracts[contract.name.lower()] = contract
@classmethod
def get(cls, name: str) -> Optional[CardContract]:
"""Get a contract by name (case-insensitive)."""
return cls._contracts.get(name.lower())
@classmethod
def list_all(cls) -> List[str]:
"""List all registered contract names."""
return list(cls._contracts.keys())
@classmethod
def get_default(cls) -> CardContract:
"""Get the default contract."""
return StandardContract()
# Register built-in contracts
ContractRegistry.register(StandardContract())
ContractRegistry.register(ClutchContract())
ContractRegistry.register(ConsistentContract())
ContractRegistry.register(PowerHeavyContract())
ContractRegistry.register(ContactFirstContract())
ContractRegistry.register(GroundballPitcherContract())
ContractRegistry.register(FlyballPitcherContract())
# =============================================================================
# USAGE EXAMPLE
# =============================================================================
def example_contract_usage():
"""
How contracts would integrate with the CardBuilder.
"""
from card_builder_sketch import CardBuilder, RawBattingChances
# Get a contract
standard = ContractRegistry.get("standard")
clutch = ContractRegistry.get("clutch")
print("Available contracts:")
for name in ContractRegistry.list_all():
contract = ContractRegistry.get(name)
print(f" - {contract.name}: {contract.description}")
print("\nRow preferences for ON_BASE category:")
print(f" Standard: {standard.get_row_preference(PlayCategory.ON_BASE)[:5]}...")
print(f" Clutch: {clutch.get_row_preference(PlayCategory.ON_BASE)[:5]}...")
# Build a custom contract
custom = CustomContract(
_name="My Custom",
_description="Walks on edges, power in middle",
rules={
PlayCategory.PATIENCE: PlacementRule(
category=PlayCategory.PATIENCE,
row_preference=RowPreference.EDGES_FIRST,
priority=1
),
PlayCategory.POWER: PlacementRule(
category=PlayCategory.POWER,
row_preference=RowPreference.MIDDLE_FIRST,
priority=2
),
}
)
print(f"\nCustom contract rows for PATIENCE: {custom.get_row_preference(PlayCategory.PATIENCE)[:5]}...")
print(f"Custom contract rows for POWER: {custom.get_row_preference(PlayCategory.POWER)[:5]}...")
# =============================================================================
# INTEGRATION WITH CARD BUILDER
# =============================================================================
"""
To integrate contracts with CardBuilder, modify the builder to accept a contract:
class CardBuilder:
def __init__(self, contract: CardContract = None):
self.contract = contract or StandardContract()
self.max_splits = self.contract.max_splits_per_column()
def _get_available_rows(self, column, category: PlayCategory) -> List[int]:
# Instead of returning rows in default order,
# use the contract's preference for this category
preferred_order = self.contract.get_row_preference(category)
return [r for r in preferred_order if column.rows[r].is_empty]
def build_batting_card(self, ...):
# Use contract's priority order
for category in self.contract.get_play_priority():
plays = self._get_plays_for_category(category, chances)
for play, raw_chance in plays:
# Use contract's row preference when placing
available = self._get_available_rows(column, category)
...
This makes the fitting algorithm contract-aware without changing its core logic.
"""
if __name__ == '__main__':
example_contract_usage()