updated build

This commit is contained in:
Linrador 2025-10-14 21:50:39 +02:00
parent 5a3faaf1fe
commit 15d369b76f
7 changed files with 424 additions and 266 deletions

View File

@ -1041,6 +1041,8 @@ export default function MapVotePanel({ match }: Props) {
src={getTeamLogo(teamLeft?.logo)}
alt={teamLeft?.name ?? 'Team'}
className="w-10 h-10 rounded-full border bg-white dark:bg-neutral-900 object-contain"
width={40}
height={40}
/>
) : <div className="w-10 h-10" />}
@ -1263,6 +1265,8 @@ export default function MapVotePanel({ match }: Props) {
src={bg}
alt={label}
className="absolute inset-0 w-full h-full object-cover"
width={40}
height={40}
/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
@ -1274,6 +1278,8 @@ export default function MapVotePanel({ match }: Props) {
alt="Picker-Team"
className={`absolute ${cornerPos} w-6 h-6 rounded-full object-contain bg-white/90 border border-white/70 shadow-sm`}
style={{ zIndex: 25 }}
width={40}
height={40}
/>
)}
@ -1303,6 +1309,8 @@ export default function MapVotePanel({ match }: Props) {
src={mapLogo}
alt={label}
className="max-h-[70%] max-w-[88%] object-contain drop-shadow-lg"
width={40}
height={40}
/>
<span className="px-2 py-0.5 rounded-md text-white/90 font-semibold text-xs md:text-sm">
{label}

View File

@ -30,9 +30,9 @@ export async function POST(req: NextRequest) {
// Panel-URL aus ENV (wie in mapvote)
const panelBase =
(process.env.PTERODACTYL_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ?? '').trim()
(process.env.PTERO_PANEL_URL ?? process.env.NEXT_PUBLIC_PTERO_PANEL_URL ?? '').trim()
if (!panelBase) {
return NextResponse.json({ error: 'PTERODACTYL_PANEL_URL not set' }, { status: 500 })
return NextResponse.json({ error: 'PTERO_PANEL_URL not set' }, { status: 500 })
}
// Server-ID aus DB (optional via Body überschreibbar)

View File

@ -1,45 +1,119 @@
// /src/app/api/matches/[matchid]/_builders.ts
// /src/app/api/matches/[matchId]/_builders.ts
import { prisma } from '@/lib/prisma'
/* ---------- schlanke Typen für die Eingaben ---------- */
type UserLike = {
steamId: string
name?: string | null
avatar?: string | null
location?: string | null
premierRank?: number | null
isAdmin?: boolean | null
createdAt?: Date | null
}
type TeamLike = {
id?: string | null
name?: string | null
logo?: string | null
leader?: UserLike | null
}
type TeamUserLink = {
steamId: string
team?: { name?: string | null; logo?: string | null } | null
}
type PlayerEntry = {
steamId: string
user: UserLike
stats?: unknown
team?: { name?: string | null } | null
}
type BaseTimes = {
matchDate?: Date | string | null
demoDate?: Date | string | null
createdAt?: Date | string | null
}
type MatchCommunityFutureInput = BaseTimes & {
id: string
title?: string | null
description?: string | null
matchType: string
map?: string | null
roundCount?: number | null
scoreA?: number | null
scoreB?: number | null
teamA?: TeamLike | null
teamB?: TeamLike | null
teamAUsers: TeamUserLink[]
teamBUsers: TeamUserLink[]
}
type MatchDefaultInput = MatchCommunityFutureInput & {
players: PlayerEntry[]
}
/* ---------- View-Model-Helfer ---------- */
export type PlayerVM = {
steamId: string
name: string
avatar: string
location: string | null
premierRank: number | null
isAdmin: boolean
createdAt: Date | null
}
/** Klein, konsistent, Frontend-freundlich */
export function toPlayerVM(u: any) {
export function toPlayerVM(u: UserLike): PlayerVM {
return {
steamId : u.steamId,
name : u.name ?? 'Unbekannt',
avatar : u.avatar ?? '/assets/img/avatars/default.png',
location : u.location ?? null,
steamId: u.steamId,
name: u.name ?? 'Unbekannt',
avatar: u.avatar ?? '/assets/img/avatars/default.png',
location: u.location ?? null,
premierRank: u.premierRank ?? null,
isAdmin : u.isAdmin ?? false,
createdAt : u.createdAt ?? null,
isAdmin: !!u.isAdmin,
createdAt: u.createdAt ?? null,
}
}
export const mapMP = (p: any) => ({
user : toPlayerVM(p.user),
export const mapMP = (p: { user: UserLike; stats?: unknown }) => ({
user: toPlayerVM(p.user),
stats: p.stats ?? null,
})
/* ---------- Zeit-Helfer ---------- */
/** Startzeit (matchDate || demoDate || createdAt) als Timestamp */
export function computeStartTs(m: any) {
export function computeStartTs(m: BaseTimes): number {
const base = m.matchDate ?? m.demoDate ?? m.createdAt
return new Date(base).getTime()
const d =
base instanceof Date
? base
: typeof base === 'string'
? new Date(base)
: new Date(0)
return d.getTime()
}
export function isFuture(m: any) {
export function isFuture(m: BaseTimes): boolean {
return computeStartTs(m) > Date.now()
}
/* ---------- Payload-Builder ---------- */
/**
* COMMUNITY + ZUKUNFT:
* zeige den ganzen Roster aus teamAUsers / teamBUsers (ohne Stats),
* damit man die Aufstellung bearbeiten kann.
*/
export async function buildCommunityFuturePayload(m: any) {
export async function buildCommunityFuturePayload(m: MatchCommunityFutureInput) {
const aIds: string[] = Array.from(
new Set((m.teamAUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean))
new Set((m.teamAUsers ?? []).map(u => String(u?.steamId)).filter(Boolean))
)
const bIds: string[] = Array.from(
new Set((m.teamBUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean))
new Set((m.teamBUsers ?? []).map(u => String(u?.steamId)).filter(Boolean))
)
const ids: string[] = [...aIds, ...bIds]
@ -49,8 +123,7 @@ export async function buildCommunityFuturePayload(m: any) {
})
: []
type PlayerVM = ReturnType<typeof toPlayerVM>
const byId = new Map<string, PlayerVM>(users.map(u => [u.steamId, toPlayerVM(u)]))
const byId = new Map<string, PlayerVM>(users.map(u => [u.steamId, toPlayerVM(u as unknown as UserLike)]))
const mapToPlayers = (list: string[]) =>
list
@ -65,32 +138,41 @@ export async function buildCommunityFuturePayload(m: any) {
const startTs = computeStartTs(m)
const editableUntil = startTs - 60 * 60 * 1000 // 1h vor Start/Vote
const firstA = m.teamAUsers?.[0]
const firstB = m.teamBUsers?.[0]
return {
id : m.id,
title : m.title,
id: m.id,
title: m.title,
description: m.description ?? null,
matchType : m.matchType,
map : m.map ?? null,
matchType: m.matchType,
map: m.map ?? null,
roundCount: m.roundCount ?? null,
scoreA : m.scoreA ?? null,
scoreB : m.scoreB ?? null,
demoDate : m.demoDate ?? null,
matchDate : (m.matchDate ?? m.demoDate ?? m.createdAt)?.toISOString?.() ?? null,
scoreA: m.scoreA ?? null,
scoreB: m.scoreB ?? null,
demoDate: m.demoDate ?? null,
matchDate:
(m.matchDate ?? m.demoDate ?? m.createdAt) &&
(m.matchDate ?? m.demoDate ?? m.createdAt) instanceof Date
? (m.matchDate as Date)?.toISOString?.() ??
(m.demoDate as Date)?.toISOString?.() ??
(m.createdAt as Date)?.toISOString?.()
: null,
editableUntil,
teamA: {
id : m.teamA?.id ?? null,
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
score : m.scoreA ?? null,
id: m.teamA?.id ?? null,
name: m.teamA?.name ?? firstA?.team?.name ?? 'Team A',
logo: m.teamA?.logo ?? firstA?.team?.logo ?? null,
score: m.scoreA ?? null,
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
players: teamAPlayers,
},
teamB: {
id : m.teamB?.id ?? null,
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
score : m.scoreB ?? null,
id: m.teamB?.id ?? null,
name: m.teamB?.name ?? firstB?.team?.name ?? 'Team B',
logo: m.teamB?.logo ?? firstB?.team?.logo ?? null,
score: m.scoreB ?? null,
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
players: teamBPlayers,
},
@ -102,53 +184,58 @@ export async function buildCommunityFuturePayload(m: any) {
* zeige nur die Spieler, die dem Match zugeordnet sind (über teamAUsers/BUsers gematcht).
* Stats bleiben erhalten (kommen aus m.players.include.stats).
*/
export function buildDefaultPayload(m: any) {
const teamAIds = new Set(m.teamAUsers.map((u: any) => u.steamId))
const teamBIds = new Set(m.teamBUsers.map((u: any) => u.steamId))
export function buildDefaultPayload(m: MatchDefaultInput) {
const teamAIds = new Set((m.teamAUsers ?? []).map(u => u.steamId))
const teamBIds = new Set((m.teamBUsers ?? []).map(u => u.steamId))
const playersA = (m.players ?? [])
.filter((p: any) => teamAIds.has(p.steamId))
.map((p: any) => ({
.filter(p => teamAIds.has(p.steamId))
.map(p => ({
user: toPlayerVM(p.user),
stats: p.stats ?? null,
team: p.team?.name ?? 'Team A',
}))
const playersB = (m.players ?? [])
.filter((p: any) => teamBIds.has(p.steamId))
.map((p: any) => ({
.filter(p => teamBIds.has(p.steamId))
.map(p => ({
user: toPlayerVM(p.user),
stats: p.stats ?? null,
team: p.team?.name ?? 'Team B',
}))
const matchDate = m.demoDate ?? m.matchDate ?? m.createdAt
const baseDate = m.demoDate ?? m.matchDate ?? m.createdAt
const iso =
baseDate instanceof Date ? baseDate.toISOString() : null
const firstA = m.teamAUsers?.[0]
const firstB = m.teamBUsers?.[0]
return {
id : m.id,
title : m.title,
id: m.id,
title: m.title,
description: m.description ?? null,
matchType : m.matchType,
map : m.map ?? null,
matchType: m.matchType,
map: m.map ?? null,
roundCount: m.roundCount ?? null,
scoreA : m.scoreA ?? null,
scoreB : m.scoreB ?? null,
demoDate : m.demoDate ?? null,
matchDate : matchDate?.toISOString?.() ?? null,
scoreA: m.scoreA ?? null,
scoreB: m.scoreB ?? null,
demoDate: m.demoDate ?? null,
matchDate: iso,
teamA: {
id : m.teamA?.id ?? null,
name : m.teamA?.name ?? m.teamAUsers[0]?.team?.name ?? 'Team A',
logo : m.teamA?.logo ?? m.teamAUsers[0]?.team?.logo ?? null,
score : m.scoreA ?? null,
id: m.teamA?.id ?? null,
name: m.teamA?.name ?? firstA?.team?.name ?? 'Team A',
logo: m.teamA?.logo ?? firstA?.team?.logo ?? null,
score: m.scoreA ?? null,
leader: m.teamA?.leader ? toPlayerVM(m.teamA.leader) : undefined,
players: playersA,
},
teamB: {
id : m.teamB?.id ?? null,
name : m.teamB?.name ?? m.teamBUsers[0]?.team?.name ?? 'Team B',
logo : m.teamB?.logo ?? m.teamBUsers[0]?.team?.logo ?? null,
score : m.scoreB ?? null,
id: m.teamB?.id ?? null,
name: m.teamB?.name ?? firstB?.team?.name ?? 'Team B',
logo: m.teamB?.logo ?? firstB?.team?.logo ?? null,
score: m.scoreB ?? null,
leader: m.teamB?.leader ? toPlayerVM(m.teamB.leader) : undefined,
players: playersB,
},

View File

@ -14,11 +14,77 @@ export const dynamic = 'force-dynamic'
type Ctx = { params: Promise<{ matchId: string }> } // 🔹 gemeinsamer Ctx-Typ
type UserLike = {
steamId: string
name?: string | null
avatar?: string | null
location?: string | null
premierRank?: number | null
isAdmin?: boolean | null
}
type TeamLike = {
id: string
name?: string | null
logo?: string | null
leaderId?: string | null
leader?: UserLike | null
}
type TeamUser = UserLike & {
team?: { id?: string | null; name?: string | null; logo?: string | null } | null
}
type VoteStepDb = {
id: string
order: number
action: MapVoteAction
teamId: string | null
map: string | null
chosenAt: Date | null
chosenBy: string | null
}
type VoteDb = {
id: string
matchId: string
bestOf: number
mapPool: string[]
currentIdx: number
locked: boolean
opensAt: Date | null
adminEditingBy: string | null
adminEditingSince: Date | null
steps: VoteStepDb[]
}
type MatchDb = {
id: string
matchType: string
title?: string | null
map?: string | null
matchDate: Date | null
demoDate: Date | null
createdAt: Date
teamA: (TeamLike & { leader?: UserLike | null }) | null
teamB: (TeamLike & { leader?: UserLike | null }) | null
teamAUsers: TeamUser[]
teamBUsers: TeamUser[]
players: Array<{ steamId: string; user: UserLike }>
mapVote?: VoteDb | null
}
type MapVisual = { label: string; bg: string; logo?: string }
/* -------------------- Konstanten -------------------- */
const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
}
// --- kleine Utils
const toIso = (d?: Date | null) => (d ? d.toISOString() : null)
const byOrder = <T extends { order: number }>(a: T, b: T) => a.order - b.order
/* -------------------- Helper -------------------- */
@ -53,11 +119,11 @@ function buildPteroClientUrl(base: string, serverId: string) {
async function sendServerCommand(command: string) {
try {
const panelBase =
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
process.env.PTERO_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERO_PANEL_URL ||
''
if (!panelBase) {
console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt Command wird nicht gesendet.')
console.warn('[mapvote] PTERO_PANEL_URL fehlt Command wird nicht gesendet.')
return
}
@ -152,90 +218,90 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
] as const
}
function shapeState(vote: any, match?: any) {
const steps = [...vote.steps]
.sort((a, b) => a.order - b.order)
.map((s: any) => ({
order : s.order,
action : mapActionToApi(s.action),
teamId : s.teamId,
map : s.map,
chosenAt: s.chosenAt ? s.chosenAt.toISOString() : null,
chosenBy: s.chosenBy ?? null,
}))
function shapeState(vote: VoteDb, match?: Pick<MatchDb, 'matchDate' | 'demoDate'>) {
const steps = [...vote.steps].sort(byOrder).map(s => ({
order: s.order,
action: mapActionToApi(s.action),
teamId: s.teamId,
map: s.map,
chosenAt: toIso(s.chosenAt),
chosenBy: s.chosenBy ?? null,
}))
const opensAtDate = vote.opensAt ? new Date(vote.opensAt) : null
const baseDate = match?.matchDate ?? match?.demoDate ?? null
const baseDate = match?.matchDate ?? match?.demoDate ?? null
const leadMinutes =
opensAtDate && baseDate
? Math.max(0, Math.round((new Date(baseDate).getTime() - opensAtDate.getTime()) / 60000))
? Math.max(0, Math.round(((baseDate as Date).getTime() - opensAtDate.getTime()) / 60000))
: null
return {
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
bestOf: vote.bestOf,
mapPool: vote.mapPool,
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : opensAtDate ? opensAtDate.toISOString() : null,
locked: vote.locked,
opensAt: toIso(vote.opensAt),
leadMinutes,
steps,
adminEdit: vote.adminEditingBy
? { enabled: true, by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null }
? {
enabled: true,
by: vote.adminEditingBy,
since: toIso(vote.adminEditingSince),
}
: { enabled: false, by: null, since: null },
}
}
// Leader -> Player-Shape fürs Frontend
function shapeLeader(leader: any | null) {
function shapeLeader(leader: UserLike | null | undefined) {
if (!leader) return null
return {
steamId : leader.steamId,
name : leader.name ?? '',
avatar : leader.avatar ?? '',
location : leader.location ?? undefined,
steamId: leader.steamId,
name: leader.name ?? '',
avatar: leader.avatar ?? '',
location: leader.location ?? undefined,
premierRank: leader.premierRank ?? undefined,
isAdmin : leader.isAdmin ?? undefined,
isAdmin: leader.isAdmin ?? undefined,
}
}
// Teams-Payload (mit Spielern) zusammenbauen
function shapeUser(u: any) {
function shapeUser(u: TeamUser | null | undefined) {
if (!u) return null
return {
steamId : u.steamId,
name : u.name ?? '',
avatar : u.avatar ?? '',
location : u.location ?? undefined,
steamId: u.steamId,
name: u.name ?? '',
avatar: u.avatar ?? '',
location: u.location ?? undefined,
premierRank: u.premierRank ?? undefined,
isAdmin : u.isAdmin ?? undefined,
isAdmin: u.isAdmin ?? undefined,
}
}
function buildTeamsPayloadFromMatch(match: any) {
const teamAPlayers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean)
const teamBPlayers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean)
function buildTeamsPayloadFromMatch(match: MatchDb) {
const teamAPlayers = (match.teamAUsers ?? []).map(shapeUser).filter(Boolean) as Array<ReturnType<typeof shapeUser>>
const teamBPlayers = (match.teamBUsers ?? []).map(shapeUser).filter(Boolean) as Array<ReturnType<typeof shapeUser>>
return {
teamA: {
id : match.teamA?.id ?? null,
name : match.teamA?.name ?? null,
logo : match.teamA?.logo ?? null,
leader : shapeLeader(match.teamA?.leader ?? null),
id: match.teamA?.id ?? null,
name: match.teamA?.name ?? null,
logo: match.teamA?.logo ?? null,
leader: shapeLeader(match.teamA?.leader ?? null),
players: teamAPlayers,
},
teamB: {
id : match.teamB?.id ?? null,
name : match.teamB?.name ?? null,
logo : match.teamB?.logo ?? null,
leader : shapeLeader(match.teamB?.leader ?? null),
id: match.teamB?.id ?? null,
name: match.teamB?.name ?? null,
logo: match.teamB?.logo ?? null,
leader: shapeLeader(match.teamB?.leader ?? null),
players: teamBPlayers,
},
}
}
async function ensureVote(matchId: string) {
const match = await prisma.match.findUnique({
async function ensureVote(matchId: string): Promise<{ match: MatchDb | null; vote: VoteDb | null }> {
const match = (await prisma.match.findUnique({
where: { id: matchId },
include: {
teamA: { include: { leader: { select: { steamId: true, name: true, avatar: true, location: true, premierRank: true, isAdmin: true } } } },
@ -245,47 +311,35 @@ async function ensureVote(matchId: string) {
players: { include: { user: true } },
mapVote: { include: { steps: true } },
},
});
})) as unknown as MatchDb | null
if (!match) return { match: null, vote: null };
if (!match) return { match: null, vote: null }
// Aktive Maps lt. Konfiguration (ohne Lobby)
const CURRENT_ACTIVE = MAP_OPTIONS
.filter(m => m.active && m.key !== 'lobby_mapvote')
.map(m => m.key);
const CURRENT_ACTIVE = MAP_OPTIONS.filter(m => m.active && m.key !== 'lobby_mapvote').map(m => m.key)
if (match.mapVote) {
let vote = match.mapVote;
// bereits gewählte Maps im Pool behalten (auch wenn jetzt inaktiv)
const chosen = vote.steps.map(s => s.map).filter(Boolean) as string[];
const desiredPool = Array.from(new Set([...CURRENT_ACTIVE, ...chosen]));
// Pool ggf. aktualisieren
if (
desiredPool.length !== vote.mapPool.length ||
desiredPool.some(k => !vote.mapPool.includes(k))
) {
vote = await prisma.mapVote.update({
let vote = match.mapVote
const chosen = vote.steps.map(s => s.map).filter(Boolean) as string[]
const desiredPool = Array.from(new Set([...CURRENT_ACTIVE, ...chosen]))
if (desiredPool.length !== vote.mapPool.length || desiredPool.some(k => !vote.mapPool.includes(k))) {
vote = (await prisma.mapVote.update({
where: { id: vote.id },
data: { mapPool: desiredPool },
include: { steps: true },
});
})) as unknown as VoteDb
}
return { match, vote };
return { match, vote }
}
// Neu anlegen
const bestOf = match.matchType === 'community' ? 3 : 1;
const mapPool = CURRENT_ACTIVE; // neu: ohne Lobby & nur aktuelle Aktive
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null });
const bestOf = match.matchType === 'community' ? 3 : 1
const mapPool = CURRENT_ACTIVE
const opensAt = voteOpensAt({ matchDate: match.matchDate, demoDate: match.demoDate })
const firstIsA = typeof randomInt === 'function' ? randomInt(0, 2) === 0 : Math.random() < 0.5
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId)
const firstIsA = (typeof randomInt === 'function') ? randomInt(0, 2) === 0 : Math.random() < 0.5;
const firstTeamId = firstIsA ? match.teamA!.id : match.teamB!.id;
const secondTeamId = firstIsA ? match.teamB!.id : match.teamA!.id;
const stepsDef = buildSteps(bestOf, firstTeamId, secondTeamId);
const created = await prisma.mapVote.create({
const created = (await prisma.mapVote.create({
data: {
matchId: match.id,
bestOf,
@ -296,9 +350,9 @@ async function ensureVote(matchId: string) {
steps: { create: stepsDef.map(s => ({ order: s.order, action: s.action as MapVoteAction, teamId: s.teamId })) },
},
include: { steps: true },
});
})) as unknown as VoteDb
return { match, vote: created };
return { match, vote: created }
}
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) {
@ -308,7 +362,7 @@ function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | nu
/* ---------- Visuals: deterministisches zufälliges Bild pro Map & Match ---------- */
function buildMapVisuals(matchId: string, mapPool: string[]) {
function buildMapVisuals(matchId: string, mapPool: string[]): Record<string, MapVisual> {
const visuals: Record<string, { label: string; bg: string; logo?: string }> = {}
for (const key of mapPool) {
const opt = MAP_OPTIONS.find(o => o.key === key)
@ -327,72 +381,55 @@ function buildMapVisuals(matchId: string, mapPool: string[]) {
return visuals
}
function uniq<T>(arr: T[]) {
return Array.from(new Set(arr))
}
function collectParticipants(match: any): string[] {
const a = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
const b = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
// ❗ keine Leader/activePlayers/match.players mehr NUR echte Teamspieler
function collectParticipants(match: Pick<MatchDb, 'teamAUsers' | 'teamBUsers'>): string[] {
const a = (match.teamAUsers ?? []).map(u => u.steamId).filter(Boolean)
const b = (match.teamBUsers ?? []).map(u => u.steamId).filter(Boolean)
return Array.from(new Set<string>([...a, ...b]))
}
async function persistMatchPlayers(match: any) {
// Teilnehmer ermitteln (du hast schon collectParticipants)
const participants = collectParticipants(match); // string[] der steamIds
async function persistMatchPlayers(match: Pick<MatchDb, 'id' | 'teamA' | 'teamB' | 'teamAUsers' | 'teamBUsers'>) {
const participants = collectParticipants(match)
const aIds = new Set((match.teamAUsers ?? []).map(u => u.steamId))
const bIds = new Set((match.teamBUsers ?? []).map(u => u.steamId))
// teamId pro Spieler bestimmen (A oder B), sonst null
const aIds = new Set((match.teamAUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean));
const bIds = new Set((match.teamBUsers ?? []).map((u: any) => String(u?.steamId)).filter(Boolean));
const ops = participants.map((steamId) => {
const onTeamA = aIds.has(String(steamId));
const onTeamB = bIds.has(String(steamId));
const teamId =
onTeamA ? match.teamA?.id
: onTeamB ? match.teamB?.id
: null;
// Upsert je Spieler fürs Match
const ops = participants.map(steamId => {
const teamId =
aIds.has(steamId) ? match.teamA?.id
: bIds.has(steamId) ? match.teamB?.id
: null
return prisma.matchPlayer.upsert({
where: { matchId_steamId: { matchId: match.id, steamId } },
update: { teamId }, // falls sich die Team-Zuordnung ändert
update: { teamId },
create: { matchId: match.id, steamId, teamId },
});
});
await prisma.$transaction(ops);
})
})
await prisma.$transaction(ops)
}
/* ---------- Export-Helfer ---------- */
type PlayerLike = { user?: { steamId: string, name?: string | null }, steamId?: string, name?: string | null }
type PlayerLike = { user?: { steamId: string; name?: string | null }; steamId?: string; name?: string | null }
type MatchLike = {
id: string | number
bestOf?: number
teamA?: { name?: string | null, players?: PlayerLike[] | any[] }
teamB?: { name?: string | null, players?: PlayerLike[] | any[] }
teamA?: { name?: string | null; players?: PlayerLike[] }
teamB?: { name?: string | null; players?: PlayerLike[] }
}
type MapVoteStep = { action: 'ban' | 'pick' | 'decider', map?: string | null, teamId?: string | null }
type MapVoteStateForExport = { bestOf?: number, steps: MapVoteStep[], locked?: boolean }
type MapVoteStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null; teamId?: string | null }
type MapVoteStateForExport = { bestOf?: number; steps: MapVoteStep[]; locked?: boolean }
function sanitizeFilePart(s?: string | null) {
return (s ?? 'team').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function playersMapFromList(list: PlayerLike[] | any[] | undefined) {
function playersMapFromList(list: PlayerLike[] | undefined) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined
const sid = p.user?.steamId ?? p.steamId
if (!sid) continue
const name = (p?.user?.name ?? p?.name ?? 'Player') as string
const name = p.user?.name ?? p.name ?? 'Player'
out[sid] = name
}
return out
}
function toDeMapName(key: string) {
if (key.startsWith('de_')) return key
return `de_${key}`
@ -436,34 +473,30 @@ function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
}
}
async function exportMatchToSftpDirect(match: any, vote: any) {
async function exportMatchToSftpDirect(match: MatchDb, vote: VoteDb) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default
const mLike: MatchLike = {
id: match.id,
bestOf: (vote?.bestOf ?? (match.matchType === 'community' ? 3 : 1)),
bestOf: vote?.bestOf ?? (match.matchType === 'community' ? 3 : 1),
teamA: { name: match.teamA?.name ?? 'Team_1', players: match.teamAUsers ?? [] },
teamB: { name: match.teamB?.name ?? 'Team_2', players: match.teamBUsers ?? [] },
}
const sLike: MapVoteStateForExport = {
bestOf: vote.bestOf,
steps: [...vote.steps]
.sort((a: any, b: any) => a.order - b.order)
.map((s: any) => ({ action: mapActionToApi(s.action), map: s.map, teamId: s.teamId })),
steps: [...vote.steps].sort(byOrder).map(s => ({ action: mapActionToApi(s.action), map: s.map, teamId: s.teamId })),
locked: vote.locked,
}
if (!sLike.locked) return
const bestOf = (mLike.bestOf ?? sLike.bestOf ?? (match.matchType === 'community' ? 3 : 1))
const bestOf = mLike.bestOf ?? sLike.bestOf ?? (match.matchType === 'community' ? 3 : 1)
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return false
// ⬇️ JSON bauen (enthält cs2MatchId/rndId)
const json = buildMatchJson(mLike, sLike)
const jsonStr = JSON.stringify(json, null, 2)
const filename = `${match.id}.json`
// --- SFTP Upload wie gehabt ---
const url = process.env.PTERO_SERVER_SFTP_URL || ''
const user = process.env.PTERO_SERVER_SFTP_USER
const pass = process.env.PTERO_SERVER_SFTP_PASSWORD
@ -485,17 +518,13 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
const sftp = new SFTPClient()
await sftp.connect({ host, port, username: user, password: pass })
const remotePath = `/game/csgo/${filename}`
await sftp.put(Buffer.from(jsonStr, 'utf8'), remotePath)
await sftp.end()
console.log(`[mapvote] Export OK → ${remotePath}`)
// erst aktuelles Match beenden/entladen …
await unloadCurrentMatch()
// … dann das neue laden
await sendServerCommand(`matchzy_loadmatch ${filename}`)
if (typeof json.matchid === 'number') {
@ -517,28 +546,25 @@ async function exportMatchToSftpDirect(match: any, vote: any) {
}
}
async function writeLiveStateToServerConfig(match: any, vote: any, mapVisuals: Record<string, any>) {
async function writeLiveStateToServerConfig(match: MatchDb, _vote: VoteDb, mapVisuals: Record<string, MapVisual>) {
try {
// gewählte Maps (PICK/DECIDER), erste Map extrahieren
const chosen = deriveChosenSteps(vote)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : null
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : null
const chosen = deriveChosenSteps(_vote)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : null
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : null
const participants = collectParticipants(match)
// Wir gehen davon aus, dass es "default" gibt.
await prisma.serverConfig.update({
where: { id: 'default' },
data: {
activeMatchId: match.id,
activeMapKey : key,
activeMapKey: key,
activeMapLabel: label,
activeMapBg : bg,
activeMapBg: bg,
activeParticipants: participants,
activeSince : new Date(),
// z.B. auf 2h Sichtbarkeit begrenzen (optional)
activeSince: new Date(),
bannerExpiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
},
})
@ -558,27 +584,20 @@ async function writeLiveStateToServerConfig(match: any, vote: any, mapVisuals: R
}
}
/** DRY: Wird in jedem "locked"-Pfad aufgerufen */
async function afterVoteLocked(match: any, vote: any, mapVisuals: Record<string, any>) {
// 1) Spieler festschreiben
async function afterVoteLocked(match: MatchDb, vote: VoteDb, mapVisuals: Record<string, MapVisual>) {
await persistMatchPlayers(match)
// 2) Serverexport + Matchzy-Load
const ok = await exportMatchToSftpDirect(match, vote) // ← wartet bis Load versucht wurde
// 3) Nur wenn Export/Load ok war: Live-State + SSE "server-config-updated"
const ok = await exportMatchToSftpDirect(match, vote)
if (ok) {
await writeLiveStateToServerConfig(match, vote, mapVisuals)
}
}
/* ---------- kleine Helfer für match-ready Payload ---------- */
function deriveChosenSteps(vote: any) {
const steps = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
return steps.filter((s: any) => (s.action === 'PICK' || s.action === 'DECIDER') && s.map)
function deriveChosenSteps(vote: Pick<VoteDb, 'steps'>) {
const steps = [...vote.steps].sort(byOrder)
return steps.filter(s => (s.action === 'PICK' || s.action === 'DECIDER') && !!s.map)
}
/* -------------------- GET -------------------- */
@ -672,7 +691,11 @@ export async function POST(req: NextRequest, ctx: Ctx) {
const updated = await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
}) as (VoteDb | null);
if (!updated) {
return NextResponse.json({ message: 'Vote nicht gefunden' }, { status: 404 })
}
await sendServerSSEMessage({
type: 'map-vote-updated',
@ -680,7 +703,7 @@ export async function POST(req: NextRequest, ctx: Ctx) {
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
// match-ready senden (erste Map + Teilnehmer)
if (updated?.locked) {
@ -734,7 +757,11 @@ export async function POST(req: NextRequest, ctx: Ctx) {
const updated = await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
}) as (VoteDb | null);
if (!updated) {
return NextResponse.json({ message: 'Vote nicht gefunden' }, { status: 404 })
}
await sendServerSSEMessage({
type: 'map-vote-updated',
@ -742,7 +769,7 @@ export async function POST(req: NextRequest, ctx: Ctx) {
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
// match-ready senden
if (updated?.locked) {
@ -834,7 +861,11 @@ export async function POST(req: NextRequest, ctx: Ctx) {
const updated = await prisma.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
}) as (VoteDb | null);
if (!updated) {
return NextResponse.json({ message: 'Vote nicht gefunden' }, { status: 404 })
}
await sendServerSSEMessage({
type: 'map-vote-updated',
@ -842,7 +873,7 @@ export async function POST(req: NextRequest, ctx: Ctx) {
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
// Falls durch diesen Schritt locked wurde → Export & match-ready
if (updated?.locked) {

View File

@ -1,3 +1,5 @@
// /src/app/api/ptero/send-command/route.ts
import { NextResponse } from 'next/server'
const PANEL = process.env.PTERO_PANEL_URL!
@ -10,7 +12,7 @@ export async function POST(req: Request) {
}
try {
const { command } = await req.json() as { command?: string }
const { command } = (await req.json()) as { command?: string }
if (!command || typeof command !== 'string') {
return NextResponse.json({ error: 'command required' }, { status: 400 })
}
@ -18,22 +20,30 @@ export async function POST(req: Request) {
const r = await fetch(`${PANEL}/api/client/servers/${SID}/command`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ command }),
// wichtig: keine Caching-Probleme
cache: 'no-store',
})
if (!r.ok) {
const msg = await r.text().catch(() => '')
return NextResponse.json({ error: 'Pterodactyl error', details: msg }, { status: r.status })
return NextResponse.json(
{ error: 'Pterodactyl error', details: msg },
{ status: r.status },
)
}
return NextResponse.json({ ok: true })
} catch (e: any) {
return NextResponse.json({ error: e?.message ?? 'unknown error' }, { status: 500 })
} catch (e: unknown) {
const message =
e instanceof Error
? e.message
: typeof e === 'string'
? e
: 'unknown error'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -4,61 +4,87 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
// Helper: Prisma-User -> Player
const toPlayer = (u: any) => ({
steamId : u?.steamId ?? '',
name : u?.name ?? 'Unbekannt',
avatar : u?.avatar ?? null,
location : u?.location ?? undefined,
premierRank: u?.premierRank ?? undefined,
isAdmin : u?.isAdmin ?? undefined,
// ---- schlanke DTO-Typen (nur was wir wirklich benutzen) ----
type DbUser = {
steamId?: string | null
name?: string | null
avatar?: string | null
location?: string | null
premierRank?: number | null
isAdmin?: boolean | null
} | null | undefined
type PlayerDTO = {
steamId: string
name: string
avatar: string | null
location?: string
premierRank?: number
isAdmin?: boolean
}
type DbMatchPlayer = {
user: DbUser
stats?: unknown
}
type MatchPlayerDTO = {
user: PlayerDTO
stats?: unknown
}
// ---- Helper: Prisma-User -> PlayerDTO (ohne any) ----
const toPlayer = (u: DbUser): PlayerDTO => ({
steamId : (u?.steamId ?? '') as string,
name : (u?.name ?? 'Unbekannt') as string,
avatar : (u?.avatar ?? null) as string | null,
location : (u?.location ?? undefined) ?? undefined,
premierRank: typeof u?.premierRank === 'number' ? u!.premierRank : undefined,
isAdmin : typeof u?.isAdmin === 'boolean' ? u!.isAdmin : undefined,
})
// Helper: Prisma-MatchPlayer -> MatchPlayer
const toMatchPlayer = (p: any) => ({
// ---- Helper: Prisma-MatchPlayer -> MatchPlayerDTO (ohne any) ----
const toMatchPlayer = (p: DbMatchPlayer): MatchPlayerDTO => ({
user : toPlayer(p.user),
stats: p.stats ?? undefined,
})
// ---- Helper: optionales m.date typsicher abgreifen ----
function safeOptionalDate(v: unknown): Date | null {
if (v && typeof v === 'object' && 'date' in v) {
const d = (v as { date?: unknown }).date
return d instanceof Date ? d : null
}
return null
}
export async function GET() {
try {
const matches = await prisma.match.findMany({
where : { matchType: 'community' },
orderBy: { demoDate: 'desc' },
include: {
teamA: {
include: { leader: true },
},
teamB: {
include: { leader: true },
},
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
players: {
include: {
user : true,
stats: true,
team : true,
},
include: { user: true, stats: true, team: true },
},
},
})
const formatted = matches.map(m => {
const matchDate =
m.demoDate ??
(m as any).date ??
m.createdAt
const matchDate = m.demoDate ?? safeOptionalDate(m) ?? m.createdAt
const teamAId = m.teamA?.id ?? null
const teamBId = m.teamB?.id ?? null
const teamAPlayers = m.players
.filter(p => (p.teamId ?? p.team?.id) === teamAId)
.map(toMatchPlayer)
.map(p => toMatchPlayer({ user: p.user, stats: p.stats }))
const teamBPlayers = m.players
.filter(p => (p.teamId ?? p.team?.id) === teamBId)
.map(toMatchPlayer)
.map(p => toMatchPlayer({ user: p.user, stats: p.stats }))
return {
id : m.id,
@ -77,7 +103,6 @@ export async function GET() {
logo : m.teamA?.logo ?? null,
score : m.scoreA,
leader : m.teamA?.leader ? toPlayer(m.teamA.leader) : undefined,
// -> neu:
players: teamAPlayers,
},
@ -87,11 +112,8 @@ export async function GET() {
logo : m.teamB?.logo ?? null,
score : m.scoreB,
leader : m.teamB?.leader ? toPlayer(m.teamB.leader) : undefined,
// -> neu:
players: teamBPlayers,
},
// -> Top-Level "players" wurde entfernt
}
})

View File

@ -5,12 +5,12 @@ import { prisma } from "@/lib/prisma";
import { decrypt } from '@/lib/crypto';
import { processUserMatches } from '../tasks/processUserMatchesTask';
import { refreshUserBansTask } from '../tasks/refreshUserBansTask';
import { refreshFaceitProfilesTask } from '../tasks/refreshFaceitProfilesTask'; // ⬅️ NEU
import { refreshFaceitProfilesTask } from '../tasks/refreshFaceitProfilesTask';
import { log } from '../lib/logger';
let runningMatches = false;
let runningBans = false;
let runningFaceit = false; // ⬅️ NEU
let runningFaceit = false;
export function startCS2MatchCron() {
log.info('🚀 CS2-CronJob Runner gestartet!');