updates
This commit is contained in:
parent
25374ef2c0
commit
5100844e77
2
.env
2
.env
@ -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
|
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||||
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
ALLSTAR_TOKEN=ed033ac0-5df7-482e-a322-e2b4601955d3
|
||||||
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
PTERODACTYL_APP_API=ptla_O6Je82OvlCBFITDRgB1ZJ95AIyUSXYnVGgwRF6pO6d9
|
||||||
PTERODACTYL_CLIENT_API=ptlc_6NXqjxieIekaULga2jmuTPyPwdziigT82PRbrg3G4S7
|
PTERODACTYL_CLIENT_API=ptlc_c31BKDEXy63fHUxeQDahk6eeC3CL19TpG2rgao7mUl5
|
||||||
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
|
PTERODACTYL_PANEL_URL=https://panel.ironieopen.de
|
||||||
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
||||||
PTERO_SERVER_SFTP_USER=army.37a11489
|
PTERO_SERVER_SFTP_USER=army.37a11489
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export default function GameBanner(props: Props) {
|
|||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx)
|
const setBannerPx = useUiChromeStore(s => s.setGameBannerPx)
|
||||||
const isSmDown = useIsSmDown()
|
const isSmDown = useIsSmDown()
|
||||||
const t = useTranslations('game-banner')
|
const tGameBanner = useTranslations('game-banner')
|
||||||
|
|
||||||
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
const phaseStr = String(phase ?? 'unknown').toLowerCase()
|
||||||
const show = !isSmDown && visible && phaseStr !== 'unknown'
|
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>Map: <span className="font-semibold">{pretty.map}</span></span>
|
||||||
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
|
<span>Phase: <span className="font-semibold">{pretty.phase}</span></span>
|
||||||
<span>Score: <span className="font-semibold">{pretty.score}</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -170,7 +170,7 @@ export default function GameBanner(props: Props) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm flex items-center gap-2">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<InfoRow />
|
<InfoRow />
|
||||||
@ -204,7 +204,7 @@ export default function GameBanner(props: Props) {
|
|||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<Button color="green" variant="solid" size="md" onClick={openGame} title="Spiel öffnen" />
|
<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 && (
|
{isConnected && (
|
||||||
@ -214,13 +214,13 @@ export default function GameBanner(props: Props) {
|
|||||||
size="md"
|
size="md"
|
||||||
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
|
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
|
||||||
onClick={() => onDisconnect?.()}
|
onClick={() => onDisconnect?.()}
|
||||||
aria-label={t('disconnected')}
|
aria-label={tGameBanner('quit')}
|
||||||
title={undefined}
|
title={undefined}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5 block" aria-hidden="true">
|
<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" />
|
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -620,7 +620,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
{/* Linke Spalte (immer dein Team) */}
|
{/* Linke Spalte (immer dein Team) */}
|
||||||
<div
|
<div
|
||||||
className={[
|
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
|
leftIsActiveTurn && !state?.locked
|
||||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||||
: 'bg-transparent shadow-none',
|
: 'bg-transparent shadow-none',
|
||||||
@ -791,7 +791,7 @@ export default function MapVotePanel({ match }: Props) {
|
|||||||
{/* Rechte Spalte (Gegner) */}
|
{/* Rechte Spalte (Gegner) */}
|
||||||
<div
|
<div
|
||||||
className={[
|
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
|
rightIsActiveTurn && !state?.locked
|
||||||
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
? 'bg-green-100 dark:bg-green-900/20 shadow-[0_0_2px_rgba(34,197,94,0.7)]'
|
||||||
: 'bg-transparent shadow-none',
|
: 'bg-transparent shadow-none',
|
||||||
|
|||||||
@ -20,7 +20,8 @@ type MatchRow = {
|
|||||||
scoreA?: number | null
|
scoreA?: number | null
|
||||||
scoreB?: number | null
|
scoreB?: number | null
|
||||||
score?: string | null
|
score?: string | null
|
||||||
team?: 'A' | 'B' | null
|
team?: 'A' | 'B' | 'CT' | 'T' | null
|
||||||
|
winnerTeam?: 'CT' | 'T' | null
|
||||||
result?: 'win' | 'loss' | 'draw' | null
|
result?: 'win' | 'loss' | 'draw' | null
|
||||||
matchType?: 'premier' | 'competitive' | string | null
|
matchType?: 'premier' | 'competitive' | string | null
|
||||||
rankNew?: number | 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 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] => {
|
const parseScoreString = (raw?: string | null): [number | null, number | null] => {
|
||||||
if (!raw) return [null, null]
|
if (!raw) return [null, null]
|
||||||
const [a, b] = raw.split(':').map(s => Number(s.trim()))
|
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 */
|
/** 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]
|
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)
|
const side = inferOwnSide(m, a, b)
|
||||||
if (side === 'A') return [a, b]
|
if (side === 'A') return [a, b]
|
||||||
if (side === 'B') return [b, a]
|
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' => {
|
const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | 'loss' | 'draw' | 'match' => {
|
||||||
if (own === null || opp === null) return 'match'
|
if (own === null || opp === null) return 'match'
|
||||||
if (own > opp) return 'win'
|
if (own > opp) return 'win'
|
||||||
@ -110,6 +120,16 @@ const computeResultFromOwn = (own: number | null, opp: number | null): 'win' | '
|
|||||||
return 'draw'
|
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 */
|
/* kleine Pill */
|
||||||
function Pill({ label, value }: { label: string; value: string }) {
|
function Pill({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
@ -149,11 +169,6 @@ export default async function MatchesList({ steamId }: Props) {
|
|||||||
const linkId = String(m.matchId ?? m.id ?? '')
|
const linkId = String(m.matchId ?? m.id ?? '')
|
||||||
const href = linkId ? `/match-details/${linkId}` : undefined
|
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 [scA, scB] = scoreOf(m)
|
||||||
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
|
const [ownScore, oppScore] = normalizeScore(m, scA, scB)
|
||||||
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
|
const result = m.result ?? computeResultFromOwn(ownScore, oppScore)
|
||||||
@ -177,6 +192,8 @@ export default async function MatchesList({ steamId }: Props) {
|
|||||||
const iconSrc = iconForMap(m.map)
|
const iconSrc = iconForMap(m.map)
|
||||||
const bgUrl = bgForMap(m.map)
|
const bgUrl = bgForMap(m.map)
|
||||||
|
|
||||||
|
console.log(m)
|
||||||
|
|
||||||
const row = (
|
const row = (
|
||||||
<div
|
<div
|
||||||
className={`relative cursor-pointer rounded-lg border p-3 transition ${rowTint}
|
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="K:" value={String(m.kills)} />
|
||||||
<Pill label="D:" value={String(m.deaths)} />
|
<Pill label="D:" value={String(m.deaths)} />
|
||||||
<Pill label="K/D:" value={kdrLabel(m.kills, m.deaths)} />
|
<Pill label="K/D:" value={kdr(m.kills, m.deaths)} />
|
||||||
<Pill label="ADR:" value={ADR} />
|
<Pill label="ADR:" value={adr(m.totalDamage, m.roundCount)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rechts: Rank-Block – an den rechten Rand geschoben */}
|
{/* Rechts: Rank-Block – an den rechten Rand geschoben */}
|
||||||
@ -269,7 +286,7 @@ export default async function MatchesList({ steamId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div key={`${linkId || 'row'}-${idx}`}>
|
<div key={`${linkId || 'row'}-${idx}`}>
|
||||||
{href ? (
|
{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}
|
{row}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
// /src/app/profile/[steamId]/stats/StatsView.tsx
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import Chart from '../../../Chart'
|
import Chart from '../../../Chart'
|
||||||
import Card from '../../../Card'
|
import Card from '../../../Card'
|
||||||
import { MatchStats } from '@/types/match'
|
import { MatchStats } from '@/types/match'
|
||||||
|
|
||||||
type Props = { stats: { matches: MatchStats[] } }
|
type Props = {
|
||||||
|
steamId: string // 👈 neu: Profil-ID
|
||||||
|
stats: { matches: MatchStats[] }
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
|
const fmtInt = (n: number) => new Intl.NumberFormat('de-DE').format(n)
|
||||||
@ -88,12 +90,11 @@ function Pill({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── component ────────────────────────────────────────────────────────────
|
// ── component ────────────────────────────────────────────────────────────
|
||||||
export default function StatsView({ stats }: Props) {
|
export default function StatsView({ steamId, stats }: Props) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
// const steamId = session?.user?.steamId // ggf. später für Highlights etc.
|
|
||||||
|
|
||||||
const matches = stats.matches ?? []
|
const matches = stats.matches ?? []
|
||||||
|
|
||||||
|
// --- KPI-Basiswerte ---
|
||||||
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
|
const totalKills = matches.reduce((sum, m) => sum + (m.kills ?? 0), 0)
|
||||||
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
|
const totalDeaths = matches.reduce((sum, m) => sum + (m.deaths ?? 0), 0)
|
||||||
const totalAssists = matches.reduce((sum, m) => sum + (m.assists ?? 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))
|
const dateLabels = matches.map((m) => fmtDate(m.date))
|
||||||
|
|
||||||
|
// --- Lokale Aggregationen für andere Charts ---
|
||||||
const killsPerMap = useMemo(() => {
|
const killsPerMap = useMemo(() => {
|
||||||
return matches.reduce<Record<string, number>>((acc, m) => {
|
return matches.reduce<Record<string, number>>((acc, m) => {
|
||||||
const k = normMapKey(m.map)
|
const k = normMapKey(m.map)
|
||||||
@ -113,17 +115,34 @@ export default function StatsView({ stats }: Props) {
|
|||||||
}, {})
|
}, {})
|
||||||
}, [matches])
|
}, [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 mapKeys = Object.keys(killsPerMap)
|
||||||
const mapNames = mapKeys.map(humanizeMap)
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* KPI row */}
|
{/* KPI row */}
|
||||||
@ -239,7 +258,11 @@ export default function StatsView({ stats }: Props) {
|
|||||||
datasets={[
|
datasets={[
|
||||||
{
|
{
|
||||||
label: 'K/D',
|
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,
|
borderColor: tone.red,
|
||||||
backgroundColor: tone.redBg,
|
backgroundColor: tone.redBg,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
@ -333,15 +356,16 @@ export default function StatsView({ stats }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 👇 Radar: Winrate je Map (ersetzt „Matches pro Map“) */}
|
||||||
<Card>
|
<Card>
|
||||||
<Chart
|
<Chart
|
||||||
type="radar"
|
type="radar"
|
||||||
title="Matches pro Map"
|
title="Winrate je Map (%)"
|
||||||
labels={mapNames}
|
labels={wrLabels}
|
||||||
datasets={[
|
datasets={[
|
||||||
{
|
{
|
||||||
label: 'Matches',
|
label: 'Winrate',
|
||||||
data: mapKeys.map((k) => gamesPerMap[k]),
|
data: wrValues, // Prozentwerte 0..100
|
||||||
backgroundColor: tone.blueBg,
|
backgroundColor: tone.blueBg,
|
||||||
borderColor: tone.blue,
|
borderColor: tone.blue,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
|
|||||||
@ -85,16 +85,19 @@ export default function PrivacySettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 sm:py-8 border-t border-gray-200 dark:border-neutral-700">
|
<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 */}
|
{/* Label-Spalte */}
|
||||||
<div className="sm:col-span-4 2xl:col-span-2">
|
<div className="sm:col-span-4 2xl:col-span-2 flex items-center">
|
||||||
<span className="sm:mt-2.5 inline-block text-sm text-gray-500 dark:text-neutral-500">
|
<span className="inline-block text-sm text-gray-500 dark:text-neutral-500">
|
||||||
{tSettings('sections.privacy.invites.label')}
|
{tSettings('sections.privacy.invites.label')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inhalt-Spalte */}
|
{/* Inhalt-Spalte */}
|
||||||
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
<div className="sm:col-span-8 xl:col-span-6 2xl:col-span-5">
|
||||||
|
{/* Switch + Hilfstext rechts → vertikal mittig */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -107,7 +110,6 @@ export default function PrivacySettings() {
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-pressed={canBeInvited}
|
aria-pressed={canBeInvited}
|
||||||
aria-label={tSettings('sections.privacy.invites.label')}
|
aria-label={tSettings('sections.privacy.invites.label')}
|
||||||
title={undefined}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@ -117,17 +119,40 @@ export default function PrivacySettings() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-2 text-sm text-gray-500 dark:text-neutral-400">
|
{/* 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')}
|
{tSettings('sections.privacy.invites.help')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Status/Fehler */}
|
{/* Status rechts daneben, bleibt in einer Zeile */}
|
||||||
<div className="mt-1 text-xs min-h-[1rem]">
|
<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>}
|
{loading && (
|
||||||
{saving && <span className="text-gray-500 dark:text-neutral-400">{tCommon('saving') ?? 'Speichern…'}</span>}
|
<span className="text-gray-500 dark:text-neutral-400">
|
||||||
{savedOk === true && <span className="text-teal-600">✓ {tCommon('saved') ?? 'Gespeichert'}</span>}
|
{tCommon('loading') ?? 'Laden…'}
|
||||||
{savedOk === false && <span className="text-red-600">{tCommon('save-failed') ?? 'Speichern fehlgeschlagen'}</span>}
|
</span>
|
||||||
{errorMsg && <p className="text-red-600">{errorMsg}</p>}
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// /src/app/profile/[steamId]/stats/page.tsx
|
|
||||||
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
|
import StatsView from '@/app/[locale]/components/profile/[steamId]/stats/StatsView'
|
||||||
import { MatchStats } from '@/types/match'
|
import { MatchStats } from '@/types/match'
|
||||||
|
|
||||||
async function getStats(steamId: string) {
|
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
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
@ -11,5 +11,10 @@ async function getStats(steamId: string) {
|
|||||||
export default async function StatsPage({ params }: { params: { steamId: string } }) {
|
export default async function StatsPage({ params }: { params: { steamId: string } }) {
|
||||||
const data = await getStats(params.steamId)
|
const data = await getStats(params.steamId)
|
||||||
if (!data) return <p>Keine Statistiken verfügbar.</p>
|
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[] }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
function buildPanelUrl(base: string, serverId: string) {
|
function buildPanelUrl(base: string, serverId: string) {
|
||||||
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
const u = new URL(base.includes('://') ? base : `https://${base}`)
|
||||||
// /api/client/servers/:id/command
|
|
||||||
const cleaned = (u.pathname || '').replace(/\/+$/,'')
|
const cleaned = (u.pathname || '').replace(/\/+$/,'')
|
||||||
|
// Client-API Endpoint
|
||||||
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
|
||||||
return u.toString()
|
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?.steamId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||||
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
if (!me?.isAdmin) return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||||
|
|
||||||
|
// Body lesen
|
||||||
let body: { command?: string; serverId?: string } = {}
|
let body: { command?: string; serverId?: string } = {}
|
||||||
try { body = await req.json() } catch {}
|
try { body = await req.json() } catch {}
|
||||||
const command = (body.command ?? '').trim()
|
const command = (body.command ?? '').trim()
|
||||||
|
if (!command) return NextResponse.json({ error: 'command required' }, { status: 400 })
|
||||||
|
|
||||||
if (!command) {
|
// Panel-URL aus ENV (wie in mapvote)
|
||||||
return NextResponse.json({ error: 'command required' }, { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Panel-Base-URL aus ENV
|
|
||||||
const panelBase =
|
const panelBase =
|
||||||
process.env.PTERODACTYL_PANEL_URL ||
|
(process.env.PTERODACTYL_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ?? '').trim()
|
||||||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
|
|
||||||
''
|
|
||||||
if (!panelBase) {
|
if (!panelBase) {
|
||||||
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
|
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({
|
const cfg = await prisma.serverConfig.findUnique({
|
||||||
where: { id: 'default' },
|
where: { id: 'default' },
|
||||||
select: { pterodactylServerId: true },
|
select: { pterodactylServerId: true },
|
||||||
})
|
})
|
||||||
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
|
const serverId = (body.serverId ?? cfg?.pterodactylServerId ?? '').trim()
|
||||||
if (!serverId) {
|
if (!serverId) return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
|
||||||
return NextResponse.json({ error: 'serverId not configured' }, { status: 503 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Userbasierter Client-API-Key
|
// ✅ Globaler Client-API-Key aus ENV (wie in mapvote)
|
||||||
const user = await prisma.user.findUnique({
|
const clientKey = (process.env.PTERODACTYL_CLIENT_API ?? '').trim()
|
||||||
where: { steamId: me.steamId! },
|
|
||||||
select: { pterodactylClientApiKey: true },
|
|
||||||
})
|
|
||||||
const clientKey = (user?.pterodactylClientApiKey ?? '').trim()
|
|
||||||
if (!clientKey) {
|
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)
|
const url = buildPanelUrl(panelBase, serverId)
|
||||||
@ -70,28 +60,38 @@ export async function POST(req: NextRequest) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ command }),
|
body: JSON.stringify({ command }),
|
||||||
// Panel ist „extern“ – niemals cachen
|
|
||||||
cache: 'no-store',
|
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) {
|
if (res.status === 204) {
|
||||||
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
|
return NextResponse.json({ ok: true, status: 204 }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await res.text().catch(() => '')
|
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) {
|
if (!res.ok) {
|
||||||
let errPayload: any = undefined
|
return NextResponse.json({ error: 'pterodactyl_error', status: res.status, body: payload }, { status: 502 })
|
||||||
try { errPayload = JSON.parse(text) } catch {}
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'pterodactyl_error', status: res.status, body: errPayload ?? text },
|
|
||||||
{ status: 502 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let json: any = {}
|
// In seltenen Fällen kommt JSON zurück
|
||||||
try { json = JSON.parse(text) } catch { json = { body: text } }
|
return NextResponse.json({ ok: true, status: res.status, response: payload }, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
return NextResponse.json({ ok: true, status: res.status, response: json }, { headers: { 'Cache-Control': 'no-store' } })
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
|
return NextResponse.json({ error: e?.message ?? 'request_failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,11 @@ const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
|
|||||||
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
|
const sleep = (ms: number) => new Promise<void>(res => setTimeout(res, ms));
|
||||||
|
|
||||||
async function unloadCurrentMatch() {
|
async function unloadCurrentMatch() {
|
||||||
// einige MatchZy Builds nutzen "matchzy_unloadmatch",
|
// Statt "matchzy_unloadmatch": Plugin hard neustarten
|
||||||
// andere trennen zwischen cancel/end. Der Unload reicht meist.
|
await sendServerCommand(`css_plugins stop "MatchZy"`)
|
||||||
await sendServerCommand('matchzy_unloadmatch')
|
await sleep(800) // kleinen Moment warten, bis das Plugin sauber entladen ist
|
||||||
// optional „end/cancel“ hinterher, falls dein Build es erfordert:
|
await sendServerCommand(`css_plugins start "MatchZy"`)
|
||||||
// await sendServerCommand('matchzy_cancelmatch')
|
await sleep(1200) // Plugin initialisieren lassen (je nach Server ggf. erhöhen)
|
||||||
await sleep(500) // Server eine halbe Sekunde Luft lassen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRandomMatchId() {
|
function makeRandomMatchId() {
|
||||||
@ -89,6 +88,8 @@ async function sendServerCommand(command: string) {
|
|||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
console.log('[mapvote] Command OK (204):', command)
|
console.log('[mapvote] Command OK (204):', command)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -41,7 +41,6 @@ export async function GET(
|
|||||||
teamAUsers: { select: { steamId: true } },
|
teamAUsers: { select: { steamId: true } },
|
||||||
teamBUsers: { select: { steamId: true } },
|
teamBUsers: { select: { steamId: true } },
|
||||||
winnerTeam: true,
|
winnerTeam: true,
|
||||||
// nur die Stats des angefragten Spielers
|
|
||||||
players: { where: { steamId }, select: { stats: true } },
|
players: { where: { steamId }, select: { stats: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -49,17 +48,19 @@ export async function GET(
|
|||||||
const hasMore = matches.length > limit
|
const hasMore = matches.length > limit
|
||||||
const page = hasMore ? matches.slice(0, limit) : matches
|
const page = hasMore ? matches.slice(0, limit) : matches
|
||||||
|
|
||||||
|
|
||||||
const items = page.map(m => {
|
const items = page.map(m => {
|
||||||
const stats = m.players[0]?.stats ?? null
|
const stats = m.players[0]?.stats ?? null
|
||||||
|
|
||||||
const kills = stats?.kills ?? 0
|
const kills = stats?.kills ?? 0
|
||||||
const deaths = stats?.deaths ?? 0
|
const deaths = stats?.deaths ?? 0
|
||||||
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
const kdr = deaths ? (kills / deaths).toFixed(2) : '∞'
|
||||||
|
|
||||||
const rankOld = stats?.rankOld ?? null
|
const rankOld = stats?.rankOld ?? null
|
||||||
const rankNew = stats?.rankNew ?? null
|
const rankNew = stats?.rankNew ?? null
|
||||||
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
|
const rankChange = rankNew != null && rankOld != null ? rankNew - rankOld : null
|
||||||
const aim = stats?.aim ?? 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 playerTeam = m.teamAUsers.some(u => u.steamId === steamId) ? 'CT' : 'T'
|
||||||
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
const score = `${m.scoreA ?? 0} : ${m.scoreB ?? 0}`
|
||||||
@ -78,6 +79,8 @@ export async function GET(
|
|||||||
deaths,
|
deaths,
|
||||||
kdr,
|
kdr,
|
||||||
aim,
|
aim,
|
||||||
|
totalDamage,
|
||||||
|
assists,
|
||||||
winnerTeam: m.winnerTeam ?? null,
|
winnerTeam: m.winnerTeam ?? null,
|
||||||
team : playerTeam,
|
team : playerTeam,
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/app/api/user/[steamId]/winrate/route.ts
Normal file
142
src/app/api/user/[steamId]/winrate/route.ts
Normal 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
128
src/lib/stats/winrate.ts
Normal 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 }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user