2025-09-20 21:28:10 +02:00

843 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /src/app/api/matches/[matchId]/mapvote/route.ts
import { NextResponse, NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { prisma } from '@/app/lib/prisma'
import { MapVoteAction } from '@/generated/prisma'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { randomInt, createHash, randomBytes } from 'crypto'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/* -------------------- Konstanten -------------------- */
const ACTION_MAP: Record<MapVoteAction, 'ban'|'pick'|'decider'> = {
BAN: 'ban', PICK: 'pick', DECIDER: 'decider',
}
/* -------------------- Helper -------------------- */
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
}
function makeRandomMatchId() {
try {
// 910-stellige ID (>= 100_000_000) Obergrenze exklusiv
return typeof randomInt === 'function'
? randomInt(100_000_000, 2_147_483_647) // bis INT32_MAX
: (Math.floor(Math.random() * (2_147_483_647 - 100_000_000)) + 100_000_000)
} catch {
return Math.floor(Math.random() * 1_000_000_000) + 100_000_000
}
}
function buildPteroClientUrl(base: string, serverId: string) {
const u = new URL(base.includes('://') ? base : `https://${base}`)
const cleaned = (u.pathname || '').replace(/\/+$/, '')
u.pathname = `${cleaned}/api/client/servers/${serverId}/command`
return u.toString()
}
async function sendServerCommand(command: string) {
try {
const panelBase =
process.env.PTERODACTYL_PANEL_URL ||
process.env.NEXT_PUBLIC_PTERODACTYL_PANEL_URL ||
''
if (!panelBase) {
console.warn('[mapvote] PTERODACTYL_PANEL_URL fehlt Command wird nicht gesendet.')
return
}
// ⬇️ Client-API-Key NUR aus .env ziehen
const clientApiKey = process.env.PTERODACTYL_CLIENT_API || ''
if (!clientApiKey) {
console.warn('[mapvote] PTERODACTYL_CLIENT_API fehlt Command wird nicht gesendet.')
return
}
// Server-ID weiterhin aus DB (ServerConfig)
const cfg = await prisma.serverConfig.findUnique({
where: { id: 'default' },
select: { pterodactylServerId: true },
})
if (!cfg?.pterodactylServerId) {
console.warn('[mapvote] pterodactylServerId fehlt in ServerConfig Command wird nicht gesendet.')
return
}
const url = buildPteroClientUrl(panelBase, cfg.pterodactylServerId)
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${clientApiKey}`, // ✅ Client-API-Key
Accept: 'Application/vnd.pterodactyl.v1+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ command }),
cache: 'no-store',
})
if (res.status === 204) {
console.log('[mapvote] Command OK (204):', command)
return
}
if (!res.ok) {
const t = await res.text().catch(() => '')
console.error('[mapvote] Command fehlgeschlagen:', res.status, t)
return
}
console.log('[mapvote] Command OK:', command)
} catch (e) {
console.error('[mapvote] Command-Fehler:', e)
}
}
// Admin-Edit-Flag setzen/zurücksetzen
async function setAdminEdit(voteId: string, by: string | null) {
return prisma.mapVote.update({
where: { id: voteId },
data: by
? { adminEditingBy: by, adminEditingSince: new Date() }
: { adminEditingBy: null, adminEditingSince: null },
include: { steps: true },
})
}
function voteOpensAt(match: { matchDate: Date | null, demoDate: Date | null }) {
const base = match.matchDate ?? match.demoDate ?? new Date()
return new Date(base.getTime() - 60 * 60 * 1000) // 1h vorher
}
function mapActionToApi(a: MapVoteAction): 'ban'|'pick'|'decider' {
return ACTION_MAP[a]
}
function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
if (bestOf === 3) {
// 2x Ban, 2x Pick, 2x Ban, Decider
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'BAN', teamId: teamAId },
{ order: 5, action: 'BAN', teamId: teamBId },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}
// BO5: 2x Ban, dann 5 Picks (kein Decider)
return [
{ order: 0, action: 'BAN', teamId: teamAId },
{ order: 1, action: 'BAN', teamId: teamBId },
{ order: 2, action: 'PICK', teamId: teamAId },
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'PICK', teamId: teamAId },
{ order: 5, action: 'PICK', teamId: teamBId },
{ order: 6, action: 'DECIDER', teamId: null },
] 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,
}))
const opensAtDate = vote.opensAt ? new Date(vote.opensAt) : null
const baseDate = match?.matchDate ?? match?.demoDate ?? null
const leadMinutes =
opensAtDate && baseDate
? Math.max(0, Math.round((new Date(baseDate).getTime() - opensAtDate.getTime()) / 60000))
: null
return {
bestOf : vote.bestOf,
mapPool : vote.mapPool as string[],
currentIndex: vote.currentIdx,
locked : vote.locked as boolean,
opensAt : opensAtDate ? opensAtDate.toISOString() : null,
leadMinutes,
steps,
adminEdit: vote.adminEditingBy
? { enabled: true, by: vote.adminEditingBy as string,
since: vote.adminEditingSince ? new Date(vote.adminEditingSince).toISOString() : null }
: { enabled: false, by: null, since: null },
}
}
// Leader -> Player-Shape fürs Frontend
function shapeLeader(leader: any | null) {
if (!leader) return null
return {
steamId : leader.steamId,
name : leader.name ?? '',
avatar : leader.avatar ?? '',
location : leader.location ?? undefined,
premierRank: leader.premierRank ?? undefined,
isAdmin : leader.isAdmin ?? undefined,
}
}
// Player -> Player-Shape (falls wir aus Team-API übernehmen)
function shapePlayer(p: any) {
if (!p) return null
return {
steamId : p.steamId,
name : p.name ?? '',
avatar : p.avatar ?? '',
location : p.location ?? undefined,
premierRank: p.premierRank ?? undefined,
isAdmin : p.isAdmin ?? undefined,
}
}
// Teams-Payload (mit Spielern) zusammenbauen
function shapeUser(u: any) {
if (!u) return null
return {
steamId : u.steamId,
name : u.name ?? '',
avatar : u.avatar ?? '',
location : u.location ?? undefined,
premierRank: u.premierRank ?? 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)
return {
teamA: {
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),
players: teamBPlayers,
},
}
}
async function ensureVote(matchId: string) {
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,
}
}
}
},
teamB: {
include: {
leader: {
select: {
steamId: true, name: true, avatar: true, location: true,
premierRank: true, isAdmin: true,
}
}
}
},
// 👉 Spieler direkt am Match laden:
teamAUsers: true,
teamBUsers: true,
// optional zusätzlich:
players: { include: { user: true } }, // falls du MatchPlayer brauchst
mapVote: { include: { steps: true } },
},
})
if (!match) return { match: null, vote: null }
// Bereits vorhanden?
if (match.mapVote) return { match, vote: match.mapVote }
// Neu anlegen
const bestOf = match.bestOf ?? 3
const mapPool = MAP_OPTIONS.filter(m => m.active).map(m => m.key)
const opensAt = voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
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({
data: {
matchId : match.id,
bestOf,
mapPool,
currentIdx: 0,
locked : false,
opensAt,
steps : {
create: stepsDef.map(s => ({
order : s.order,
action: s.action as MapVoteAction,
teamId: s.teamId,
})),
},
},
include: { steps: true },
})
return { match, vote: created }
}
function computeAvailableMaps(mapPool: string[], steps: Array<{ map: string | null }>) {
const used = new Set(steps.map(s => s.map).filter(Boolean) as string[])
return mapPool.filter(m => !used.has(m))
}
/* ---------- Visuals: deterministisches zufälliges Bild pro Map & Match ---------- */
function buildMapVisuals(matchId: string, mapPool: string[]) {
const visuals: Record<string, { label: string; bg: string; logo?: string }> = {}
for (const key of mapPool) {
const opt = MAP_OPTIONS.find(o => o.key === key)
const label = opt?.label ?? key
const imgs = opt?.images ?? []
let bg = `/assets/img/maps/${key}/${key}_1_png.webp`
if (imgs.length > 0) {
const h = createHash('sha256').update(`${matchId}:${key}`).digest('hex')
const n = parseInt(h.slice(0, 8), 16)
const idx = n % imgs.length
bg = imgs[idx]
}
const logo = opt?.icon ?? `/assets/img/mapicons/map_icon_${key}.svg` // ⬅️ neu
visuals[key] = { label, bg, logo }
}
return visuals
}
function uniq<T>(arr: T[]) {
return Array.from(new Set(arr))
}
function collectParticipants(match: any): string[] {
const fromMatchPlayers =
(match.players ?? [])
.map((mp: any) => mp?.user?.steamId)
.filter(Boolean)
const fromTeamUsersA = (match.teamAUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
const fromTeamUsersB = (match.teamBUsers ?? []).map((u: any) => u?.steamId).filter(Boolean)
const fromActiveA = Array.isArray(match.teamA?.activePlayers) ? match.teamA.activePlayers : []
const fromActiveB = Array.isArray(match.teamB?.activePlayers) ? match.teamB.activePlayers : []
const leaderA = match.teamA?.leader?.steamId ? [match.teamA.leader.steamId] : []
const leaderB = match.teamB?.leader?.steamId ? [match.teamB.leader.steamId] : []
return uniq<string>([
...fromMatchPlayers,
...fromTeamUsersA, ...fromTeamUsersB,
...fromActiveA, ...fromActiveB,
...leaderA, ...leaderB,
])
}
async function persistMatchPlayers(match: any) {
// Teilnehmer ermitteln (du hast schon collectParticipants)
const participants = collectParticipants(match); // string[] der steamIds
// 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
return prisma.matchPlayer.upsert({
where: { matchId_steamId: { matchId: match.id, steamId } },
update: { teamId }, // falls sich die Team-Zuordnung ändert
create: { matchId: match.id, steamId, teamId },
});
});
await prisma.$transaction(ops);
}
/* ---------- Export-Helfer ---------- */
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[] }
}
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) {
const out: Record<string, string> = {}
for (const p of list ?? []) {
const sid = (p?.user?.steamId ?? (p as any)?.steamId) as string | undefined
if (!sid) continue
const name = (p?.user?.name ?? p?.name ?? 'Player') as string
out[sid] = name
}
return out
}
function toDeMapName(key: string) {
if (key.startsWith('de_')) return key
return `de_${key}`
}
// ⬇️ buildMatchJson anpassen: matchid statt "" → zufälliger Integer
function buildMatchJson(match: MatchLike, state: MapVoteStateForExport) {
const bestOf = match.bestOf ?? state.bestOf ?? 3
const chosen = (state.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
const maplist = chosen.slice(0, bestOf).map(s => toDeMapName(s.map!))
const map_sides = maplist.map((_, i) => {
if (i === maplist.length - 1) return 'knife'
return i % 2 === 0 ? 'team1_ct' : 'team2_ct'
})
const team1Name = match.teamA?.name ?? 'Team_1'
const team2Name = match.teamB?.name ?? 'Team_2'
const team1Players = playersMapFromList(match.teamA?.players)
const team2Players = playersMapFromList(match.teamB?.players)
// 👇 hier neu: zufällige Integer-ID
const rndId = makeRandomMatchId()
return {
matchid: rndId, // vorher: ""
team1: { name: team1Name, players: team1Players },
team2: { name: team2Name, players: team2Players },
num_maps: bestOf,
maplist,
map_sides,
spectators: { players: {} as Record<string, string> },
clinch_series: true,
players_per_team: 5,
cvars: {
hostname: `Iron:e Open 4 | ${team1Name} vs ${team2Name}`,
mp_friendlyfire: '1',
},
}
}
async function exportMatchToSftpDirect(match: any, vote: any) {
try {
const SFTPClient = (await import('ssh2-sftp-client')).default
const mLike: MatchLike = {
id: match.id,
bestOf: match.bestOf ?? vote.bestOf ?? 3,
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 })),
locked: vote.locked,
}
if (!sLike.locked) return
const bestOf = mLike.bestOf ?? sLike.bestOf ?? 3
const chosen = (sLike.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
if (chosen.length < bestOf) return
// ⬇️ 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
if (!url || !user || !pass) {
throw new Error('SFTP-Umgebungsvariablen fehlen (PTERO_SERVER_SFTP_URL, _USER, _PASSWORD).')
}
let host = url
let port = 22
try {
const u = new URL(url.includes('://') ? url : `sftp://${url}`)
host = u.hostname
port = Number(u.port) || 22
} catch {
const [h, p] = url.split(':')
host = h ?? url
port = p ? Number(p) : 22
}
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}`)
// Spieler persistieren + cs2MatchId speichern wie gehabt
await persistMatchPlayers(match)
if (typeof json.matchid === 'number') {
await prisma.match.update({
where: { id: match.id },
data: { cs2MatchId: json.matchid, exportedAt: new Date() },
})
} else {
await prisma.match.update({
where: { id: match.id },
data: { exportedAt: new Date() },
})
}
// ⬇️ OPTIONAL: cs2MatchId + exportedAt im Match speichern
if (typeof json.matchid === 'number') {
await prisma.match.update({
where: { id: match.id },
data: { cs2MatchId: json.matchid, exportedAt: new Date() },
})
} else {
await prisma.match.update({
where: { id: match.id },
data: { exportedAt: new Date() },
})
}
} catch (err) {
console.error('[mapvote] Export fehlgeschlagen:', err)
}
}
/* ---------- 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)
}
/* -------------------- GET -------------------- */
export async function GET(req: NextRequest, { params }: { params: { matchId: string } }) {
try {
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing matchId' }, { status: 400 })
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, vote.mapPool)
return NextResponse.json(
{ ...shapeState(vote, match), mapVisuals, teams }, // 👈 match mitgeben
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch (e) {
console.error('[map-vote][GET] error', e)
return NextResponse.json({ message: 'Fehler beim Laden' }, { status: 500 })
}
}
/* -------------------- POST ------------------- */
export async function POST(req: NextRequest, { params }: { params: { matchId: string } }) {
const session = await getServerSession(authOptions(req))
const me = session?.user as { steamId: string; isAdmin?: boolean } | undefined
if (!me?.steamId) return NextResponse.json({ message: 'Nicht eingeloggt' }, { status: 401 })
const matchId = params.matchId
if (!matchId) return NextResponse.json({ message: 'Missing id' }, { status: 400 })
type ToggleBody = { map?: string; adminEdit?: boolean }
let body: ToggleBody = {}
try { body = await req.json() } catch {}
try {
const { match, vote } = await ensureVote(matchId)
if (!match || !vote) return NextResponse.json({ message: 'Match nicht gefunden' }, { status: 404 })
/* -------- Admin-Edit umschalten (früher Exit) -------- */
if (typeof body.adminEdit === 'boolean') {
if (!me.isAdmin) {
return NextResponse.json({ message: 'Nur Admins dürfen den Edit-Mode setzen' }, { status: 403 })
}
const updated = await setAdminEdit(vote.id, body.adminEdit ? me.steamId : null)
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated.mapPool)
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
/* -------- Wenn anderer Admin editiert: Voting sperren -------- */
if (vote.adminEditingBy && vote.adminEditingBy !== me.steamId) {
return NextResponse.json({ message: 'Admin-Edit aktiv Voting vorübergehend deaktiviert' }, { status: 423 })
}
/* -------- Zeitfenster prüfen (Admins dürfen trotzdem) -------- */
const opensAt = vote.opensAt ?? voteOpensAt({ matchDate: match.matchDate ?? null, demoDate: match.demoDate ?? null })
const isOpen = new Date() >= new Date(opensAt)
if (!isOpen && !me.isAdmin) return NextResponse.json({ message: 'Voting ist noch nicht offen' }, { status: 403 })
// Schon abgeschlossen?
if (vote.locked) return NextResponse.json({ message: 'Voting bereits abgeschlossen' }, { status: 409 })
// Aktuellen Schritt bestimmen
const stepsSorted = [...vote.steps].sort((a: any, b: any) => a.order - b.order)
const current = stepsSorted.find((s: any) => s.order === vote.currentIdx)
if (!current) {
// Kein Schritt mehr -> Vote abschließen
await prisma.mapVote.update({ where: { id: vote.id }, data: { locked: true } })
const updated = await prisma.mapVote.findUnique({ where: { id: vote.id }, include: { steps: true } })
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// match-ready senden (erste Map + Teilnehmer)
if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
const available = computeAvailableMaps(vote.mapPool, stepsSorted)
// DECIDER automatisch setzen, wenn nur noch 1 Map übrig
if (current.action === 'DECIDER') {
if (available.length !== 1) {
return NextResponse.json({ message: 'DECIDER noch nicht bestimmbar' }, { status: 400 })
}
const lastMap = available[0]
await prisma.$transaction(async (tx) => {
await tx.mapVoteStep.update({
where: { id: current.id },
data : { map: lastMap, chosenAt: new Date(), chosenBy: me.steamId },
})
await tx.mapVote.update({
where: { id: vote.id },
data : { currentIdx: vote.currentIdx + 1, locked: true },
})
})
const updated = await prisma.mapVote.findUnique({
where: { id: vote.id },
include: { steps: true },
})
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// match-ready senden
if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
// Export serverseitig
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
}
// Rechte prüfen (Admin oder Leader des Teams am Zug)
const isLeaderA = !!(match as any).teamA?.leaderId && (match as any).teamA.leaderId === me.steamId
const isLeaderB = !!(match as any).teamB?.leaderId && (match as any).teamB.leaderId === me.steamId
const allowed = me.isAdmin || (current.teamId && (
(current.teamId === match.teamA?.id && isLeaderA) ||
(current.teamId === match.teamB?.id && isLeaderB)
))
if (!allowed) return NextResponse.json({ message: 'Keine Berechtigung für diesen Schritt' }, { status: 403 })
// Payload validieren
const map = body.map?.trim()
if (!map) return NextResponse.json({ message: 'map fehlt' }, { status: 400 })
if (!vote.mapPool.includes(map)) return NextResponse.json({ message: 'Map nicht im Pool' }, { status: 400 })
if (!available.includes(map)) return NextResponse.json({ message: 'Map bereits vergeben' }, { status: 409 })
// Schritt setzen & ggf. weiterdrehen (+ Decider evtl. auto)
await prisma.$transaction(async (tx) => {
// aktuellen Schritt setzen
await tx.mapVoteStep.update({
where: { id: current.id },
data : { map, chosenAt: new Date(), chosenBy: me.steamId },
})
// neuen Zustand ermitteln
const after = await tx.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
if (!after) return
const stepsAfter = [...after.steps].sort((a: any, b: any) => a.order - b.order)
let idx = after.currentIdx + 1
let locked = false
// Falls nächster Schritt DECIDER und genau 1 Map übrig -> auto setzen & locken
const next = stepsAfter.find(s => s.order === idx)
if (next?.action === 'DECIDER') {
const avail = computeAvailableMaps(after.mapPool, stepsAfter)
if (avail.length === 1) {
await tx.mapVoteStep.update({
where: { id: next.id },
data : { map: avail[0], chosenAt: new Date(), chosenBy: me.steamId },
})
idx += 1
locked = true
}
}
// Ende erreicht?
const maxOrder = Math.max(...stepsAfter.map(s => s.order))
if (idx > maxOrder) locked = true
await tx.mapVote.update({
where: { id: after.id },
data : { currentIdx: idx, locked },
})
})
const updated = await prisma.mapVote.findUnique({
where : { id: vote.id },
include: { steps: true },
})
await sendServerSSEMessage({
type: 'map-vote-updated',
payload: { matchId, opensAt: updated?.opensAt ?? null },
})
const teams = buildTeamsPayloadFromMatch(match)
const mapVisuals = buildMapVisuals(match.id, updated!.mapPool)
// Falls durch diesen Schritt locked wurde → Export & match-ready
if (updated?.locked) {
await sleep(3000);
const chosen = deriveChosenSteps(updated)
const first = chosen[0]
const key = first?.map ?? null
const label = key ? (mapVisuals?.[key]?.label ?? key) : '?'
const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp'
const participants = collectParticipants(match)
await sendServerSSEMessage({
type: 'match-ready',
matchId,
firstMap: { key, label, bg },
participants,
});
await exportMatchToSftpDirect(match, updated)
}
return NextResponse.json({ ...shapeState(updated, match), mapVisuals, teams })
} catch (e) {
console.error('[map-vote][POST] error', e)
return NextResponse.json({ message: 'Aktion fehlgeschlagen' }, { status: 500 })
}
}