Files
Cs2-Mapveto/server/src/veto.js
T
SPP UserandClaude Opus 5.5 104aa1a67b Backend: Match-Lobbys, Map-Veto, Echtzeit und CS2-Serveradapter
Express + Socket.IO + SQLite. Veto-Engine mit konfigurierbaren Map-Pools
und validierten Abläufen, rollenbasierte Berechtigungen, Server-Lebenszyklus
mit Demo-, Docker- und Static-Pool-Adapter (RCON), automatisches Herunterfahren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:50:22 +01:00

163 lines
6.5 KiB
JavaScript
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.
// 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;
}
}