Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions features/tourney/matcherino.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
# Fuzzy match: ratio >= this → accept (minor typos). Below → team name mismatch warning.
TEAM_NAME_SIMILARITY_THRESHOLD = 0.60

# Per-tourney cache of bracket team names (teams don't change mid-tourney).
# Keyed by bounty_id → list of {"name": str, "entrant_id": int}.
_bracket_teams_cache: dict[str, list[dict]] = {}


def clear_bracket_teams_cache():
"""Clear cached team lists. Call when a tourney session ends."""
_bracket_teams_cache.clear()


def _normalize_for_compare(s: str) -> str:
"""Normalize string for similarity: strip, lower, collapse whitespace."""
Expand Down Expand Up @@ -76,6 +85,129 @@ def _team_name_matches(
return matches, best_ratio, best_name


def find_match_by_team_name(url: str, topic_team_name: str) -> dict:
"""
Fallback when no valid match number is provided: fuzzy-match the team
name against all bracket entrants, then locate their current match.

Returns dict with:
- status: "found" | "no_match" (or "error" key on failure)
- match_number: visual match number (if found)
- matched_team: bracket team name (if found)
- ratio: similarity ratio (if found)
"""
id_match = re.search(r"tournaments/(\d+)", url)
if not id_match:
return {"error": "Invalid Matcherino URL."}

topic_n = _normalize_for_compare(topic_team_name)
if not topic_n:
return {"error": "No team name provided for lookup."}

bounty_id = id_match.group(1)
api_url = f"https://api.matcherino.com/__api/brackets?bountyId={bounty_id}&id=0&isAdmin=false"

try:
response = session.get(api_url, timeout=10)
if response.status_code != 200:
return {"error": f"Failed to fetch API. Status: {response.status_code}"}
data = response.json()
except requests.exceptions.RequestException as e:
return {"error": f"Matcherino connection failed: {str(e)}"}
except Exception as e:
return {"error": f"Parsing failed: {str(e)}"}

try:
bracket_data = data["body"][0]
raw_matches = bracket_data.get("matches", [])
raw_entrants = bracket_data.get("entrants", [])

if not raw_matches:
return {"error": "Bracket is empty."}

# Build entrant map (id → name)
entrant_map: dict[int, str] = {}
for e in raw_entrants:
e_id = e.get("id")
name = (
e.get("name")
or (e.get("team") and e["team"].get("name"))
or "Unknown Team"
)
entrant_map[e_id] = name

# Cache team list per tournament (teams don't change mid-tourney)
if bounty_id not in _bracket_teams_cache:
_bracket_teams_cache[bounty_id] = [
{"name": name, "entrant_id": eid}
for eid, name in entrant_map.items()
if eid > 1 and name.upper() not in ("TBD", "BYE", "UNKNOWN TEAM")
]

# Fuzzy match against cached teams
best_ratio = 0.0
best_team_name: str | None = None
best_entrant_id: int | None = None
for team in _bracket_teams_cache[bounty_id]:
team_n = _normalize_for_compare(team["name"])
ratio = difflib.SequenceMatcher(None, topic_n, team_n).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_team_name = team["name"]
best_entrant_id = team["entrant_id"]

if best_ratio < TEAM_NAME_SIMILARITY_THRESHOLD:
return {
"status": "no_match",
"best_ratio": best_ratio,
"best_team": best_team_name,
}

# Team found — locate their current visual match number
visible_matches = []
for m in raw_matches:
e_a = m.get("entrantA", {}).get("entrantId", 0)
e_b = m.get("entrantB", {}).get("entrantId", 0)
if e_a != 1 and e_b != 1:
visible_matches.append(m)

visible_matches.sort(key=lambda x: x.get("matchNum", 9999))

# Collect all matches this team participates in
team_matches: list[tuple[int, dict]] = []
for i, m in enumerate(visible_matches, start=1):
e_a = m.get("entrantA", {}).get("entrantId", 0)
e_b = m.get("entrantB", {}).get("entrantId", 0)
if best_entrant_id in (e_a, e_b):
team_matches.append((i, m))

if not team_matches:
return {
"status": "no_match",
"best_ratio": best_ratio,
"best_team": best_team_name,
}

# Prefer the latest non-closed match; fall back to last match overall
finished = ("closed", "completed", "complete", "done")
latest_active = None
for visual_num, m in team_matches:
if str(m.get("status", "")).lower() not in finished:
latest_active = (visual_num, m)

resolved_visual_num = latest_active[0] if latest_active else team_matches[-1][0]

return {
"status": "found",
"match_number": resolved_visual_num,
"matched_team": best_team_name,
"ratio": best_ratio,
}

except Exception as e:
return {"error": f"An unexpected error occurred: {e}"}


def fetch_ticket_context(
url: str, target_match_number: int, topic_team_name: str | None = None
) -> dict:
Expand Down
112 changes: 109 additions & 3 deletions features/tourney/tourney_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
fetch_ticket_context,
fetch_payout_report,
fetch_bracket_progress,
find_match_by_team_name,
clear_bracket_teams_cache,
)

