Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/quarterback/viperball. Version of pre-1906 Gridiron Football based off a silly Reddit question viperball.xyz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561"""Viperball Player Development Arcs
Applies offseason attribute changes to PlayerCard objects based on:- Development trait (normal / quick / slow / late_bloomer)- Academic year (Freshman → Sophomore → Junior → Senior → Graduate)- Potential (1-5 stars): higher potential = larger ceiling for gains- Age/position: slight late-career decline in physical attributes
Called once per offseason by Dynasty.advance_season() AFTER awards are resolved.
Usage: from engine.development import apply_offseason_development
report = apply_offseason_development(player_card) print(report.summary)"""
from __future__ import annotations
import randomfrom dataclasses import dataclass, fieldfrom typing import List, Optionalfrom engine.player_card import PlayerCard
# ──────────────────────────────────────────────# YEAR ORDER# ──────────────────────────────────────────────
_YEAR_ORDER = ["Freshman", "Sophomore", "Junior", "Senior", "Graduate"]_YEAR_INDEX = {y: i for i, y in enumerate(_YEAR_ORDER)}
def _next_year(year: str) -> str: idx = _YEAR_INDEX.get(year, 1) if idx >= len(_YEAR_ORDER) - 1: return "Graduate" # Stays Graduate (5th year / grad student) return _YEAR_ORDER[idx + 1]
# ──────────────────────────────────────────────# DEVELOPMENT REPORT# ──────────────────────────────────────────────
@dataclassclass DevelopmentEvent: """A single notable change during offseason development.""" player_name: str event_type: str # "breakout" | "decline" | "steady_growth" | "stagnation" description: str attr_changes: dict = field(default_factory=dict) # attr -> delta
@dataclassclass DevelopmentReport: """Results of all player development for a single offseason.""" notable_events: List[DevelopmentEvent] = field(default_factory=list) year_updated: dict = field(default_factory=dict) # player_name -> new year
@property def breakouts(self) -> List[DevelopmentEvent]: return [e for e in self.notable_events if e.event_type == "breakout"]
@property def declines(self) -> List[DevelopmentEvent]: return [e for e in self.notable_events if e.event_type == "decline"]
def display(self): if self.breakouts: print(f"\n BREAKOUT PLAYERS ({len(self.breakouts)})") print(f" {'-' * 50}") for ev in self.breakouts: print(f" {ev.player_name}: {ev.description}") if self.declines: print(f"\n DECLINING PLAYERS ({len(self.declines)})") print(f" {'-' * 50}") for ev in self.declines: print(f" {ev.player_name}: {ev.description}")
# ──────────────────────────────────────────────# ATTRIBUTE GAIN TABLES# ──────────────────────────────────────────────
# Gain ranges (min, max) per attribute by development profile.# All values represent potential +points in one offseason._GAINS_BY_PROFILE = { "quick": { "speed": (1, 3), "stamina": (1, 2), "agility": (1, 3), "power": (0, 2), "awareness": (1, 3), "hands": (1, 2), "kicking": (0, 2), "kick_power": (0, 2), "kick_accuracy":(0, 2), "lateral_skill":(1, 3), "tackling": (0, 2), }, "normal": { "speed": (0, 2), "stamina": (0, 2), "agility": (0, 2), "power": (0, 2), "awareness": (0, 2), "hands": (0, 2), "kicking": (0, 1), "kick_power": (0, 1), "kick_accuracy":(0, 1), "lateral_skill":(0, 2), "tackling": (0, 2), }, "slow": { "speed": (0, 1), "stamina": (0, 1), "agility": (0, 1), "power": (0, 1), "awareness": (0, 2), "hands": (0, 1), "kicking": (0, 1), "kick_power": (0, 1), "kick_accuracy":(0, 1), "lateral_skill":(0, 1), "tackling": (0, 1), }, # Bust profile: attributes stagnate or REGRESS. These players never # reach their recruiting hype. ~15% of 5-stars, ~20% of 4-stars, # ~10% of 3-stars. Great coaching can slow the decline but can't # fully prevent it. "bust": { "speed": (-2, 0), "stamina": (-1, 1), "agility": (-2, 0), "power": (-1, 1), "awareness": (-1, 1), "hands": (-2, 0), "kicking": (-1, 0), "kick_power": (-1, 0), "kick_accuracy":(-1, 0), "lateral_skill":(-2, 0), "tackling": (-1, 1), }, "late_bloomer": { # Early years: minimal gains (Freshman, Sophomore) "early": { "speed": (0, 1), "stamina": (0, 1), "agility": (0, 1), "power": (0, 1), "awareness": (0, 2), "hands": (0, 1), "kicking": (0, 1), "kick_power": (0, 1), "kick_accuracy":(0, 1), "lateral_skill":(0, 1), "tackling": (0, 1), }, # Late years: moderate jumps (Junior → Senior / Senior → Graduate) "late": { "speed": (1, 4), "stamina": (1, 3), "agility": (1, 4), "power": (1, 3), "awareness": (1, 4), "hands": (1, 3), "kicking": (1, 2), "kick_power": (1, 2), "kick_accuracy":(1, 2), "lateral_skill":(1, 4), "tackling": (1, 3), }, },}
# Physical decline in Senior/Graduate year (slight negative adjustment)_SENIOR_DECLINE_ATTRS = ["speed", "stamina", "agility"]_DECLINE_RANGE = (-2, 0) # -2 to 0 points
def _clamp(val: int, lo: int = 10, hi: int = 99) -> int: return max(lo, min(hi, val))
# ──────────────────────────────────────────────# MAIN FUNCTION# ──────────────────────────────────────────────
def apply_offseason_development( card: PlayerCard, rng: Optional[random.Random] = None, dev_boost: float = 0.0,) -> DevelopmentEvent | None: """ Apply one offseason of development to a PlayerCard.
Modifies attributes in-place and advances the player's year. Returns a DevelopmentEvent if the change is notable (breakout or decline), or None for routine development.
Args: dev_boost: Extra development points from DraftyQueenz donations (0-8). Scales the upper bound of gains upward. """ if rng is None: rng = random.Random()
dev = card.development year = card.year potential = card.potential year_idx = _YEAR_INDEX.get(year, 1)
if dev == "late_bloomer": if year_idx <= 1: gains = _GAINS_BY_PROFILE["late_bloomer"]["early"] else: gains = _GAINS_BY_PROFILE["late_bloomer"]["late"] elif dev == "bust": gains = _GAINS_BY_PROFILE["bust"] else: gains = _GAINS_BY_PROFILE.get(dev, _GAINS_BY_PROFILE["normal"])
# Potential scales the ceiling, but elite coaching can push past it. # Base: potential 1 → 0.6x, potential 5 → 1.0x # Coaching boost adds up to +0.4x on top, meaning a great coach can # take a potential-2 player (0.7x) up to ~1.1x — surpassing their # natural ceiling. This is how a 2-star at a well-coached program # can develop into a 4-star caliber player. potential_scale = 0.6 + (potential - 1) * 0.1 coaching_ceiling_bonus = min(0.4, dev_boost / 10.0) potential_scale += coaching_ceiling_bonus
# For busts, coaching can mitigate but not fully prevent decline. # Great coaching (dev_boost >= 4) shifts bust gains toward 0 instead # of negative — the player stagnates rather than regresses. if dev == "bust" and dev_boost >= 4: bust_mitigation = min(1.0, dev_boost / 8.0) # 0.5 to 1.0 gains = { attr: (int(lo * (1.0 - bust_mitigation)), max(hi, 1)) for attr, (lo, hi) in gains.items() }
boost_scale = 1.0 + (dev_boost / 16.0)
total_gain = 0 attr_changes = {} attrs_to_develop = [ "speed", "stamina", "agility", "power", "awareness", "hands", "kicking", "kick_power", "kick_accuracy", "lateral_skill", "tackling", ]
for attr in attrs_to_develop: lo, hi = gains[attr] scaled_hi = max(lo, int(hi * potential_scale * boost_scale)) delta = rng.randint(lo, scaled_hi)
# Apply gain old_val = getattr(card, attr) new_val = _clamp(old_val + delta) setattr(card, attr, new_val) actual_delta = new_val - old_val if actual_delta != 0: attr_changes[attr] = actual_delta total_gain += actual_delta
# Senior/Graduate physical decline for normal/slow event_type = None event_desc = None
if year in ("Senior", "Graduate") and dev in ("normal", "slow"): for attr in _SENIOR_DECLINE_ATTRS: delta = rng.randint(_DECLINE_RANGE[0], _DECLINE_RANGE[1]) old_val = getattr(card, attr) new_val = _clamp(old_val + delta) setattr(card, attr, new_val) actual = new_val - old_val if actual != 0: attr_changes[attr] = attr_changes.get(attr, 0) + actual
# Classify event if dev == "bust" and total_gain <= -5: event_type = "decline" event_desc = f"Failed to develop — bust trajectory ({total_gain:+d} total attributes)" elif dev == "bust" and total_gain >= 0: event_type = "breakout" event_desc = f"Coaching rescued a bust prospect — stagnation avoided (+{total_gain})" elif dev == "late_bloomer" and year_idx >= 2 and total_gain >= 12: event_type = "breakout" event_desc = f"Late-bloomer breakout (+{total_gain} total attributes)" elif dev == "quick" and year_idx <= 1 and total_gain >= 15: event_type = "breakout" event_desc = f"Sophomore explosion (+{total_gain} total attributes)" elif year in ("Senior", "Graduate") and total_gain <= -3: event_type = "decline" event_desc = f"Physical decline in final year ({total_gain:+d} total attributes)" elif total_gain >= 10 and potential >= 4: event_type = "breakout" event_desc = f"High-potential breakout (+{total_gain} total attributes)"
# Advance year (skip if redshirted) if getattr(card, '_redshirt_this_season', False): card._was_redshirted = True card.redshirt = True card.redshirt_used = True else: card.year = _next_year(year)
if event_type: return DevelopmentEvent( player_name=card.full_name, event_type=event_type, description=event_desc, attr_changes=attr_changes, ) return None
REDSHIRT_AI_RATE = 0.22
def apply_redshirt_decisions( players: list, injured_players: Optional[list] = None, is_human: bool = False, rng: Optional[random.Random] = None,) -> List[str]: """ Decide which players get redshirted this offseason.
Eligibility: - Injured players (season-ending injury) are always eligible - Players who played 4 or fewer games are eligible - Only non-Graduate players can be redshirted
AI teams redshirt ~22% of eligible players. Human teams must set redshirt manually (not handled here).
Returns list of redshirted player names. """ if rng is None: rng = random.Random()
if injured_players is None: injured_players = []
injured_names = set(injured_players) redshirted = []
for card in players: year = card.year if year in ("Graduate", "graduate"): continue
already_used = getattr(card, 'redshirt_used', False) if already_used: continue
games = getattr(card, 'season_games_played', getattr(card, '_season_games', 99)) is_injured = card.full_name in injured_names if hasattr(card, 'full_name') else False
eligible = is_injured or games <= 4
if not eligible: continue
if is_human: continue
if rng.random() < REDSHIRT_AI_RATE: card._redshirt_this_season = True redshirted.append(card.full_name if hasattr(card, 'full_name') else str(card))
return redshirted
def apply_team_development( players: list, rng: Optional[random.Random] = None, dev_boost: float = 0.0,) -> DevelopmentReport: """ Apply offseason development to a list of PlayerCard objects.
Args: players: list of PlayerCard objects rng: optional seeded Random instance for reproducibility dev_boost: DraftyQueenz development boost (0-8) to amplify stat gains
Returns: DevelopmentReport with notable events """ if rng is None: rng = random.Random()
report = DevelopmentReport()
for card in players: event = apply_offseason_development(card, rng=rng, dev_boost=dev_boost) if event: report.notable_events.append(event) report.year_updated[card.full_name] = card.year
return report
def get_preseason_breakout_candidates( players: list, top_n: int = 3,) -> list: """ Return up to top_n players most likely to break out next season.
Criteria: - late_bloomer developers approaching their junior/senior year - High potential (4-5 stars) on a normal development path in early years - High potential but overall rating noticeably below their potential ceiling """ candidates = []
for card in players: score = 0 year_idx = _YEAR_INDEX.get(card.year, 1) ceiling = 60 + card.potential * 7 # rough potential ceiling estimate
if card.development == "late_bloomer" and year_idx == 2: score += 40 # Junior late bloomer: prime breakout candidate elif card.development == "late_bloomer" and year_idx == 1: score += 20
if card.potential >= 4 and card.overall < ceiling - 8: score += 25
if card.development == "quick" and year_idx == 0: score += 30 # Freshman quick developer = sophomore jump incoming
if score > 0: candidates.append((score, card))
candidates.sort(key=lambda x: x[0], reverse=True) return [card for _, card in candidates[:top_n]]
# ──────────────────────────────────────────────# PRO PLAYER DEVELOPMENT (age-based)# ──────────────────────────────────────────────# Used by WVL (Women's Viperball League) for pro player aging.# College development uses year-based progression above.
_PHYSICAL_ATTRS = ["speed", "stamina", "agility", "power"]_MENTAL_ATTRS = ["awareness", "hands", "lateral_skill"]_KICKING_ATTRS = ["kicking", "kick_power", "kick_accuracy", "tackling"]
# Age brackets: (min_age, max_age) → (physical_delta_range, mental_delta_range, kicking_delta_range)_PRO_AGE_CURVES = { (18, 22): {"physical": (1, 4), "mental": (1, 3), "kicking": (0, 2)}, # Growth (23, 25): {"physical": (0, 2), "mental": (1, 3), "kicking": (0, 2)}, # Peak early (26, 28): {"physical": (-1, 1), "mental": (0, 2), "kicking": (0, 1)}, # Peak plateau (29, 31): {"physical": (-3, -1), "mental": (-1, 1), "kicking": (-1, 0)}, # Decline (32, 33): {"physical": (-5, -2), "mental": (-2, 0), "kicking": (-2, -1)},# Steep decline (34, 99): {"physical": (-7, -3), "mental": (-3, -1), "kicking": (-3, -1)},# Sharp decline}
def _get_age_curve(age: int) -> dict: for (lo, hi), curve in _PRO_AGE_CURVES.items(): if lo <= age <= hi: return curve return _PRO_AGE_CURVES[(34, 99)]
def apply_pro_development( card: PlayerCard, rng: Optional[random.Random] = None,) -> Optional[DevelopmentEvent]: """Apply age-based development to a pro player.
Increments age by 1 and adjusts attributes based on age curve. Returns a DevelopmentEvent if notable change occurred, else None. """ if rng is None: rng = random.Random()
if card.age is None: return None
card.age += 1 curve = _get_age_curve(card.age)
changes = {} for attr in _PHYSICAL_ATTRS: lo, hi = curve["physical"] delta = rng.randint(lo, hi) old_val = getattr(card, attr) new_val = max(10, min(99, old_val + delta)) if new_val != old_val: setattr(card, attr, new_val) changes[attr] = new_val - old_val
for attr in _MENTAL_ATTRS: lo, hi = curve["mental"] delta = rng.randint(lo, hi) old_val = getattr(card, attr) new_val = max(10, min(99, old_val + delta)) if new_val != old_val: setattr(card, attr, new_val) changes[attr] = new_val - old_val
for attr in _KICKING_ATTRS: lo, hi = curve["kicking"] delta = rng.randint(lo, hi) old_val = getattr(card, attr) new_val = max(10, min(99, old_val + delta)) if new_val != old_val: setattr(card, attr, new_val) changes[attr] = new_val - old_val
if not changes: return None
total_delta = sum(changes.values()) if total_delta >= 5: event_type = "breakout" desc = f"Age {card.age}: strong growth ({total_delta:+d} total)" elif total_delta <= -5: event_type = "decline" desc = f"Age {card.age}: significant decline ({total_delta:+d} total)" else: event_type = "steady_growth" desc = f"Age {card.age}: steady ({total_delta:+d} total)"
return DevelopmentEvent( player_name=card.full_name, event_type=event_type, description=desc, attr_changes=changes, )
def should_retire(card: PlayerCard, rng: Optional[random.Random] = None) -> bool: """Determine if a pro player should retire based on age and overall rating.""" if card.age is None: return False if rng is None: rng = random.Random()
if card.age >= 38: return True if card.age >= 35 and card.overall < 55: return True if card.age >= 34 and card.overall < 50: return True # Chance-based retirement for older players if card.age >= 32: retire_chance = (card.age - 31) * 0.10 # 10% at 32, 20% at 33, etc. if card.overall < 60: retire_chance += 0.15 return rng.random() < retire_chance return False