Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda3f4ea9c | ||
|
|
2d65634d6e |
No files matched your search
@@ -148,13 +148,28 @@ Beim Start wird jeder Ablauf geprüft:
|
||||
- genau eine Entscheidungsmap
|
||||
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
||||
|
||||
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
|
||||
Passt kein hinterlegter Ablauf zur Map-Anzahl, erzeugt die Anwendung einen (siehe unten). Unauswählbar ist ein Format nur, wenn es zu wenige Maps gibt (mit Begründung in der Oberfläche).
|
||||
|
||||
### Maps im Admin-Bereich wählen
|
||||
|
||||
Unter Einstellungen → **Map-Pools** wählt der Admin pro Modus, welche Maps im Veto stehen (mindestens 2). Die Auswahl steht in der Datenbank und gilt für neue Matches. Laufende Matches behalten ihre Maps und ihren Ablauf. Maps mit `"active": false` in der Konfiguration stehen im Katalog, sind aber erst nach Auswahl aktiv.
|
||||
|
||||
**Ablauf passend zur Map-Anzahl:** Passt ein hinterlegter Ablauf zur Anzahl der gewählten Maps, gilt er. Sonst erzeugt die Anwendung einen Ablauf:
|
||||
|
||||
1. bis zu zwei Bans (X, dann Y),
|
||||
2. die Picks der Serie im Wechsel, die Seite wählt jeweils das andere Team,
|
||||
3. die restlichen Bans im Wechsel,
|
||||
4. die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.
|
||||
|
||||
Jede weitere Map bedeutet also einen Ban mehr. BO3 braucht mindestens 3 Maps, BO5 mindestens 5. Der Admin-Bereich zeigt nach dem Speichern den Ablauf für jedes Format.
|
||||
|
||||
Beispiel Competitive mit allen 10 Maps, BO3: X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X, Y, X, Y, X bannen → Entscheidungsmap, Y wählt Seite.
|
||||
|
||||
### Mitgelieferte Abläufe
|
||||
|
||||
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
|
||||
**Competitive (Katalog mit 10 Maps):** Ancient, Anubis, Cache, Dust II, Inferno, Mirage, Nuke, Overpass, Train, Vertigo. Alle 10 sind aktiv. Die festen Abläufe unten gelten für die klassischen 7 Maps (Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train), bei jeder anderen Auswahl erzeugt die Anwendung den Ablauf.
|
||||
|
||||
| Format | Ablauf |
|
||||
| Format (7 Maps) | Ablauf |
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
@@ -166,7 +181,7 @@ Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Be
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
||||
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
|
||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit mindestens 5 Maps wird es automatisch auswählbar. |
|
||||
|
||||
### Regel für Startseiten
|
||||
|
||||
@@ -456,6 +471,7 @@ Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach d
|
||||
|
||||
### Reiter „Einstellungen“
|
||||
|
||||
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
|
||||
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
|
||||
- **Docker-Hosts:** Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
|
||||
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
|
||||
import CopyField from '../components/CopyField.jsx';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
|
||||
const KEY = 'cs2mv:admin';
|
||||
const FILTER_KEY = 'cs2mv:admin:filter';
|
||||
@@ -146,6 +147,7 @@ export default function AdminPage() {
|
||||
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
|
||||
</nav>
|
||||
|
||||
{tab === 'settings' && token && <MapPoolsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
|
||||
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
|
||||
@@ -905,3 +907,96 @@ function KnifePanel({ token, setNotice }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const STEP_LABEL = { ban: 'Ban', pick: 'Pick', side: 'Seite' };
|
||||
|
||||
/** Veto-Ablauf als kurze Schrittfolge; X = Team, das das Veto beginnt, Y = das andere Team. */
|
||||
function FlowSteps({ flow }) {
|
||||
return (
|
||||
<ol className="flow-steps">
|
||||
{flow.map((raw, i) => {
|
||||
if (raw === 'decider') return <li key={i} className="flow-decider">Entscheidung</li>;
|
||||
const [type, actor] = raw.split(':');
|
||||
return <li key={i} className={`flow-${type}`}>{STEP_LABEL[type]} {actor === 'X' ? '1' : '2'}</li>;
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Welche Maps gespielt werden, pro Modus. Gilt für neue Matches, laufende behalten ihre Maps. */
|
||||
function MapPoolsPanel({ token, setNotice }) {
|
||||
const [pools, setPools] = useState(null);
|
||||
const [selection, setSelection] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const apply = (res) => {
|
||||
setPools(res.pools);
|
||||
setSelection(Object.fromEntries(res.pools.map((p) => [p.mode, p.maps.filter((m) => m.active).map((m) => m.id)])));
|
||||
};
|
||||
useEffect(() => {
|
||||
api('/admin/map-pools', { token }).then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
|
||||
}, [token, setNotice]);
|
||||
if (!pools) return null;
|
||||
|
||||
const toggle = (mode, id) => {
|
||||
const cur = new Set(selection[mode]);
|
||||
if (cur.has(id)) cur.delete(id);
|
||||
else cur.add(id);
|
||||
setSelection({ ...selection, [mode]: [...cur] });
|
||||
};
|
||||
const save = async (mode) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
apply(await api('/admin/map-pools', { method: 'PUT', body: { mode, maps: selection[mode] }, token }));
|
||||
setNotice({ tone: 'ok', text: 'Maps gespeichert – gilt für neue Matches' });
|
||||
} catch (err) {
|
||||
setNotice({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-settings">
|
||||
<h2>Map-Pools</h2>
|
||||
<p className="small muted">
|
||||
Welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an: Für jede weitere Map gibt es einen Ban mehr.
|
||||
Laufende Matches behalten ihre Maps. „1“ ist das Team, das das Veto beginnt.
|
||||
</p>
|
||||
{pools.map((p) => {
|
||||
const chosen = new Set(selection[p.mode]);
|
||||
const saved = new Set(p.maps.filter((m) => m.active).map((m) => m.id));
|
||||
const dirty = chosen.size !== saved.size || [...chosen].some((id) => !saved.has(id));
|
||||
return (
|
||||
<div key={p.mode} className="setting-group">
|
||||
<h3>{p.label} <span className="muted small">{chosen.size} von {p.maps.length} Maps</span></h3>
|
||||
<div className="pool-maps">
|
||||
{p.maps.map((m) => (
|
||||
<label key={m.id} className={`pool-map ${chosen.has(m.id) ? 'is-on' : ''}`}>
|
||||
<input type="checkbox" checked={chosen.has(m.id)} onChange={() => toggle(p.mode, m.id)} />
|
||||
<MapImage map={m} className="pool-map-art" />
|
||||
<span className="pool-map-name">{m.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!dirty && (
|
||||
<div className="pool-flows">
|
||||
{Object.entries(p.formats).map(([f, info]) => (
|
||||
<div key={f} className="pool-flow">
|
||||
<strong>{f.toUpperCase()}</strong>
|
||||
{info.supported ? <FlowSteps flow={info.flow} /> : <span className="muted small">{info.reason}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-actions">
|
||||
<button className="btn btn-sm btn-primary" disabled={busy || !dirty || chosen.size < 2} onClick={() => save(p.mode)}>Speichern</button>
|
||||
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => setSelection({ ...selection, [p.mode]: [...saved] })}>Verwerfen</button>}
|
||||
{chosen.size < 2 && <span className="small tone-error">Mindestens 2 Maps</span>}
|
||||
{dirty && chosen.size >= 2 && <span className="small muted">Nach dem Speichern steht hier der neue Veto-Ablauf.</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -612,3 +612,17 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
|
||||
.knife-list .btn { margin-left: auto; }
|
||||
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
|
||||
.pool-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.pool-map { position: relative; display: flex; flex-direction: column; justify-content: flex-end; min-height: 78px; isolation: isolate; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; cursor: pointer; opacity: .45; transition: opacity .15s ease, border-color .15s ease; }
|
||||
.pool-map.is-on { opacity: 1; border-color: var(--primary); }
|
||||
.pool-map input { position: absolute; top: 6px; left: 6px; z-index: 2; }
|
||||
|
||||
.pool-map-name { position: relative; z-index: 1; padding: 5px 8px; font-size: .9rem; font-weight: 700; text-shadow: 0 1px 6px rgba(0, 0, 0, .7); }
|
||||
.pool-flows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
|
||||
.pool-flow { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
|
||||
.pool-flow strong { min-width: 36px; font-size: .85rem; }
|
||||
.flow-steps { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
|
||||
.flow-steps li { font-size: .75rem; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
|
||||
.flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
|
||||
.flow-steps .flow-decider { color: var(--text); border-color: var(--primary); }
|
||||
+137
-17
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user