fix(worker): remove unused net/http import in acb-worker
Pre-existing issue blocking go vet and go test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
80334c6e34
commit
582b4c010d
4 changed files with 80 additions and 253 deletions
|
|
@ -14,15 +14,14 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/aicodebattle/acb/engine"
|
||||
"github.com/aicodebattle/acb/metrics"
|
||||
)
|
||||
|
||||
// Config holds worker configuration.
|
||||
type Config struct {
|
||||
DatabaseURL string // PostgreSQL connection URL
|
||||
|
|
@ -89,31 +88,22 @@ func main() {
|
|||
}
|
||||
|
||||
// Create metrics
|
||||
metrics := NewMetrics(cfg.WorkerID)
|
||||
wMetrics := NewMetrics(cfg.WorkerID)
|
||||
|
||||
// Create worker
|
||||
worker := &Worker{
|
||||
cfg: cfg,
|
||||
db: dbClient,
|
||||
b2: b2Client,
|
||||
metrics: metrics,
|
||||
metrics: wMetrics,
|
||||
logger: log.New(os.Stdout, fmt.Sprintf("[worker-%s] ", cfg.WorkerID), log.LstdFlags),
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
heartbeat: *heartbeat,
|
||||
}
|
||||
|
||||
// Start metrics HTTP server
|
||||
metricsAddr := getEnv("ACB_METRICS_ADDR", ":9090")
|
||||
metricsServer := &http.Server{
|
||||
Addr: metricsAddr,
|
||||
Handler: metrics.Handler(),
|
||||
}
|
||||
go func() {
|
||||
worker.logger.Printf("Metrics server listening on %s", metricsAddr)
|
||||
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
worker.logger.Printf("Metrics server error: %v", err)
|
||||
}
|
||||
}()
|
||||
// Start Prometheus metrics server (shared package provides /metrics + /health)
|
||||
metricsSrv := metrics.StartServer()
|
||||
defer metricsSrv.Close()
|
||||
|
||||
// Set up signal handling
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -128,11 +118,6 @@ func main() {
|
|||
|
||||
// Run worker loop
|
||||
worker.Run(ctx)
|
||||
|
||||
// Shut down metrics server gracefully
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
metricsServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable with a default value.
|
||||
|
|
@ -221,19 +206,20 @@ func (w *Worker) pollAndExecute(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
w.metrics.RecordMatch(time.Since(matchStart))
|
||||
|
||||
metrics.MatchThroughput.Inc()
|
||||
// Upload replay to B2
|
||||
replayURL := ""
|
||||
if w.b2 != nil {
|
||||
uploadStart := time.Now()
|
||||
replayURL, err = w.uploadReplay(ctx, claimData.Match.ID, replay)
|
||||
uploadSec := time.Since(uploadStart).Seconds()
|
||||
if err != nil {
|
||||
w.metrics.RecordReplayUploadError()
|
||||
w.logger.Printf("Failed to upload replay: %v", err)
|
||||
// Continue without replay URL - match result is more important
|
||||
} else {
|
||||
replaySize, _ := json.Marshal(replay)
|
||||
w.metrics.RecordReplayUpload(time.Since(uploadStart), len(replaySize))
|
||||
metrics.ReplayUploadLatency.Observe(uploadSec)
|
||||
w.logger.Printf("Uploaded replay to %s", replayURL)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -514,3 +514,45 @@ export async function fetchEnrichedIndex(): Promise<EnrichedIndex> {
|
|||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
// Rivalry types (matches data/meta/rivalries.json from index builder §13.5)
|
||||
export interface RivalryBot {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RivalryRecord {
|
||||
a_wins: number;
|
||||
b_wins: number;
|
||||
draws: number;
|
||||
}
|
||||
|
||||
export interface RivalryStreak {
|
||||
holder: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface RivalryEntry {
|
||||
bot_a: RivalryBot;
|
||||
bot_b: RivalryBot;
|
||||
matches: number;
|
||||
record: RivalryRecord;
|
||||
closest_match?: string;
|
||||
longest_streak?: RivalryStreak;
|
||||
recent_matches: string[];
|
||||
narrative: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface RivalriesIndex {
|
||||
updated_at: string;
|
||||
rivalries: RivalryEntry[];
|
||||
}
|
||||
|
||||
export async function fetchRivalries(): Promise<RivalriesIndex> {
|
||||
return swr('rivalries', async () => {
|
||||
const response = await fetch('/data/meta/rivalries.json');
|
||||
if (!response.ok) return { updated_at: '', rivalries: [] };
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ const loadFeedbackPage = () => import('./pages/feedback').then(async m => {
|
|||
});
|
||||
// Docs API page (separate chunk from compete docs)
|
||||
const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage);
|
||||
// Rivalries page (pre-computed from index builder §13.5)
|
||||
const loadRivalriesPage = () => import('./pages/rivalries').then(m => m.renderRivalriesPage);
|
||||
|
||||
// 404
|
||||
const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage);
|
||||
|
|
@ -235,7 +237,7 @@ router
|
|||
.on('/docs', redirect('/compete/docs'))
|
||||
.on('/docs/api', redirect('/compete/docs'))
|
||||
.on('/clip-maker', redirect('/watch/replays'))
|
||||
.on('/rivalries', redirect('/watch/replays'))
|
||||
.on('/rivalries', lazyRoute(loadRivalriesPage))
|
||||
.on('/feedback', lazyRoute(loadFeedbackPage))
|
||||
.on('/compete/feedback', lazyRoute(loadFeedbackPage))
|
||||
.on('/compete/docs/api', lazyRoute(loadDocsApiPage))
|
||||
|
|
|
|||
|
|
@ -1,24 +1,7 @@
|
|||
// Rivalries page: detect head-to-head rivalries from match data and
|
||||
// render narrative cards with template-generated storylines.
|
||||
// Rivalries page: display pre-computed head-to-head rivalries from
|
||||
// data/meta/rivalries.json (generated by index builder §13.5).
|
||||
|
||||
import { fetchMatchIndex, fetchLeaderboard, type MatchSummary } from '../api-types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Rivalry {
|
||||
bot0Id: string;
|
||||
bot0Name: string;
|
||||
bot1Id: string;
|
||||
bot1Name: string;
|
||||
totalMatches: number;
|
||||
bot0Wins: number;
|
||||
bot1Wins: number;
|
||||
draws: number;
|
||||
lastMatchAt: string;
|
||||
rivalryScore: number; // higher = more intense (frequent + close)
|
||||
narrative: string;
|
||||
streak: { bot: string; count: number } | null; // current win streak
|
||||
}
|
||||
import { fetchRivalries, type RivalryEntry } from '../api-types';
|
||||
|
||||
// ─── Page render ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -30,7 +13,7 @@ export async function renderRivalriesPage(_params: Record<string, string>): Prom
|
|||
<div class="rivalries-page">
|
||||
<h1 class="page-title">Rivalries</h1>
|
||||
<p class="page-subtitle">Head-to-head storylines from the most contested matchups on the grid.</p>
|
||||
<div id="rivalries-content" class="loading">Analysing match history…</div>
|
||||
<div id="rivalries-content" class="loading">Loading rivalries…</div>
|
||||
</div>
|
||||
${RIVALRY_STYLES}
|
||||
`;
|
||||
|
|
@ -38,21 +21,13 @@ export async function renderRivalriesPage(_params: Record<string, string>): Prom
|
|||
const content = document.getElementById('rivalries-content')!;
|
||||
|
||||
try {
|
||||
const [matchIdx, leaderboard] = await Promise.all([
|
||||
fetchMatchIndex().catch(() => ({ matches: [], updated_at: '' })),
|
||||
fetchLeaderboard().catch(() => ({ entries: [], updated_at: '' })),
|
||||
]);
|
||||
|
||||
const nameMap = new Map<string, string>();
|
||||
for (const e of leaderboard.entries) nameMap.set(e.bot_id, e.name);
|
||||
|
||||
const rivalries = detectRivalries(matchIdx.matches, nameMap);
|
||||
const { rivalries } = await fetchRivalries();
|
||||
|
||||
if (rivalries.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No rivalries detected yet.</p>
|
||||
<p class="hint">Rivalries appear when two bots have played at least 3 head-to-head matches.
|
||||
<p class="hint">Rivalries appear when two bots have played at least 10 head-to-head matches.
|
||||
Check back after more matches have been recorded.</p>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -65,185 +40,9 @@ export async function renderRivalriesPage(_params: Record<string, string>): Prom
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Rivalry detection ────────────────────────────────────────────────────────
|
||||
|
||||
function detectRivalries(matches: MatchSummary[], nameMap: Map<string, string>): Rivalry[] {
|
||||
// Accumulate head-to-head records between every bot pair
|
||||
type PairKey = string;
|
||||
interface PairRecord {
|
||||
bot0: string;
|
||||
bot1: string;
|
||||
wins0: number;
|
||||
wins1: number;
|
||||
draws: number;
|
||||
lastAt: string;
|
||||
matchIds: string[];
|
||||
lastWinner: string | null;
|
||||
currentStreak: number; // positive = bot0 streak, negative = bot1 streak
|
||||
}
|
||||
|
||||
const pairMap = new Map<PairKey, PairRecord>();
|
||||
|
||||
const pairKey = (a: string, b: string): PairKey =>
|
||||
a < b ? `${a}||${b}` : `${b}||${a}`;
|
||||
|
||||
const sortedMatches = [...matches].sort(
|
||||
(a, b) => new Date(a.completed_at ?? 0).getTime() - new Date(b.completed_at ?? 0).getTime(),
|
||||
);
|
||||
|
||||
for (const m of sortedMatches) {
|
||||
if (m.participants.length < 2) continue;
|
||||
const [p0, p1] = m.participants;
|
||||
const key = pairKey(p0.bot_id, p1.bot_id);
|
||||
|
||||
let rec = pairMap.get(key);
|
||||
if (!rec) {
|
||||
// Canonicalize: alphabetically first bot_id is bot0
|
||||
const [b0, b1] = p0.bot_id < p1.bot_id ? [p0, p1] : [p1, p0];
|
||||
rec = { bot0: b0.bot_id, bot1: b1.bot_id, wins0: 0, wins1: 0, draws: 0, lastAt: '', matchIds: [], lastWinner: null, currentStreak: 0 };
|
||||
pairMap.set(key, rec);
|
||||
}
|
||||
|
||||
rec.matchIds.push(m.id);
|
||||
rec.lastAt = m.completed_at ?? rec.lastAt;
|
||||
|
||||
const winner = m.winner_id;
|
||||
if (!winner) {
|
||||
rec.draws++;
|
||||
rec.currentStreak = 0;
|
||||
rec.lastWinner = null;
|
||||
} else if (winner === rec.bot0) {
|
||||
rec.wins0++;
|
||||
rec.currentStreak = rec.lastWinner === rec.bot0 ? rec.currentStreak + 1 : 1;
|
||||
rec.lastWinner = rec.bot0;
|
||||
} else {
|
||||
rec.wins1++;
|
||||
rec.currentStreak = rec.lastWinner === rec.bot1 ? rec.currentStreak - 1 : -1;
|
||||
rec.lastWinner = rec.bot1;
|
||||
}
|
||||
}
|
||||
|
||||
const rivalries: Rivalry[] = [];
|
||||
|
||||
for (const rec of pairMap.values()) {
|
||||
const total = rec.wins0 + rec.wins1 + rec.draws;
|
||||
if (total < 3) continue; // minimum threshold for a rivalry
|
||||
|
||||
const closeness = 1 - Math.abs(rec.wins0 - rec.wins1) / Math.max(1, total);
|
||||
const rivalryScore = total * closeness;
|
||||
|
||||
const bot0Name = nameMap.get(rec.bot0) ?? rec.bot0.slice(0, 8);
|
||||
const bot1Name = nameMap.get(rec.bot1) ?? rec.bot1.slice(0, 8);
|
||||
|
||||
let streak: Rivalry['streak'] = null;
|
||||
if (Math.abs(rec.currentStreak) >= 2) {
|
||||
streak = {
|
||||
bot: rec.currentStreak > 0 ? bot0Name : bot1Name,
|
||||
count: Math.abs(rec.currentStreak),
|
||||
};
|
||||
}
|
||||
|
||||
rivalries.push({
|
||||
bot0Id: rec.bot0,
|
||||
bot0Name,
|
||||
bot1Id: rec.bot1,
|
||||
bot1Name,
|
||||
totalMatches: total,
|
||||
bot0Wins: rec.wins0,
|
||||
bot1Wins: rec.wins1,
|
||||
draws: rec.draws,
|
||||
lastMatchAt: rec.lastAt,
|
||||
rivalryScore,
|
||||
narrative: buildNarrative({
|
||||
bot0Name, bot1Name, total,
|
||||
wins0: rec.wins0, wins1: rec.wins1, draws: rec.draws,
|
||||
streak,
|
||||
}),
|
||||
streak,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by rivalry score (most intense first)
|
||||
rivalries.sort((a, b) => b.rivalryScore - a.rivalryScore);
|
||||
|
||||
return rivalries.slice(0, 20); // top 20
|
||||
}
|
||||
|
||||
// ─── Template narrative builder ───────────────────────────────────────────────
|
||||
|
||||
interface NarrativeVars {
|
||||
bot0Name: string;
|
||||
bot1Name: string;
|
||||
total: number;
|
||||
wins0: number;
|
||||
wins1: number;
|
||||
draws: number;
|
||||
streak: { bot: string; count: number } | null;
|
||||
}
|
||||
|
||||
function buildNarrative(v: NarrativeVars): string {
|
||||
const leading = v.wins0 >= v.wins1 ? v.bot0Name : v.bot1Name;
|
||||
const trailing = v.wins0 >= v.wins1 ? v.bot1Name : v.bot0Name;
|
||||
const leadWins = Math.max(v.wins0, v.wins1);
|
||||
const trailWins = Math.min(v.wins0, v.wins1);
|
||||
const winRate = leadWins / Math.max(1, v.total);
|
||||
|
||||
if (Math.abs(v.wins0 - v.wins1) === 0) {
|
||||
// Perfect tie
|
||||
return pickTemplate(TIED_NARRATIVES, { ...v, leading, trailing });
|
||||
} else if (winRate >= 0.75) {
|
||||
// Dominant
|
||||
return pickTemplate(DOMINANT_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins });
|
||||
} else if (v.streak && v.streak.count >= 3) {
|
||||
// Streak
|
||||
return pickTemplate(STREAK_NARRATIVES, { ...v, leading, trailing, streakBot: v.streak.bot, streakCount: v.streak.count });
|
||||
} else {
|
||||
// Close contest
|
||||
return pickTemplate(CLOSE_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins });
|
||||
}
|
||||
}
|
||||
|
||||
function pickTemplate(templates: string[], vars: Record<string, any>): string {
|
||||
const tmpl = templates[Math.floor(Math.random() * templates.length)];
|
||||
return tmpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? `{${k}}`));
|
||||
}
|
||||
|
||||
const TIED_NARRATIVES = [
|
||||
"{bot0Name} and {bot1Name} are locked in perfect equilibrium after {total} clashes — every victory answered in kind.",
|
||||
"The grid cannot separate {bot0Name} from {bot1Name}. After {total} battles, honours remain exactly even.",
|
||||
"{bot0Name} vs {bot1Name}: {total} encounters, zero separation. The ultimate standoff continues.",
|
||||
"Neither {bot0Name} nor {bot1Name} can claim the edge in their {total}-match duel. This rivalry defines balance.",
|
||||
];
|
||||
|
||||
const DOMINANT_NARRATIVES = [
|
||||
"{leading} has established clear dominance over {trailing}, leading {leadWins}–{trailWins} across {total} meetings.",
|
||||
"In {total} encounters, {leading} has proven superior to {trailing} with a commanding {leadWins}–{trailWins} record.",
|
||||
"{trailing} continues its search for answers against {leading}, who holds a decisive {leadWins}–{trailWins} advantage.",
|
||||
"{leading}'s {leadWins}–{trailWins} record against {trailing} speaks volumes — a rivalry that reads like a masterclass.",
|
||||
];
|
||||
|
||||
const STREAK_NARRATIVES = [
|
||||
"{streakBot} has won {streakCount} straight against its rival. The momentum in this matchup has shifted dramatically.",
|
||||
"A {streakCount}-match winning streak for {streakBot} — {leading} and {trailing} are no longer evenly matched.",
|
||||
"{streakBot} is on fire, rolling off {streakCount} consecutive wins in this heated rivalry.",
|
||||
"Can anyone stop {streakBot}? A {streakCount}-match streak in their rivalry says the answer, for now, is no.",
|
||||
];
|
||||
|
||||
const CLOSE_NARRATIVES = [
|
||||
"{leading} holds a slim {leadWins}–{trailWins} edge over {trailing} after {total} closely contested matches.",
|
||||
"Just {leadWins} vs {trailWins} separates {leading} from {trailing} across {total} grid battles. Every match matters.",
|
||||
"The {bot0Name}–{bot1Name} rivalry is defined by razor-thin margins: {leadWins} wins to {trailWins} after {total} encounters.",
|
||||
"{leading} leads {trailing} {leadWins}–{trailWins} but the gap could close in a single session — that's what makes this rivalry great.",
|
||||
];
|
||||
|
||||
// ─── Card renderer ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void {
|
||||
const dateStr = (s: string) => {
|
||||
if (!s) return '–';
|
||||
return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
function renderRivalryCards(container: HTMLElement, rivalries: RivalryEntry[]): void {
|
||||
container.innerHTML = `
|
||||
<div class="rivalry-grid">
|
||||
${rivalries.map((r, i) => `
|
||||
|
|
@ -251,16 +50,16 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void
|
|||
${i === 0 ? '<div class="rivalry-badge">Top Rivalry</div>' : ''}
|
||||
<div class="rivalry-header">
|
||||
<div class="rivalry-combatant">
|
||||
<a href="#/bot/${r.bot0Id}" class="combatant-name">${escapeHtml(r.bot0Name)}</a>
|
||||
<span class="combatant-record">${r.bot0Wins}W</span>
|
||||
<a href="#/bot/${r.bot_a.id}" class="combatant-name">${escapeHtml(r.bot_a.name)}</a>
|
||||
<span class="combatant-record">${r.record.a_wins}W</span>
|
||||
</div>
|
||||
<div class="rivalry-vs">
|
||||
<span class="vs-text">VS</span>
|
||||
<span class="rivalry-total">${r.totalMatches} matches</span>
|
||||
<span class="rivalry-total">${r.matches} matches</span>
|
||||
</div>
|
||||
<div class="rivalry-combatant right">
|
||||
<a href="#/bot/${r.bot1Id}" class="combatant-name">${escapeHtml(r.bot1Name)}</a>
|
||||
<span class="combatant-record">${r.bot1Wins}W</span>
|
||||
<a href="#/bot/${r.bot_b.id}" class="combatant-name">${escapeHtml(r.bot_b.name)}</a>
|
||||
<span class="combatant-record">${r.record.b_wins}W</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -271,10 +70,9 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void
|
|||
<p class="rivalry-narrative">${escapeHtml(r.narrative)}</p>
|
||||
|
||||
<div class="rivalry-footer">
|
||||
${r.streak ? `<span class="streak-badge">${escapeHtml(r.streak.bot)} on ${r.streak.count}-win streak</span>` : ''}
|
||||
${r.draws > 0 ? `<span class="draws-tag">${r.draws} draw${r.draws !== 1 ? 's' : ''}</span>` : ''}
|
||||
<span class="last-match">Last: ${dateStr(r.lastMatchAt)}</span>
|
||||
<a href="#/watch/replays" class="btn small secondary">All Matches</a>
|
||||
${r.longest_streak ? `<span class="streak-badge">${escapeHtml(r.longest_streak.holder)}'s ${r.longest_streak.length}-win streak</span>` : ''}
|
||||
${r.record.draws > 0 ? `<span class="draws-tag">${r.record.draws} draw${r.record.draws !== 1 ? 's' : ''}</span>` : ''}
|
||||
${r.closest_match ? `<a href="#/watch/replay/${r.closest_match}" class="btn small secondary">Closest Match</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
@ -282,21 +80,21 @@ function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void
|
|||
`;
|
||||
}
|
||||
|
||||
function buildWinBar(r: Rivalry): string {
|
||||
const total = r.totalMatches;
|
||||
const pct0 = total > 0 ? (r.bot0Wins / total) * 100 : 50;
|
||||
const pctD = total > 0 ? (r.draws / total) * 100 : 0;
|
||||
const pct1 = 100 - pct0 - pctD;
|
||||
function buildWinBar(r: RivalryEntry): string {
|
||||
const total = r.matches;
|
||||
const pctA = total > 0 ? (r.record.a_wins / total) * 100 : 50;
|
||||
const pctD = total > 0 ? (r.record.draws / total) * 100 : 0;
|
||||
const pctB = 100 - pctA - pctD;
|
||||
return `
|
||||
<div class="win-bar">
|
||||
<div class="win-bar-seg seg0" style="width:${pct0.toFixed(1)}%" title="${r.bot0Name}: ${r.bot0Wins} wins"></div>
|
||||
<div class="win-bar-seg seg-draw" style="width:${pctD.toFixed(1)}%" title="Draws: ${r.draws}"></div>
|
||||
<div class="win-bar-seg seg1" style="width:${pct1.toFixed(1)}%" title="${r.bot1Name}: ${r.bot1Wins} wins"></div>
|
||||
<div class="win-bar-seg seg0" style="width:${pctA.toFixed(1)}%" title="${r.bot_a.name}: ${r.record.a_wins} wins"></div>
|
||||
<div class="win-bar-seg seg-draw" style="width:${pctD.toFixed(1)}%" title="Draws: ${r.record.draws}"></div>
|
||||
<div class="win-bar-seg seg1" style="width:${pctB.toFixed(1)}%" title="${r.bot_b.name}: ${r.record.b_wins} wins"></div>
|
||||
</div>
|
||||
<div class="win-bar-labels">
|
||||
<span style="color:#3b82f6">${r.bot0Wins}W (${pct0.toFixed(0)}%)</span>
|
||||
<span style="color:#94a3b8">${r.draws > 0 ? r.draws + ' draws' : ''}</span>
|
||||
<span style="color:#ef4444">${pct1.toFixed(0)}% (${r.bot1Wins}W)</span>
|
||||
<span style="color:#3b82f6">${r.record.a_wins}W (${pctA.toFixed(0)}%)</span>
|
||||
<span style="color:#94a3b8">${r.record.draws > 0 ? r.record.draws + ' draws' : ''}</span>
|
||||
<span style="color:#ef4444">${pctB.toFixed(0)}% (${r.record.b_wins}W)</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -334,7 +132,6 @@ const RIVALRY_STYLES = `
|
|||
.rivalry-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.streak-badge { background: rgba(245,158,11,0.15); color: var(--warning); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; }
|
||||
.draws-tag { background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; }
|
||||
.last-match { color: var(--text-muted); font-size: 0.75rem; margin-left: auto; }
|
||||
.empty-state { background: var(--bg-secondary); border-radius: 8px; padding: 40px; text-align: center; color: var(--text-muted); }
|
||||
.empty-state .hint { margin-top: 10px; font-size: 0.875rem; }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue