Merge pull request #7 from calcorum/bug-live-scoreboard
Bug live scoreboard
This commit is contained in:
commit
268ac9a547
@ -12,6 +12,7 @@ from discord import app_commands
|
||||
from config import get_config
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from utils.discord_helpers import set_channel_visibility
|
||||
from views.embeds import EmbedColors, EmbedTemplate
|
||||
|
||||
|
||||
@ -115,7 +116,8 @@ class AdminCommands(commands.Cog):
|
||||
value="**`/admin-status`** - Display bot status and information\n"
|
||||
"**`/admin-reload <cog>`** - Reload a specific cog\n"
|
||||
"**`/admin-sync`** - Sync application commands\n"
|
||||
"**`/admin-clear <count>`** - Clear messages from channel",
|
||||
"**`/admin-clear <count>`** - Clear messages from channel\n"
|
||||
"**`/admin-clear-scorecards`** - Clear live scorebug channel and hide it",
|
||||
inline=False
|
||||
)
|
||||
|
||||
@ -498,6 +500,104 @@ class AdminCommands(commands.Cog):
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@app_commands.command(
|
||||
name="admin-clear-scorecards",
|
||||
description="Manually clear the live scorebug channel and hide it from members"
|
||||
)
|
||||
@logged_command("/admin-clear-scorecards")
|
||||
async def admin_clear_scorecards(self, interaction: discord.Interaction):
|
||||
"""
|
||||
Manually clear #live-sba-scores channel and set @everyone view permission to off.
|
||||
|
||||
This is useful for:
|
||||
- Cleaning up stale scorebug displays
|
||||
- Manually hiding the channel when games finish
|
||||
- Testing channel visibility functionality
|
||||
"""
|
||||
await interaction.response.defer()
|
||||
|
||||
# Get the live-sba-scores channel
|
||||
config = get_config()
|
||||
guild = self.bot.get_guild(config.guild_id)
|
||||
|
||||
if not guild:
|
||||
await interaction.followup.send(
|
||||
"❌ Could not find guild. Check configuration.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
live_scores_channel = discord.utils.get(guild.text_channels, name='live-sba-scores')
|
||||
|
||||
if not live_scores_channel:
|
||||
await interaction.followup.send(
|
||||
"❌ Could not find #live-sba-scores channel.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Clear all messages from the channel
|
||||
deleted_count = 0
|
||||
async for message in live_scores_channel.history(limit=100):
|
||||
try:
|
||||
await message.delete()
|
||||
deleted_count += 1
|
||||
except discord.NotFound:
|
||||
pass # Message already deleted
|
||||
|
||||
self.logger.info(f"Cleared {deleted_count} messages from #live-sba-scores")
|
||||
|
||||
# Hide channel from @everyone
|
||||
visibility_success = await set_channel_visibility(
|
||||
live_scores_channel,
|
||||
visible=False,
|
||||
reason="Admin manual clear via /admin-clear-scorecards"
|
||||
)
|
||||
|
||||
if visibility_success:
|
||||
visibility_status = "✅ Hidden from @everyone"
|
||||
else:
|
||||
visibility_status = "⚠️ Could not change visibility (check permissions)"
|
||||
|
||||
# Create success embed
|
||||
embed = EmbedTemplate.success(
|
||||
title="Live Scorebug Channel Cleared",
|
||||
description="Successfully cleared the #live-sba-scores channel"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Clear Details",
|
||||
value=f"**Channel:** {live_scores_channel.mention}\n"
|
||||
f"**Messages Deleted:** {deleted_count}\n"
|
||||
f"**Visibility:** {visibility_status}\n"
|
||||
f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Next Steps",
|
||||
value="• Channel is now hidden from @everyone\n"
|
||||
"• Bot retains full access to the channel\n"
|
||||
"• Channel will auto-show when games are published\n"
|
||||
"• Live scorebug tracker runs every 3 minutes",
|
||||
inline=False
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(
|
||||
"❌ Missing permissions to clear messages or modify channel permissions.",
|
||||
ephemeral=True
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error clearing scorecards: {e}", exc_info=True)
|
||||
await interaction.followup.send(
|
||||
f"❌ Failed to clear channel: {str(e)}",
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Load the admin commands cog."""
|
||||
|
||||
@ -7,10 +7,11 @@ import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
|
||||
from services.scorebug_service import ScorebugService
|
||||
from services.scorebug_service import ScorebugData, ScorebugService
|
||||
from services.team_service import team_service
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from utils.scorebug_helpers import create_scorebug_embed
|
||||
from views.embeds import EmbedTemplate, EmbedColors
|
||||
from exceptions import SheetsException
|
||||
from .scorecard_tracker import ScorecardTracker
|
||||
@ -190,8 +191,8 @@ class ScorebugCommands(commands.Cog):
|
||||
if scorebug_data.home_team_id:
|
||||
home_team = await team_service.get_team(scorebug_data.home_team_id)
|
||||
|
||||
# Create scorebug embed
|
||||
embed = await self._create_scorebug_embed(
|
||||
# Create scorebug embed using shared utility
|
||||
embed = create_scorebug_embed(
|
||||
scorebug_data,
|
||||
away_team,
|
||||
home_team,
|
||||
@ -224,134 +225,6 @@ class ScorebugCommands(commands.Cog):
|
||||
)
|
||||
await interaction.edit_original_response(content=None, embed=embed)
|
||||
|
||||
async def _create_scorebug_embed(
|
||||
self,
|
||||
scorebug_data,
|
||||
away_team,
|
||||
home_team,
|
||||
full_length: bool
|
||||
) -> discord.Embed:
|
||||
"""
|
||||
Create a rich embed from scorebug data.
|
||||
|
||||
Args:
|
||||
scorebug_data: ScorebugData object
|
||||
away_team: Away team object (optional)
|
||||
home_team: Home team object (optional)
|
||||
full_length: Include full details
|
||||
|
||||
Returns:
|
||||
Discord embed with scorebug information
|
||||
"""
|
||||
# Determine winning team for embed color
|
||||
if scorebug_data.away_score > scorebug_data.home_score and away_team:
|
||||
embed_color = away_team.get_color_int()
|
||||
thumbnail_url = away_team.thumbnail if away_team.thumbnail else None
|
||||
elif scorebug_data.home_score > scorebug_data.away_score and home_team:
|
||||
embed_color = home_team.get_color_int()
|
||||
thumbnail_url = home_team.thumbnail if home_team.thumbnail else None
|
||||
else:
|
||||
embed_color = EmbedColors.INFO
|
||||
thumbnail_url = None
|
||||
|
||||
# Create embed with header as title
|
||||
embed = discord.Embed(
|
||||
title=scorebug_data.header,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
if thumbnail_url:
|
||||
embed.set_thumbnail(url=thumbnail_url)
|
||||
|
||||
# Add score information
|
||||
away_abbrev = away_team.abbrev if away_team else "AWAY"
|
||||
home_abbrev = home_team.abbrev if home_team else "HOME"
|
||||
|
||||
score_text = (
|
||||
f"```\n"
|
||||
f"{away_abbrev:<6} {scorebug_data.away_score:>3}\n"
|
||||
f"{home_abbrev:<6} {scorebug_data.home_score:>3}\n"
|
||||
f"```"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Score",
|
||||
value=score_text,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add game state
|
||||
if not scorebug_data.is_final:
|
||||
embed.add_field(
|
||||
name="Status",
|
||||
value=f"**{scorebug_data.which_half}**",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add runners on base if present
|
||||
if scorebug_data.runners and any(scorebug_data.runners):
|
||||
runners_text = self._format_runners(scorebug_data.runners)
|
||||
if runners_text:
|
||||
embed.add_field(
|
||||
name="Runners",
|
||||
value=runners_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add matchups if full length
|
||||
if full_length and scorebug_data.matchups and any(scorebug_data.matchups):
|
||||
matchups_text = self._format_matchups(scorebug_data.matchups)
|
||||
if matchups_text:
|
||||
embed.add_field(
|
||||
name="Matchups",
|
||||
value=matchups_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add summary if full length
|
||||
if full_length and scorebug_data.summary and any(scorebug_data.summary):
|
||||
summary_text = self._format_summary(scorebug_data.summary)
|
||||
if summary_text:
|
||||
embed.add_field(
|
||||
name="Summary",
|
||||
value=summary_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
def _format_runners(self, runners) -> str:
|
||||
"""Format runners on base for display."""
|
||||
# runners is a list of [runner_name, runner_position] pairs
|
||||
runner_lines = []
|
||||
for runner_data in runners:
|
||||
if runner_data and len(runner_data) >= 2 and runner_data[0]:
|
||||
runner_lines.append(f"**{runner_data[1]}:** {runner_data[0]}")
|
||||
|
||||
return "\n".join(runner_lines) if runner_lines else ""
|
||||
|
||||
def _format_matchups(self, matchups) -> str:
|
||||
"""Format current matchups for display."""
|
||||
# matchups is a list of [batter, pitcher] pairs
|
||||
matchup_lines = []
|
||||
for matchup_data in matchups:
|
||||
if matchup_data and len(matchup_data) >= 2 and matchup_data[0]:
|
||||
matchup_lines.append(f"{matchup_data[0]} vs {matchup_data[1]}")
|
||||
|
||||
return "\n".join(matchup_lines) if matchup_lines else ""
|
||||
|
||||
def _format_summary(self, summary) -> str:
|
||||
"""Format game summary for display."""
|
||||
# summary is a list of summary line pairs
|
||||
summary_lines = []
|
||||
for summary_data in summary:
|
||||
if summary_data and len(summary_data) >= 1 and summary_data[0]:
|
||||
# Join both columns if present
|
||||
line = " - ".join([str(x) for x in summary_data if x])
|
||||
summary_lines.append(line)
|
||||
|
||||
return "\n".join(summary_lines) if summary_lines else ""
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
"""Load the scorebug commands cog."""
|
||||
|
||||
@ -645,7 +645,7 @@ class InjuryGroup(app_commands.Group):
|
||||
return
|
||||
|
||||
# Clear player's il_return field
|
||||
await player_service.update_player(player.id, {'il_return': None})
|
||||
await player_service.update_player(player.id, {'il_return': ''})
|
||||
|
||||
# Success response
|
||||
success_embed = EmbedTemplate.success(
|
||||
|
||||
@ -187,7 +187,8 @@ class InjuryService(BaseService[Injury]):
|
||||
True if successfully cleared, False otherwise
|
||||
"""
|
||||
try:
|
||||
updated_injury = await self.patch(injury_id, {'is_active': False})
|
||||
# Note: API expects is_active as query parameter, not JSON body
|
||||
updated_injury = await self.patch(injury_id, {'is_active': False}, use_query_params=True)
|
||||
|
||||
if updated_injury:
|
||||
logger.info(f"Cleared injury {injury_id}")
|
||||
|
||||
@ -22,10 +22,24 @@ class ScorebugData:
|
||||
self.away_score = data.get('away_score', 0)
|
||||
self.home_score = data.get('home_score', 0)
|
||||
self.which_half = data.get('which_half', '')
|
||||
self.inning = data.get('inning', 1)
|
||||
self.is_final = data.get('is_final', False)
|
||||
self.runners = data.get('runners', [])
|
||||
self.matchups = data.get('matchups', [])
|
||||
self.summary = data.get('summary', [])
|
||||
self.outs = data.get('outs', 0)
|
||||
self.win_percentage = data.get('win_percentage', 50.0)
|
||||
|
||||
# Current matchup information
|
||||
self.pitcher_name = data.get('pitcher_name', '')
|
||||
self.pitcher_url = data.get('pitcher_url', '')
|
||||
self.pitcher_stats = data.get('pitcher_stats', '')
|
||||
self.batter_name = data.get('batter_name', '')
|
||||
self.batter_url = data.get('batter_url', '')
|
||||
self.batter_stats = data.get('batter_stats', '')
|
||||
self.on_deck_name = data.get('on_deck_name', '')
|
||||
self.in_hole_name = data.get('in_hole_name', '')
|
||||
|
||||
# Additional data
|
||||
self.runners = data.get('runners', []) # [Catcher, On First, On Second, On Third]
|
||||
self.summary = data.get('summary', []) # Play-by-play summary lines
|
||||
|
||||
@property
|
||||
def score_line(self) -> str:
|
||||
@ -37,6 +51,22 @@ class ScorebugData:
|
||||
"""Check if game is currently active (not final)."""
|
||||
return not self.is_final
|
||||
|
||||
@property
|
||||
def current_matchup(self) -> str:
|
||||
"""Get formatted current matchup string."""
|
||||
if self.batter_name and self.pitcher_name:
|
||||
return f"{self.batter_name} vs {self.pitcher_name}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def situation(self) -> str:
|
||||
"""Get game situation (outs and runners)."""
|
||||
parts = []
|
||||
if self.outs is not None:
|
||||
outs_text = "out" if self.outs == 1 else "outs"
|
||||
parts.append(f"{self.outs} {outs_text}")
|
||||
return ", ".join(parts) if parts else ""
|
||||
|
||||
|
||||
class ScorebugService(SheetsService):
|
||||
"""Google Sheets integration for reading live scorebug data."""
|
||||
@ -69,9 +99,13 @@ class ScorebugService(SheetsService):
|
||||
Raises:
|
||||
SheetsException: If scorecard cannot be read
|
||||
"""
|
||||
self.logger.info(f"📖 Reading scorebug data from sheet: {sheet_url_or_key}")
|
||||
self.logger.debug(f" Full length mode: {full_length}")
|
||||
|
||||
try:
|
||||
# Open scorecard
|
||||
scorecard = await self.open_scorecard(sheet_url_or_key)
|
||||
self.logger.debug(f" ✅ Scorecard opened successfully")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
@ -88,101 +122,226 @@ class ScorebugService(SheetsService):
|
||||
lambda: scorebug_tab.get_values('B2', 'S20', include_tailing_empty_rows=True)
|
||||
)
|
||||
|
||||
self.logger.debug(f"Raw scorebug data (first 10 rows): {all_data[:10]}")
|
||||
self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows")
|
||||
self.logger.debug(f"📊 First row length: {len(all_data[0]) if all_data else 0} columns")
|
||||
self.logger.debug(f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)")
|
||||
self.logger.debug(f"📊 Raw data structure (all rows):")
|
||||
for idx, row in enumerate(all_data):
|
||||
self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}")
|
||||
|
||||
# Extract game state (B2:G8)
|
||||
# This corresponds to columns B-G (indices 0-5 in all_data)
|
||||
# Rows 2-8 in sheet (indices 0-6 in all_data)
|
||||
game_state = [
|
||||
all_data[0][:6], all_data[1][:6], all_data[2][:6], all_data[3][:6],
|
||||
all_data[4][:6], all_data[5][:6], all_data[6][:6]
|
||||
]
|
||||
|
||||
self.logger.debug(f"Extracted game_state: {game_state}")
|
||||
self.logger.debug(f"🎮 Extracted game_state (B2:G8):")
|
||||
for idx, row in enumerate(game_state):
|
||||
self.logger.debug(f" game_state[{idx}] (Sheet row {idx + 2}): {row}")
|
||||
|
||||
# Extract team IDs from game_state (already read from Scorebug tab)
|
||||
# game_state[3] is away team row, game_state[4] is home team row
|
||||
# First column (index 0) contains the team ID
|
||||
try:
|
||||
away_team_id = int(game_state[3][0]) if len(game_state) > 3 and len(game_state[3]) > 0 else None
|
||||
home_team_id = int(game_state[4][0]) if len(game_state) > 4 and len(game_state[4]) > 0 else None
|
||||
# game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6)
|
||||
# First column (index 0) contains the team ID - this is column B in the sheet
|
||||
self.logger.debug(f"🏟️ Extracting team IDs from game_state:")
|
||||
self.logger.debug(f" Away team row: game_state[3] = Sheet row 5, column B (index 0)")
|
||||
self.logger.debug(f" Home team row: game_state[4] = Sheet row 6, column B (index 0)")
|
||||
|
||||
self.logger.debug(f"Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}")
|
||||
try:
|
||||
away_team_id_raw = game_state[3][0] if len(game_state) > 3 and len(game_state[3]) > 0 else None
|
||||
home_team_id_raw = game_state[4][0] if len(game_state) > 4 and len(game_state[4]) > 0 else None
|
||||
|
||||
self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'")
|
||||
self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'")
|
||||
|
||||
away_team_id = int(away_team_id_raw) if away_team_id_raw else None
|
||||
home_team_id = int(home_team_id_raw) if home_team_id_raw else None
|
||||
|
||||
self.logger.debug(f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}")
|
||||
|
||||
if away_team_id is None or home_team_id is None:
|
||||
raise ValueError(f'Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})')
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.error(f"Failed to parse team IDs from scorebug: {e}")
|
||||
self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}")
|
||||
raise ValueError(f'Could not extract team IDs from scorecard')
|
||||
|
||||
# Parse game state
|
||||
self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):")
|
||||
header = game_state[0][0] if game_state[0] else ''
|
||||
is_final = header[-5:] == 'FINAL' if header else False
|
||||
|
||||
self.logger.debug(f"Header: '{header}', Is Final: {is_final}")
|
||||
self.logger.debug(f"Away team row (game_state[3]): {game_state[3] if len(game_state) > 3 else 'N/A'}")
|
||||
self.logger.debug(f"Home team row (game_state[4]): {game_state[4] if len(game_state) > 4 else 'N/A'}")
|
||||
self.logger.debug(f" Header value: '{header}'")
|
||||
self.logger.debug(f" Is Final: {is_final}")
|
||||
|
||||
# Parse scores with validation
|
||||
self.logger.debug(f"⚾ Parsing scores:")
|
||||
self.logger.debug(f" Away score: game_state[3][2] (Sheet row 5, column D)")
|
||||
self.logger.debug(f" Home score: game_state[4][2] (Sheet row 6, column D)")
|
||||
|
||||
try:
|
||||
away_score_raw = game_state[3][2] if len(game_state) > 3 and len(game_state[3]) > 2 else '0'
|
||||
self.logger.debug(f"Raw away score value: '{away_score_raw}'")
|
||||
away_score = int(away_score_raw)
|
||||
self.logger.debug(f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})")
|
||||
away_score = int(away_score_raw) if away_score_raw != '' else 0
|
||||
self.logger.debug(f" ✅ Parsed away score: {away_score}")
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.warning(f"Failed to parse away score: {e}")
|
||||
self.logger.warning(f" ⚠️ Failed to parse away score: {e}")
|
||||
away_score = 0
|
||||
|
||||
try:
|
||||
home_score_raw = game_state[4][2] if len(game_state) > 4 and len(game_state[4]) > 2 else '0'
|
||||
self.logger.debug(f"Raw home score value: '{home_score_raw}'")
|
||||
home_score = int(home_score_raw)
|
||||
self.logger.debug(f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})")
|
||||
home_score = int(home_score_raw) if home_score_raw != '' else 0
|
||||
self.logger.debug(f" ✅ Parsed home score: {home_score}")
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.warning(f"Failed to parse home score: {e}")
|
||||
self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
|
||||
home_score = 0
|
||||
|
||||
try:
|
||||
inning_raw = game_state[3][5] if len(game_state) > 3 and len(game_state[3]) > 5 else '0'
|
||||
self.logger.debug(f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})")
|
||||
inning = int(inning_raw) if inning_raw != '' else 1
|
||||
self.logger.debug(f" ✅ Parsed inning: {inning}")
|
||||
except (ValueError, IndexError) as e:
|
||||
self.logger.warning(f" ⚠️ Failed to parse home score: {e}")
|
||||
inning = 1
|
||||
|
||||
self.logger.debug(f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):")
|
||||
which_half = game_state[3][4] if len(game_state) > 3 and len(game_state[3]) > 4 else ''
|
||||
self.logger.debug(f" Which half value: '{which_half}'")
|
||||
|
||||
self.logger.debug(f"Parsed values - Away: {away_score}, Home: {home_score}, Which Half: '{which_half}'")
|
||||
# Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4)
|
||||
self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):")
|
||||
try:
|
||||
outs_raw = all_data[4][4] if len(all_data) > 4 and len(all_data[4]) > 4 else '0'
|
||||
self.logger.debug(f" Raw outs value: '{outs_raw}'")
|
||||
# Handle "2" or any number
|
||||
outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0
|
||||
self.logger.debug(f" ✅ Parsed outs: {outs}")
|
||||
except (ValueError, IndexError, AttributeError) as e:
|
||||
self.logger.warning(f" ⚠️ Failed to parse outs: {e}")
|
||||
outs = 0
|
||||
|
||||
# Extract runners (K11:L14 → offset in all_data)
|
||||
runners = [
|
||||
all_data[9][9:11] if len(all_data) > 9 else [],
|
||||
all_data[10][9:11] if len(all_data) > 10 else [],
|
||||
all_data[11][9:11] if len(all_data) > 11 else [],
|
||||
all_data[12][9:11] if len(all_data) > 12 else []
|
||||
# Extract matchup information - K3:O6 (rows 3-6, columns K-O)
|
||||
# In all_data: rows 1-4 (sheet rows 3-6), columns 9-13 (sheet columns K-O)
|
||||
self.logger.debug(f"⚔️ Extracting matchups from K3:O6:")
|
||||
matchups = [
|
||||
all_data[1][9:14] if len(all_data) > 1 else [], # Pitcher (row 3)
|
||||
all_data[2][9:14] if len(all_data) > 2 else [], # Batter (row 4)
|
||||
all_data[3][9:14] if len(all_data) > 3 else [], # On Deck (row 5)
|
||||
all_data[4][9:14] if len(all_data) > 4 else [], # In Hole (row 6)
|
||||
]
|
||||
|
||||
# Extract matchups if full_length (M11:N14 → offset in all_data)
|
||||
matchups = []
|
||||
if full_length:
|
||||
matchups = [
|
||||
all_data[9][11:13] if len(all_data) > 9 else [],
|
||||
all_data[10][11:13] if len(all_data) > 10 else [],
|
||||
all_data[11][11:13] if len(all_data) > 11 else [],
|
||||
all_data[12][11:13] if len(all_data) > 12 else []
|
||||
]
|
||||
# Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats
|
||||
pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else ''
|
||||
pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else ''
|
||||
pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else ''
|
||||
self.logger.debug(f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}")
|
||||
|
||||
# Extract summary if full_length (Q11:R14 → offset in all_data)
|
||||
# Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position
|
||||
batter_name = matchups[1][0] if len(matchups[1]) > 0 else ''
|
||||
batter_url = matchups[1][1] if len(matchups[1]) > 1 else ''
|
||||
batter_stats = matchups[1][2] if len(matchups[1]) > 2 else ''
|
||||
self.logger.debug(f" Batter: {batter_name} | {batter_stats} | {batter_url}")
|
||||
|
||||
# On Deck: matchups[2][0]=name
|
||||
on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else ''
|
||||
on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else ''
|
||||
self.logger.debug(f" On Deck: {on_deck_name}")
|
||||
|
||||
# In Hole: matchups[3][0]=name
|
||||
in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else ''
|
||||
in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else ''
|
||||
self.logger.debug(f" In Hole: {in_hole_name}")
|
||||
|
||||
# Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D)
|
||||
self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):")
|
||||
try:
|
||||
win_pct_raw = all_data[6][2] if len(all_data) > 6 and len(all_data[6]) > 2 else '50%'
|
||||
self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'")
|
||||
# Remove % sign if present and convert to float
|
||||
win_pct_str = str(win_pct_raw).replace('%', '').strip()
|
||||
win_percentage = float(win_pct_str) if win_pct_str else 50.0
|
||||
self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%")
|
||||
except (ValueError, IndexError, AttributeError) as e:
|
||||
self.logger.warning(f" ⚠️ Failed to parse win percentage: {e}")
|
||||
win_percentage = 50.0
|
||||
|
||||
self.logger.debug(f"📊 Final parsed values:")
|
||||
self.logger.debug(f" Away team {away_team_id}: {away_score}")
|
||||
self.logger.debug(f" Home team {home_team_id}: {home_score}")
|
||||
self.logger.debug(f" Game state: '{which_half}'")
|
||||
self.logger.debug(f" Outs: {outs}")
|
||||
self.logger.debug(f" Win percentage: {win_percentage}%")
|
||||
self.logger.debug(f" Current matchup: {batter_name} vs {pitcher_name}")
|
||||
self.logger.debug(f" Status: {'FINAL' if is_final else 'IN PROGRESS'}")
|
||||
|
||||
# Extract runners - K11:L14 (rows 11-14, columns K-L)
|
||||
# In all_data: rows 9-12 (sheet rows 11-14), columns 9-10 (sheet columns K-L)
|
||||
# runners[0] = Catcher, runners[1] = On First, runners[2] = On Second, runners[3] = On Third
|
||||
# Each runner is [name, URL]
|
||||
self.logger.debug(f"🏃 Extracting runners from K11:L14:")
|
||||
runners = [
|
||||
all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11)
|
||||
all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12)
|
||||
all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13)
|
||||
all_data[12][9:11] if len(all_data) > 12 else [] # On Third (row 14)
|
||||
]
|
||||
self.logger.debug(f" Catcher: {runners[0]}")
|
||||
self.logger.debug(f" On First: {runners[1]}")
|
||||
self.logger.debug(f" On Second: {runners[2]}")
|
||||
self.logger.debug(f" On Third: {runners[3]}")
|
||||
|
||||
# Extract summary if full_length (R3:S20 for inning-by-inning plays)
|
||||
# This is the "Play by Play" summary section
|
||||
summary = []
|
||||
if full_length:
|
||||
summary = [
|
||||
all_data[9][15:17] if len(all_data) > 9 else [],
|
||||
all_data[10][15:17] if len(all_data) > 10 else [],
|
||||
all_data[11][15:17] if len(all_data) > 11 else [],
|
||||
all_data[12][15:17] if len(all_data) > 12 else []
|
||||
]
|
||||
self.logger.debug(f"📋 Extracting summary from R3:S20:")
|
||||
# R3:S20 is columns 16-17 (R-S), rows 3-20 (indices 1-18)
|
||||
for row_idx in range(1, min(19, len(all_data))):
|
||||
if len(all_data[row_idx]) > 17:
|
||||
play_line = [all_data[row_idx][16], all_data[row_idx][17]]
|
||||
if play_line[0] or play_line[1]: # Only add if not empty
|
||||
summary.append(play_line)
|
||||
self.logger.debug(f" Found {len(summary)} summary lines")
|
||||
else:
|
||||
self.logger.debug(f"📝 Skipping summary (compact view)")
|
||||
|
||||
return ScorebugData({
|
||||
self.logger.debug(f"✅ Scorebug data extraction complete!")
|
||||
|
||||
scorebug_data = ScorebugData({
|
||||
'away_team_id': away_team_id,
|
||||
'home_team_id': home_team_id,
|
||||
'header': header,
|
||||
'away_score': away_score,
|
||||
'home_score': home_score,
|
||||
'which_half': which_half,
|
||||
'inning': inning,
|
||||
'is_final': is_final,
|
||||
'runners': runners,
|
||||
'matchups': matchups,
|
||||
'summary': summary
|
||||
'outs': outs,
|
||||
'win_percentage': win_percentage,
|
||||
'pitcher_name': pitcher_name,
|
||||
'pitcher_url': pitcher_url,
|
||||
'pitcher_stats': pitcher_stats,
|
||||
'batter_name': batter_name,
|
||||
'batter_url': batter_url,
|
||||
'batter_stats': batter_stats,
|
||||
'on_deck_name': on_deck_name,
|
||||
'in_hole_name': in_hole_name,
|
||||
'runners': runners, # [Catcher, On First, On Second, On Third], each is [name, URL]
|
||||
'summary': summary # Play-by-play lines from R3:S20
|
||||
})
|
||||
|
||||
self.logger.debug(f"🎯 Created ScorebugData object:")
|
||||
self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}")
|
||||
self.logger.debug(f" Home Team ID: {scorebug_data.home_team_id}")
|
||||
self.logger.debug(f" Header: '{scorebug_data.header}'")
|
||||
self.logger.debug(f" Score Line: {scorebug_data.score_line}")
|
||||
self.logger.debug(f" Which Half: '{scorebug_data.which_half}'")
|
||||
self.logger.debug(f" Is Final: {scorebug_data.is_final}")
|
||||
self.logger.debug(f" Is Active: {scorebug_data.is_active}")
|
||||
|
||||
return scorebug_data
|
||||
|
||||
except pygsheets.WorksheetNotFound:
|
||||
self.logger.error(f"Scorebug tab not found in scorecard")
|
||||
raise SheetsException("Scorebug tab not found. Is this a valid scorecard?")
|
||||
|
||||
@ -10,6 +10,8 @@ from discord.ext import tasks, commands
|
||||
|
||||
from models.team import Team
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.scorebug_helpers import create_scorebug_embed
|
||||
from utils.discord_helpers import set_channel_visibility
|
||||
from services.scorebug_service import ScorebugData, ScorebugService
|
||||
from services.team_service import team_service
|
||||
from commands.gameplay.scorecard_tracker import ScorecardTracker
|
||||
@ -92,8 +94,13 @@ class LiveScorebugTracker:
|
||||
all_scorecards = self.scorecard_tracker.get_all_scorecards()
|
||||
|
||||
if not all_scorecards:
|
||||
# No active scorebugs - clear the channel
|
||||
# No active scorebugs - clear the channel and hide it
|
||||
await self._clear_live_scores_channel(live_scores_channel)
|
||||
await set_channel_visibility(
|
||||
live_scores_channel,
|
||||
visible=False,
|
||||
reason="No active games"
|
||||
)
|
||||
return
|
||||
|
||||
# Read all scorebugs and create embeds
|
||||
@ -114,11 +121,12 @@ class LiveScorebugTracker:
|
||||
if away_team is None or home_team is None:
|
||||
raise ValueError(f'Error looking up teams in scorecard; IDs provided: {scorebug_data.away_team_id} & {scorebug_data.home_team_id}')
|
||||
|
||||
# Create compact embed
|
||||
embed = await self._create_compact_scorebug_embed(
|
||||
# Create compact embed using shared utility
|
||||
embed = create_scorebug_embed(
|
||||
scorebug_data,
|
||||
away_team,
|
||||
home_team
|
||||
home_team,
|
||||
full_length=False # Compact view for live channel
|
||||
)
|
||||
|
||||
active_scorebugs.append(embed)
|
||||
@ -140,66 +148,20 @@ class LiveScorebugTracker:
|
||||
|
||||
# Update live scores channel
|
||||
if active_scorebugs:
|
||||
await set_channel_visibility(
|
||||
live_scores_channel,
|
||||
visible=True,
|
||||
reason="Active games in progress"
|
||||
)
|
||||
await self._post_scorebugs_to_channel(live_scores_channel, active_scorebugs)
|
||||
else:
|
||||
# All games finished - clear the channel
|
||||
# All games finished - clear the channel and hide it
|
||||
await self._clear_live_scores_channel(live_scores_channel)
|
||||
|
||||
async def _create_compact_scorebug_embed(
|
||||
self,
|
||||
scorebug_data,
|
||||
away_team: Team,
|
||||
home_team: Team
|
||||
) -> discord.Embed:
|
||||
"""
|
||||
Create a compact scorebug embed for the live channel.
|
||||
|
||||
Args:
|
||||
scorebug_data: ScorebugData object
|
||||
away_team: Away team object (optional)
|
||||
home_team: Home team object (optional)
|
||||
|
||||
Returns:
|
||||
Discord embed with compact scorebug
|
||||
"""
|
||||
# Determine winning team for embed color
|
||||
if scorebug_data.away_score > scorebug_data.home_score and away_team:
|
||||
embed_color = away_team.get_color_int()
|
||||
elif scorebug_data.home_score > scorebug_data.away_score and home_team:
|
||||
embed_color = home_team.get_color_int()
|
||||
else:
|
||||
embed_color = EmbedColors.INFO
|
||||
|
||||
# Create compact embed
|
||||
embed = discord.Embed(
|
||||
title=scorebug_data.header,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
# Add score
|
||||
away_abbrev = away_team.abbrev if away_team else "AWAY"
|
||||
home_abbrev = home_team.abbrev if home_team else "HOME"
|
||||
|
||||
score_text = (
|
||||
f"```\n"
|
||||
f"{away_abbrev:<4} {scorebug_data.away_score:>2}\n"
|
||||
f"{home_abbrev:<4} {scorebug_data.home_score:>2}\n"
|
||||
f"```"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Score",
|
||||
value=score_text,
|
||||
inline=True
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Status",
|
||||
value=f"**{scorebug_data.which_half}**",
|
||||
inline=True
|
||||
)
|
||||
|
||||
return embed
|
||||
await set_channel_visibility(
|
||||
live_scores_channel,
|
||||
visible=False,
|
||||
reason="No active games"
|
||||
)
|
||||
|
||||
async def _post_scorebugs_to_channel(
|
||||
self,
|
||||
|
||||
@ -245,12 +245,13 @@ class TestInjuryService:
|
||||
"""Test clearing an injury."""
|
||||
with patch('api.client.get_config', return_value=mock_config):
|
||||
with aioresponses() as m:
|
||||
# Mock the PATCH request (note: patch sends data in body, not URL)
|
||||
# Mock the PATCH request - API expects is_active as query parameter
|
||||
# Note: Python's str(False) converts to "False" (capital F)
|
||||
cleared_data = sample_injury_data.copy()
|
||||
cleared_data['is_active'] = False
|
||||
|
||||
m.patch(
|
||||
'https://api.example.com/v3/injuries/1',
|
||||
'https://api.example.com/v3/injuries/1?is_active=False',
|
||||
payload=cleared_data
|
||||
)
|
||||
|
||||
@ -263,8 +264,9 @@ class TestInjuryService:
|
||||
"""Test clearing injury when it fails."""
|
||||
with patch('api.client.get_config', return_value=mock_config):
|
||||
with aioresponses() as m:
|
||||
# Note: Python's str(False) converts to "False" (capital F)
|
||||
m.patch(
|
||||
'https://api.example.com/v3/injuries/1',
|
||||
'https://api.example.com/v3/injuries/1?is_active=False',
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@ -119,3 +119,55 @@ def format_key_plays(
|
||||
key_plays_text += f"{play_description}\n"
|
||||
|
||||
return key_plays_text
|
||||
|
||||
|
||||
async def set_channel_visibility(
|
||||
channel: discord.TextChannel,
|
||||
visible: bool,
|
||||
reason: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Set channel visibility for @everyone.
|
||||
|
||||
The bot's permissions are based on its role, not @everyone, so the bot
|
||||
will retain access even when @everyone view permission is removed.
|
||||
|
||||
Args:
|
||||
channel: Discord text channel to modify
|
||||
visible: If True, grant @everyone view permission; if False, deny it
|
||||
reason: Optional reason for audit log
|
||||
|
||||
Returns:
|
||||
True if permissions updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
guild = channel.guild
|
||||
everyone_role = guild.default_role
|
||||
|
||||
if visible:
|
||||
# Grant @everyone permission to view channel
|
||||
default_reason = "Channel made visible to all members"
|
||||
await channel.set_permissions(
|
||||
everyone_role,
|
||||
view_channel=True,
|
||||
reason=reason or default_reason
|
||||
)
|
||||
logger.info(f"Set #{channel.name} to VISIBLE for @everyone")
|
||||
else:
|
||||
# Remove @everyone view permission
|
||||
default_reason = "Channel hidden from members"
|
||||
await channel.set_permissions(
|
||||
everyone_role,
|
||||
view_channel=False,
|
||||
reason=reason or default_reason
|
||||
)
|
||||
logger.info(f"Set #{channel.name} to HIDDEN for @everyone")
|
||||
|
||||
return True
|
||||
|
||||
except discord.Forbidden:
|
||||
logger.error(f"Missing permissions to modify #{channel.name} permissions")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting channel visibility for #{channel.name}: {e}")
|
||||
return False
|
||||
|
||||
207
utils/scorebug_helpers.py
Normal file
207
utils/scorebug_helpers.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""
|
||||
Scorebug Display Helpers
|
||||
|
||||
Utility functions for formatting and displaying scorebug information.
|
||||
"""
|
||||
import discord
|
||||
from typing import Optional
|
||||
|
||||
from views.embeds import EmbedColors
|
||||
|
||||
|
||||
def create_scorebug_embed(
|
||||
scorebug_data,
|
||||
away_team,
|
||||
home_team,
|
||||
full_length: bool = True
|
||||
) -> discord.Embed:
|
||||
"""
|
||||
Create a rich embed from scorebug data.
|
||||
|
||||
Args:
|
||||
scorebug_data: ScorebugData object with game information
|
||||
away_team: Away team object (optional)
|
||||
home_team: Home team object (optional)
|
||||
full_length: If True, includes pitcher/batter/runners/summary; if False, compact view
|
||||
|
||||
Returns:
|
||||
Discord embed with scorebug information
|
||||
"""
|
||||
# Determine embed color based on win probability (not score!)
|
||||
# This creates a fun twist where the favored team's color shows,
|
||||
# even if they're currently losing
|
||||
if scorebug_data.win_percentage > 50 and home_team:
|
||||
embed_color = home_team.get_color_int() # Home team favored
|
||||
elif scorebug_data.win_percentage < 50 and away_team:
|
||||
embed_color = away_team.get_color_int() # Away team favored
|
||||
else:
|
||||
embed_color = EmbedColors.INFO # Even game (50/50)
|
||||
|
||||
# Create embed with header as title
|
||||
embed = discord.Embed(
|
||||
title=scorebug_data.header,
|
||||
color=embed_color
|
||||
)
|
||||
|
||||
# Get team abbreviations for use throughout
|
||||
away_abbrev = away_team.abbrev if away_team else "AWAY"
|
||||
home_abbrev = home_team.abbrev if home_team else "HOME"
|
||||
|
||||
# Create ASCII scorebug with bases visualization
|
||||
occupied = '●'
|
||||
unoccupied = '○'
|
||||
|
||||
# runners[0]=Catcher, [1]=On First, [2]=On Second, [3]=On Third
|
||||
first_base = unoccupied if not scorebug_data.runners[1] or not scorebug_data.runners[1][0] else occupied
|
||||
second_base = unoccupied if not scorebug_data.runners[2] or not scorebug_data.runners[2][0] else occupied
|
||||
third_base = unoccupied if not scorebug_data.runners[3] or not scorebug_data.runners[3][0] else occupied
|
||||
half = '▲' if scorebug_data.which_half == 'Top' else '▼'
|
||||
|
||||
if not scorebug_data.is_final:
|
||||
inning = f'{half} {scorebug_data.inning}'
|
||||
outs = f'{scorebug_data.outs} Out{"s" if scorebug_data.outs != 1 else ""}'
|
||||
else:
|
||||
# Final inning display
|
||||
final_inning = scorebug_data.inning if scorebug_data.which_half == "Bot" else scorebug_data.inning - 1
|
||||
inning = f'F/{final_inning}'
|
||||
outs = ''
|
||||
|
||||
game_state_text = (
|
||||
f'```\n'
|
||||
f'{away_abbrev: ^4}{scorebug_data.away_score: ^3} {second_base}{inning: >10}\n'
|
||||
f'{home_abbrev: ^4}{scorebug_data.home_score: ^3} {third_base} {first_base}{outs: >8}\n'
|
||||
f'```'
|
||||
)
|
||||
|
||||
# Add win probability bar
|
||||
embed.add_field(
|
||||
name='Win Probability',
|
||||
value=create_team_progress_bar(
|
||||
scorebug_data.win_percentage,
|
||||
away_abbrev,
|
||||
home_abbrev
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add game state
|
||||
embed.add_field(
|
||||
name='Game State',
|
||||
value=game_state_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
# If not full_length, return compact version
|
||||
if not full_length:
|
||||
return embed
|
||||
|
||||
# Full length - add pitcher and batter info
|
||||
if scorebug_data.pitcher_name:
|
||||
embed.add_field(
|
||||
name='Pitcher',
|
||||
value=f'[{scorebug_data.pitcher_name}]({scorebug_data.pitcher_url})\n{scorebug_data.pitcher_stats}',
|
||||
inline=True
|
||||
)
|
||||
|
||||
if scorebug_data.batter_name:
|
||||
embed.add_field(
|
||||
name='Batter',
|
||||
value=f'[{scorebug_data.batter_name}]({scorebug_data.batter_url})\n{scorebug_data.batter_stats}',
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add baserunners if present
|
||||
on_first = scorebug_data.runners[1] if scorebug_data.runners[1] else ''
|
||||
on_second = scorebug_data.runners[2] if scorebug_data.runners[2] else ''
|
||||
on_third = scorebug_data.runners[3] if scorebug_data.runners[3] else ''
|
||||
have_baserunners = len(on_first[0]) + len(on_second[0]) + len(on_third[0]) > 0
|
||||
|
||||
if have_baserunners > 0:
|
||||
br_string = ''
|
||||
if len(on_first) > 0:
|
||||
br_string += f'On First: [{on_first[0]}]({on_first[1]})\n'
|
||||
if len(on_second) > 0:
|
||||
br_string += f'On Second: [{on_second[0]}]({on_second[1]})\n'
|
||||
if len(on_third) > 0:
|
||||
br_string += f'On Third: [{on_third[0]}]({on_third[1]})\n'
|
||||
|
||||
embed.add_field(name=' ', value=' ', inline=False) # Spacer
|
||||
embed.add_field(
|
||||
name='Baserunners',
|
||||
value=br_string,
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add catcher
|
||||
if scorebug_data.runners[0] and scorebug_data.runners[0][0]:
|
||||
embed.add_field(
|
||||
name='Catcher',
|
||||
value=f'[{scorebug_data.runners[0][0]}]({scorebug_data.runners[0][1]})',
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Add inning summary if not final
|
||||
if not scorebug_data.is_final and scorebug_data.summary:
|
||||
i_string = ''
|
||||
for line in scorebug_data.summary:
|
||||
if line and len(line) >= 2 and line[0]:
|
||||
i_string += f'- Play {line[0]}: {line[1]}\n'
|
||||
|
||||
if i_string and "IFERROR" not in i_string:
|
||||
embed.add_field(
|
||||
name='Inning Summary',
|
||||
value=i_string,
|
||||
inline=False
|
||||
)
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
def create_team_progress_bar(
|
||||
win_percentage: float,
|
||||
away_abbrev: str,
|
||||
home_abbrev: str,
|
||||
length: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Create a proportional progress bar showing each team's win probability.
|
||||
|
||||
Args:
|
||||
win_percentage: Home team's win percentage (0-100)
|
||||
away_abbrev: Away team abbreviation (e.g., "POR")
|
||||
home_abbrev: Home team abbreviation (e.g., "WV")
|
||||
length: Total bar length in blocks (default 10)
|
||||
|
||||
Returns:
|
||||
Formatted bar with dark blocks (▓) weighted toward winning team.
|
||||
Arrow extends from the side with the advantage.
|
||||
Examples:
|
||||
Home winning: "POR ░▓▓▓▓▓▓▓▓▓► WV 95.0%"
|
||||
Away winning: "POR ◄▓▓▓▓▓▓▓░░░ WV 30.0%"
|
||||
Even game: "POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%"
|
||||
"""
|
||||
# Calculate blocks for each team (home team's percentage)
|
||||
home_blocks = int((win_percentage / 100) * length)
|
||||
away_blocks = length - home_blocks
|
||||
|
||||
if win_percentage > 50:
|
||||
# Home team (right side) is winning
|
||||
away_char = '░' # Light blocks for losing team
|
||||
home_char = '▓' # Dark blocks for winning team
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrow extends from right side
|
||||
return f'{away_abbrev} {bar}► {home_abbrev} {win_percentage:.1f}%'
|
||||
elif win_percentage < 50:
|
||||
# Away team (left side) is winning
|
||||
away_char = '▓' # Dark blocks for winning team
|
||||
home_char = '░' # Light blocks for losing team
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrow extends from left side
|
||||
return f'{away_abbrev} ◄{bar} {home_abbrev} {win_percentage:.1f}%'
|
||||
else:
|
||||
# Even game (50/50)
|
||||
away_char = '▓'
|
||||
home_char = '▓'
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrows on both sides for balanced display
|
||||
return f'{away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%'
|
||||
Loading…
Reference in New Issue
Block a user