- Renamed check_d20 → chaos_d20 throughout dice system - Expanded PlayOutcome enum with granular variants (SINGLE_1/2, DOUBLE_2/3, GROUNDBALL_A/B/C, etc.) - Integrated PlayOutcome from app.config into PlayResolver - Added play_metadata support for uncapped hit tracking - Updated all tests (139/140 passing) Week 6: 100% Complete - Ready for Phase 3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
165 lines
5.9 KiB
Python
165 lines
5.9 KiB
Python
"""
|
|
Play outcome definitions for card-based resolution system.
|
|
|
|
Both SBA and PD leagues use the same resolution mechanics:
|
|
1. Roll 1d6 → determines column (1-3: batter card, 4-6: pitcher card)
|
|
2. Roll 2d6 → selects row 2-12 on that card
|
|
3. Roll 1d20 → resolves split results if needed
|
|
4. Outcome from card is a PlayOutcome enum value
|
|
|
|
League Differences:
|
|
- PD: Card data digitized in PdBattingRating/PdPitchingRating
|
|
Can auto-resolve using probabilities OR manual selection
|
|
- SBA: Physical cards only (not digitized)
|
|
Players read physical cards and manually enter outcomes
|
|
|
|
This module defines the universal PlayOutcome enum used by both leagues.
|
|
|
|
Author: Claude
|
|
Date: 2025-10-28
|
|
"""
|
|
import logging
|
|
from enum import Enum
|
|
|
|
logger = logging.getLogger(f'{__name__}.PlayOutcome')
|
|
|
|
|
|
class PlayOutcome(str, Enum):
|
|
"""
|
|
Universal play outcome types for both SBA and PD leagues.
|
|
|
|
These represent all possible results that can appear on player cards.
|
|
Each outcome triggers specific game logic in the PlayResolver.
|
|
|
|
Usage:
|
|
- PD: Outcomes determined from PdBattingRating/PdPitchingRating data
|
|
- SBA: Outcomes manually entered by players reading physical cards
|
|
"""
|
|
|
|
# ==================== Outs ====================
|
|
STRIKEOUT = "strikeout"
|
|
|
|
# Groundballs - 3 variants for different defensive outcomes
|
|
GROUNDBALL_A = "groundball_a" # Double play if possible, else groundout
|
|
GROUNDBALL_B = "groundball_b" # Standard groundout
|
|
GROUNDBALL_C = "groundball_c" # Standard groundout
|
|
|
|
# Flyouts - 3 variants for different trajectories/depths
|
|
FLYOUT_A = "flyout_a" # Flyout variant A
|
|
FLYOUT_B = "flyout_b" # Flyout variant B
|
|
FLYOUT_C = "flyout_c" # Flyout variant C
|
|
|
|
LINEOUT = "lineout"
|
|
POPOUT = "popout"
|
|
|
|
# ==================== Hits ====================
|
|
# Singles - variants for different advancement rules
|
|
SINGLE_1 = "single_1" # Single with standard advancement
|
|
SINGLE_2 = "single_2" # Single with enhanced advancement
|
|
SINGLE_UNCAPPED = "single_uncapped" # si(cf) - pitching card, decision tree
|
|
|
|
# Doubles - variants for batter advancement
|
|
DOUBLE_2 = "double_2" # Double to 2nd base
|
|
DOUBLE_3 = "double_3" # Double to 3rd base (extra advancement)
|
|
DOUBLE_UNCAPPED = "double_uncapped" # do(cf) - pitching card, decision tree
|
|
|
|
TRIPLE = "triple"
|
|
HOMERUN = "homerun"
|
|
|
|
# ==================== Walks/HBP ====================
|
|
WALK = "walk"
|
|
HIT_BY_PITCH = "hbp"
|
|
INTENTIONAL_WALK = "intentional_walk"
|
|
|
|
# ==================== Errors ====================
|
|
ERROR = "error"
|
|
|
|
# ==================== Interrupt Plays ====================
|
|
# These are logged as separate plays with Play.pa = 0
|
|
WILD_PITCH = "wild_pitch" # Play.wp = 1
|
|
PASSED_BALL = "passed_ball" # Play.pb = 1
|
|
STOLEN_BASE = "stolen_base" # Play.sb = 1
|
|
CAUGHT_STEALING = "caught_stealing" # Play.cs = 1
|
|
BALK = "balk" # Play.balk = 1 / Logged during steal attempt
|
|
PICK_OFF = "pick_off" # Play.pick_off = 1 / Runner picked off
|
|
|
|
# ==================== Ballpark Power ====================
|
|
BP_HOMERUN = "bp_homerun" # Ballpark HR (Play.bphr = 1)
|
|
BP_SINGLE = "bp_single" # Ballpark single (Play.bp1b = 1)
|
|
BP_FLYOUT = "bp_flyout" # Ballpark flyout (Play.bpfo = 1)
|
|
BP_LINEOUT = "bp_lineout" # Ballpark lineout (Play.bplo = 1)
|
|
|
|
# ==================== Helper Methods ====================
|
|
|
|
def is_hit(self) -> bool:
|
|
"""Check if outcome is a hit (counts toward batting average)."""
|
|
return self in {
|
|
self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED,
|
|
self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED,
|
|
self.TRIPLE, self.HOMERUN,
|
|
self.BP_HOMERUN, self.BP_SINGLE
|
|
}
|
|
|
|
def is_out(self) -> bool:
|
|
"""Check if outcome records an out."""
|
|
return self in {
|
|
self.STRIKEOUT,
|
|
self.GROUNDBALL_A, self.GROUNDBALL_B, self.GROUNDBALL_C,
|
|
self.FLYOUT_A, self.FLYOUT_B, self.FLYOUT_C,
|
|
self.LINEOUT, self.POPOUT,
|
|
self.CAUGHT_STEALING, self.PICK_OFF,
|
|
self.BP_FLYOUT, self.BP_LINEOUT
|
|
}
|
|
|
|
def is_walk(self) -> bool:
|
|
"""Check if outcome is a walk."""
|
|
return self in {self.WALK, self.INTENTIONAL_WALK}
|
|
|
|
def is_uncapped(self) -> bool:
|
|
"""
|
|
Check if outcome is uncapped (requires advancement decision).
|
|
|
|
Uncapped hits only trigger decisions when on_base_code > 0.
|
|
"""
|
|
return self in {self.SINGLE_UNCAPPED, self.DOUBLE_UNCAPPED}
|
|
|
|
def is_interrupt(self) -> bool:
|
|
"""
|
|
Check if outcome is an interrupt play (logged with pa=0).
|
|
|
|
Interrupt plays don't change the batter, only advance runners.
|
|
"""
|
|
return self in {
|
|
self.WILD_PITCH, self.PASSED_BALL,
|
|
self.STOLEN_BASE, self.CAUGHT_STEALING,
|
|
self.BALK, self.PICK_OFF
|
|
}
|
|
|
|
def is_extra_base_hit(self) -> bool:
|
|
"""Check if outcome is an extra-base hit (2B, 3B, HR)."""
|
|
return self in {
|
|
self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED,
|
|
self.TRIPLE, self.HOMERUN, self.BP_HOMERUN
|
|
}
|
|
|
|
def get_bases_advanced(self) -> int:
|
|
"""
|
|
Get number of bases batter advances (for standard outcomes).
|
|
|
|
Returns:
|
|
0 for outs/walks, 1-4 for hits
|
|
|
|
Note: Uncapped hits return base value; actual advancement
|
|
determined by decision tree.
|
|
"""
|
|
if self in {self.SINGLE_1, self.SINGLE_2, self.SINGLE_UNCAPPED, self.BP_SINGLE}:
|
|
return 1
|
|
elif self in {self.DOUBLE_2, self.DOUBLE_3, self.DOUBLE_UNCAPPED}:
|
|
return 2
|
|
elif self == self.TRIPLE:
|
|
return 3
|
|
elif self in {self.HOMERUN, self.BP_HOMERUN}:
|
|
return 4
|
|
else:
|
|
return 0
|