Files
SREBOT/web/views/game-detail.ejs
T
FURRO404 2b399fdb81 add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:17:02 -07:00

1001 lines
41 KiB
Plaintext

<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Match Detail | <%= botName %></title>
<meta name="description" content="View detailed match scoreboard and battle logs.">
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preload" href="/Fonts/symbols_skyquake.ttf" as="font" type="font/ttf" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/output.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" media="print" onload="this.media='all'">
<style>
@font-face {
font-family: 'skyquakesymbols';
src: url('/Fonts/symbols_skyquake.ttf') format('truetype');
font-display: swap;
}
body { background: #1b1b1b; min-height: 100vh; }
.text-accent { color: #F5F5DC; }
.text-muted { color: #90EE90; }
.btn-primary {
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4);
color: #1E1E1E; font-weight: 700;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-primary:hover {
box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
.game-detail-container {
max-width: 1400px;
margin: 0 auto;
padding: 6rem 1rem 2rem 1rem;
min-height: calc(100vh - 200px);
}
/* Map Hero */
.map-hero {
position: relative;
border-radius: 1rem;
overflow: hidden;
margin-bottom: 2rem;
min-height: 200px;
background: rgba(30, 30, 30, 0.8);
border: 1px solid rgba(144, 238, 144, 0.15);
}
.map-hero-bg {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-size: cover;
background-position: center;
opacity: 0.25;
}
.map-hero-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(to bottom, rgba(27,27,27,0.4), rgba(27,27,27,0.95));
}
.map-hero-content {
position: relative;
z-index: 1;
padding: 2.5rem 2rem;
text-align: center;
}
.match-type-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(144, 238, 144, 0.15);
color: #90EE90;
border: 1px solid rgba(144, 238, 144, 0.3);
margin-bottom: 1rem;
}
.match-map-name {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.75rem;
}
.match-squadrons {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.sq-winner { color: #90EE90; text-shadow: 0 0 10px rgba(144, 238, 144, 0.3); font-family: 'skyquakesymbols', 'Inter', sans-serif; }
.sq-loser { color: rgba(255, 255, 255, 0.7); font-family: 'skyquakesymbols', 'Inter', sans-serif; }
.sq-vs { color: rgba(255, 255, 255, 0.4); font-size: 1rem; font-weight: 400; }
.match-meta {
display: flex;
justify-content: center;
gap: 2rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
flex-wrap: wrap;
}
.match-meta-item i { margin-right: 0.4rem; color: #90EE90; }
/* Replay Video */
.video-container {
display: inline-block;
max-width: 500px;
width: 100%;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid rgba(144, 238, 144, 0.15);
background: rgba(30, 30, 30, 0.6);
position: relative;
}
.video-container video {
width: 100%;
display: block;
}
.video-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem;
color: rgba(255, 255, 255, 0.5);
gap: 0.75rem;
flex-direction: column;
}
.video-loading .spinner {
width: 24px; height: 24px;
border: 2px solid rgba(144, 238, 144, 0.2);
border-top-color: #90EE90;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.video-loading .subtext {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.35);
}
.video-fallback {
padding: 2rem;
color: rgba(255, 255, 255, 0.4);
font-size: 0.9rem;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
/* Ground / Air toggle */
.rc-mode-toggle {
display: none; /* shown via JS when air data available */
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 2px;
gap: 2px;
margin-left: auto;
margin-right: 1rem;
}
.rc-mode-toggle.visible { display: inline-flex; }
.rc-mode-btn {
padding: 4px 14px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.03em;
}
.rc-mode-btn:hover { color: rgba(255, 255, 255, 0.75); }
.rc-mode-btn.active {
background: rgba(144, 238, 144, 0.15);
color: #90EE90;
box-shadow: 0 0 8px rgba(144, 238, 144, 0.1);
}
/* Scoreboard */
.scoreboard-section { margin-bottom: 2rem; }
.teams-detailed {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.teams-detailed { grid-template-columns: 1fr; }
.match-squadrons { font-size: 1.1rem; gap: 0.75rem; }
.match-map-name { font-size: 1.8rem; }
}
.team-detailed {
background: linear-gradient(135deg, rgba(62, 78, 62, 0.15) 0%, rgba(44, 44, 44, 0.15) 100%);
border-radius: 0.75rem;
padding: 1.25rem;
border: 1px solid rgba(144, 238, 144, 0.1);
backdrop-filter: blur(10px);
}
.team-detailed h4 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: center;
font-family: 'skyquakesymbols', 'Inter', sans-serif;
}
.team-detailed.winning-team h4 { color: #90EE90; text-shadow: 0 0 10px rgba(144, 238, 144, 0.3); }
.team-detailed.losing-team h4 { color: rgba(255, 255, 255, 0.6); }
.players-table {
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
overflow: hidden;
}
.player-header {
display: grid;
grid-template-columns: 1fr 50px 50px 50px 50px 50px;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(144, 238, 144, 0.6);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.player-row {
display: grid;
grid-template-columns: 1fr 50px 50px 50px 50px 50px;
padding: 0.45rem 0.75rem;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
transition: background 0.15s ease;
}
.player-row:hover { background: rgba(255, 255, 255, 0.04); }
.player-identity { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.player-info { display: flex; flex-direction: column; min-width: 0; }
.player-name {
font-size: 0.85rem;
font-weight: 500;
color: rgb(250, 227, 200);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-name a {
color: inherit;
text-decoration: none;
transition: color 0.15s;
}
.player-name a:hover { color: #90EE90; text-decoration: underline; }
.player-squadron-live {
font-family: 'skyquakesymbols', monospace;
color: rgba(255, 255, 255, 0.3);
font-size: 0.8rem;
margin-right: 0.25rem;
}
.player-vehicle {
font-family: 'skyquakesymbols', 'Inter', sans-serif;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-vehicle.dead { color: rgba(255, 60, 60, 0.4); }
.vehicle-icon {
width: 28px; height: 28px;
object-fit: contain;
border-radius: 3px;
background: rgba(0, 0, 0, 0.5);
padding: 2px;
filter: brightness(1.1) contrast(1.2);
flex-shrink: 0;
}
.stat-col {
text-align: center;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
.stat-col.stat-kill { color: rgb(60, 255, 60); }
.stat-col.stat-death { color: rgb(255, 60, 60); }
.stat-col.stat-cap { color: rgb(255, 255, 0); }
.stat-col.stat-assist { color: rgb(230, 150, 90); }
.stat-col.stat-zero { color: rgba(255, 255, 255, 0.2); }
/* Logs */
.logs-section { margin-bottom: 2rem; }
.log-card {
background: linear-gradient(135deg, rgba(62, 78, 62, 0.15) 0%, rgba(44, 44, 44, 0.15) 100%);
border-radius: 0.75rem;
border: 1px solid rgba(144, 238, 144, 0.1);
backdrop-filter: blur(10px);
margin-bottom: 1rem;
overflow: hidden;
}
.log-header {
padding: 0.75rem 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #F5F5DC;
transition: background 0.15s;
}
.log-header:hover { background: rgba(255, 255, 255, 0.03); }
.log-header > i { color: #90EE90; transition: transform 0.3s; }
.log-header.expanded > i { transform: rotate(180deg); }
.log-body {
display: none;
max-height: 400px;
overflow-y: auto;
padding: 0 1.25rem 1rem;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
line-height: 1.6;
}
.log-body.show { display: block; }
.log-entry { padding: 0.15rem 0; color: rgba(255,255,255,0.4); }
.log-entry .bl-win { color: rgb(100, 255, 100); }
.log-entry .bl-lose { color: rgb(255, 100, 100); }
.log-entry .bl-time { color: rgba(255,255,255,0.25); }
.log-entry .bl-action { color: rgba(255,255,255,0.4); }
.log-entry.chat-team { color: rgba(255, 255, 255, 0.7); }
.log-entry.chat-all { color: rgba(255, 200, 100, 0.9); }
/* Loading state */
.loading-state {
text-align: center;
padding: 6rem 2rem;
color: rgba(255, 255, 255, 0.5);
}
.loading-state .spinner {
width: 40px; height: 40px;
border: 3px solid rgba(144, 238, 144, 0.2);
border-top-color: #90EE90;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1.5rem;
}
/* Error state */
.error-state {
text-align: center;
padding: 6rem 2rem;
color: rgba(255, 255, 255, 0.5);
display: none;
}
.error-state i { font-size: 3rem; margin-bottom: 1rem; color: rgba(255, 60, 60, 0.5); }
/* Back link */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #90EE90;
font-size: 0.9rem;
margin-bottom: 1.5rem;
text-decoration: none;
transition: opacity 0.15s;
}
.back-link:hover { opacity: 0.8; }
/* ── Replay Canvas Viewer ── */
#replayViewerContainer {
width: 100%;
}
.rc-layout {
display: grid;
grid-template-columns: minmax(190px, 230px) minmax(560px, 720px) minmax(190px, 230px);
width: min(100%, 1210px);
gap: 0.5rem;
align-items: start;
justify-content: center;
margin: 0 auto;
}
@media (max-width: 1120px) {
.rc-layout { grid-template-columns: 1fr; }
.rc-panel { max-height: 200px; }
}
.rc-panel {
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 0.4rem;
overflow-y: auto;
font-size: 0.78rem;
}
.rc-panel-head {
padding: 0.5rem 0.6rem 0.4rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
position: sticky;
top: 0;
background: rgba(27,27,27,0.95);
backdrop-filter: blur(4px);
z-index: 1;
text-align: center;
}
.rc-panel-label {
font-weight: 700;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.rc-clan-tag {
font-family: 'skyquakesymbols', monospace;
font-size: 0.85rem;
letter-spacing: 0;
text-transform: none;
}
.rc-panel-list { padding: 0.25rem 0; }
.rc-row {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.6rem;
cursor: pointer;
transition: background 0.12s, opacity 0.3s;
border-left: 2px solid transparent;
}
.rc-row:hover, .rc-row.rc-hl {
background: rgba(255,255,255,0.06);
}
.rc-panel-win .rc-row.rc-hl { border-left-color: rgba(0,200,0,0.5); }
.rc-panel-lose .rc-row.rc-hl { border-left-color: rgba(220,30,30,0.5); }
.rc-row.rc-dead { opacity: 0.4; }
.rc-row.rc-gone { opacity: 0.2; cursor: default; }
.rc-row.rc-dead:hover, .rc-row.rc-gone:hover { background: transparent; }
.rc-type-icon {
width: 28px;
height: 22px;
object-fit: contain;
flex-shrink: 0;
filter: brightness(0) invert(1);
opacity: 0.55;
}
.rc-panel-win .rc-type-icon { filter: brightness(0) invert(1) sepia(1) saturate(3) hue-rotate(80deg); opacity: 0.6; }
.rc-panel-lose .rc-type-icon { filter: brightness(0) invert(1) sepia(1) saturate(3) hue-rotate(-10deg); opacity: 0.6; }
.rc-row-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.rc-row-name {
color: rgba(255,255,255,0.82);
font-size: 0.76rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-row-veh {
color: rgba(255,255,255,0.32);
font-size: 0.65rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rc-row-status {
flex-shrink: 0;
font-size: 0.6rem;
color: rgba(255,255,255,0.3);
width: 16px;
text-align: center;
}
.rc-center {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.rc-canvas {
width: 100%;
height: auto;
border-radius: 0.4rem;
background: #111;
cursor: crosshair;
border: 1px solid rgba(255,255,255,0.06);
}
.rc-controls {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.45rem 0;
}
.rc-btn {
background: rgba(255,255,255,0.07);
border: 1px solid rgba(255,255,255,0.06);
color: rgba(255,255,255,0.6);
padding: 0.25rem 0.45rem;
border-radius: 0.2rem;
cursor: pointer;
font-size: 0.72rem;
transition: all 0.12s;
font-family: inherit;
}
.rc-btn:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
.rc-play { font-size: 0.8rem; padding: 0.25rem 0.55rem; }
.rc-speeds { display: flex; gap: 1px; }
.rc-sp.active {
background: rgba(245,245,220,0.15);
color: #F5F5DC;
border-color: rgba(245,245,220,0.2);
}
.rc-scrub {
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255,255,255,0.12) var(--rc-progress, 0%));
border-radius: 3px;
outline: none;
cursor: pointer;
padding: 6px 0;
box-sizing: content-box;
background-clip: content-box;
}
.rc-scrub::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: #90ee90;
cursor: pointer;
box-shadow: 0 0 3px rgba(0,0,0,0.5);
}
.rc-scrub::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: #90ee90;
border: none;
cursor: pointer;
box-shadow: 0 0 3px rgba(0,0,0,0.5);
}
.rc-time {
font-size: 0.65rem;
color: rgba(255,255,255,0.4);
white-space: nowrap;
min-width: 65px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Battle Log (glass morphism) ── */
.rc-log-wrap {
width: 100%;
margin-top: 0.4rem;
}
.rc-log {
max-height: 130px;
overflow-y: auto;
padding: 0.6rem 0.8rem;
border-radius: 0.5rem;
background: rgba(255,255,255,0.04);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.06);
box-shadow: 0 4px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.04);
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
.rc-log::-webkit-scrollbar { width: 4px; }
.rc-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.rc-log:empty::after {
content: 'Waiting for events...';
color: rgba(255,255,255,0.15);
font-size: 0.7rem;
font-style: italic;
}
.rc-ev {
font-size: 0.72rem;
padding: 0.2rem 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
animation: rcFadeIn 0.3s ease-out;
}
.rc-ev:last-child { border-bottom: none; }
.rc-ev-kill { }
.rc-ev-damage { opacity: 0.55; }
.rc-ev-time {
display: inline-block;
width: 32px;
color: rgba(255,255,255,0.25);
font-size: 0.65rem;
font-variant-numeric: tabular-nums;
margin-right: 0.4rem;
}
.rc-ev-win { color: #5cdf5c; font-weight: 500; }
.rc-ev-lose { color: #e85555; font-weight: 500; }
.rc-ev-action { color: rgba(255,255,255,0.35); font-size: 0.68rem; }
.rc-ev-weapon {
color: rgba(255,255,255,0.2);
font-size: 0.62rem;
margin-left: 0.3rem;
}
@keyframes rcFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="text-white antialiased">
<%- include('partials/nav', { activePage: 'games' }) %>
<div class="game-detail-container">
<a href="/games" class="back-link"><i class="fas fa-arrow-left"></i> <%= t('nav.games') %></a>
<!-- Loading -->
<div id="loadingState" class="loading-state">
<div class="spinner"></div>
<p><%= t('games.loadingMatch') %></p>
</div>
<!-- Error -->
<div id="errorState" class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<h3 id="errorMessage"><%= t('games.matchNotFound') %></h3>
</div>
<!-- Content (hidden until loaded) -->
<div id="matchContent" style="display: none;">
<!-- Map Hero -->
<div class="map-hero" id="mapHero">
<div class="map-hero-bg" id="mapHeroBg"></div>
<div class="map-hero-overlay"></div>
<div class="map-hero-content">
<div class="match-type-badge" id="matchTypeBadge"></div>
<div class="match-map-name" id="matchMapName"></div>
<div class="match-squadrons">
<span class="sq-winner" id="sqWinner"></span>
<span class="sq-vs">VS</span>
<span class="sq-loser" id="sqLoser"></span>
</div>
<div class="match-meta" id="matchMeta"></div>
</div>
</div>
<!-- Scoreboard -->
<div class="scoreboard-section">
<div class="teams-detailed" id="scoreboardContainer"></div>
</div>
<!-- Replay Canvas Viewer (collapsible) -->
<div class="logs-section">
<div class="log-card">
<div class="log-header" onclick="toggleReplayViewer()">
<span><i class="fas fa-play-circle" style="margin-right: 0.5rem;"></i><%= t('games.replayVideo') %></span>
<div class="rc-mode-toggle" id="rcModeToggle" onclick="event.stopPropagation()">
<button class="rc-mode-btn active" data-mode="ground" onclick="switchReplayMode('ground')"><i class="fas fa-crosshairs" style="margin-right: 4px;"></i><%= t('games.modeGround') %></button>
<button class="rc-mode-btn" data-mode="air" onclick="switchReplayMode('air')"><i class="fas fa-plane" style="margin-right: 4px;"></i><%= t('games.modeAir') %></button>
</div>
<i class="fas fa-chevron-down" id="replayViewerToggleIcon"></i>
</div>
<div class="log-body" id="replayViewerBody" style="max-height: none; padding: 1rem; font-family: inherit;">
<div class="video-loading" id="replayLoading">
<div class="spinner"></div>
<span><%= t('games.loadingReplay') %></span>
</div>
<div id="replayViewerContainer" style="display: none;"></div>
</div>
</div>
</div>
<!-- Logs -->
<div class="logs-section" id="logsSection" style="display: none;">
<div class="log-card">
<div class="log-header" onclick="toggleLog('chatLog')">
<span><i class="fas fa-comment-dots" style="margin-right: 0.5rem;"></i><%= t('games.chatLog') %></span>
<i class="fas fa-chevron-down" id="chatLogIcon"></i>
</div>
<div class="log-body" id="chatLogBody"></div>
</div>
<div class="log-card">
<div class="log-header" onclick="toggleLog('battleLog')">
<span><i class="fas fa-crosshairs" style="margin-right: 0.5rem;"></i><%= t('games.battleLog') %></span>
<i class="fas fa-chevron-down" id="battleLogIcon"></i>
</div>
<div class="log-body" id="battleLogBody"></div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<%- include('partials/footer') %>
<script>
window.__lang = '<%= lang %>';
window.__i18n = <%- localeJson %>;
window.__t = function(key) {
var parts = key.split('.'), obj = window.__i18n;
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
return obj !== undefined ? obj : key;
};
window.switchLanguage = function(lang) {
var next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
if (next === document.documentElement.lang) return;
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
window.location.reload();
};
</script>
<script src="/js/main.js?v=3"></script>
<script src="/js/api-client.js?v=3"></script>
<script src="/js/vehicle-i18n.js"></script>
<script src="/js/header-search.js?v=2"></script>
<script src="/js/replay-canvas.js?v=13"></script>
<script>
const sessionId = '<%= sessionId %>';
const __t = window.__t;
let vehicleIconMappings = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatBattleLogLine(line) {
// Format: +[TIME] [SQUAD] Player (Vehicle) ACTION [SQUAD] Player (Vehicle)
// or: -[TIME] [SQUAD] Player (Vehicle) ACTION [SQUAD] Player (Vehicle)
const isWinnerAction = line.startsWith('+');
const winCls = isWinnerAction ? 'bl-win' : 'bl-lose';
const loseCls = isWinnerAction ? 'bl-lose' : 'bl-win';
// Parse: sign[TIME] rest
const m = line.match(/^([+-])(\[[^\]]*\])\s+(.+)$/);
if (!m) return escapeHtml(line);
const time = m[2];
const rest = m[3];
// Split on action words
const actionMatch = rest.match(/^(.+?)\s+(destroyed|damaged|shot down|crashed|killed)\s+(?:\(([^)]*)\)\s+)?(.+)$/i);
if (!actionMatch) {
return `<span class="bl-time">${escapeHtml(m[1] + time)}</span> <span class="${winCls}">${escapeHtml(rest)}</span>`;
}
const attacker = actionMatch[1];
const action = actionMatch[2];
const actionExtra = actionMatch[3] || '';
const victim = actionMatch[4];
const actionText = actionExtra ? `${action} (${actionExtra})` : action;
return `<span class="bl-time">${escapeHtml(m[1] + time)}</span> `
+ `<span class="${winCls}">${escapeHtml(attacker)}</span> `
+ `<span class="bl-action">${escapeHtml(actionText)}</span> `
+ `<span class="${loseCls}">${escapeHtml(victim)}</span>`;
}
function normalizeVehicleName(name) {
if (!name) return name;
// Mirror BOT/data_parser.py + server.js: drop ace-pilot prefixes
// and the (TEL) tag, but keep visible decoration glyphs (▄ ◢ ◊
// etc.) — they're country / event indicators rendered through
// the skyquake font. Only the Private Use Area gets stripped
// (truly nothing renders that legibly).
name = name.replace(/Weizman[\u2019\u0027]s\s*/g, '');
name = name.replace(/Plagis[\u2019\u0027]\s*/g, '');
name = name.replace(/\s*\(TEL\)/g, '');
name = name.replace(/[\uE000-\uF8FF]/g, '');
return name.trim();
}
function getVehicleIcon(vehicleInternal) {
if (!vehicleInternal || !vehicleIconMappings) return null;
const vehicleName = vehicleInternal.toString().toLowerCase();
if (vehicleIconMappings[vehicleName]) return vehicleIconMappings[vehicleName];
const variations = [
vehicleName, vehicleName.replace(/\s+/g, '_'), vehicleName.replace(/\s+/g, '-'),
vehicleName.replace(/_/g, '-'), vehicleName.replace(/-/g, '_'),
vehicleName.replace(/\./g, ''), vehicleName.replace(/\./g, '_'), vehicleName.replace(/[^\w\s-]/g, '')
];
for (const v of variations) { if (vehicleIconMappings[v]) return vehicleIconMappings[v]; }
return null;
}
function getMapImage(mapName) {
if (!mapName) return null;
const cleanMapName = mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim();
const fileName = cleanMapName.replace(/\s+/g, '_').replace(/[^\w\u00C0-\u024F\-_.()]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '').toLowerCase();
return `/MAPS/${fileName}.jpg`;
}
function renderTeamTable(players, isWinner) {
const sorted = [...players].sort((a, b) => (b.score || 0) - (a.score || 0));
return sorted.map(player => {
const icon = getVehicleIcon(player.vehicle_internal || player.vehicle_new || player.vehicle || player.vehicleInternal || player.vehicleName);
const iconHtml = icon ? `<img src="${icon}" alt="" class="vehicle-icon" loading="lazy" onerror="this.style.display='none'">` : '';
const sqTag = player.squadron_name ? `<span class="player-squadron-live">[${escapeHtml(player.squadron_name)}]</span>` : '';
const dead = (parseInt(player.deaths) || 0) > 0;
const veh = normalizeVehicleName(player.vehicle_new || player.vehicle) || 'Unknown';
const uid = player.uid || player.UID;
const nameHtml = uid ? `<a href="/players/${uid}">${sqTag}${escapeHtml(player.nick || 'Unknown')}</a>` : `${sqTag}${escapeHtml(player.nick || 'Unknown')}`;
const ak = player.air_kills || 0, gk = player.ground_kills || 0;
const ast = player.assists || 0, dth = player.deaths || 0, cap = player.captures || 0;
return `<div class="player-row">
<div class="player-identity">
${iconHtml}
<div class="player-info">
<span class="player-name">${nameHtml}</span>
<span class="player-vehicle${dead || veh === 'DISCONNECTED' ? ' dead' : ''}" data-vehicle-internal="${escapeHtml(player.vehicle_internal || player.vehicleInternal || '')}">${escapeHtml(veh)}</span>
</div>
</div>
<span class="stat-col ${ak > 0 ? 'stat-kill' : 'stat-zero'}">${ak}</span>
<span class="stat-col ${gk > 0 ? 'stat-kill' : 'stat-zero'}">${gk}</span>
<span class="stat-col ${ast > 0 ? 'stat-assist' : 'stat-zero'}">${ast}</span>
<span class="stat-col ${dth > 0 ? 'stat-death' : 'stat-zero'}">${dth}</span>
<span class="stat-col ${cap > 0 ? 'stat-cap' : 'stat-zero'}">${cap}</span>
</div>`;
}).join('');
}
let replayViewerLoaded = false;
let replayViewer = null;
function toggleReplayViewer() {
const body = document.getElementById('replayViewerBody');
const icon = document.getElementById('replayViewerToggleIcon');
const header = icon.closest('.log-header');
body.classList.toggle('show');
header.classList.toggle('expanded');
if (body.classList.contains('show') && !replayViewerLoaded) {
replayViewerLoaded = true;
loadReplayViewer();
}
}
async function loadReplayViewer() {
const container = document.getElementById('replayViewerContainer');
const loading = document.getElementById('replayLoading');
try {
const resp = await fetch(`/api/match/${sessionId}/replay-canvas`);
if (!resp.ok) {
if (resp.status === 404) throw new Error('No paths data found');
let reason = '';
try {
const errJson = await resp.json();
reason = errJson && (errJson.reason || errJson.error) ? (errJson.reason || errJson.error) : '';
} catch (_) {}
throw new Error(reason || `HTTP ${resp.status}`);
}
const data = await resp.json();
if (!data.entities || !data.players) throw new Error('Invalid replay data');
loading.style.display = 'none';
container.style.display = 'block';
if (typeof ReplayCanvas === 'undefined') {
throw new Error('ReplayCanvas class not loaded');
}
replayViewer = new ReplayCanvas(container, data);
await replayViewer.init();
// Show toggle if air mode is available
if (replayViewer.hasAirMode) {
document.getElementById('rcModeToggle').classList.add('visible');
}
} catch (err) {
loading.style.display = 'block';
loading.innerHTML = '<div class="video-fallback" style="color:#ff6666;">' + (err.message || 'Unknown error') + '</div>';
}
}
function switchReplayMode(mode) {
if (!replayViewer) return;
replayViewer.setMode(mode);
document.querySelectorAll('.rc-mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
}
function toggleLog(logId) {
const body = document.getElementById(logId + 'Body');
const icon = document.getElementById(logId + 'Icon');
const header = icon.closest('.log-header');
body.classList.toggle('show');
header.classList.toggle('expanded');
}
async function loadMatchDetail() {
try {
const match = await window.apiClient.getMatch(sessionId);
// Hide loading, show content
document.getElementById('loadingState').style.display = 'none';
document.getElementById('matchContent').style.display = 'block';
// Map hero
const mapName = match.map_name || 'Unknown Map';
const cleanMapName = mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim() || 'Unknown Map';
const mapImg = getMapImage(mapName);
if (mapImg) document.getElementById('mapHeroBg').style.backgroundImage = `url('${mapImg}')`;
const TYPE_LABELS = { sqb: __t('live.squadronBattle'), rb: __t('live.randomBattle') };
document.getElementById('matchTypeBadge').textContent = TYPE_LABELS[match.game_type] || match.game_type || 'Match';
document.getElementById('matchMapName').textContent = cleanMapName;
document.getElementById('sqWinner').textContent = match.winning_tag || match.winning_squadron || 'Unknown';
document.getElementById('sqLoser').textContent = match.losing_tag || match.losing_squadron || 'Unknown';
// Meta
const endTime = match.endtime_iso ? new Date(match.endtime_iso).toISOString().replace('T', ' ').substring(0, 16) + ' UTC' : 'Unknown';
document.getElementById('matchMeta').innerHTML = `
<span class="match-meta-item"><i class="fas fa-clock"></i>${endTime}</span>
<span class="match-meta-item"><i class="fas fa-fingerprint"></i>${escapeHtml(sessionId)}</span>
`;
// Scoreboard
const winPlayers = match.winning_team?.players || [];
const losePlayers = match.losing_team?.players || [];
const headerHtml = `<div class="player-header">
<span>${__t('common.player')}</span>
<span style="text-align:center">${__t('live.air')}</span>
<span style="text-align:center">${__t('live.gnd')}</span>
<span style="text-align:center">${__t('live.ast')}</span>
<span style="text-align:center">${__t('live.dth')}</span>
<span style="text-align:center">${__t('live.cap')}</span>
</div>`;
document.getElementById('scoreboardContainer').innerHTML = `
<div class="team-detailed winning-team">
<h4><i class="fas fa-trophy" style="margin-right: 0.5rem; font-size: 0.9rem;"></i>${escapeHtml(match.winning_tag || match.winning_squadron || 'Unknown')}</h4>
<div class="players-table">${headerHtml}${renderTeamTable(winPlayers, true)}</div>
</div>
<div class="team-detailed losing-team">
<h4>${escapeHtml(match.losing_tag || match.losing_squadron || 'Unknown')}</h4>
<div class="players-table">${headerHtml}${renderTeamTable(losePlayers, false)}</div>
</div>
`;
// Replay data (chat/battle logs)
if (match.replay_available) {
loadReplayData();
}
} catch (error) {
console.error('Failed to load match:', error);
document.getElementById('loadingState').style.display = 'none';
document.getElementById('errorState').style.display = 'block';
}
}
async function loadReplayData() {
try {
const replay = await window.apiClient.getMatchReplay(sessionId);
if (!replay.available) return;
document.getElementById('logsSection').style.display = 'block';
// Add duration/mode to meta
const metaEl = document.getElementById('matchMeta');
if (replay.duration) {
const mins = Math.floor(replay.duration / 60);
const secs = replay.duration % 60;
metaEl.innerHTML += `<span class="match-meta-item"><i class="fas fa-hourglass-half"></i>${mins}m ${secs}s</span>`;
}
if (replay.mode) {
metaEl.innerHTML += `<span class="match-meta-item"><i class="fas fa-gamepad"></i>${escapeHtml(replay.mode)}</span>`;
}
// Chat log
const chatBody = document.getElementById('chatLogBody');
if (replay.chat_log && replay.chat_log.length > 0) {
chatBody.innerHTML = replay.chat_log.map(line => {
const cls = line.includes('[TEAM]') ? 'chat-team' : 'chat-all';
return `<div class="log-entry ${cls}">${escapeHtml(line)}</div>`;
}).join('');
} else {
chatBody.innerHTML = `<div class="log-entry" style="color: rgba(255,255,255,0.3);">${__t('games.noChatLog')}</div>`;
}
// Battle log
const battleBody = document.getElementById('battleLogBody');
if (replay.battle_log && replay.battle_log.length > 0) {
battleBody.innerHTML = replay.battle_log.map(line => {
return `<div class="log-entry">${formatBattleLogLine(line)}</div>`;
}).join('');
} else {
battleBody.innerHTML = `<div class="log-entry" style="color: rgba(255,255,255,0.3);">${__t('games.noBattleLog')}</div>`;
}
} catch (error) {
console.error('Failed to load replay data:', error);
}
}
document.addEventListener('DOMContentLoaded', async () => {
try {
const iconsRes = await window.apiClient.getVehicleIcons();
vehicleIconMappings = iconsRes.mappings || {};
} catch (e) { vehicleIconMappings = {}; }
loadMatchDetail();
});
</script>
</body>
</html>