Map-Pools: Overpass, Vertigo und Cache für Competitive, Map-Auswahl im Admin, Veto-Ablauf passend zur Map-Anzahl
This commit is contained in:
1 parent
e3834bb071
commit
2d65634d6e
13 files changed
+459
-45
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user