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.
This commit is contained in:
commit
f08297dd0d
27 files changed
+6355
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user