// Reine Veto-Logik ohne Datenbank- oder Netzwerkzugriff. // Ein Ablauf (flow) ist eine Liste von Schritten wie "ban:X", "pick:Y", "side:X", "decider". // X ist das Team, das das Veto beginnt, Y das andere Team. export const BEST_OF = { bo1: 1, bo3: 3, bo5: 5 }; export const SIDES = ['CT', 'T']; export function parseStep(raw) { if (raw === 'decider') return { type: 'decider' }; const match = /^(ban|pick|side):(X|Y)$/.exec(raw); if (!match) throw new Error(`Unbekannter Veto-Schritt "${raw}"`); return { type: match[1], actor: match[2] }; } /** * Prüft, ob ein Ablauf zu Map-Pool und Best-of-Format passt. * Gibt eine Liste deutschsprachiger Fehlermeldungen zurück (leer = gültig). */ export function validateFlow(flow, mapCount, format) { const errors = []; const bestOf = BEST_OF[format]; if (!bestOf) return [`Unbekanntes Format "${format}"`]; if (!Array.isArray(flow) || flow.length === 0) return ['Ablauf ist leer']; let steps; try { steps = flow.map(parseStep); } catch (err) { return [err.message]; } const bans = steps.filter((s) => s.type === 'ban').length; const picks = steps.filter((s) => s.type === 'pick').length; const deciders = steps.filter((s) => s.type === 'decider').length; if (deciders !== 1) errors.push('Ablauf braucht genau einen "decider"-Schritt'); if (picks !== bestOf - 1) errors.push(`${format.toUpperCase()} braucht ${bestOf - 1} Pick(s), Ablauf hat ${picks}`); if (bans + picks + 1 !== mapCount) { errors.push(`Bans + Picks + Entscheidungsmap (${bans + picks + 1}) müssen der Pool-Größe (${mapCount}) entsprechen`); } const deciderIndex = steps.findIndex((s) => s.type === 'decider'); steps.forEach((step, i) => { if (step.type === 'side') { const prev = steps[i - 1]; if (!prev || (prev.type !== 'pick' && prev.type !== 'decider')) { errors.push(`Schritt ${i + 1}: Seitenwahl muss direkt auf einen Pick oder die Entscheidungsmap folgen`); } } if (deciderIndex !== -1 && i > deciderIndex && step.type !== 'side') { errors.push(`Schritt ${i + 1}: Nach der Entscheidungsmap ist nur noch eine Seitenwahl erlaubt`); } }); return errors; } function teamFor(actor, firstTeam) { const second = firstTeam === 'A' ? 'B' : 'A'; return actor === 'X' ? firstTeam : second; } function other(team) { return team === 'A' ? 'B' : 'A'; } /** * Berechnet den vollständigen Veto-Zustand aus den bisherigen Aktionen. * actions: [{ type, team, map?, side? }] in Reihenfolge der Schritte (inkl. automatischer "decider"-Aktion). */ export function computeVetoState({ maps, flow, firstTeam, actions }) { const steps = flow.map(parseStep); const mapState = new Map(maps.map((m) => [m.id, { ...m, status: 'available', by: null, order: null }])); const series = []; let lastMapRef = null; actions.forEach((action, i) => { const step = steps[i]; if (!step || step.type !== action.type) throw new Error(`Aktion ${i + 1} passt nicht zum Ablauf`); if (action.type === 'ban') { Object.assign(mapState.get(action.map), { status: 'banned', by: action.team, order: i }); } else if (action.type === 'pick' || action.type === 'decider') { const entry = mapState.get(action.map); Object.assign(entry, { status: action.type === 'pick' ? 'picked' : 'decider', by: action.team ?? null, order: i }); lastMapRef = { map: action.map, pickedBy: action.team ?? null, sideChosenBy: null, startSides: null }; series.push(lastMapRef); } else if (action.type === 'side') { lastMapRef.sideChosenBy = action.team; lastMapRef.startSides = { [action.team]: action.side, [other(action.team)]: action.side === 'CT' ? 'T' : 'CT' }; } }); const currentIndex = actions.length; const raw = steps[currentIndex]; const done = currentIndex >= steps.length; let current = null; if (!done) { current = { index: currentIndex, type: raw.type, team: raw.actor ? teamFor(raw.actor, firstTeam) : null }; if (raw.type === 'side') current.map = lastMapRef?.map ?? null; } // Serienreihenfolge: Picks in Pick-Reihenfolge, Entscheidungsmap zuletzt (ergibt sich aus der Aktionsreihenfolge). return { steps: steps.map((s, i) => ({ index: i, type: s.type, team: s.actor ? teamFor(s.actor, firstTeam) : null, done: i < currentIndex, })), maps: [...mapState.values()], series: series.map((s, i) => ({ ...s, number: i + 1 })), current, done, }; } /** * Validiert eine Captain-Aktion und gibt die anzuhängenden Aktionen zurück * (die eigentliche Aktion plus ggf. die automatisch übernommene Entscheidungsmap). */ export function resolveAction({ maps, flow, firstTeam, actions }, { team, stepIndex, map, side }) { const state = computeVetoState({ maps, flow, firstTeam, actions }); if (state.done) throw new VetoError('Das Veto ist bereits abgeschlossen', 'veto_done'); if (stepIndex !== state.current.index) { throw new VetoError('Diese Aktion ist veraltet – der Veto-Stand hat sich bereits geändert', 'stale_step'); } if (state.current.team !== team) throw new VetoError('Dein Team ist gerade nicht am Zug', 'not_your_turn'); const result = []; const { type } = state.current; if (type === 'ban' || type === 'pick') { const target = state.maps.find((m) => m.id === map); if (!target) throw new VetoError('Diese Map gehört nicht zum Map-Pool', 'invalid_map'); if (target.status !== 'available') throw new VetoError('Diese Map ist nicht mehr verfügbar', 'map_unavailable'); result.push({ type, team, map }); } else if (type === 'side') { if (!SIDES.includes(side)) throw new VetoError('Bitte CT oder T wählen', 'invalid_side'); result.push({ type, team, side, map: state.current.map }); } else { throw new VetoError('Dieser Schritt wird automatisch ausgeführt', 'auto_step'); } // Automatische Schritte direkt anhängen (Entscheidungsmap = letzte verfügbare Map). let next = [...actions, ...result]; for (;;) { const after = computeVetoState({ maps, flow, firstTeam, actions: next }); if (after.done || after.current.type !== 'decider') break; const remaining = after.maps.filter((m) => m.status === 'available'); if (remaining.length !== 1) throw new Error('Ablauf inkonsistent: Entscheidungsmap nicht eindeutig'); const decider = { type: 'decider', team: null, map: remaining[0].id }; result.push(decider); next = [...next, decider]; } return result; } export class VetoError extends Error { constructor(message, code) { super(message); this.code = code; this.status = 409; } }