This commit is contained in:
Linrador 2025-09-27 14:44:13 +02:00
parent 25374ef2c0
commit 5100844e77
12 changed files with 471 additions and 126 deletions

2
.env
View File

@ -13,7 +13,7 @@ NEXTAUTH_URL=https://ironieopen.local
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
PTERODACTYL_CLIENT_API=ptlc_c31BKDEXy63fHUxeQDahk6eeC3CL19TpG2rgao7mUl5
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
PTERO_SERVER_SFTP_USER=army.37a11489

View File

@ -70,7 +70,7 @@ export default function GameBanner(props: Props) {
const ref = useRef<HTMLDivElement | null>(null)
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx)
const isSmDown = useIsSmDown()
const t = useTranslations('game-banner')
const tGameBanner = useTranslations('game-banner')
const phaseStr = String(phase ?? 'unknown').toLowerCase()
const show = !isSmDown && visible && phaseStr !== 'unknown'
@ -118,7 +118,7 @@ export default function GameBanner(props: Props) {
<span>Map: <span className="font-semibold">{pretty.map}</span></span>
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
<span>Score: <span className="font-semibold">{pretty.score}</span></span>
<span>{t('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
<span>{tGameBanner('player-connected')}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div>
)
@ -170,7 +170,7 @@ export default function GameBanner(props: Props) {
<div className="flex-1 min-w-0">
<div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
{isConnected ? (serverLabel ?? 'CS2 Server') : t('not-connected')}
{isConnected ? (serverLabel ?? 'CS2 Server') : tGameBanner('not-connected')}
</span>
</div>
<InfoRow />
@ -204,7 +204,7 @@ export default function GameBanner(props: Props) {
{isConnected ? (
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
) : (
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={t('reconnect')} />
<Button color="green" variant="solid" size="md" onClick={onReconnect} title={tGameBanner('reconnect')} />
)}
{isConnected && (
@ -214,13 +214,13 @@ export default function GameBanner(props: Props) {
size="md"
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
onClick={() => onDisconnect?.()}
aria-label={t('disconnected')}
aria-label={tGameBanner('quit')}
title={undefined}
>
<svg viewBox="0 0 24 24" className="h-5 w-5 block" aria-hidden="true">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="mt-0.5 text-[11px] font-medium opacity-90">{t('quit')}</span>
<span className="mt-0.5 text-[11px] font-medium opacity-90">{tGameBanner('quit')}</span>
</Button>
)}
</div>

View File

@ -620,7 +620,7 @@ export default function MapVotePanel({ match }: Props) {
{/* Linke Spalte (immer dein Team) */}
<div
className={[
'flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
'flex flex-col items-start max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
leftIsActiveTurn && !state?.locked
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
: 'bg-transparent shadow-none',
@ -791,7 +791,7 @@ export default function MapVotePanel({ match }: Props) {
{/* Rechte Spalte (Gegner) */}
<div
className={[
'flex flex-col items-start gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
'flex flex-col items-end max-w-[260px] md:max-w-[400px] gap-3 rounded-lg p-2 transition-all duration-300 ease-in-out',
rightIsActiveTurn && !state?.locked
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
: 'bg-transparent shadow-none',

View File

@ -20,7 +20,8 @@ type MatchRow = {
scoreA?: number | null
scoreB?: number | null
score?: string | null
team?: 'A' | 'B' | null
team?: 'A' | 'B' | 'CT' | 'T' | null
winnerTeam?: 'CT' | 'T' | null
result?: 'win' | 'loss' | 'draw' | null
matchType?: 'premier' | 'competitive' | string | null
rankNew?: number | null
@ -57,9 +58,6 @@ const fmtDateTime = (iso: string) =>
const isFiniteNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
const kdrLabel = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number' ? (d === 0 ? '∞' : (k / d).toFixed(2)) : '-'
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
if (!raw) return [null, null]
const [a, b] = raw.split(':').map(s => Number(s.trim()))
@ -95,14 +93,26 @@ const inferOwnSide = (m: MatchRow, a: number | null, b: number | null): 'A' | 'B
}
/** Score so anordnen, dass eigene Punkte links stehen */
const normalizeScore = (m: MatchRow, a: number | null, b: number | null): [number | null, number | null] => {
const normalizeScore = (
m: MatchRow,
a: number | null, // CT
b: number | null, // T
): [number | null, number | null] => {
if (a === null || b === null) return [a, b]
// 0) Explizite Team-Seite aus API (CT/T)
if (m.team === 'CT') return [a, b]
if (m.team === 'T') return [b, a]
// 1) Alte A/B-Logik
const side = inferOwnSide(m, a, b)
if (side === 'A') return [a, b]
if (side === 'B') return [b, a]
return [a, b] // nicht bestimmbar → unverändert
return [a, b]
}
const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | 'loss' | 'draw' | 'match' => {
if (own === null || opp === null) return 'match'
if (own > opp) return 'win'
@ -110,6 +120,16 @@ const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | '
return 'draw'
}
const kdr = (k?: number, d?: number) =>
typeof k === 'number' && typeof d === 'number'
? (d === 0 ? '∞' : (k / d).toFixed(2))
: '-'
const adr = (dmg?: number | null, rounds?: number | null) =>
typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0
? (dmg / rounds).toFixed(1)
: '-'
/* kleine Pill */
function Pill({ label, value }: { label: string; value: string }) {
return (
@ -149,11 +169,6 @@ export default async function MatchesList({ steamId }: Props) {
const linkId = String(m.matchId ?? m.id ?? '')
const href = linkId ? `/match-details/${linkId}` : undefined
const ADR =
isFiniteNum(m.totalDamage) && isFiniteNum(m.roundCount) && (m.roundCount ?? 0) > 0
? ((m.totalDamage as number) / (m.roundCount as number)).toFixed(1)
: '-'
const [scA, scB] = scoreOf(m)
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
@ -177,6 +192,8 @@ export default async function MatchesList({ steamId }: Props) {
const iconSrc = iconForMap(m.map)
const bgUrl = bgForMap(m.map)
console.log(m)
const row = (
<div
className={`relative cursor-pointer rounded-lg border p-3 transition ${rowTint}
@ -232,8 +249,8 @@ export default async function MatchesList({ steamId }: Props) {
<Pill label="K:" value={String(m.kills)} />
<Pill label="D:" value={String(m.deaths)} />
<Pill label="K/D:" value={kdrLabel(m.kills, m.deaths)} />
<Pill label="ADR:" value={ADR} />
<Pill label="K/D:" value={kdr(m.kills, m.deaths)} />
<Pill label="ADR:" value={adr(m.totalDamage, m.roundCount)} />
</div>
{/* Rechts: Rank-Block an den rechten Rand geschoben */}
@ -269,7 +286,7 @@ export default async function MatchesList({ steamId }: Props) {
return (
<div key={`${linkId || 'row'}-${idx}`}>
{href ? (
<Link href={href} className="block focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg">
<Link href={href} className="block rounded-lg">
{row}
</Link>
) : (

View File

@ -1,13 +1,15 @@
// /src/app/profile/[steamId]/stats/StatsView.tsx
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useSession } from 'next-auth/react'
import Chart from '../../../Chart'
import Card from '../../../Card'
import { MatchStats } from '@/types/match'
type Props = { stats: { matches: MatchStats[] } }
type Props = {
steamId: string // 👈 neu: Profil-ID
stats: { matches: MatchStats[] }
}
// ── helpers ──────────────────────────────────────────────────────────────
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
@ -88,12 +90,11 @@ function Pill({
}
// ── component ────────────────────────────────────────────────────────────
export default function StatsView({ stats }: Props) {
export default function StatsView({ steamId, stats }: Props) {
const { data: session } = useSession()
// const steamId = session?.user?.steamId // ggf. später für Highlights etc.
const matches = stats.matches ?? []
// --- KPI-Basiswerte ---
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 0), 0)
@ -105,6 +106,7 @@ export default function StatsView({ stats }: Props) {
const dateLabels = matches.map((m) => fmtDate(m.date))
// --- Lokale Aggregationen für andere Charts ---
const killsPerMap = useMemo(() => {
return matches.reduce<Record<string, number>>((acc, m) => {
const k = normMapKey(m.map)
@ -113,17 +115,34 @@ export default function StatsView({ stats }: Props) {
}, {})
}, [matches])
const gamesPerMap = useMemo(() => {
return matches.reduce<Record<string, number>>((acc, m) => {
const k = normMapKey(m.map)
acc[k] = (acc[k] || 0) + 1
return acc
}, {})
}, [matches])
const mapKeys = Object.keys(killsPerMap)
const mapNames = mapKeys.map(humanizeMap)
// --- WINRATE je Map vom API-Endpoint laden ---
const [wrLabels, setWrLabels] = useState<string[]>([])
const [wrValues, setWrValues] = useState<number[]>([])
useEffect(() => {
let aborted = false
;(async () => {
try {
const r = await fetch(`/api/user/${steamId}/winrate`, { cache: 'no-store' })
if (!r.ok) throw new Error('Winrate-Laden fehlgeschlagen')
const json: { labels: string[]; values: number[] } = await r.json()
if (!aborted) {
setWrLabels(json.labels || [])
setWrValues(json.values || [])
}
} catch (e) {
if (!aborted) {
setWrLabels([])
setWrValues([])
}
}
})()
return () => { aborted = true }
}, [steamId])
return (
<div className="space-y-6">
{/* KPI row */}
@ -205,9 +224,9 @@ export default function StatsView({ stats }: Props) {
labels={['Kills', 'Assists', 'Deaths']}
datasets={[
{
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [tone.blue, tone.amber, tone.red],
label: 'Anteile',
data: [totalKills, totalAssists, totalDeaths],
backgroundColor: [tone.blue, tone.amber, tone.red],
},
]}
/>
@ -239,7 +258,11 @@ export default function StatsView({ stats }: Props) {
datasets={[
{
label: 'K/D',
data: matches.map((m) => kd(m.kills, m.deaths) === Infinity ? (m.kills ?? 0) : (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)),
data: matches.map((m) =>
kd(m.kills, m.deaths) === Infinity
? (m.kills ?? 0)
: (m.kills ?? 0) / Math.max(1, m.deaths ?? 0)
),
borderColor: tone.red,
backgroundColor: tone.redBg,
borderWidth: 2,
@ -333,15 +356,16 @@ export default function StatsView({ stats }: Props) {
/>
</Card>
{/* 👇 Radar: Winrate je Map (ersetzt „Matches pro Map“) */}
<Card>
<Chart
type="radar"
title="Matches pro Map"
labels={mapNames}
title="Winrate je Map (%)"
labels={wrLabels}
datasets={[
{
label: 'Matches',
data: mapKeys.map((k) => gamesPerMap[k]),
label: 'Winrate',
data: wrValues, // Prozentwerte 0..100
backgroundColor: tone.blueBg,
borderColor: tone.blue,
borderWidth: 2,

View File

@ -85,49 +85,74 @@ export default function PrivacySettings() {
return (
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-start">
{/* Zeile: alles vertikal mittig */}
<div className="grid sm:grid-cols-12 gap-y-2 sm:gap-y-0 sm:gap-x-5 items-center">
{/* Label-Spalte */}
<div className="sm:col-span-4 2xl:col-span-2">
<span className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
<div className="sm:col-span-4 2xl:col-span-2 flex items-center">
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
{tSettings('sections.privacy.invites.label')}
</span>
</div>
{/* Inhalt-Spalte */}
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
{/* Toggle */}
<button
type="button"
disabled={loading || saving}
onClick={() => setCanBeInvited(v => !v)}
className={[
'relative inline-flex h-6 w-11 items-center rounded-full transition',
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
'disabled:opacity-60 disabled:cursor-not-allowed'
].join(' ')}
aria-pressed={canBeInvited}
aria-label={tSettings('sections.privacy.invites.label')}
title={undefined}
>
<span
{/* Switch + Hilfstext rechts → vertikal mittig */}
<div className="flex items-center gap-3">
{/* Toggle */}
<button
type="button"
disabled={loading || saving}
onClick={() => setCanBeInvited(v => !v)}
className={[
'inline-block h-5 w-5 transform rounded-full bg-white transition',
canBeInvited ? 'translate-x-5' : 'translate-x-1',
'relative inline-flex h-6 w-11 items-center rounded-full transition',
canBeInvited ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-neutral-700',
'disabled:opacity-60 disabled:cursor-not-allowed'
].join(' ')}
/>
</button>
aria-pressed={canBeInvited}
aria-label={tSettings('sections.privacy.invites.label')}
>
<span
className={[
'inline-block h-5 w-5 transform rounded-full bg-white transition',
canBeInvited ? 'translate-x-5' : 'translate-x-1',
].join(' ')}
/>
</button>
<p className="mt-2 text-sm text-gray-500 dark:text-neutral-400">
{tSettings('sections.privacy.invites.help')}
</p>
{/* Rechts: Hilfs-Text + Status NEBENeinander */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
{/* Hilfstext links, darf umbrechen */}
<p className="m-0 text-sm text-gray-500 dark:text-neutral-400 min-w-0 flex-1">
{tSettings('sections.privacy.invites.help')}
</p>
{/* Status/Fehler */}
<div className="mt-1 text-xs min-h-[1rem]">
{loading && <span className="text-gray-500 dark:text-neutral-400">{tCommon('loading') ?? 'Laden…'}</span>}
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving') ?? 'Speichern…'}</span>}
{savedOk === true && <span className="text-teal-600"> {tCommon('saved') ?? 'Gespeichert'}</span>}
{savedOk === false && <span className="text-red-600">{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}</span>}
{errorMsg && <p className="text-red-600">{errorMsg}</p>}
{/* Status rechts daneben, bleibt in einer Zeile */}
<div className="ml-auto text-xs whitespace-nowrap" aria-live="polite">
{loading && (
<span className="text-gray-500 dark:text-neutral-400">
{tCommon('loading') ?? 'Laden…'}
</span>
)}
{saving && (
<span className="text-gray-500 dark:text-neutral-400">
{tCommon('saving') ?? 'Speichern…'}
</span>
)}
{savedOk === true && (
<span className="text-teal-600">
{tCommon('saved') ?? 'Gespeichert'}
</span>
)}
{savedOk === false && (
<span className="text-red-600">
{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}
</span>
)}
{errorMsg && <span className="text-red-600">{errorMsg}</span>}
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
// /src/app/profile/[steamId]/stats/page.tsx
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
import { MatchStats } from '@/types/match'
async function getStats(steamId: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'}/api/stats/${steamId}`, { cache: 'no-store' })
const base = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000'
const res = await fetch(`${base}/api/stats/${steamId}`, { cache: 'no-store' })
if (!res.ok) return null
return res.json()
}
@ -11,5 +11,10 @@ async function getStats(steamId: string) {
export default async function StatsPage({ params }: { params: { steamId: string } }) {
const data = await getStats(params.steamId)
if (!data) return <p>Keine Statistiken verfügbar.</p>
return <StatsView stats={{ matches: data.stats as MatchStats[] }} />
return (
<StatsView
steamId={params.steamId}
stats={{ matches: data.stats as MatchStats[] }}
/>
)
}

View File

@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic'
function buildPanelUrl(base: string, serverId: string) {
const u = new URL(base.includes('://') ? base : `https://${base}`)
// /api/client/servers/:id/command
const cleaned = (u.pathname || '').replace(/\/+$/,'')
// Client-API Endpoint
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
return u.toString()
}
@ -22,41 +22,31 @@ export async function POST(req: NextRequest) {
if (!me?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
// Body lesen
let body: { command?: string; serverId?: string } = {}
try { body = await req.json() } catch {}
const command = (body.command ?? '').trim()
if (!command) return NextResponse.json({ error: 'command required' }, { status: 400 })
if (!command) {
return NextResponse.json({ error: 'command required' }, { status: 400 })
}
// Panel-Base-URL aus ENV
// Panel-URL aus ENV (wie in mapvote)
const panelBase =
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
''
(process.env.PTERODACTYL_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ?? '').trim()
if (!panelBase) {
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
}
// ServerId (global aus Config, optional via Body überschreibbar)
// Server-ID aus DB (optional via Body überschreibbar)
const cfg = await prisma.serverConfig.findUnique({
where: { id: 'default' },
select: { pterodactylServerId: true },
})
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
if (!serverId) {
return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
}
if (!serverId) return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
// Userbasierter Client-API-Key
const user = await prisma.user.findUnique({
where: { steamId: me.steamId! },
select: { pterodactylClientApiKey: true },
})
const clientKey = (user?.pterodactylClientApiKey ?? '').trim()
// ✅ Globaler Client-API-Key aus ENV (wie in mapvote)
const clientKey = (process.env.PTERODACTYL_CLIENT_API ?? '').trim()
if (!clientKey) {
return NextResponse.json({ error: 'missing client api key for user' }, { status: 403 })
return NextResponse.json({ error: 'PTERODACTYL_CLIENT_API not set' }, { status: 500 })
}
const url = buildPanelUrl(panelBase, serverId)
@ -70,28 +60,38 @@ export async function POST(req: NextRequest) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
// Panel ist „extern“ niemals cachen
cache: 'no-store',
})
// Pterodactyl antwortet häufig mit 204 No Content
// Pterodactyl antwortet bei Erfolg häufig mit 204 No Content
if (res.status === 204) {
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
}
const text = await res.text().catch(() => '')
let payload: any
try { payload = JSON.parse(text) } catch { payload = text }
if (res.status === 401) {
return NextResponse.json({
error: 'pterodactyl_unauthenticated',
detail: payload,
hint: 'Prüfe PTERODACTYL_CLIENT_API und dass es ein gültiger CLIENT-API-Key ist.',
}, { status: 502 })
}
if (res.status === 403) {
return NextResponse.json({
error: 'pterodactyl_forbidden',
detail: payload,
hint: 'Der Key ist gültig, hat aber keine Rechte auf diesen Server (Owner/Subuser + Console).',
}, { status: 502 })
}
if (!res.ok) {
let errPayload: any = undefined
try { errPayload = JSON.parse(text) } catch {}
return NextResponse.json(
{ error: 'pterodactyl_error', status: res.status, body: errPayload ?? text },
{ status: 502 }
)
return NextResponse.json({ error: 'pterodactyl_error', status: res.status, body: payload }, { status: 502 })
}
let json: any = {}
try { json = JSON.parse(text) } catch { json = { body: text } }
return NextResponse.json({ ok: true, status: res.status, response: json }, { headers: { 'Cache-Control': 'no-store' } })
// In seltenen Fällen kommt JSON zurück
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
} catch (e: any) {
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
}

View File

@ -23,12 +23,11 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
async function unloadCurrentMatch() {
// einige MatchZy Builds nutzen "matchzy_unloadmatch",
// andere trennen zwischen cancel/end. Der Unload reicht meist.
await sendServerCommand('matchzy_unloadmatch')
// optional „end/cancel“ hinterher, falls dein Build es erfordert:
// await sendServerCommand('matchzy_cancelmatch')
await sleep(500) // Server eine halbe Sekunde Luft lassen
// Statt "matchzy_unloadmatch": Plugin hard neustarten
await sendServerCommand(`css_plugins stop "MatchZy"`)
await sleep(800) // kleinen Moment warten, bis das Plugin sauber entladen ist
await sendServerCommand(`css_plugins start "MatchZy"`)
await sleep(1200) // Plugin initialisieren lassen (je nach Server ggf. erhöhen)
}
function makeRandomMatchId() {
@ -89,6 +88,8 @@ async function sendServerCommand(command: string) {
cache: 'no-store',
})
console.log(res);
if (res.status === 204) {
console.log('[mapvote] Command OK (204):', command)
return

View File

@ -41,7 +41,6 @@ export async function GET(
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
winnerTeam: true,
// nur die Stats des angefragten Spielers
players: { where: { steamId }, select: { stats: true } },
},
})
@ -49,17 +48,19 @@ export async function GET(
const hasMore = matches.length > limit
const page = hasMore ? matches.slice(0, limit) : matches
const items = page.map(m => {
const stats = m.players[0]?.stats ?? null
const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
const aim = stats?.aim ?? null
const kills = stats?.kills ?? 0
const deaths = stats?.deaths ?? 0
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
const rankOld = stats?.rankOld ?? null
const rankNew = stats?.rankNew ?? null
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
const aim = stats?.aim ?? null
const totalDamage = stats?.totalDamage ?? null // <- HINZU
const assists = stats?.assists ?? null // <- optional
const playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
@ -78,6 +79,8 @@ export async function GET(
deaths,
kdr,
aim,
totalDamage,
assists,
winnerTeam: m.winnerTeam ?? null,
team : playerTeam,
}

View File

@ -0,0 +1,142 @@
// /src/app/api/user/[steamId]/winrate/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { MAP_OPTIONS } from '@/lib/mapOptions'
/** Map-Key normalisieren (z.B. "maps/de_inferno.bsp" -> "de_inferno") */
function normMapKey(raw?: string | null) {
return (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '')
}
// Label- und Ordnungs-Lookup aus MAP_OPTIONS aufbauen
const MAP_LABEL_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.label] as const))
const MAP_ACTIVE_BY_KEY = new Map(MAP_OPTIONS.map(o => [o.key, o.active] as const))
const MAP_ORDER_BY_KEY = new Map(
MAP_OPTIONS.map((o, idx) => [o.key, idx] as const) // Reihenfolge wie in mapOptions
)
// Optional: bestimmte Pseudo-„Maps“ ignorieren (Lobby, etc.)
const IGNORED_KEYS = new Set(['lobby_mapvote'])
function labelFor(key: string) {
return MAP_LABEL_BY_KEY.get(key)
?? key.replace(/^de_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
/** Gewinner-Seite ermitteln */
function computeWinnerSide(m: {
winnerTeam: string | null
teamAId: string | null
teamBId: string | null
scoreA: number | null
scoreB: number | null
}): 'A' | 'B' | null {
const w = (m.winnerTeam ?? '').trim().toLowerCase()
if (w) {
if (w === 'a' || w === (m.teamAId ?? '').toLowerCase()) return 'A'
if (w === 'b' || w === (m.teamBId ?? '').toLowerCase()) return 'B'
}
if (typeof m.scoreA === 'number' && typeof m.scoreB === 'number') {
if (m.scoreA > m.scoreB) return 'A'
if (m.scoreB > m.scoreA) return 'B'
}
return null
}
/**
* GET /api/user/:steamId/winrate?types=premier,competitive&onlyActive=true
*
* Antwort:
* {
* labels: string[] // z.B. ["Inferno", "Mirage", ...] aus MAP_OPTIONS
* keys: string[] // z.B. ["de_inferno", "de_mirage", ...]
* values: number[] // Winrate 0..100 (ein Nachkomma)
* byMap: Record<key, { wins, losses, total, pct }>
* }
*/
export async function GET(req: NextRequest, { params }: { params: { steamId: string } }) {
const steamId = params.steamId
if (!steamId) return NextResponse.json({ error: 'Steam-ID fehlt' }, { status: 400 })
try {
const { searchParams } = new URL(req.url)
const typesParam = searchParams.get('types')
const types = typesParam ? typesParam.split(',').map(s => s.trim()).filter(Boolean) : []
const onlyActive = (searchParams.get('onlyActive') ?? 'true').toLowerCase() !== 'false'
const matches = await prisma.match.findMany({
where: {
players: { some: { steamId } },
...(types.length ? { matchType: { in: types } } : {}),
},
select: {
id: true,
map: true,
scoreA: true,
scoreB: true,
teamAId: true,
teamBId: true,
winnerTeam: true,
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
},
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
take: 1000,
})
const byMap: Record<string, { wins: number; losses: number; total: number; pct: number }> = {}
for (const m of matches) {
const keyRaw = normMapKey(m.map) || 'unknown'
if (IGNORED_KEYS.has(keyRaw)) continue
if (onlyActive && MAP_ACTIVE_BY_KEY.has(keyRaw) && !MAP_ACTIVE_BY_KEY.get(keyRaw)) continue
const key = keyRaw
if (!byMap[key]) byMap[key] = { wins: 0, losses: 0, total: 0, pct: 0 }
const inA = m.teamAUsers.some(u => u.steamId === steamId)
const inB = !inA && m.teamBUsers.some(u => u.steamId === steamId)
if (!inA && !inB) continue
const winner = computeWinnerSide({
winnerTeam: m.winnerTeam ?? null,
teamAId: m.teamAId ?? null,
teamBId: m.teamBId ?? null,
scoreA: m.scoreA ?? null,
scoreB: m.scoreB ?? null,
})
byMap[key].total += 1
if (winner) {
if ((winner === 'A' && inA) || (winner === 'B' && inB)) byMap[key].wins += 1
else byMap[key].losses += 1
}
}
// Prozente berechnen
const presentKeys = Object.keys(byMap)
for (const k of presentKeys) {
const it = byMap[k]
it.pct = it.total > 0 ? Math.round((it.wins / it.total) * 1000) / 10 : 0 // 1 Nachkomma
}
// Sortierung: 1) Reihenfolge aus MAP_OPTIONS, 2) danach alphabetisch (Label)
const sortedKeys = presentKeys.sort((a, b) => {
const ia = MAP_ORDER_BY_KEY.has(a) ? (MAP_ORDER_BY_KEY.get(a) as number) : Number.POSITIVE_INFINITY
const ib = MAP_ORDER_BY_KEY.has(b) ? (MAP_ORDER_BY_KEY.get(b) as number) : Number.POSITIVE_INFINITY
if (ia !== ib) return ia - ib
return labelFor(a).localeCompare(labelFor(b), 'de', { sensitivity: 'base' })
})
const labels = sortedKeys.map(k => labelFor(k))
const values = sortedKeys.map(k => byMap[k].pct)
return NextResponse.json(
{ labels, keys: sortedKeys, values, byMap },
{ headers: { 'Cache-Control': 'no-store' } }
)
} catch (err) {
console.error('[winrate] Fehler:', err)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

128
src/lib/stats/winrate.ts Normal file
View File

@ -0,0 +1,128 @@
// /src/lib/stats/winrate.ts
import 'server-only'
import { prisma } from '@/lib/prisma'
export type WinBucket = {
wins: number
losses: number
draws: number
winrate: number // 0..1
}
export type WinrateSummary = {
total: WinBucket
byMap: Record<string, WinBucket>
sampleSize: number // anzahl gewerteter matches (ohne fehlende scores)
}
export type WinrateOptions = {
types?: string[] // z.B. ['premier', 'competitive']
from?: Date | null // ab datum (demoDate)
to?: Date | null // bis datum (demoDate)
}
/**
* Robust ermitteln, ob der User Match gewonnen hat.
* Nutzt bevorzugt scoreA/scoreB; fällt sonst (best effort) auf winnerTeam zurück.
*/
function resultForUser(match: {
scoreA: number | null
scoreB: number | null
winnerTeam: string | null
teamAUsers: { steamId: string }[]
teamBUsers: { steamId: string }[]
teamAId?: string | null
teamBId?: string | null
}, steamId: string): 'win' | 'loss' | 'draw' | 'unknown' {
const inA = match.teamAUsers.some(u => u.steamId === steamId)
const inB = match.teamBUsers.some(u => u.steamId === steamId)
// Falls Scores vorhanden sind → daran entscheiden
if (match.scoreA != null && match.scoreB != null) {
if (match.scoreA === match.scoreB) return 'draw'
if (inA && match.scoreA > match.scoreB) return 'win'
if (inB && match.scoreB > match.scoreA) return 'win'
if (inA || inB) return 'loss'
}
// Fallback: winnerTeam (kann 'A'/'B', teamId oder teamName sein wir behandeln A/B/Id)
if (match.winnerTeam) {
const wt = String(match.winnerTeam).toLowerCase()
const aIds = new Set([String(match.teamAId ?? ''), 'a'])
const bIds = new Set([String(match.teamBId ?? ''), 'b'])
if (inA && aIds.has(wt)) return 'win'
if (inB && bIds.has(wt)) return 'win'
if (inA || inB) return 'loss'
}
return 'unknown'
}
function newBucket(): WinBucket {
return { wins: 0, losses: 0, draws: 0, winrate: 0 }
}
function finalizeBucket(b: WinBucket) {
const played = b.wins + b.losses // Draws werden meist nicht in WR eingerechnet
b.winrate = played > 0 ? b.wins / played : 0
return b
}
/**
* Aggregiert Winrates (gesamt & je Map) für einen User.
* Keine Schema-Änderung nötig basiert auf Match.map + Score + Team-Zugehörigkeit.
*/
export async function getUserWinrates(
steamId: string,
opts: WinrateOptions = {}
): Promise<WinrateSummary> {
const { types, from, to } = opts
const matches = await prisma.match.findMany({
where: {
players: { some: { steamId } }, // hat mitgespielt
...(types?.length ? { matchType: { in: types } } : {}),
...(from ? { demoDate: { gte: from } } : {}),
...(to ? { demoDate: { lte: to } } : {}),
},
orderBy: [{ demoDate: 'desc' }, { id: 'desc' }],
select: {
id: true,
map: true,
scoreA: true,
scoreB: true,
winnerTeam: true,
teamAId: true,
teamBId: true,
teamAUsers: { select: { steamId: true } },
teamBUsers: { select: { steamId: true } },
},
})
const total = newBucket()
const byMap: Record<string, WinBucket> = {}
// „gewertete“ Spiele: solche mit eindeutigem Ergebnis (Scores oder winnerTeam nutzbar)
let sampleSize = 0
for (const m of matches) {
const mapKey = (m.map ?? 'Unknown').toLowerCase()
if (!byMap[mapKey]) byMap[mapKey] = newBucket()
const res = resultForUser(m, steamId)
if (res === 'unknown') continue
sampleSize += 1
const buckets = [total, byMap[mapKey]]
for (const b of buckets) {
if (res === 'win') b.wins += 1
else if (res === 'loss') b.losses += 1
else b.draws += 1
}
}
finalizeBucket(total)
for (const k of Object.keys(byMap)) finalizeBucket(byMap[k])
return { total, byMap, sampleSize }
}