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