from database.mongo import (
Expand Down Expand Up @@ -729,15 +731,56 @@ async def match_refresher_task(self):
if team_res:
topic_team_name = team_res.group(1).strip() or None

# Fallback: no valid match number → resolve from team name
if match_num is None:
continue
if not topic_team_name:
continue
lookup = find_match_by_team_name(bracket_url, topic_team_name)
if lookup.get("status") != "found":
continue
match_num = lookup["match_number"]
# Persist resolved match number in topic so future refreshes skip the lookup
try:
updated_topic = re.sub(
r"bracket:[^|]*",
f"bracket:{match_num}",
channel.topic,
)
await channel.edit(topic=updated_topic)
except Exception:
pass

# 4. Fetch Fresh Match Data (with topic team for fuzzy mismatch check)
data = fetch_ticket_context(
bracket_url, match_num, topic_team_name=topic_team_name
)
if data.get("status") != "success":
continue
# Match number not in bracket — try team name fallback
if topic_team_name:
lookup = find_match_by_team_name(bracket_url, topic_team_name)
if lookup.get("status") == "found":
match_num = lookup["match_number"]
data = fetch_ticket_context(
bracket_url, match_num, topic_team_name=topic_team_name
)
if data.get("status") == "success":
# Persist corrected match number in topic
if channel.topic:
try:
updated_topic = re.sub(
r"bracket:[^|]*",
f"bracket:{match_num}",
channel.topic,
)
await channel.edit(topic=updated_topic)
except Exception:
pass
else:
continue
else:
continue
else:
continue

# 5. Construct the Live Embed with Relative Timestamp
now_ts = int(discord.utils.utcnow().timestamp())
Expand Down Expand Up @@ -773,7 +816,69 @@ async def match_refresher_task(self):
inline=True,
)

# For mismatches, keep the warning simple.
# Mismatch: team name doesn't match either team — try to auto-correct
if is_mismatch and topic_team_name:
lookup = find_match_by_team_name(bracket_url, topic_team_name)
if lookup.get("status") == "found":
resolved_num = lookup["match_number"]
data = fetch_ticket_context(
bracket_url, resolved_num, topic_team_name=topic_team_name
)
if data.get("status") == "success":
match_num = resolved_num
is_mismatch = data.get("team_name_mismatch", False)
best_match_team = data.get("team_name_best_match")

# Update topic with corrected match number
if channel.topic:
try:
updated_topic = re.sub(
r"bracket:[^|]*",
f"bracket:{resolved_num}",
channel.topic,
)
await channel.edit(topic=updated_topic)
except Exception:
pass

# Rebuild embed with corrected data
now_ts = int(discord.utils.utcnow().timestamp())
embed = discord.Embed(
title=f"📊 Live Match Update: Match #{resolved_num}",
description=f"**Last Update:** <t:{now_ts}:R>",
color=discord.Color.red()
if is_mismatch
else discord.Color.gold(),
)
embed.add_field(
name="Match Status",
value=f"`{data['match_status'].upper()}`",
inline=True,
)
embed.add_field(name="\u200b", value="\u200b", inline=True)
embed.add_field(name="\u200b", value="\u200b", inline=True)

team_a, team_b = data["team_a"], data["team_b"]
p_a = (
"\n".join([f"• {p}" for p in team_a["players"]])
or "• *No players*"
)
p_b = (
"\n".join([f"• {p}" for p in team_b["players"]])
or "• *No players*"
)
embed.add_field(
name=f"🔵 {team_a['name']} ({team_a['score']})",
value=f"**Roster:**\n{p_a}",
inline=True,
)
embed.add_field(name="⚔️", value="\u200b", inline=True)
embed.add_field(
name=f"🔴 {team_b['name']} ({team_b['score']})",
value=f"**Roster:**\n{p_b}",
inline=True,
)

if is_mismatch:
warning_text = "The team name in this ticket does not closely match either team in the bracket for this match."
if topic_team_name:
Expand Down Expand Up @@ -1474,6 +1579,7 @@ async def _retry_winner_post():

# 4. Close Session in DB
await end_tourney_session(session["_id"])
clear_bracket_teams_cache()
# ------------------------------

await unlock_command(ctx)
Expand Down
Loading
Loading