Function bodies 688 total
compute_statistical_leaders function · python · L25-L126 (102 LOC)src/pinwheel/core/memorial.py
async def compute_statistical_leaders(repo: Repository, season_id: str) -> dict:
"""Compute top 3 per statistical category from box scores.
Categories: PPG (points per game), APG (assists per game),
SPG (steals per game), FG% (field goal percentage, min 10 FGA).
Returns:
Dict with keys "ppg", "apg", "spg", "fg_pct", each containing
a list of up to 3 dicts with hooper_id, hooper_name, team_name,
value, and games.
"""
# Fetch all box scores for the season via join on game results
stmt = (
select(BoxScoreRow)
.join(GameResultRow, BoxScoreRow.game_id == GameResultRow.id)
.where(GameResultRow.season_id == season_id)
)
result = await repo.session.execute(stmt)
all_box_scores = list(result.scalars().all())
if not all_box_scores:
return {"ppg": [], "apg": [], "spg": [], "fg_pct": []}
# Aggregate per-hooper stats
hooper_stats: dict[str, dict] = {}
for bs in all_box_scores:
compute_key_moments function · python · L129-L233 (105 LOC)src/pinwheel/core/memorial.py
async def compute_key_moments(repo: Repository, season_id: str) -> list[dict]:
"""Identify 5-8 most notable games from the season.
Criteria (in priority order):
1. Playoff games (always notable)
2. Closest margin (regular season nail-biters)
3. Largest blowout
4. Elam Ending activations (games with elam_target set)
Each moment dict includes: game_id, round_number, home_team_name,
away_team_name, home_score, away_score, margin, winner_name,
moment_type, elam_target.
Returns:
List of 5-8 moment dicts, deduplicated.
"""
# Fetch all games with box scores
stmt = (
select(GameResultRow)
.where(GameResultRow.season_id == season_id)
.options(selectinload(GameResultRow.box_scores))
.order_by(GameResultRow.round_number, GameResultRow.matchup_index)
)
result = await repo.session.execute(stmt)
all_games = list(result.scalars().all())
if not all_games:
return []
# Fetch playocompute_head_to_head function · python · L236-L305 (70 LOC)src/pinwheel/core/memorial.py
async def compute_head_to_head(repo: Repository, season_id: str) -> list[dict]:
"""Compute team-vs-team win/loss records and point differentials.
Returns:
List of dicts, each with: team_a_id, team_a_name, team_b_id,
team_b_name, team_a_wins, team_b_wins, point_differential
(positive means team_a scored more total).
"""
all_games = await repo.get_all_game_results_for_season(season_id)
if not all_games:
return []
# Build team name cache
team_names: dict[str, str] = {}
async def _team_name(tid: str) -> str:
if tid not in team_names:
team = await repo.get_team(tid)
team_names[tid] = team.name if team else tid
return team_names[tid]
# Aggregate matchup data: key = (min(team_a, team_b), max(team_a, team_b))
matchups: dict[tuple[str, str], dict] = {}
for game in all_games:
a, b = game.home_team_id, game.away_team_id
# Canonical key: alphabetically sorted socompute_rule_timeline function · python · L308-L365 (58 LOC)src/pinwheel/core/memorial.py
async def compute_rule_timeline(repo: Repository, season_id: str) -> list[dict]:
"""Build chronological timeline of rule changes during the season.
Returns:
List of dicts ordered by sequence number, each with: round_number,
parameter, old_value, new_value, proposer_id, proposer_name.
"""
# Get rule.enacted events
rule_events = await repo.get_events_by_type(
season_id=season_id,
event_types=["rule.enacted"],
)
if not rule_events:
return []
# Also fetch proposal.submitted events to find proposers
proposal_events = await repo.get_events_by_type(
season_id=season_id,
event_types=["proposal.submitted"],
)
# Map proposal_id -> governor_id
proposal_to_governor: dict[str, str] = {}
for evt in proposal_events:
pid = evt.payload.get("id", evt.aggregate_id)
if evt.governor_id:
proposal_to_governor[pid] = evt.governor_id
# Build name lookup cache
govergather_memorial_data function · python · L368-L406 (39 LOC)src/pinwheel/core/memorial.py
async def gather_memorial_data(
repo: Repository,
season_id: str,
awards: list[dict] | None = None,
) -> dict:
"""Orchestrate all memorial data collection.
Calls each compute function and assembles the full memorial data dict.
Args:
repo: Database repository.
season_id: The season to gather data for.
awards: Pre-computed awards list. If None, an empty list is used.
Awards are typically computed separately by compute_awards().
Returns:
Dict matching SeasonMemorial fields, suitable for JSON storage.
"""
statistical_leaders = await compute_statistical_leaders(repo, season_id)
key_moments = await compute_key_moments(repo, season_id)
head_to_head = await compute_head_to_head(repo, season_id)
rule_timeline = await compute_rule_timeline(repo, season_id)
return {
# AI narrative placeholders
"season_narrative": "",
"championship_recap": "",
"champion_profile": "",
MetaStore.__init__ method · python · L34-L39 (6 LOC)src/pinwheel/core/meta.py
def __init__(self) -> None:
# {entity_type: {entity_id: {field: value}}}
self._data: dict[str, dict[str, dict[str, MetaValueType]]] = defaultdict(
lambda: defaultdict(dict)
)
self._dirty: set[tuple[str, str]] = set()MetaStore.get method · python · L41-L49 (9 LOC)src/pinwheel/core/meta.py
def get(
self,
entity_type: str,
entity_id: str,
field: str,
default: MetaValueType = None,
) -> MetaValueType:
"""Read a meta value. Returns default if not set."""
return self._data[entity_type][entity_id].get(field, default)Powered by Repobility — scan your code at https://repobility.com
MetaStore.set method · python · L51-L60 (10 LOC)src/pinwheel/core/meta.py
def set(
self,
entity_type: str,
entity_id: str,
field: str,
value: MetaValueType,
) -> None:
"""Write a meta value. Marks the entity as dirty for DB flush."""
self._data[entity_type][entity_id][field] = value
self._dirty.add((entity_type, entity_id))MetaStore.increment method · python · L62-L75 (14 LOC)src/pinwheel/core/meta.py
def increment(
self,
entity_type: str,
entity_id: str,
field: str,
amount: int | float = 1,
) -> MetaValueType:
"""Increment a numeric meta value. Initializes to 0 if not set."""
current = self.get(entity_type, entity_id, field, default=0)
if not isinstance(current, (int, float)):
current = 0
new_val = current + amount
self.set(entity_type, entity_id, field, new_val)
return new_valMetaStore.decrement method · python · L77-L85 (9 LOC)src/pinwheel/core/meta.py
def decrement(
self,
entity_type: str,
entity_id: str,
field: str,
amount: int | float = 1,
) -> MetaValueType:
"""Decrement a numeric meta value. Initializes to 0 if not set."""
return self.increment(entity_type, entity_id, field, -amount)MetaStore.toggle method · python · L87-L97 (11 LOC)src/pinwheel/core/meta.py
def toggle(
self,
entity_type: str,
entity_id: str,
field: str,
) -> bool:
"""Toggle a boolean meta value. Initializes to False if not set."""
current = self.get(entity_type, entity_id, field, default=False)
new_val = not bool(current)
self.set(entity_type, entity_id, field, new_val)
return new_valMetaStore.get_all method · python · L99-L105 (7 LOC)src/pinwheel/core/meta.py
def get_all(
self,
entity_type: str,
entity_id: str,
) -> dict[str, MetaValueType]:
"""Get all meta fields for an entity."""
return dict(self._data[entity_type][entity_id])MetaStore.get_dirty_entities method · python · L107-L118 (12 LOC)src/pinwheel/core/meta.py
def get_dirty_entities(self) -> list[tuple[str, str, dict[str, MetaValueType]]]:
"""Return all modified entities and their current meta state.
Returns list of (entity_type, entity_id, meta_dict) tuples.
Clears the dirty set after reading.
"""
result: list[tuple[str, str, dict[str, MetaValueType]]] = []
for entity_type, entity_id in self._dirty:
meta = dict(self._data[entity_type][entity_id])
result.append((entity_type, entity_id, meta))
self._dirty.clear()
return resultMetaStore.load_entity method · python · L120-L127 (8 LOC)src/pinwheel/core/meta.py
def load_entity(
self,
entity_type: str,
entity_id: str,
meta: dict[str, MetaValueType],
) -> None:
"""Load meta from DB into the store. Does NOT mark as dirty."""
self._data[entity_type][entity_id] = dict(meta)MilestoneDefinition.to_move method · python · L44-L52 (9 LOC)src/pinwheel/core/milestones.py
def to_move(self) -> Move:
"""Create the Move model for this milestone."""
return Move(
name=self.move_name,
trigger=self.move_trigger,
effect=self.move_effect,
attribute_gate=self.attribute_gate,
source="earned",
)Same scanner, your repo: https://repobility.com — Repobility
check_milestones function · python · L101-L140 (40 LOC)src/pinwheel/core/milestones.py
def check_milestones(
season_stats: dict[str, int],
existing_move_names: set[str],
milestones: list[MilestoneDefinition] | None = None,
) -> list[Move]:
"""Check which milestones a hooper has reached and return newly earned moves.
Args:
season_stats: Aggregated box score stats for the hooper this season.
Keys are stat column names, values are cumulative totals.
existing_move_names: Set of move names the hooper already has.
Used to prevent re-granting the same move.
milestones: Optional override for milestone definitions.
Defaults to DEFAULT_MILESTONES.
Returns:
List of Move objects for newly earned milestones. Empty if none qualify.
"""
if milestones is None:
milestones = DEFAULT_MILESTONES
earned: list[Move] = []
for milestone in milestones:
# Skip if hooper already has this move
if milestone.move_name in existing_move_names:
continue
check_gate function · python · L101-L106 (6 LOC)src/pinwheel/core/moves.py
def check_gate(move: Move, agent: HooperState) -> bool:
"""Check if agent meets the attribute gate for a move."""
for attr_name, min_val in move.attribute_gate.items():
if getattr(agent.hooper.attributes, attr_name, 0) < min_val:
return False
return Truecheck_trigger function · python · L109-L130 (22 LOC)src/pinwheel/core/moves.py
def check_trigger(
move: Move,
agent: HooperState,
action: str,
last_possession_three: bool,
is_elam: bool,
) -> bool:
"""Check if a move's trigger condition is met."""
trigger = move.trigger
if trigger == "made_three_last_possession":
return last_possession_three
if trigger == "half_court_setup":
return action in ("mid_range", "three_point", "pass")
if trigger == "opponent_iso":
return action in ("drive", "at_rim", "mid_range")
if trigger == "drive_action":
return action in ("drive", "at_rim")
if trigger == "stamina_below_40":
return agent.current_stamina < 0.4
if trigger == "elam_period":
return is_elam
return trigger == "any_possession"get_triggered_moves function · python · L133-L150 (18 LOC)src/pinwheel/core/moves.py
def get_triggered_moves(
agent: HooperState,
action: str,
last_possession_three: bool,
is_elam: bool,
rng: random.Random,
) -> list[Move]:
"""Return all moves that trigger for this agent in this situation."""
triggered = []
for move in agent.hooper.moves:
if not check_gate(move, agent):
continue
if not check_trigger(move, agent, action, last_possession_three, is_elam):
continue
# Moves activate with 70% probability when conditions are met
if rng.random() < 0.7:
triggered.append(move)
return triggeredapply_move_modifier function · python · L153-L187 (35 LOC)src/pinwheel/core/moves.py
def apply_move_modifier(
move: Move,
base_probability: float,
rng: random.Random,
) -> float:
"""Apply a move's effect to shot probability. Returns modified probability."""
name = move.name
if name == "Heat Check":
return min(0.99, base_probability + 0.15)
if name == "Ankle Breaker":
return min(0.99, base_probability + 0.15)
if name == "Clutch Gene":
return min(0.99, base_probability + 0.20)
if name == "Chess Move":
return min(0.99, base_probability + 0.10)
if name == "No-Look Pass":
return min(0.99, base_probability + 0.10)
if name == "Wild Card":
delta = rng.choice([0.25, -0.15])
return max(0.01, min(0.99, base_probability + delta))
if name == "Lockdown Stance":
# Defensive move — when a lockdown defender activates this move,
# it reduces the offensive player's shot probability by 12%
# representing tighter, more disciplined defense on this possession.
narrate_winner function · python · L65-L110 (46 LOC)src/pinwheel/core/narrate.py
def narrate_winner(
player: str,
action: str,
move: str = "",
seed: int = 0,
registry: ActionRegistry | None = None,
) -> str:
"""Generate a vivid Elam banner description for the game-winning play.
When ``registry`` is provided, looks up ``narration_winner`` templates
from the action's ``ActionDefinition``. Falls back to legacy hardcoded
templates when no registry is provided or the action has no winner
narration templates.
Args:
player: Name of the player who made the winning shot.
action: Action name (e.g. 'three_point', 'half_court_heave').
move: Name of the activated move (for flourish suffix).
seed: RNG seed for deterministic template selection.
registry: Optional ActionRegistry for data-driven narration.
Returns:
A vivid one-line description of the game-winning play.
"""
rng = random.Random(seed)
# Try registry-based narration first
if registry is not None:
_resolve_foul_desc function · python · L215-L227 (13 LOC)src/pinwheel/core/narrate.py
def _resolve_foul_desc(
action: str,
action_def: ActionDefinition | None,
) -> str:
"""Resolve the short foul description for a given action.
Uses the ActionDefinition's ``narration_foul_desc`` when available,
falls back to the legacy hardcoded mapping, and ultimately defaults
to 'shot' for unknown actions.
"""
if action_def is not None and action_def.narration_foul_desc:
return action_def.narration_foul_desc
return _LEGACY_FOUL_DESC.get(action, "shot")_narrate_made function · python · L230-L256 (27 LOC)src/pinwheel/core/narrate.py
def _narrate_made(
player: str,
defender: str,
action: str,
rng: random.Random,
action_def: ActionDefinition | None,
) -> str:
"""Generate narration for a made shot.
Uses data-driven templates from the ActionDefinition when available,
falls back to legacy hardcoded templates, and ultimately to generic
text for unknown actions.
"""
# Try data-driven templates first
if action_def is not None and action_def.narration_made:
return rng.choice(action_def.narration_made).format(
player=player,
defender=defender,
)
# Legacy hardcoded path
templates = _LEGACY_MADE.get(action)
if templates:
return rng.choice(templates).format(player=player, defender=defender)
# Generic fallback
return f"{player} scores"Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
_narrate_missed function · python · L259-L285 (27 LOC)src/pinwheel/core/narrate.py
def _narrate_missed(
player: str,
defender: str,
action: str,
rng: random.Random,
action_def: ActionDefinition | None,
) -> str:
"""Generate narration for a missed shot.
Uses data-driven templates from the ActionDefinition when available,
falls back to legacy hardcoded templates, and ultimately to generic
text for unknown actions.
"""
# Try data-driven templates first
if action_def is not None and action_def.narration_missed:
return rng.choice(action_def.narration_missed).format(
player=player,
defender=defender,
)
# Legacy hardcoded path
templates = _LEGACY_MISSED.get(action)
if templates:
return rng.choice(templates).format(player=player, defender=defender)
# Generic fallback
return f"{player} misses"narrate_play function · python · L288-L373 (86 LOC)src/pinwheel/core/narrate.py
def narrate_play(
player: str,
defender: str,
action: str,
result: str,
points: int,
move: str = "",
rebounder: str = "",
is_offensive_rebound: bool = False,
score_home: int = 0,
score_away: int = 0,
seed: int = 0,
assist_id: str = "",
registry: ActionRegistry | None = None,
) -> str:
"""Generate a one-line play-by-play description from structured data.
When ``registry`` is provided, looks up narration templates from the
action's ``ActionDefinition`` for made/missed shots and foul descriptions.
Falls back to legacy hardcoded templates when no registry is provided or
the action has no narration templates defined.
When a shot misses and a rebounder is specified, appends a rebound
narration indicating who grabbed the board (offensive or defensive).
Args:
player: Name of the ball handler.
defender: Name of the primary defender.
action: Action name (e.g. 'three_point', 'half_court__build_bedrock_facts function · python · L109-L130 (22 LOC)src/pinwheel/core/narrative.py
def _build_bedrock_facts(ruleset: RuleSet) -> str:
"""Build verified structural facts about the league from the current ruleset.
These facts appear at the top of every AI prompt and must not be contradicted.
"""
semis_wins = (ruleset.playoff_semis_best_of // 2) + 1
finals_wins = (ruleset.playoff_finals_best_of // 2) + 1
return (
f"League: {ruleset.teams_count} teams, 3v3 basketball.\n"
f"No byes — every team plays every round during the regular season.\n"
f"Playoffs: top {ruleset.playoff_teams} teams qualify. "
f"Semifinals are best-of-{ruleset.playoff_semis_best_of} "
f"(first to {semis_wins} wins). "
f"Finals are best-of-{ruleset.playoff_finals_best_of} "
f"(first to {finals_wins} wins).\n"
f"Elam Ending: activates after Q{ruleset.elam_trigger_quarter} "
f"if margin is within {ruleset.elam_margin} points.\n"
f"Scoring: 3pt={ruleset.three_point_value}, 2pt={ruleset.two_point_value}_compute_phase function · python · L395-L410 (16 LOC)src/pinwheel/core/narrative.py
def _compute_phase(status: str) -> str:
"""Map season status to a narrative phase label."""
phase_map: dict[str, str] = {
"setup": "regular",
"active": "regular",
"tiebreaker_check": "regular",
"tiebreakers": "tiebreakers",
"regular_season_complete": "regular",
"playoffs": "semifinal",
"championship": "championship",
"offseason": "offseason",
"completed": "regular",
"complete": "regular",
"archived": "regular",
}
return phase_map.get(status, "regular")_compute_season_arc function · python · L413-L436 (24 LOC)src/pinwheel/core/narrative.py
def _compute_season_arc(
round_number: int,
total_rounds: int,
phase: str,
) -> str:
"""Determine narrative arc position within the season.
Returns one of: 'early', 'mid', 'late', 'playoff', 'championship'.
"""
if phase in ("semifinal", "finals"):
return "playoff"
if phase == "championship":
return "championship"
if total_rounds == 0:
return "early"
pct = round_number / total_rounds
if pct <= 0.33:
return "early"
elif pct <= 0.66:
return "mid"
else:
return "late"_compute_streaks function · python · L439-L477 (39 LOC)src/pinwheel/core/narrative.py
def _compute_streaks(games: list[object]) -> dict[str, int]:
"""Compute current win/loss streaks per team from game results.
Positive = win streak, negative = loss streak. Only counts the
current streak (resets on reversal).
Args:
games: List of game result rows sorted by round_number.
Returns:
Dict mapping team_id to streak length.
"""
# Sort games by round_number to process chronologically
sorted_games = sorted(games, key=lambda g: (g.round_number, g.matchup_index))
# Track per-team results in order
team_results: dict[str, list[bool]] = {}
for g in sorted_games:
for tid in (g.home_team_id, g.away_team_id):
if tid not in team_results:
team_results[tid] = []
team_results[tid].append(g.winner_team_id == tid)
# Compute current streak from the end
streaks: dict[str, int] = {}
for tid, results in team_results.items():
if not results:
streaks[tid] _compute_head_to_head function · python · L480-L525 (46 LOC)src/pinwheel/core/narrative.py
def _compute_head_to_head(
games: list[object],
team_a_id: str,
team_b_id: str,
phase_filter: str | None = None,
) -> dict[str, object]:
"""Compute head-to-head record between two teams.
Args:
games: All game results for the season.
team_a_id: First team ID.
team_b_id: Second team ID.
phase_filter: If set, only count games matching this phase
(e.g. "playoff"). Uses getattr to check game.phase.
Returns:
Dict with wins_a, wins_b, total_games, last_winner.
"""
pair = frozenset({team_a_id, team_b_id})
wins_a = 0
wins_b = 0
total = 0
last_winner: str | None = None
sorted_games = sorted(games, key=lambda g: g.round_number)
for g in sorted_games:
if frozenset({g.home_team_id, g.away_team_id}) == pair:
# Apply phase filter if requested
if phase_filter is not None:
game_phase = getattr(g, "phase", None)
if game_phase !_compute_hot_players function · python · L528-L572 (45 LOC)src/pinwheel/core/narrative.py
async def _compute_hot_players(
repo: Repository,
season_id: str,
games: list[object],
) -> list[dict[str, object]]:
"""Find players with notable recent performances.
Looks for hoopers who scored 20+ points in their most recent game.
Args:
repo: Repository for box score queries.
season_id: Current season.
games: All game results.
Returns:
List of dicts with hooper_id, name, team_name, stat, value, games.
"""
hot: list[dict[str, object]] = []
if not games:
return hot
# Get the most recent round's games
max_round = max(g.round_number for g in games)
recent_games = [g for g in games if g.round_number == max_round]
for game in recent_games:
game_row = await repo.get_game_result(game.id)
if not game_row or not game_row.box_scores:
continue
for bs in game_row.box_scores:
if bs.points >= 20:
hooper = await repo.get_hooper(bs.hoopeRepobility analyzer · published findings · https://repobility.com
_build_rules_narrative function · python · L575-L606 (32 LOC)src/pinwheel/core/narrative.py
def _build_rules_narrative(
rule_changes: list[dict[str, object]],
) -> str:
"""Build a human-readable summary of active rule changes.
Args:
rule_changes: List of rule change dicts.
Returns:
A summary string like 'Three-pointers worth 5 (changed Round 4)'.
"""
if not rule_changes:
return ""
parts: list[str] = []
for rc in rule_changes:
param = str(rc.get("parameter", ""))
new_val = rc.get("new_value")
round_enacted = rc.get("round_enacted")
# Humanize parameter name
label = param.replace("_", " ").title()
narrative = rc.get("narrative", "")
if narrative:
parts.append(str(narrative))
elif round_enacted is not None:
parts.append(f"{label} set to {new_val} (changed Round {round_enacted})")
else:
parts.append(f"{label} set to {new_val}")
return "; ".join(parts)format_narrative_for_prompt function · python · L609-L749 (141 LOC)src/pinwheel/core/narrative.py
def format_narrative_for_prompt(ctx: NarrativeContext) -> str:
"""Format NarrativeContext as a text block suitable for AI prompt injection.
This produces a structured text summary that can be appended to
commentary, report, or any AI prompt to give the model dramatic context.
Args:
ctx: The narrative context to format.
Returns:
Multi-line string with all relevant narrative context.
"""
lines: list[str] = []
# Bedrock facts at the TOP — ground truth the AI must not contradict
if ctx.bedrock_facts:
lines.append("=== LEAGUE FACTS (do not contradict) ===")
lines.append(ctx.bedrock_facts)
lines.append("")
# Phase and arc
if ctx.phase not in ("regular",):
phase_labels = {
"semifinal": "SEMIFINAL PLAYOFFS",
"finals": "CHAMPIONSHIP FINALS",
"championship": "CHAMPIONSHIP CELEBRATION",
"tiebreakers": "TIEBREAKER GAMES",
"offseason": "OFFSEAScompute_strength_of_schedule function · python · L20-L66 (47 LOC)src/pinwheel/core/narrative_standings.py
def compute_strength_of_schedule(
results: list[dict],
standings: list[dict],
) -> dict[str, dict[str, int]]:
"""Compute each team's record against above-.500 opponents.
A team is "above .500" if ``wins > losses`` in the current standings.
Returns ``{team_id: {"wins": N, "losses": M}}`` for games against those
opponents only.
Args:
results: Game result dicts (same format as ``compute_standings``).
standings: Current standings list from ``compute_standings``.
Returns:
Dict mapping team_id to ``{"wins": int, "losses": int}``.
"""
above_500: set[str] = set()
for s in standings:
if s["wins"] > s["losses"]:
above_500.add(s["team_id"])
sos: dict[str, dict[str, int]] = {}
all_team_ids = {s["team_id"] for s in standings}
for tid in all_team_ids:
sos[tid] = {"wins": 0, "losses": 0}
for r in results:
home_id = r["home_team_id"]
away_id = r["away_team_id"]
wcompute_magic_numbers function · python · L74-L148 (75 LOC)src/pinwheel/core/narrative_standings.py
def compute_magic_numbers(
standings: list[dict],
total_rounds: int,
games_per_round: int,
num_playoff_spots: int = 2,
) -> dict[str, int | None]:
"""Compute playoff clinch magic numbers.
The magic number is the number of additional wins a team needs such that
no team outside the playoff spots can catch them, regardless of remaining
outcomes.
For a team at position *i* (0-indexed), the magic number relative to the
team at position ``num_playoff_spots`` (the first team out) is::
magic = remaining_games_for_chaser + 1 - (my_wins - chaser_wins)
A magic number of 0 or less means the team has clinched.
``None`` means the team is *outside* the playoff picture (or clinch is
impossible with remaining games).
Args:
standings: Sorted standings list.
total_rounds: Total scheduled rounds in the regular season.
games_per_round: Average games per team per round (typically 1 for
round-robin where compute_standings_trajectory function · python · L156-L199 (44 LOC)src/pinwheel/core/narrative_standings.py
def compute_standings_trajectory(
results_with_rounds: list[dict],
current_round: int,
lookback: int = 3,
) -> dict[str, int]:
"""Compute how many positions each team moved in the last *lookback* rounds.
Positive = moved up (improved), negative = moved down.
Args:
results_with_rounds: Game result dicts with an extra ``round_number`` key.
current_round: The current (latest played) round number.
lookback: How many rounds to look back (default 3).
Returns:
Dict mapping team_id to position delta (positive = improved).
"""
if current_round <= lookback:
return {}
cutoff_round = current_round - lookback
# Standings at the cutoff point (games up to but not including cutoff+1)
earlier_results = [r for r in results_with_rounds if r["round_number"] <= cutoff_round]
current_results = results_with_rounds # all results
if not earlier_results:
return {}
old_standings = compute_standingscompute_most_improved function · python · L207-L268 (62 LOC)src/pinwheel/core/narrative_standings.py
def compute_most_improved(
results_with_rounds: list[dict],
current_round: int,
window: int = 3,
) -> tuple[str | None, float, float]:
"""Find the team with the biggest win-rate improvement in the last *window* rounds.
Returns ``(team_id, old_pct, new_pct)`` or ``(None, 0.0, 0.0)`` if no
improvement can be computed.
Args:
results_with_rounds: Game result dicts with ``round_number`` key.
current_round: Latest played round number.
window: Number of recent rounds to compare against earlier play.
Returns:
Tuple of (team_id, old_win_pct, new_win_pct).
"""
if current_round <= window:
return None, 0.0, 0.0
cutoff = current_round - window
early = [r for r in results_with_rounds if r["round_number"] <= cutoff]
recent = [r for r in results_with_rounds if r["round_number"] > cutoff]
if not early or not recent:
return None, 0.0, 0.0
def _win_rates(results: list[dict]) -> dict[str, flocompute_narrative_callouts function · python · L276-L436 (161 LOC)src/pinwheel/core/narrative_standings.py
def compute_narrative_callouts(
standings: list[dict],
streaks: dict[str, int],
current_round: int,
total_rounds: int,
sos: dict[str, dict[str, int]],
magic_numbers: dict[str, int | None],
trajectory: dict[str, int],
most_improved_team: str | None,
team_names: dict[str, str],
) -> list[str]:
"""Generate 2-6 narrative callouts for the standings page.
Builds on the original ``_compute_standings_callouts`` with richer context:
strength of schedule, magic numbers, trajectory, and most improved.
Args:
standings: Sorted standings list.
streaks: Current streak per team (positive = wins, negative = losses).
current_round: Latest played round.
total_rounds: Total scheduled regular-season rounds.
sos: Strength of schedule data from ``compute_strength_of_schedule``.
magic_numbers: Magic numbers from ``compute_magic_numbers``.
trajectory: Position deltas from ``compute_standings_trajectorybuild_league_context function · python · L52-L160 (109 LOC)src/pinwheel/core/onboarding.py
async def build_league_context(
repo: Repository,
season_id: str,
season_name: str,
season_status: str,
governance_interval: int = 1,
) -> LeagueContext:
"""Gather a snapshot of the current league state.
All data comes from existing repository queries. This function is
safe to call from any context (Discord bot, web handler, tests)
and does not make AI calls.
Args:
repo: An active Repository instance (session must be open).
season_id: The season to gather context for.
season_name: Display name for the season.
season_status: Raw status string from the SeasonRow.
governance_interval: Governance tally interval (from settings).
Returns:
A LeagueContext with all fields populated from the database.
"""
phase = normalize_phase(season_status)
# --- Standings ---
games = await repo.get_all_games(season_id)
game_dicts = [
{
"home_team_id": g.home_team_id,
Powered by Repobility — scan your code at https://repobility.com
get_surface_modifiers function · python · L88-L93 (6 LOC)src/pinwheel/core/possession.py
def get_surface_modifiers(surface: str) -> SurfaceModifiers:
"""Look up surface modifiers for a venue surface type.
Unknown surfaces are treated as hardwood (no modifiers).
"""
return SURFACE_EFFECTS.get(surface, SurfaceModifiers())select_ball_handler function · python · L117-L143 (27 LOC)src/pinwheel/core/possession.py
def select_ball_handler(
offense: list[HooperState],
rng: random.Random,
rules: RuleSet | None = None,
total_team_fga: int = 0,
) -> HooperState:
"""Pick who handles the ball. Weighted by passing + IQ.
When rules.max_shot_share < 1.0, players who have exceeded their share
of the team's field goal attempts get a reduced selection weight.
"""
if not offense:
raise ValueError("No offensive players available")
max_share = rules.max_shot_share if rules else 1.0
weights = []
for a in offense:
base = max(1, a.current_attributes.passing + a.current_attributes.iq)
# Reduce weight for players exceeding their max shot share
if max_share < 1.0 and total_team_fga > 0:
player_share = a.field_goals_attempted / total_team_fga
if player_share > max_share:
# Scale down proportionally — the more over the cap, the less likely
overshoot = player_share - max_share
select_action function · python · L146-L169 (24 LOC)src/pinwheel/core/possession.py
def select_action(
handler: HooperState,
game_state: GameState,
rules: RuleSet,
rng: random.Random,
effect_biases: PossessionContext | None = None,
surface: SurfaceModifiers | None = None,
action_registry: ActionRegistry | None = None,
) -> ShotType:
"""Select shot type based on handler attributes and game state.
Always uses the registry-based path. When ``action_registry`` is
``None``, a default basketball registry is built from the rules.
Surface modifiers adjust shot selection weights: speed_at_rim_modifier
scales the speed component of at_rim, while the per-type weight modifiers
are additive percentages of the pre-surface weight.
"""
if action_registry is None:
action_registry = ActionRegistry(basketball_actions(rules))
return _select_action_registry(
handler, game_state, rules, rng,
effect_biases, surface, action_registry,
)_select_action_registry function · python · L172-L255 (84 LOC)src/pinwheel/core/possession.py
def _select_action_registry(
handler: HooperState,
game_state: GameState,
rules: RuleSet,
rng: random.Random,
effect_biases: PossessionContext | None,
surface: SurfaceModifiers | None,
action_registry: ActionRegistry,
) -> ShotType:
"""Registry-based action selection — data-driven weights.
Builds selection weights from ``registry.shot_actions()`` instead of
hardcoded constants. For the standard basketball registry, this produces
identical RNG draws because the weights are algebraically equivalent.
"""
# three_point_distance: default 22.15 ft (NBA 3PT distance).
distance_from_default = rules.three_point_distance - 22.15
three_distance_penalty = distance_from_default * 2.0
# Build weights from registry shot actions, sorted by name for determinism
shot_actions = sorted(action_registry.shot_actions(), key=lambda a: a.name)
weights: dict[str, float] = {}
for action_def in shot_actions:
name = action_def.ncheck_turnover function · python · L258-L285 (28 LOC)src/pinwheel/core/possession.py
def check_turnover(
handler: HooperState,
scheme: DefensiveScheme,
rng: random.Random,
rules: RuleSet | None = None,
effect_turnover_modifier: float = 0.0,
crowd_pressure_modifier: float = 0.0,
) -> bool:
"""Check if the offense turns the ball over.
The base turnover rate is scaled by rules.turnover_rate_modifier (default 1.0).
effect_turnover_modifier is additive from PossessionContext.
crowd_pressure_modifier is additive from home court advantage (applied to away offense).
"""
base_to_rate = 0.08
modifier = rules.turnover_rate_modifier if rules else 1.0
iq_reduction = handler.current_attributes.iq / 1000.0
scheme_bonus = SCHEME_TURNOVER_BONUS[scheme]
stamina_penalty = (1.0 - handler.current_stamina) * 0.05
to_prob = (
(base_to_rate * modifier)
- iq_reduction
+ scheme_bonus
+ stamina_penalty
+ effect_turnover_modifier
+ crowd_pressure_modifier
)
return rng.randocheck_foul function · python · L288-L312 (25 LOC)src/pinwheel/core/possession.py
def check_foul(
defender: HooperState,
shot_type: ShotType,
scheme: DefensiveScheme,
rng: random.Random,
rules: RuleSet | None = None,
defensive_intensity: float = 0.0,
) -> bool:
"""Check if the defender commits a foul.
The base foul rate is scaled by rules.foul_rate_modifier (default 1.0).
Higher defensive_intensity (positive) increases foul rate --- tighter defense
means more contact. Only positive intensity adds fouls; relaxed defense
(negative intensity) does not reduce fouls below the base rate.
"""
base_foul_rate = 0.08
modifier = rules.foul_rate_modifier if rules else 1.0
# Aggressive schemes foul more
scheme_add = {"man_tight": 0.03, "press": 0.04, "man_switch": 0.01, "zone": 0.0}
# Low-IQ defenders foul more
iq_penalty = max(0, (50 - defender.current_attributes.iq)) / 500.0
# High defensive intensity causes more fouls: +0.5 intensity -> +4% foul rate
intensity_add = max(0.0, defensive_intensity) attempt_rebound function · python · L315-L354 (40 LOC)src/pinwheel/core/possession.py
def attempt_rebound(
offense: list[HooperState],
defense: list[HooperState],
rng: random.Random,
rules: RuleSet | None = None,
) -> tuple[HooperState, bool]:
"""Resolve a rebound after a missed shot. Returns (rebounder, is_offensive).
The offensive rebound base weight is governed by rules.offensive_rebound_weight
(default 5.0) while defensive is fixed at 10.0.
**Fate --- lucky bounces:** High-Fate offensive players get a bonus to
offensive rebound weight, representing fortunate ball bounces.
``fate_bonus = (fate / 100.0) * 3.0`` --- a Fate-90 player adds +2.7
to their offensive rebound weight.
"""
all_players = [(a, True) for a in offense] + [(a, False) for a in defense]
if not all_players:
return offense[0], True
off_reb_weight = rules.offensive_rebound_weight if rules else 5.0
# Weight by a combination of attributes
weights = []
for agent, is_off in all_players:
# Defense gets natural rebound check_shot_clock_violation function · python · L357-L380 (24 LOC)src/pinwheel/core/possession.py
def check_shot_clock_violation(
handler: HooperState,
scheme: DefensiveScheme,
rng: random.Random,
) -> bool:
"""Check if the offense commits a shot clock violation.
Strong defense + low IQ + fatigue -> higher chance of not getting a shot off.
"""
base_rate = 0.02
# Aggressive schemes make it harder to get a shot off
scheme_factor: dict[DefensiveScheme, float] = {
"press": 2.0,
"man_tight": 1.5,
"man_switch": 1.0,
"zone": 0.5,
}
# Fatigue makes it harder to create a shot
fatigue_factor = 1.0 + (1.0 - handler.current_stamina) * 0.8
# High IQ handlers manage the clock better
iq_factor = 1.0 - (handler.current_attributes.iq / 200.0)
prob = base_rate * scheme_factor[scheme] * fatigue_factor * max(0.3, iq_factor)
return rng.random() < max(0.005, min(0.12, prob))Same scanner, your repo: https://repobility.com — Repobility
drain_stamina function · python · L383-L434 (52 LOC)src/pinwheel/core/possession.py
def drain_stamina(
agents: list[HooperState],
scheme: DefensiveScheme,
is_defense: bool,
rules: RuleSet | None = None,
defensive_intensity: float = 0.0,
pace_modifier: float = 1.0,
is_away: bool = False,
altitude_ft: int = 0,
surface_stamina_multiplier: float = 1.0,
) -> None:
"""Drain stamina for all agents after a possession.
Base drain rate is governed by rules.stamina_drain_rate (default 0.007).
Higher defensive_intensity increases stamina drain for defenders --- playing
tighter defense is more physically demanding.
Faster pace (pace_modifier < 1.0) increases stamina drain for both teams ---
faster possessions mean more physical effort per unit of game time.
When home_court_enabled, away teams drain extra stamina (away_fatigue_factor).
High-altitude venues (altitude_ft) add stamina drain scaled by
rules.altitude_stamina_penalty.
surface_stamina_multiplier scales the total drain by the venue surface faccompute_possession_duration function · python · L437-L448 (12 LOC)src/pinwheel/core/possession.py
def compute_possession_duration(
rules: RuleSet,
rng: random.Random,
pace_modifier: float = 1.0,
) -> float:
"""Compute clock time consumed by one possession (seconds).
Dead-ball time between possessions is governed by rules.dead_ball_time_seconds.
"""
play_time = rules.shot_clock_seconds * rng.uniform(0.4, 1.0)
dead_time = rules.dead_ball_time_seconds
return (play_time * pace_modifier) + dead_timePresentationState.reset method · python · L80-L90 (11 LOC)src/pinwheel/core/presenter.py
def reset(self) -> None:
"""Reset state for a new presentation."""
self.is_active = False
self.current_round = 0
self.current_game_index = 0
self.cancel_event = asyncio.Event()
self.live_games = {}
self.game_results = []
self.game_summaries = []
self.name_cache = {}
self.color_cache = {}