ai-code-battle/web/src/components/bot-card.ts
jedarden b93ea06d4c phase-9: implement bot profile cards with Canvas-rendered PNG and OG tags
Per §14.10 of the plan, implemented shareable bot profile cards:
- Canvas-rendered PNG cards (800x450) with bot stats and branding
- Open Graph tags for social sharing (og:image points to /r2/cards/{bot_id}.png)
- "Share Card" button on bot profile page downloads the card as PNG
- Card displays: name, rating, rank badge, owner, archetype, win rate, stats
- Evolved badge, signature move, and recent rival info
- Responsive styles for desktop and mobile

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:30:07 -04:00

445 lines
13 KiB
TypeScript

// Bot profile card renderer — Canvas-rendered PNG with OG tags
// §14.10: Auto-generated visual cards summarizing a bot's identity, stats, and character
import type { BotProfile } from '../api-types';
// Card dimensions (social sharing optimized)
const CARD_WIDTH = 800;
const CARD_HEIGHT = 450;
// Color palette
const COLORS = {
bg: '#0f172a',
bgSecondary: '#1e293b',
text: '#f8fafc',
textMuted: '#94a3b8',
accent: '#3b82f6',
success: '#22c55e',
warning: '#eab308',
border: '#334155',
player1: '#3b82f6',
player2: '#ef4444',
player3: '#22c55e',
player4: '#eab308',
player5: '#a855f7',
player6: '#06b6d4',
};
// Archetype labels (simplified for card display)
const ARCHETYPE_LABELS: Record<string, string> = {
aggressive: 'AGGRESSIVE RUSHER',
defensive: 'DEFENSIVE TURTLE',
economic: 'ENERGY HOARDER',
balanced: 'BALANCED FIGHTER',
swarm: 'FORMATION SWARM',
hunter: 'TARGET HUNTER',
gatherer: 'RESOURCE GATHERER',
unknown: 'STRATEGY UNKNOWN',
};
/**
* Render a bot profile card to an offscreen canvas and return as PNG blob
*/
export async function renderBotCard(
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
// Decorative gradient header
const gradient = ctx.createLinearGradient(0, 0, CARD_WIDTH, 0);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.15)');
gradient.addColorStop(1, 'rgba(168, 85, 247, 0.15)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CARD_WIDTH, 80);
// Top section: Bot name and rank
drawHeader(ctx, profile, rank);
// Middle section: Main stats box
drawStatsBox(ctx, profile);
// Bottom section: Win rates and signature
drawWinRates(ctx, profile);
// Footer: Platform URL
drawFooter(ctx);
// Convert to PNG blob
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob!);
}, 'image/png');
});
}
/**
* Draw the header section with bot name, rank, and rating
*/
function drawHeader(ctx: CanvasRenderingContext2D, profile: BotProfile, rank?: number): void {
const y = 25;
const leftX = 20;
// Bot name
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 28px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(truncateText(profile.name, 30), leftX, y);
// Rank badge
if (rank !== undefined) {
const rankText = `#${rank}`;
const rankWidth = ctx.measureText(rankText).width + 16;
ctx.fillStyle = COLORS.accent;
roundRect(ctx, CARD_WIDTH - 20 - rankWidth, y - 14, rankWidth, 28, 6);
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(rankText, CARD_WIDTH - 20 - rankWidth / 2, y);
}
// Owner and rating
ctx.fillStyle = COLORS.textMuted;
ctx.font = '14px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`by ${truncateText(profile.owner_id, 25)}`, leftX, y + 30);
const ratingText = `Rating: ${Math.round(profile.rating)}`;
ctx.textAlign = 'right';
ctx.fillText(ratingText, CARD_WIDTH - 20, y + 30);
// Evolved badge
if (profile.evolved) {
const evolvedText = 'EVOLVED';
const evolvedWidth = ctx.measureText(evolvedText).width + 12;
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
ctx.strokeStyle = COLORS.player5;
ctx.lineWidth = 1;
roundRect(ctx, leftX, y + 42, evolvedWidth, 20, 4, true, true);
ctx.fillStyle = COLORS.player5;
ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(evolvedText, leftX + evolvedWidth / 2, y + 52);
}
}
/**
* Draw the main stats box with archetype and season info
*/
function drawStatsBox(ctx: CanvasRenderingContext2D, profile: BotProfile): void {
const boxX = 20;
const boxY = 90;
const boxW = CARD_WIDTH - 40;
const boxH = 140;
// Box background
ctx.fillStyle = COLORS.bgSecondary;
roundRect(ctx, boxX, boxY, boxW, boxH, 12);
// Border
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
roundRect(ctx, boxX, boxY, boxW, boxH, 12, false, true);
// Archetype section
const archetype = inferArchetype(profile);
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 14px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Archetype:', boxX + 16, boxY + 24);
ctx.fillStyle = COLORS.accent;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText(ARCHETYPE_LABELS[archetype] || ARCHETYPE_LABELS.unknown, boxX + 16, boxY + 44);
// Season info (use current date as proxy for season)
const seasonInfo = `${new Date().getFullYear()} Season · ${profile.matches_played} games`;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '12px -apple-system, sans-serif';
ctx.fillText(seasonInfo, boxX + 16, boxY + 64);
// Stats row
const statsY = boxY + 90;
const statSpacing = (boxW - 32) / 3;
// Matches
drawStat(ctx, boxX + 16, statsY, profile.matches_played.toString(), 'Matches', COLORS.text);
// Win rate
const winRate = profile.win_rate.toFixed(1) + '%';
drawStat(ctx, boxX + 16 + statSpacing, statsY, winRate, 'Win Rate', COLORS.success);
// Rating deviation
const rd = '±' + Math.round(profile.rating_deviation);
drawStat(ctx, boxX + 16 + statSpacing * 2, statsY, rd, 'Uncertainty', COLORS.textMuted);
}
/**
* Draw win rate bars and signature/rival info
*/
function drawWinRates(ctx: CanvasRenderingContext2D, profile: BotProfile): void {
const startY = 250;
const leftX = 20;
const rightX = CARD_WIDTH / 2 + 10;
const colWidth = CARD_WIDTH / 2 - 30;
// Win rate bar (calculated from matches_won / matches_played)
const winRate = profile.matches_won / Math.max(1, profile.matches_played);
const barWidth = Math.round(winRate * 100);
void barWidth; // Mark as intentionally unused for now
// Label
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Win Rate', leftX, startY);
// Bar background
const barY = startY + 12;
ctx.fillStyle = COLORS.bgSecondary;
roundRect(ctx, leftX, barY, colWidth, 20, 4);
// Bar fill
const fillWidth = (colWidth * barWidth) / 100;
ctx.fillStyle = winRate >= 0.5 ? COLORS.success : COLORS.warning;
roundRect(ctx, leftX, barY, fillWidth, 20, 4);
// Percentage text
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`${barWidth}%`, leftX + colWidth / 2, barY + 26);
// Stats icons (kills, energy, captures - placeholder since not in profile)
const statsY = barY + 40;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('⚔️ ' + profile.matches_won + ' wins', leftX, statsY);
ctx.fillText('💎 ' + Math.round(profile.matches_played * profile.win_rate / 100) + ' efficiency', leftX, statsY + 18);
// Right column: Signature move
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText('Signature', rightX, startY);
const signature = generateSignature(profile);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
wrapText(ctx, signature, rightX, startY + 16, colWidth, 14);
// Rival info (if available)
if (profile.recent_matches.length > 0) {
const rivalName = profile.recent_matches[0]?.participants.find(p => p.bot_id !== profile.id)?.name;
if (rivalName) {
ctx.fillStyle = COLORS.text;
ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.fillText('Recent Rival', rightX, startY + 60);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.fillText(`vs ${truncateText(rivalName, 20)}`, rightX, startY + 76);
}
}
}
/**
* Draw footer with platform URL
*/
function drawFooter(ctx: CanvasRenderingContext2D): void {
const footerY = CARD_HEIGHT - 24;
ctx.fillStyle = COLORS.textMuted;
ctx.font = '12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('aicodebattle.com', CARD_WIDTH / 2, footerY);
// Decorative line
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(CARD_WIDTH / 2 - 50, footerY - 10);
ctx.lineTo(CARD_WIDTH / 2 + 50, footerY - 10);
ctx.stroke();
}
/**
* Draw a single stat with value and label
*/
function drawStat(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
value: string,
label: string,
color: string,
): void {
ctx.fillStyle = color;
ctx.font = 'bold 20px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(value, x, y);
ctx.fillStyle = COLORS.textMuted;
ctx.font = '11px -apple-system, sans-serif';
ctx.fillText(label, x, y + 16);
}
/**
* Infer archetype from bot stats (simplified heuristic)
*/
function inferArchetype(profile: BotProfile): string {
const wr = profile.win_rate;
const matches = profile.matches_played;
// High win rate with fewer matches → aggressive/hunter
if (wr > 60 && matches < 100) return 'aggressive';
// Moderate win rate with many matches → balanced
if (wr >= 45 && wr <= 55 && matches > 100) return 'balanced';
// Lower win rate → defensive/gatherer
if (wr < 45) return 'defensive';
// High win rate with many matches → swarm
if (wr > 55 && matches > 100) return 'swarm';
return 'balanced';
}
/**
* Generate a signature move description from bot stats
*/
function generateSignature(profile: BotProfile): string {
const wr = profile.win_rate;
if (profile.evolved) {
return `LLM-evolved strategy combining adaptive positioning with dynamic resource allocation. Generation ${profile.generation ?? '?'}, island ${profile.island ?? '?'}.`;
}
if (wr > 60) {
return 'Dominates through aggressive positioning and efficient eliminations. Favors early confrontation over economic buildup.';
}
if (wr < 40) {
return 'Focuses on defensive perimeter control and resource accumulation. Prefers long-term economic advantage over early aggression.';
}
return 'Balanced approach mixing territorial control with opportunistic engagements. Adapts strategy based on opponent positioning.';
}
/**
* Helper: Draw a rounded rectangle
*/
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
fill = true,
stroke = false,
): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
if (fill) ctx.fill();
if (stroke) ctx.stroke();
}
/**
* Helper: Truncate text to fit width
*/
function truncateText(text: string, maxWidth: number): string {
if (text.length <= maxWidth) return text;
return text.substring(0, maxWidth - 3) + '...';
}
/**
* Helper: Wrap text to fit width
*/
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number,
): void {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const testLine = line + word + ' ';
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== '') {
ctx.fillText(line.trim(), x, currentY);
line = word + ' ';
currentY += lineHeight;
} else {
line = testLine;
}
}
ctx.fillText(line.trim(), x, currentY);
}
/**
* Generate a shareable bot card URL
*/
export function getBotCardURL(botId: string): string {
return `/r2/cards/${botId}.png`;
}
/**
* Download a bot card as PNG
*/
export async function downloadBotCard(
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<void> {
const blob = await renderBotCard(profile, rank, _rivals);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${profile.name.replace(/\s+/g, '_')}_card.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Render bot card to a canvas element for preview
*/
export async function renderBotCardToCanvas(
canvas: HTMLCanvasElement,
profile: BotProfile,
rank?: number,
_rivals?: { name: string; record: string }[],
): Promise<void> {
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
// Decorative gradient header
const gradient = ctx.createLinearGradient(0, 0, CARD_WIDTH, 0);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.15)');
gradient.addColorStop(1, 'rgba(168, 85, 247, 0.15)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CARD_WIDTH, 80);
drawHeader(ctx, profile, rank);
drawStatsBox(ctx, profile);
drawWinRates(ctx, profile);
drawFooter(ctx);
}