replay canvas
This commit is contained in:
+30
-4
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Tree, { prewarmTreeCanvas } from '../Tree/Tree'
|
||||
import FallingLeaves from '../Tree/FallingLeaves'
|
||||
import ReplayCanvasPanel from './ReplayCanvas'
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('en-GB')
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -152,13 +153,36 @@ function displayTeamName(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function ParticipantNames({ participants }) {
|
||||
function ParticipantNames({ participants, spread = false }) {
|
||||
if (!participants.length) {
|
||||
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
|
||||
}
|
||||
|
||||
// On wide rows (battle logs), spread the two teams to opposite ends with a
|
||||
// centered "vs" so each side gets an equal share of the available width.
|
||||
if (spread && participants.length === 2) {
|
||||
const [first, second] = participants
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-left text-sm font-semibold ${first.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
>
|
||||
{first.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
vs
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-right text-sm font-semibold ${second.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
>
|
||||
{second.name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">
|
||||
<div className={`flex min-w-0 flex-wrap gap-x-3 gap-y-1${spread ? ' justify-between' : ''}`}>
|
||||
{participants.map((participant) => (
|
||||
<span
|
||||
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-win' : 'text-loss'}`}
|
||||
@@ -3085,6 +3109,8 @@ function GamePage({ gameId, navigate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReplayCanvasPanel gameId={gameId} />
|
||||
|
||||
{battleEvents.length || logs.battle_log.length ? (
|
||||
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<summary className="cursor-pointer px-5 py-4 font-semibold">Battle Log</summary>
|
||||
@@ -3275,7 +3301,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
{matches.map((match) => (
|
||||
<button
|
||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_auto] md:items-center"
|
||||
className="grid w-full gap-x-8 gap-y-2 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.7fr)_auto] md:items-center"
|
||||
key={match.session_id}
|
||||
onClick={() => navigate(gamePath(match.session_id))}
|
||||
type="button"
|
||||
@@ -3286,7 +3312,7 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
{formatDate(match.timestamp)} · {match.session_id}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantNames participants={gameParticipants(match)} />
|
||||
<ParticipantNames participants={gameParticipants(match)} spread />
|
||||
<p className="text-sm">{formatMatchSize(match.player_count)}</p>
|
||||
</button>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -466,6 +466,444 @@ h3 {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.replay-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.replay-status {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-soft);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.replay-status-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.rc-mode-toggle {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.rc-mode-toggle.visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.rc-mode-btn {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-soft);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 14px;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.rc-mode-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.rc-mode-btn.active {
|
||||
background: var(--color-fury-cyan);
|
||||
color: var(--color-fury-white);
|
||||
}
|
||||
|
||||
.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 {
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 8px;
|
||||
background: #15100b;
|
||||
color: #fff2e6;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.rc-panel-head {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(21, 16, 11, 0.95);
|
||||
padding: 0.5rem 0.6rem 0.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-panel-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
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;
|
||||
border-left: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.6rem;
|
||||
transition: background-color 120ms ease, opacity 300ms ease;
|
||||
}
|
||||
|
||||
.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 {
|
||||
cursor: default;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.rc-row.rc-dead:hover,
|
||||
.rc-row.rc-gone:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rc-type-icon {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rc-row-info {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rc-row-name {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-row-veh {
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 0.65rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-row-status {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-center {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rc-canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.rc-tickets {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.rc-tk-val {
|
||||
min-width: 2.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-tk-val-win {
|
||||
color: #5cdf5c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rc-tk-val-lose {
|
||||
color: #e85555;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rc-tk-track {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.rc-tk-fill {
|
||||
height: 100%;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
|
||||
.rc-tk-fill-win {
|
||||
background: #2a8f2a;
|
||||
}
|
||||
|
||||
.rc-tk-fill-lose {
|
||||
background: #b22020;
|
||||
}
|
||||
|
||||
.rc-game-over .rc-tk-track {
|
||||
animation: rcTkGlow 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.rc-game-over .rc-tk-val-win,
|
||||
.rc-game-over .rc-panel-win .rc-panel-label {
|
||||
animation: rcTkTextGlow 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rcTkGlow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 2px 0 rgba(92, 223, 92, 0.25);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 7px 1px rgba(92, 223, 92, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rcTkTextGlow {
|
||||
0%,
|
||||
100% {
|
||||
text-shadow: 0 0 3px rgba(92, 223, 92, 0.35);
|
||||
}
|
||||
|
||||
50% {
|
||||
text-shadow: 0 0 9px rgba(92, 223, 92, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.rc-controls {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.rc-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.45rem;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.rc-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.rc-play {
|
||||
min-width: 54px;
|
||||
}
|
||||
|
||||
.rc-speeds {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.rc-sp.active {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff2e6;
|
||||
}
|
||||
|
||||
.rc-scrub {
|
||||
box-sizing: content-box;
|
||||
height: 6px;
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
background: linear-gradient(to right, #2a6e2a var(--rc-progress, 0%), rgba(255, 255, 255, 0.14) var(--rc-progress, 0%));
|
||||
background-clip: content-box;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rc-scrub::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #90ee90;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rc-scrub::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #90ee90;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rc-time {
|
||||
min-width: 65px;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 0.65rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rc-log-wrap {
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.rc-log {
|
||||
overflow-y: auto;
|
||||
max-height: 130px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.6rem 0.8rem;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.rc-log:empty::after {
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
content: "Waiting for events...";
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rc-ev {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.rc-ev:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rc-ev-damage {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.rc-ev-time {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
margin-right: 0.4rem;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-size: 0.65rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rc-ev-win {
|
||||
color: #5cdf5c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-ev-lose {
|
||||
color: #e85555;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-ev-action {
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.rc-ev-weapon {
|
||||
margin-left: 0.3rem;
|
||||
color: rgba(255, 255, 255, 0.34);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
@keyframes scrollPulse {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
|
||||
+257
@@ -1,4 +1,5 @@
|
||||
const fs = require('node:fs')
|
||||
const { execFile } = require('node:child_process')
|
||||
const crypto = require('node:crypto')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
@@ -60,6 +61,24 @@ const VEHICLE_ICONS_DIR = path.resolve(
|
||||
__dirname,
|
||||
process.env.VEHICLE_ICONS_DIR || path.join('dist', 'vehicle-icons'),
|
||||
)
|
||||
const BOTS_REPO_DIR = path.resolve(
|
||||
expandHome(process.env.BOTS_REPO_DIR || path.join(__dirname, '..', 'BOTS')),
|
||||
)
|
||||
const TSSBOT_REPO_DIR = path.resolve(process.env.TSSBOT_REPO_DIR || path.join(BOTS_REPO_DIR, 'TSSBOT'))
|
||||
const TSS_REPLAY_SAMPLE_DIR = path.join(TSSBOT_REPO_DIR, 'replays_sample')
|
||||
const TSS_REPLAYS_DIR = path.resolve(
|
||||
expandHome(
|
||||
process.env.TSS_REPLAYS_DIR ||
|
||||
(process.env.STORAGE_VOL_PATH
|
||||
? path.join(expandHome(process.env.STORAGE_VOL_PATH), 'REPLAYS', 'TSS')
|
||||
: TSS_REPLAY_SAMPLE_DIR),
|
||||
),
|
||||
)
|
||||
const SHARED_DIR = path.resolve(process.env.SHARED_DIR || path.join(BOTS_REPO_DIR, 'SHARED'))
|
||||
const TSS_REPLAY_PYTHON = path.resolve(
|
||||
expandHome(process.env.TSS_REPLAY_PYTHON || path.join(SHARED_DIR, '.venv', 'bin', 'python')),
|
||||
)
|
||||
const TSS_REPLAY_RENDER_TIMEOUT_MS = Number(process.env.TSS_REPLAY_RENDER_TIMEOUT_MS || 30000)
|
||||
const MAX_TEAM_NAME_LENGTH = 80
|
||||
const MAX_CACHE_ENTRIES = 200
|
||||
const MAX_RATE_LIMIT_KEYS = 1000
|
||||
@@ -2064,6 +2083,219 @@ function serveVehicleIcon(req, res) {
|
||||
})
|
||||
}
|
||||
|
||||
function safeUniquePaths(paths) {
|
||||
return [...new Set(paths.filter(Boolean).map((value) => path.resolve(value)))]
|
||||
}
|
||||
|
||||
function resolveTssReplaySessionDir(sessionId) {
|
||||
const sid = String(sessionId || '').toLowerCase()
|
||||
const candidates = safeUniquePaths([
|
||||
path.join(TSS_REPLAYS_DIR, sid),
|
||||
path.join(TSS_REPLAYS_DIR, `0${sid}`),
|
||||
path.join(TSS_REPLAY_SAMPLE_DIR, sid),
|
||||
path.join(TSS_REPLAY_SAMPLE_DIR, `0${sid}`),
|
||||
])
|
||||
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir
|
||||
} catch {
|
||||
// Keep trying the remaining replay roots.
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(TSS_REPLAYS_DIR, sid)
|
||||
}
|
||||
|
||||
function findTssReplayDataPath(sessionDir) {
|
||||
const candidates = [
|
||||
path.join(sessionDir, 'replay_data.json.gz'),
|
||||
path.join(sessionDir, 'replay_data.json'),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate
|
||||
} catch {
|
||||
// Ignore unreadable candidates and let the caller return 404.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readFileResponse(req, res, filePath, headers = {}) {
|
||||
fs.readFile(filePath, (error, data) => {
|
||||
if (error) {
|
||||
sendJson(res, 404, { error: 'File not found' })
|
||||
return
|
||||
}
|
||||
send(res, 200, data, headers)
|
||||
})
|
||||
}
|
||||
|
||||
function runReplayCanvasRenderer(replayPath, jsonPath) {
|
||||
const pythonBin = fs.existsSync(TSS_REPLAY_PYTHON) ? TSS_REPLAY_PYTHON : 'python3'
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
pythonBin,
|
||||
['-m', 'BOT.render_replay', replayPath, jsonPath],
|
||||
{
|
||||
cwd: TSSBOT_REPO_DIR,
|
||||
timeout: TSS_REPLAY_RENDER_TIMEOUT_MS,
|
||||
env: process.env,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(String(stderr || stdout || error.message || 'Replay renderer failed').trim()))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
let tssCanvasRenderCount = 0
|
||||
const TSS_CANVAS_RENDER_MAX = 3
|
||||
|
||||
async function serveTssReplayCanvas(req, res, sessionId) {
|
||||
if (!sessionId || !/^[A-Za-z0-9_-]{1,96}$/.test(sessionId)) {
|
||||
sendJson(res, 400, { error: 'Invalid game ID' })
|
||||
return
|
||||
}
|
||||
if (!isSameOriginRequest(req)) {
|
||||
sendJson(res, 403, { error: 'API access is restricted to this site' })
|
||||
return
|
||||
}
|
||||
if (isRateLimited(req)) {
|
||||
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
||||
return
|
||||
}
|
||||
|
||||
const sessionDir = resolveTssReplaySessionDir(sessionId)
|
||||
const replayPath = findTssReplayDataPath(sessionDir)
|
||||
const jsonPath = path.join(sessionDir, 'replay_canvas.json')
|
||||
|
||||
if (!replayPath) {
|
||||
sendJson(res, 404, { available: false, reason: 'No replay data available' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonStat = fs.existsSync(jsonPath) ? fs.statSync(jsonPath) : null
|
||||
const replayStat = fs.statSync(replayPath)
|
||||
if (jsonStat && jsonStat.size > 0 && jsonStat.mtimeMs >= replayStat.mtimeMs) {
|
||||
readFileResponse(req, res, jsonPath, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=86400',
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through and attempt regeneration.
|
||||
}
|
||||
|
||||
if (tssCanvasRenderCount >= TSS_CANVAS_RENDER_MAX) {
|
||||
sendJson(res, 503, { available: false, reason: 'Too many replays processing; try again shortly' })
|
||||
return
|
||||
}
|
||||
|
||||
tssCanvasRenderCount += 1
|
||||
try {
|
||||
await runReplayCanvasRenderer(replayPath, jsonPath)
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
sendJson(res, 500, { available: false, reason: 'Replay JSON generation produced no output' })
|
||||
return
|
||||
}
|
||||
readFileResponse(req, res, jsonPath, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'public, max-age=86400',
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath)
|
||||
} catch {
|
||||
// Ignore cache cleanup errors.
|
||||
}
|
||||
sendJson(res, 500, { available: false, reason: 'Replay JSON generation failed', detail: error.message })
|
||||
} finally {
|
||||
tssCanvasRenderCount -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function serveReplayIcon(req, res) {
|
||||
let iconName = ''
|
||||
try {
|
||||
const requestPath = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname)
|
||||
iconName = requestPath.slice('/api/icons/type/'.length)
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'Bad request' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!iconName || !/^[A-Za-z0-9_-]+$/.test(iconName)) {
|
||||
sendJson(res, 400, { error: 'Invalid icon name' })
|
||||
return
|
||||
}
|
||||
|
||||
const iconsBase = path.join(SHARED_DIR, 'ICONS')
|
||||
const candidates = [
|
||||
path.join(iconsBase, `${iconName}.png`),
|
||||
path.join(iconsBase, 'FALLBACKS', `${iconName}.png`),
|
||||
path.join(iconsBase, 'MINIS', `${iconName}.png`),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const relative = path.relative(iconsBase, candidate)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
|
||||
if (fs.existsSync(candidate)) {
|
||||
readFileResponse(req, res, candidate, {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': 'public, max-age=604800',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJson(res, 404, { error: 'Icon not found' })
|
||||
}
|
||||
|
||||
function serveReplayMinimap(req, res) {
|
||||
let level = ''
|
||||
let fullMap = false
|
||||
try {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`)
|
||||
const requestPath = decodeURIComponent(url.pathname)
|
||||
level = requestPath.slice('/api/match/minimap/'.length)
|
||||
fullMap = url.searchParams.get('type') === 'full'
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'Bad request' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!level || !/^[A-Za-z0-9_]+$/.test(level)) {
|
||||
sendJson(res, 400, { error: 'Invalid level name' })
|
||||
return
|
||||
}
|
||||
|
||||
const minimapsDir = path.join(SHARED_DIR, 'MAPS', 'MINIMAPS')
|
||||
const names = fullMap
|
||||
? [`${level}.png`, `${level}_map.png`]
|
||||
: [`${level}_tankmap.png`, `${level}.png`, `${level}_map.png`]
|
||||
|
||||
for (const name of [...new Set(names)]) {
|
||||
const candidate = path.resolve(minimapsDir, name)
|
||||
const relative = path.relative(minimapsDir, candidate)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
|
||||
if (fs.existsSync(candidate)) {
|
||||
readFileResponse(req, res, candidate, {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': 'public, max-age=604800',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: 'Minimap not found' })
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === 'GET' && req.url === '/robots.txt') {
|
||||
sendRobotsTxt(req, res)
|
||||
@@ -2189,6 +2421,31 @@ const server = http.createServer((req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
let pathname = ''
|
||||
try {
|
||||
pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname
|
||||
} catch {
|
||||
pathname = ''
|
||||
}
|
||||
|
||||
const replayMatch = pathname.match(/^\/api\/tss\/games\/([A-Za-z0-9_-]{1,96})\/replay-canvas$/)
|
||||
if (replayMatch) {
|
||||
serveTssReplayCanvas(req, res, replayMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/icons/type/')) {
|
||||
serveReplayIcon(req, res)
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/match/minimap/')) {
|
||||
serveReplayMinimap(req, res)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url.startsWith('/vehicle-icons/')) {
|
||||
serveVehicleIcon(req, res)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user