Map-Pools: Overpass, Vertigo und Cache für Competitive, Map-Auswahl im Admin, Veto-Ablauf passend zur Map-Anzahl

This commit is contained in:
mregosar committed 2026-10-29 21:03:29 +01:00
1 parent e3834bb071
commit 2d65634d6e
13 files changed
+459 -45

No files matched your search

+137 -17
View File
@@ -1,5 +1,5 @@
{
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Ungültige Abläufe werden beim Start gemeldet und das Format ist dann nicht auswählbar. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
"$comment": "Map-Pools und Veto-Abläufe. Schritte: ban:X, pick:X, side:X, decider. X = erstes Team (startet das Veto), Y = zweites Team. 'side' folgt immer auf einen Pick bzw. auf 'decider' und bezieht sich auf diese Map. Welche Maps gespielt werden, wählt der Admin im Admin-Bereich (Einstellungen → Map-Pools); 'active': false = nur im Katalog. Die hier hinterlegten Abläufe gelten, wenn sie zur Anzahl der aktiven Maps passen, sonst erzeugt die Anwendung einen passenden Ablauf. Optional pro Map: image = Bild-URL oder Pfad (z. B. /maps/de_dust2.jpg aus client/public). Standardbilder: github.com/ghostcap-gaming/cs2-map-images (Bilder aus dem Spiel, Rechte bei Valve).",
"pools": {
"competitive": {
"label": "Competitive 5 gegen 5",
@@ -7,18 +7,104 @@
"gameType": 0,
"gameMode": 1,
"maps": [
{ "id": "de_ancient", "name": "Ancient", "color": "#3f8f5a", "image": "/maps/de_ancient.webp" },
{ "id": "de_anubis", "name": "Anubis", "color": "#c79a3d", "image": "/maps/de_anubis.webp" },
{ "id": "de_dust2", "name": "Dust II", "color": "#d3a45c", "image": "/maps/de_dust2.webp" },
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
{ "id": "de_mirage", "name": "Mirage", "color": "#b98a4a", "image": "/maps/de_mirage.webp" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
{ "id": "de_train", "name": "Train", "color": "#7a7f8a", "image": "/maps/de_train.webp" }
{
"id": "de_ancient",
"name": "Ancient",
"color": "#3f8f5a",
"image": "/maps/de_ancient.webp"
},
{
"id": "de_anubis",
"name": "Anubis",
"color": "#c79a3d",
"image": "/maps/de_anubis.webp"
},
{
"id": "de_cache",
"name": "Cache",
"color": "#6f8a4a",
"image": "/maps/de_cache.webp"
},
{
"id": "de_dust2",
"name": "Dust II",
"color": "#d3a45c",
"image": "/maps/de_dust2.webp"
},
{
"id": "de_inferno",
"name": "Inferno",
"color": "#c4573a",
"image": "/maps/de_inferno.webp"
},
{
"id": "de_mirage",
"name": "Mirage",
"color": "#b98a4a",
"image": "/maps/de_mirage.webp"
},
{
"id": "de_nuke",
"name": "Nuke",
"color": "#4a8fbf",
"image": "/maps/de_nuke.webp"
},
{
"id": "de_overpass",
"name": "Overpass",
"color": "#5f8f7a",
"image": "/maps/de_overpass.webp"
},
{
"id": "de_train",
"name": "Train",
"color": "#7a7f8a",
"image": "/maps/de_train.webp"
},
{
"id": "de_vertigo",
"name": "Vertigo",
"color": "#8a74b8",
"image": "/maps/de_vertigo.webp"
}
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "ban:Y", "ban:X", "ban:Y", "decider", "side:X"],
"bo3": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "ban:X", "ban:Y", "decider", "side:X"],
"bo5": ["ban:X", "ban:Y", "pick:X", "side:Y", "pick:Y", "side:X", "pick:X", "side:Y", "pick:Y", "side:X", "decider", "side:Y"]
"bo1": [
"ban:X",
"ban:Y",
"ban:X",
"ban:Y",
"ban:X",
"ban:Y",
"decider",
"side:X"
],
"bo3": [
"ban:X",
"ban:Y",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"ban:X",
"ban:Y",
"decider",
"side:X"
],
"bo5": [
"ban:X",
"ban:Y",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"pick:X",
"side:Y",
"pick:Y",
"side:X",
"decider",
"side:Y"
]
}
},
"wingman": {
@@ -27,14 +113,48 @@
"gameType": 0,
"gameMode": 2,
"maps": [
{ "id": "de_inferno", "name": "Inferno", "color": "#c4573a", "image": "/maps/de_inferno.webp" },
{ "id": "de_nuke", "name": "Nuke", "color": "#4a8fbf", "image": "/maps/de_nuke.webp" },
{ "id": "de_overpass", "name": "Overpass", "color": "#5f8f7a", "image": "/maps/de_overpass.webp" },
{ "id": "de_vertigo", "name": "Vertigo", "color": "#8a74b8", "image": "/maps/de_vertigo.webp" }
{
"id": "de_inferno",
"name": "Inferno",
"color": "#c4573a",
"image": "/maps/de_inferno.webp"
},
{
"id": "de_nuke",
"name": "Nuke",
"color": "#4a8fbf",
"image": "/maps/de_nuke.webp"
},
{
"id": "de_overpass",
"name": "Overpass",
"color": "#5f8f7a",
"image": "/maps/de_overpass.webp"
},
{
"id": "de_vertigo",
"name": "Vertigo",
"color": "#8a74b8",
"image": "/maps/de_vertigo.webp"
}
],
"flows": {
"bo1": ["ban:X", "ban:Y", "ban:X", "decider", "side:Y"],
"bo3": ["ban:X", "pick:Y", "side:X", "pick:X", "side:Y", "decider", "side:X"]
"bo1": [
"ban:X",
"ban:Y",
"ban:X",
"decider",
"side:Y"
],
"bo3": [
"ban:X",
"pick:Y",
"side:X",
"pick:X",
"side:Y",
"decider",
"side:X"
]
}
}
}
+16 -1
View File
@@ -15,7 +15,7 @@ function bearer(req) {
* (nur im Speicher, verfällt nach 12 Stunden oder beim Neustart). Admin-Aktionen nutzen die bestehenden
* Organisator-Funktionen mit einem Admin-Zugang, die Berechtigungen bleiben also an einer Stelle.
*/
export function createAdminRouter({ service, manager, config, settings, knife }) {
export function createAdminRouter({ service, manager, config, settings, knife, pools }) {
const router = express.Router();
const sessions = new Map(); // Token -> Ablaufzeit (ms)
const loginLimit = createRateLimiter({ windowMs: 60_000, max: 5 });
@@ -125,6 +125,21 @@ export function createAdminRouter({ service, manager, config, settings, knife })
return hostView();
}));
// Map-Pools: welche Maps gespielt werden. Der Veto-Ablauf passt sich der Anzahl an.
const poolView = () => ({
pools: Object.values(pools).map((p) => ({
mode: p.mode,
label: p.label,
maps: p.allMaps.map((m) => ({ ...m, active: p.maps.some((a) => a.id === m.id) })),
formats: p.formats,
})),
});
router.get('/map-pools', requireAdmin, wrap(() => poolView()));
router.put('/map-pools', requireAdmin, wrap((req) => {
settings.saveMapSelection(pools, req.body?.mode, req.body?.maps);
return poolView();
}));
// Messer-Server für die Seitenwahl
router.get('/knife', requireAdmin, wrap(() => ({
servers: knife.list(),
+2
View File
@@ -21,6 +21,8 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
const settings = new ServerSettings({ db, config });
settings.load();
const pools = loadMapPools(config.mapPoolFile);
// Im Admin-Bereich gewählte Maps (Einstellungen → Map-Pools)
settings.applyMapSelection(pools);
const notifier = createDiscordNotifier(config.discordWebhookUrl, { logger });
const service = new MatchService({ db, pools, config, notifier });
+1 -1
View File
@@ -32,7 +32,7 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
});
router.use(express.json({ limit: '32kb' }));
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife }));
router.use('/admin', createAdminRouter({ service, manager, config, settings, knife, pools }));
const limiters = {
create: createRateLimiter({ windowMs: 60_000, max: 10 }),
+40 -16
View File
@@ -1,5 +1,5 @@
import fs from 'node:fs';
import { BEST_OF, validateFlow } from './veto.js';
import { BEST_OF, generateFlow, validateFlow } from './veto.js';
/**
* Lädt die Map-Pools und markiert jede Kombination aus Modus und Format als unterstützt oder nicht.
@@ -22,32 +22,56 @@ export function buildMapPools(raw) {
}
ids.add(map.id);
}
const formats = {};
for (const format of Object.keys(BEST_OF)) {
const flow = pool.flows?.[format];
if (!flow) {
formats[format] = { supported: false, reason: 'Kein Veto-Ablauf für dieses Format hinterlegt' };
continue;
}
const errors = validateFlow(flow, pool.maps.length, format);
formats[format] = errors.length
? { supported: false, reason: errors.join('; ') }
: { supported: true, flow };
if (errors.length) console.warn(`[map-pools] ${mode}/${format} deaktiviert: ${errors.join('; ')}`);
}
// "active": false = Map ist im Katalog, wird aber erst nach Auswahl im Admin-Bereich gespielt
const active = pool.maps.filter((m) => m.active !== false).map((m) => m.id);
pools[mode] = {
mode,
label: pool.label ?? mode,
teamSize: pool.teamSize,
gameType: pool.gameType,
gameMode: pool.gameMode,
maps: pool.maps,
formats,
allMaps: pool.maps.map(({ active: _ignored, ...m }) => m),
flows: pool.flows ?? {},
maps: [],
formats: {},
};
setActiveMaps(pools[mode], active, { warn: true });
}
return pools;
}
/**
* Veto-Abläufe für die aktiven Maps: Ein in der Konfiguration hinterlegter Ablauf gilt, wenn er zur Map-Anzahl passt,
* sonst erzeugt die Anwendung einen passenden Ablauf.
*/
export function formatsFor(pool, mapCount, { warn = false } = {}) {
const formats = {};
for (const format of Object.keys(BEST_OF)) {
const configured = pool.flows?.[format];
if (configured && validateFlow(configured, mapCount, format).length === 0) {
formats[format] = { supported: true, flow: configured, generated: false };
continue;
}
const flow = generateFlow(mapCount, format);
if (!flow) {
formats[format] = { supported: false, reason: `${format.toUpperCase()} braucht mindestens ${Math.max(BEST_OF[format], 2)} Maps` };
continue;
}
const errors = validateFlow(flow, mapCount, format);
formats[format] = errors.length ? { supported: false, reason: errors.join('; ') } : { supported: true, flow, generated: true };
if (errors.length && warn) console.warn(`[map-pools] ${pool.mode}/${format} deaktiviert: ${errors.join('; ')}`);
}
return formats;
}
/** Aktive Maps eines Pools setzen (Reihenfolge wie im Katalog) und die Formate neu berechnen. */
export function setActiveMaps(pool, ids, options) {
const wanted = new Set(ids);
pool.maps = pool.allMaps.filter((m) => wanted.has(m.id));
pool.formats = formatsFor(pool, pool.maps.length, options);
return pool;
}
export function publicPools(pools) {
return Object.values(pools).map((p) => ({
mode: p.mode,
+29
View File
@@ -3,9 +3,11 @@
import { HttpError } from './matchService.js';
import { parseDockerHosts } from './config.js';
import { setActiveMaps } from './mapPools.js';
const SETTINGS_KEY = 'server_settings';
const HOSTS_KEY = 'docker_hosts';
const MAPS_KEY = 'map_selection';
/**
* Änderbare Felder. `get`/`set` greifen auf das gemeinsame config-Objekt zu, das Match-Service,
@@ -192,4 +194,31 @@ export class ServerSettings {
hostsOverridden() {
return this.readJson(HOSTS_KEY) != null;
}
/** Beim Start: im Admin-Bereich gewählte Maps auf die Pools legen. Unbekannte Maps werden ignoriert. */
applyMapSelection(pools) {
const saved = this.readJson(MAPS_KEY) ?? {};
for (const [mode, ids] of Object.entries(saved)) {
const pool = pools[mode];
if (!pool || !Array.isArray(ids)) continue;
const known = ids.filter((id) => pool.allMaps.some((m) => m.id === id));
if (known.length >= 2) setActiveMaps(pool, known);
}
}
/** Aktive Maps eines Modus setzen und speichern. Gilt für neue Matches, laufende behalten ihre Maps. */
saveMapSelection(pools, mode, ids) {
const pool = pools[mode];
if (!pool) throw new HttpError(400, `Unbekannter Modus: ${mode}`, 'invalid');
if (!Array.isArray(ids)) throw new HttpError(400, 'Map-Liste erwartet', 'invalid');
const unique = [...new Set(ids.map(String))];
const unknown = unique.filter((id) => !pool.allMaps.some((m) => m.id === id));
if (unknown.length) throw new HttpError(400, `Unbekannte Map: ${unknown.join(', ')}`, 'invalid');
if (unique.length < 2) throw new HttpError(400, 'Mindestens 2 Maps auswählen', 'invalid');
setActiveMaps(pool, unique);
const saved = this.readJson(MAPS_KEY) ?? {};
saved[mode] = pool.maps.map((m) => m.id);
this.writeJson(MAPS_KEY, saved);
return pool;
}
}
+29
View File
@@ -54,6 +54,35 @@ export function validateFlow(flow, mapCount, format) {
return errors;
}
/**
* Erzeugt einen Veto-Ablauf für eine beliebige Anzahl Maps. Bans und Picks wechseln sich zwischen den Teams ab:
* erst bis zu zwei Bans, dann die Picks (die Seite wählt jeweils das andere Team), dann die restlichen Bans.
* Die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
* Liefert null, wenn es für das Format zu wenige Maps gibt.
*/
export function generateFlow(mapCount, format) {
const bestOf = BEST_OF[format];
if (!bestOf || mapCount < Math.max(bestOf, 2)) return null;
const picks = bestOf - 1;
const bans = mapCount - bestOf;
const before = Math.min(2, bans);
const steps = [];
let actor = 'X';
const next = () => {
const current = actor;
actor = actor === 'X' ? 'Y' : 'X';
return current;
};
for (let i = 0; i < before; i += 1) steps.push(`ban:${next()}`);
for (let i = 0; i < picks; i += 1) {
const picker = next();
steps.push(`pick:${picker}`, `side:${picker === 'X' ? 'Y' : 'X'}`);
}
for (let i = before; i < bans; i += 1) steps.push(`ban:${next()}`);
steps.push('decider', `side:${actor}`);
return steps;
}
function teamFor(actor, firstTeam) {
const second = firstTeam === 'A' ? 'B' : 'A';
return actor === 'X' ? firstTeam : second;
+33
View File
@@ -574,3 +574,36 @@ test('Messer-Server: nur Admin startet, Startseite sieht laufende, Ports zählen
await close();
}
});
test('Map-Pools im Admin: Auswahl gilt für neue Matches, Veto passt sich an, laufende Matches behalten ihre Maps', async () => {
const { app, call, close } = await setup({ adminPassword: 'admin-geheim' });
try {
const token = (await call('POST', '/admin/login', { body: { password: 'admin-geheim' } })).body.token;
const before = (await call('POST', '/matches', { body: { teamA: 'Vorher A', teamB: 'Vorher B', mode: 'competitive', format: 'bo3' } })).body;
const view = (await call('GET', '/admin/map-pools', { token })).body;
const comp = view.pools.find((p) => p.mode === 'competitive');
assert.ok(['de_overpass', 'de_vertigo', 'de_cache'].every((id) => comp.maps.some((m) => m.id === id && m.active)));
// Nur 5 Maps: BO5 geht noch, BO3 hat dann 2 Bans
const five = ['de_cache', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke'];
const saved = await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: five } });
assert.equal(saved.status, 200, JSON.stringify(saved.body));
const pool = saved.body.pools.find((p) => p.mode === 'competitive');
assert.equal(pool.maps.filter((m) => m.active).length, 5);
assert.deepEqual(pool.formats.bo3.flow, ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
// Neue Matches bekommen die neue Auswahl, das alte behält seine 10 Maps
const after = (await call('POST', '/matches', { body: { teamA: 'Nachher A', teamB: 'Nachher B', mode: 'competitive', format: 'bo3' } })).body;
assert.equal(JSON.parse(app.service.getMatch(after.id).maps).length, 5);
assert.equal(JSON.parse(app.service.getMatch(before.id).maps).length, 10);
// Öffentliche Konfiguration (Match erstellen) zeigt die neuen Formate
const cfg = (await call('GET', '/config')).body;
assert.equal(cfg.pools.find((p) => p.mode === 'competitive').maps.length, 5);
// Ungültig: zu wenige oder unbekannte Maps
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache'] } })).status, 400);
assert.equal((await call('PUT', '/admin/map-pools', { token, body: { mode: 'competitive', maps: ['de_cache', 'de_foo'] } })).status, 400);
assert.equal((await call('PUT', '/admin/map-pools', { body: { mode: 'competitive', maps: five } })).status, 401);
} finally {
await close();
}
});
+43 -6
View File
@@ -2,11 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { computeVetoState, resolveAction, validateFlow } from '../src/veto.js';
import { buildMapPools, loadMapPools } from '../src/mapPools.js';
import { computeVetoState, generateFlow, resolveAction, validateFlow } from '../src/veto.js';
import { buildMapPools, loadMapPools, setActiveMaps } from '../src/mapPools.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const pools = loadMapPools(path.join(here, '..', 'config', 'map-pools.json'));
// Die klassischen 7 Competitive-Maps: dafür gilt der fest hinterlegte Ablauf
const SEVEN = ['de_ancient', 'de_anubis', 'de_dust2', 'de_inferno', 'de_mirage', 'de_nuke', 'de_train'];
const competitive7 = () => setActiveMaps(structuredClone(pools.competitive), SEVEN);
test('alle ausgelieferten Abläufe sind gültig, Wingman-BO5 ist nicht unterstützt', () => {
assert.equal(pools.competitive.formats.bo1.supported, true);
@@ -37,8 +40,42 @@ test('ungültiger Ablauf in der Konfiguration deaktiviert nur dieses Format', ()
},
});
assert.equal(custom.mini.formats.bo1.supported, true);
assert.equal(custom.mini.formats.bo3.supported, false);
assert.match(custom.mini.formats.bo5.reason, /Kein Veto-Ablauf/);
assert.equal(custom.mini.formats.bo1.generated, false, 'passender fester Ablauf wird genommen');
// Ungültiger fester Ablauf: die Anwendung erzeugt einen passenden
assert.equal(custom.mini.formats.bo3.supported, true);
assert.equal(custom.mini.formats.bo3.generated, true);
assert.match(custom.mini.formats.bo5.reason, /mindestens 5 Maps/);
});
test('Erzeugte Abläufe sind für jede Map-Anzahl gültig', () => {
for (let n = 2; n <= 12; n += 1) {
for (const format of ['bo1', 'bo3', 'bo5']) {
const flow = generateFlow(n, format);
const need = { bo1: 2, bo3: 3, bo5: 5 }[format];
if (n < need) {
assert.equal(flow, null, `${format} mit ${n} Maps`);
continue;
}
assert.deepEqual(validateFlow(flow, n, format), [], `${format} mit ${n} Maps: ${flow.join(' ')}`);
}
}
// Bans und Picks wechseln sich ab, die Seite der Entscheidungsmap wählt das Team, das nicht zuletzt dran war
assert.deepEqual(generateFlow(5, 'bo3'), ['ban:X', 'ban:Y', 'pick:X', 'side:Y', 'pick:Y', 'side:X', 'decider', 'side:X']);
assert.deepEqual(generateFlow(3, 'bo1'), ['ban:X', 'ban:Y', 'decider', 'side:X']);
});
test('Aktive Maps wählen: Formate passen sich an, Reihenfolge bleibt wie im Katalog', () => {
const pool = structuredClone(pools.competitive);
setActiveMaps(pool, ['de_vertigo', 'de_cache', 'de_mirage', 'de_unbekannt']);
assert.deepEqual(pool.maps.map((m) => m.id), ['de_cache', 'de_mirage', 'de_vertigo']);
assert.equal(pool.formats.bo1.supported, true);
assert.equal(pool.formats.bo3.supported, true);
assert.equal(pool.formats.bo5.supported, false);
// 10 Maps (alle): kein fester Ablauf passt, es wird erzeugt
assert.equal(pools.competitive.maps.length, 10);
assert.equal(pools.competitive.formats.bo3.generated, true);
// 7 klassische Maps: fester Ablauf aus der Konfiguration
assert.equal(competitive7().formats.bo3.generated, false);
});
function play(pool, format, firstTeam, choices) {
@@ -52,7 +89,7 @@ function play(pool, format, firstTeam, choices) {
}
test('BO3 Competitive: kompletter Ablauf mit Seitenwahl und Entscheidungsmap', () => {
const state = play(pools.competitive, 'bo3', 'B', [
const state = play(competitive7(), 'bo3', 'B', [
{ map: 'de_train' }, // B bannt
{ map: 'de_nuke' }, // A bannt
{ map: 'de_mirage' }, // B pickt
@@ -82,7 +119,7 @@ test('BO1 Wingman: Entscheidungsmap wird automatisch übernommen', () => {
});
test('falsches Team, veraltete Schritte und vergebene Maps werden abgelehnt', () => {
const pool = pools.competitive;
const pool = competitive7();
const ctx = { maps: pool.maps, flow: pool.formats.bo1.flow, firstTeam: 'A', actions: [] };
assert.throws(() => resolveAction(ctx, { team: 'B', stepIndex: 0, map: 'de_nuke' }), { code: 'not_your_turn' });
assert.throws(() => resolveAction(ctx, { team: 'A', stepIndex: 1, map: 'de_nuke' }), { code: 'stale_step' });