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>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 16:50:22 +01:00
commit 104aa1a67b
27 files changed
+6355

No files matched your search

+162
View File
@@ -0,0 +1,162 @@
// 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;
}
}