Merge feature/messer-match-server in main
# Conflicts: # client/src/styles.css
This commit is contained in:
commit
148cd99856
10 files changed
+220
-20
No files matched your search
@@ -283,12 +283,12 @@ Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen au
|
||||
Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.
|
||||
|
||||
- Modus wählbar: **Competitive 5v5** (12 Plätze, Maps aus dem Competitive-Pool) oder **Wingman 2v2** (6 Plätze, Maps aus dem Wingman-Pool).
|
||||
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). In der Lobby eines Matches mit Messerrunde stehen die freien Messer-Server mit „Verbinden“.
|
||||
- Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (`Maxlan | Messer 1 5v5 (Seitenwahl)`). Sie dienen als Ausweichmöglichkeit; Matches mit Messerrunde nutzen ihren eigenen Server (siehe unten).
|
||||
- Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (`KNIFE_WARMUP_SECONDS`, Standard 90). Nach der Runde startet das Spiel von selbst neu (`mp_match_end_restart`). Wird der Server leer, setzt die App ihn zusätzlich zurück.
|
||||
- Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
|
||||
- Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
|
||||
- Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
|
||||
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, zeigt die Lobby beiden Teams die freien Messer-Server (zuerst die mit passender Teamgröße) mit „Verbinden“. Nach der Runde klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Das gewählte Team beginnt das Veto. Im Admin-Bereich gibt es dieselben zwei Knöpfe in der Matchkarte.
|
||||
- **Im Match:** Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → **Messerrunde**, läuft die Messerrunde auf dem **eigenen Server des Matches**: Sobald er hochgefahren ist, richtet die App dort eine Messerrunde mit dem Passwort des Matches ein. Die Lobby zeigt den Spielern beider Teams die Verbindungsdaten („In CS2 verbinden“). Die Aufwärmphase läuft ohne Ende, bis der Organisator in der Lobby **Messerrunde starten** klickt; mit **Runde wiederholen** geht es zurück in die Aufwärmphase. Danach klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Nach dem Veto richtet die App denselben Server für das Match ein und nimmt die Messer-Einstellungen zurück, die Spieler bleiben einfach drauf. Ohne eigenen Server (z. B. Adapter `static` ohne freien Server) zeigt die Lobby wie bisher die freien Messer-Server. Im Admin-Bereich gibt es die Knöpfe „Team A/B beginnt“ auch in der Matchkarte.
|
||||
- Weitere Einstellungen: `KNIFE_MAP` (Standard-Map, muss in einem Map-Pool sein), `KNIFE_MAX_SERVERS` (Standard 4).
|
||||
|
||||
### Server im Steam-Serverbrowser (LAN-Reiter)
|
||||
|
||||
@@ -61,13 +61,14 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
|
||||
)}
|
||||
|
||||
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
|
||||
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
|
||||
{server?.knife && <p className="muted small">Vor dem Veto läuft hier die Messerrunde, die Verbindungsdaten stehen in der Lobby. Nach dem Veto wird derselbe Server für das Match eingerichtet.</p>}
|
||||
{server && !server.knife && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
|
||||
<p className="muted small">
|
||||
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{server?.connect && (
|
||||
{server?.connect && !server.knife && (
|
||||
<div className="connect">
|
||||
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
|
||||
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
|
||||
|
||||
@@ -13,6 +13,7 @@ import EventLog from '../components/EventLog.jsx';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SelfJoin from '../components/SelfJoin.jsx';
|
||||
import ChatBox from '../components/ChatBox.jsx';
|
||||
import CopyField from '../components/CopyField.jsx';
|
||||
|
||||
export default function MatchPage() {
|
||||
const { id } = useParams();
|
||||
@@ -172,7 +173,7 @@ function Stage(ctx) {
|
||||
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{state.vetoStart === 'knife' && <KnifeRound state={state} />}
|
||||
{state.vetoStart === 'knife' && (state.server?.knife ? <OwnKnifeServer state={state} isOrg={isOrg} act={act} busy={busy} /> : <KnifeRound state={state} />)}
|
||||
{isOrg && state.vetoStart === 'knife' ? (
|
||||
<div className="knife-decide">
|
||||
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
|
||||
@@ -222,6 +223,50 @@ function Stage(ctx) {
|
||||
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
|
||||
* Frei = niemand drauf (oder Spielerzahl unbekannt).
|
||||
*/
|
||||
/** Messerrunde auf dem eigenen Match-Server: derselbe Server wird nach dem Veto für das Match eingerichtet. */
|
||||
function OwnKnifeServer({ state, isOrg, act, busy }) {
|
||||
const { server } = state;
|
||||
const { knife, connect } = server;
|
||||
const live = knife.phase === 'live';
|
||||
return (
|
||||
<div className="knife-round">
|
||||
<h3>Seitenwahl per Messerrunde</h3>
|
||||
<p className="small">
|
||||
Für euer Match läuft ein eigener Server. Beide Teams verbinden sich, spielen dort eine Runde nur mit Messer, danach legt der
|
||||
Organisator fest, welches Team das Veto beginnt. Nach dem Veto geht es auf demselben Server weiter.
|
||||
</p>
|
||||
{!knife.ready ? (
|
||||
<p className="small tone-pending">
|
||||
<span className="status-dot tone-pending pulse" /> Der Server fährt hoch, die Verbindungsdaten erscheinen gleich hier.
|
||||
{server.message && <span className="muted"> ({server.message})</span>}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`small ${live ? 'tone-ok' : 'tone-ok'}`}>
|
||||
<span className={`status-dot ${live ? 'tone-ok' : 'tone-ok'}`} />
|
||||
{live ? ' Messerrunde läuft' : ' Aufwärmphase: verbinden und warten, bis der Organisator die Runde startet'}
|
||||
{server.playerCount != null && <span className="muted"> · {server.playerCount} auf dem Server</span>}
|
||||
</p>
|
||||
{connect && (
|
||||
<div className="knife-connect">
|
||||
<CopyField label="In die CS2-Konsole einfügen" value={connect.command} secret />
|
||||
<a className="btn btn-primary" href={connect.steamUrl}>In CS2 verbinden</a>
|
||||
</div>
|
||||
)}
|
||||
{isOrg && (
|
||||
<div className="row knife-actions">
|
||||
{!live && <button className="btn btn-sm btn-primary" disabled={!!busy} onClick={() => act('server/knife-start', null, 'Messerrunde gestartet')}>Messerrunde starten</button>}
|
||||
<button className="btn btn-sm" disabled={!!busy} onClick={() => act('server/knife-reset', null, 'Zurück in der Aufwärmphase')}>
|
||||
{live ? 'Runde wiederholen (Aufwärmphase)' : 'Aufwärmphase neu starten'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KnifeRound({ state }) {
|
||||
const [servers, setServers] = useState(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -772,3 +772,6 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.pull-skipped span { color: var(--muted); }
|
||||
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
|
||||
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
|
||||
.knife-connect { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; margin-top: 10px; }
|
||||
.knife-connect > :first-child { flex: 1 1 260px; }
|
||||
.knife-actions { margin-top: 10px; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -165,6 +165,9 @@ function migrate(db) {
|
||||
}
|
||||
const serverColumns = columns('servers');
|
||||
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
||||
// Seitenwahl per Messerrunde auf dem Match-Server: wann sie eingerichtet wurde und was gerade läuft
|
||||
if (!serverColumns.has('knife_at')) db.exec('ALTER TABLE servers ADD COLUMN knife_at INTEGER');
|
||||
if (!serverColumns.has('knife_phase')) db.exec('ALTER TABLE servers ADD COLUMN knife_phase TEXT');
|
||||
// Abgeschlossene Matches lassen sich archivieren; sie verschwinden dann aus der normalen Admin-Liste
|
||||
if (!columns('matches').has('archived_at')) db.exec('ALTER TABLE matches ADD COLUMN archived_at INTEGER');
|
||||
if (!columns('knife_servers').has('mode')) db.exec("ALTER TABLE knife_servers ADD COLUMN mode TEXT NOT NULL DEFAULT 'competitive'");
|
||||
|
||||
@@ -123,6 +123,8 @@ export function createApiRouter({ service, manager, config, pools, adapter, sett
|
||||
action('server/retry', (v) => manager.retry(v));
|
||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||
action('server/knife-start', (v) => manager.knifeAction(v, 'start'));
|
||||
action('server/knife-reset', (v) => manager.knifeAction(v, 'reset'));
|
||||
action('series/next', (v) => manager.loadNextMap(v));
|
||||
action('finish', (v) => service.close(v, 'finished'));
|
||||
action('cancel', (v) => service.close(v, 'cancelled'));
|
||||
|
||||
@@ -1065,25 +1065,21 @@ export class MatchService extends EventEmitter {
|
||||
updatedAt: row.updated_at,
|
||||
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
|
||||
};
|
||||
// Messerrunde auf dem Match-Server: Spieler bekommen die Verbindungsdaten schon in der Lobby
|
||||
const knife = match.status === 'lobby' && row.knife_at && row.status === 'ready';
|
||||
if (match.status === 'lobby' && match.veto_start === 'knife') view.knife = { ready: !!knife, phase: knife ? row.knife_phase : null };
|
||||
if (viewer.isOrganizer) {
|
||||
// Zeitpunkt der letzten Anforderung (neuer Start oder "Server neu starten / wiederholen")
|
||||
view.requestedAt = row.started_at ?? null;
|
||||
if (row.host) view.address = { host: row.host, port: row.port, tvPort: row.tv_port };
|
||||
}
|
||||
const configured = row.status === 'configured';
|
||||
if (knife && viewer.role !== 'public' && (viewer.isOrganizer || viewer.playerId)) view.connect = this.connectInfo(row);
|
||||
if (!configured || viewer.role === 'public') return view;
|
||||
|
||||
const isTeam = viewer.isOrganizer || !!viewer.playerId;
|
||||
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
|
||||
if (isTeam) {
|
||||
view.connect = {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
password: row.server_password,
|
||||
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
||||
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
||||
};
|
||||
}
|
||||
if (isTeam) view.connect = this.connectInfo(row);
|
||||
if (isSpectatorView && this.config.match.tvEnabled) {
|
||||
view.gotv = {
|
||||
host: row.host,
|
||||
@@ -1095,4 +1091,14 @@ export class MatchService extends EventEmitter {
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
connectInfo(row) {
|
||||
return {
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
password: row.server_password,
|
||||
command: `password ${row.server_password}; connect ${row.host}:${row.port}`,
|
||||
steamUrl: `steam://connect/${row.host}:${row.port}/${row.server_password}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,15 @@ const MAX_RESTARTS = 5;
|
||||
* Konsolenbefehle für eine Messerrunde: nur Messer, kein Geld, eine Runde, danach automatisch neues Spiel.
|
||||
* `pool` bestimmt den Spielmodus (Competitive 5v5 oder Wingman 2v2), `warmupSeconds` die Zeit zum Verbinden.
|
||||
*/
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90 }) {
|
||||
export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, warmupSeconds = 90, password = '', endlessWarmup = false }) {
|
||||
return {
|
||||
map,
|
||||
beforeMapChange: [
|
||||
`game_type ${Number(pool.gameType)}`,
|
||||
`game_mode ${Number(pool.gameMode)}`,
|
||||
'sv_password ""',
|
||||
'tv_enable 0',
|
||||
`sv_password "${password}"`,
|
||||
// Auf dem Match-Server (mit Passwort) bleibt CSTV, wie es ist: das Match braucht es danach
|
||||
...(password ? [] : ['tv_enable 0']),
|
||||
`changelevel ${map}`,
|
||||
],
|
||||
afterMapChange: [
|
||||
@@ -56,15 +57,38 @@ export function buildKnifeCommands({ map, pool = { gameType: 0, gameMode: 1 }, w
|
||||
'mp_autoteambalance 0',
|
||||
'mp_limitteams 0',
|
||||
'mp_autokick 0',
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde
|
||||
// Aufwärmphase zum Verbinden, danach beginnt die Messerrunde. Endlos: bis der Organisator sie startet.
|
||||
'mp_do_warmup_period 1',
|
||||
`mp_warmuptime ${Math.max(10, Math.min(600, Number(warmupSeconds) || 90))}`,
|
||||
'mp_warmup_pausetimer 0',
|
||||
`mp_warmup_pausetimer ${endlessWarmup ? 1 : 0}`,
|
||||
'mp_warmup_start',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Messer-Einstellungen auf CS2-Standard zurück, bevor auf demselben Server das Match eingerichtet wird.
|
||||
* Was die Match-Konfiguration selbst setzt (Startgeld, Kaufzeit, Rundenzeit …), steht dort.
|
||||
*/
|
||||
export const KNIFE_UNDO_COMMANDS = [
|
||||
'mp_ct_default_secondary weapon_hkp2000',
|
||||
'mp_t_default_secondary weapon_glock',
|
||||
'mp_ct_default_primary ""',
|
||||
'mp_t_default_primary ""',
|
||||
'mp_ct_default_grenades ""',
|
||||
'mp_t_default_grenades ""',
|
||||
'mp_give_player_c4 1',
|
||||
'mp_maxmoney 16000',
|
||||
'mp_afterroundmoney 0',
|
||||
'mp_free_armor 0',
|
||||
'mp_weapons_allow_map_placed 1',
|
||||
'mp_death_drop_gun 1',
|
||||
'mp_death_drop_grenade 2',
|
||||
'mp_match_end_restart 0',
|
||||
'mp_roundtime_defuse 1.92',
|
||||
'mp_roundtime_hostage 1.92',
|
||||
];
|
||||
|
||||
/** Befehle, mit denen ein laufender Messer-Server für die nächsten Teams zurückgesetzt wird. */
|
||||
export const KNIFE_RESET_COMMANDS = ['mp_warmup_start'];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildMapCommands, sanitizeConsoleText } from './cs2Config.js';
|
||||
import { buildKnifeCommands, KNIFE_UNDO_COMMANDS } from './knifeServers.js';
|
||||
import { DemoMatchSimulator } from './demoMatchSimulator.js';
|
||||
import { backupPrefix, describeBackup, isValidBackupFile, pointsForMap } from '../rounds.js';
|
||||
import { HttpError, CLOSED_STATUSES } from '../matchService.js';
|
||||
@@ -305,7 +306,7 @@ export class ServerManager {
|
||||
VALUES (@match_id, @adapter, @simulated, 'starting', @message, NULL, NULL, NULL, NULL,
|
||||
@rcon, @server, @tv, @key, @attempts, @now, @now)
|
||||
ON CONFLICT(match_id) DO UPDATE SET adapter = excluded.adapter, simulated = excluded.simulated,
|
||||
status = 'starting', message = excluded.message, handle = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||||
status = 'starting', message = excluded.message, handle = NULL, knife_at = NULL, knife_phase = NULL, host = NULL, port = NULL, tv_port = NULL,
|
||||
rcon_password = excluded.rcon_password, server_password = excluded.server_password,
|
||||
tv_password = excluded.tv_password, config_key = excluded.config_key, attempts = excluded.attempts,
|
||||
started_at = excluded.started_at, ready_at = NULL, configured_at = NULL, stopped_at = NULL,
|
||||
@@ -406,12 +407,66 @@ export class ServerManager {
|
||||
maybeConfigure(matchId) {
|
||||
const match = this.service.getMatch(matchId);
|
||||
const row = this.row(matchId);
|
||||
// Seitenwahl per Messerrunde: derselbe Server dient vor dem Veto als Messer-Server
|
||||
if (this.wantsKnife(match, row) && !row.knife_at) {
|
||||
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||
return;
|
||||
}
|
||||
// Auch im laufenden Match: nach einem Neustart des Servers die aktuelle Map samt Passwort wieder laden
|
||||
if (['server_setup', 'live'].includes(match.status) && row?.status === 'ready') {
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Match in der Lobby mit Messerrunde und hochgefahrenem Server. */
|
||||
wantsKnife(match, row) {
|
||||
return match?.status === 'lobby' && match.veto_start === 'knife' && row?.status === 'ready';
|
||||
}
|
||||
|
||||
/** Messerrunde auf dem Match-Server einrichten: Aufwärmphase ohne Ende, Passwort des Matches. */
|
||||
async configureKnife(matchId) {
|
||||
if (this.busy.has(matchId)) throw new HttpError(409, 'Für diesen Server läuft bereits eine Aktion', 'busy');
|
||||
this.busy.add(matchId);
|
||||
try {
|
||||
const row = this.row(matchId);
|
||||
const match = this.service.getMatch(matchId);
|
||||
const pool = this.service.poolFor(match);
|
||||
const map = pool.maps.some((m) => m.id === this.config.knife?.map) ? this.config.knife.map : pool.maps[0].id;
|
||||
this.update(matchId, { message: 'Messerrunde wird eingerichtet' });
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), buildKnifeCommands({ map, pool, password: row.server_password, endlessWarmup: true }));
|
||||
} catch (err) {
|
||||
this.update(matchId, { knife_at: null, knife_phase: null, message: `Messerrunde konnte nicht eingerichtet werden: ${err.message}` });
|
||||
this.service.log(matchId, 'server', `Messerrunde nicht eingerichtet: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
this.update(matchId, { knife_at: Date.now(), knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||
this.service.log(matchId, 'server', 'Server für die Messerrunde bereit (Aufwärmphase)');
|
||||
} finally {
|
||||
this.busy.delete(matchId);
|
||||
}
|
||||
// Ist das Veto inzwischen durch, gleich das Match einrichten (der Aufruf dafür kam, während wir beschäftigt waren)
|
||||
if (this.service.getMatch(matchId).status !== 'lobby') this.maybeConfigure(matchId);
|
||||
}
|
||||
|
||||
/** Organisator: Messerrunde starten (Aufwärmphase beenden) oder wiederholen (zurück in die Aufwärmphase). */
|
||||
async knifeAction(viewer, what) {
|
||||
this.service.require(viewer, 'organizer');
|
||||
const match = this.service.getMatch(viewer.matchId);
|
||||
const row = this.row(viewer.matchId);
|
||||
if (match.status !== 'lobby' || !row?.knife_at) throw new HttpError(409, 'Der Server ist noch nicht für die Messerrunde bereit', 'wrong_state');
|
||||
const start = what === 'start';
|
||||
try {
|
||||
await this.adapter.sendCommands(JSON.parse(row.handle), start ? ['mp_warmup_pausetimer 0', 'mp_warmup_end'] : ['mp_warmup_pausetimer 1', 'mp_warmup_start']);
|
||||
} catch (err) {
|
||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
||||
}
|
||||
this.update(viewer.matchId, start
|
||||
? { knife_phase: 'live', message: 'Messerrunde läuft' }
|
||||
: { knife_phase: 'warmup', message: 'Messerrunde: Aufwärmphase, bis der Organisator startet' });
|
||||
this.service.log(viewer.matchId, 'server', start ? 'Messerrunde gestartet' : 'Messerrunde zurück in die Aufwärmphase');
|
||||
}
|
||||
|
||||
async configure(matchId) {
|
||||
const row = this.row(matchId);
|
||||
if (!row?.handle) throw new HttpError(409, 'Es gibt noch keinen gestarteten Server', 'no_server');
|
||||
@@ -444,6 +499,8 @@ export class ServerManager {
|
||||
});
|
||||
this.simulator.stop(matchId);
|
||||
this.service.resetLiveScore(matchId, seriesMap.number);
|
||||
// Lief vorher die Messerrunde auf diesem Server, deren Einstellungen zurücknehmen
|
||||
if (row.knife_at) plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||
} catch (err) {
|
||||
@@ -454,6 +511,8 @@ export class ServerManager {
|
||||
this.update(matchId, {
|
||||
status: 'configured',
|
||||
configured_at: Date.now(),
|
||||
knife_at: null,
|
||||
knife_phase: null,
|
||||
message: this.adapter.simulated
|
||||
? `Simuliert: ${mapName} wäre jetzt geladen – kein echter Server`
|
||||
: `${mapName} geladen – Aufwärmphase läuft`,
|
||||
@@ -482,6 +541,11 @@ export class ServerManager {
|
||||
await this.provision(matchId);
|
||||
return;
|
||||
}
|
||||
if (this.wantsKnife(match, row)) {
|
||||
this.service.log(matchId, 'server', 'Organisator richtet die Messerrunde neu ein');
|
||||
this.configureKnife(matchId).catch((err) => this.logger.error(err));
|
||||
return;
|
||||
}
|
||||
if (row.status === 'config_failed' || (row.status === 'ready' && ['server_setup', 'live'].includes(match.status))) {
|
||||
this.service.log(matchId, 'server', 'Organisator wiederholt die Konfiguration');
|
||||
this.configure(matchId).catch((err) => this.logger.error(err));
|
||||
@@ -609,6 +673,8 @@ export class ServerManager {
|
||||
const webActive = this.service.presence(row.match_id).size > 0;
|
||||
// Ein (neu) gestarteter Server zählt als Nutzung, sonst stoppt ein Neustart nach langer Pause sofort wieder
|
||||
let lastUse = Math.max(match.last_activity_at, row.started_at ?? 0);
|
||||
// Spieler auf dem Server während der Messerrunde: in Benutzung
|
||||
if (row.knife_at && row.player_count > 0) continue;
|
||||
if (row.status === 'configured' && row.player_count !== null) {
|
||||
// Echter Server mit bekannter Spielerzahl: entscheidend ist, ob jemand auf dem Server ist.
|
||||
if (row.player_count > 0) continue;
|
||||
|
||||
@@ -713,3 +713,53 @@ test('Konsole: nur für Admins, Ausgabe kommt live, unbekannte Server 404', asyn
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, danach derselbe Server fürs Match', async () => {
|
||||
const { app, call, close } = await setup();
|
||||
try {
|
||||
const created = await call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'knife' } });
|
||||
const { id, organizerToken: org } = created.body;
|
||||
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
|
||||
const tokens = {};
|
||||
for (const [name, team] of [['a1', 'A'], ['a2', 'A'], ['b1', 'B'], ['b2', 'B']]) {
|
||||
tokens[name] = (await call('POST', `/matches/${id}/join`, { body: { code: codes[team], nickname: name } })).body.playerToken;
|
||||
}
|
||||
// Vor dem Start: Aktionen gehen noch nicht
|
||||
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 409);
|
||||
|
||||
await app.manager.poll();
|
||||
await waitFor(() => app.manager.row(id).knife_phase === 'warmup', { label: 'Messerrunde eingerichtet' });
|
||||
const player = (await call('GET', `/matches/${id}`, { token: tokens.b1 })).body;
|
||||
assert.deepEqual(player.server.knife, { ready: true, phase: 'warmup' });
|
||||
assert.equal(player.server.connect.password, app.manager.row(id).server_password, 'Spieler bekommen das Passwort des Matches');
|
||||
const pub = (await call('GET', `/matches/${id}`)).body;
|
||||
assert.equal(pub.server?.connect, undefined, 'öffentlich keine Verbindungsdaten');
|
||||
|
||||
// Nur der Organisator startet die Runde
|
||||
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: tokens.a1 })).status, 403);
|
||||
assert.equal((await call('POST', `/matches/${id}/server/knife-start`, { token: org })).status, 200);
|
||||
assert.equal(app.manager.row(id).knife_phase, 'live');
|
||||
assert.equal((await call('POST', `/matches/${id}/server/knife-reset`, { token: org })).status, 200);
|
||||
assert.equal(app.manager.row(id).knife_phase, 'warmup');
|
||||
|
||||
// Nach dem Veto wird derselbe Server fürs Match eingerichtet, die Messer-Einstellungen werden zurückgenommen
|
||||
const handle = app.manager.row(id).handle;
|
||||
const plans = [];
|
||||
const configure = app.adapter.configure.bind(app.adapter);
|
||||
app.adapter.configure = async (h, plan) => { plans.push(plan); return configure(h, plan); };
|
||||
for (const t of Object.values(tokens)) await call('POST', `/matches/${id}/ready`, { token: t, body: { ready: true } });
|
||||
assert.equal((await call('POST', `/matches/${id}/veto/start`, { token: org, body: { firstTeam: 'A' } })).status, 200);
|
||||
const snap = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||
const capA = snap.teams.A.players.find((p) => p.isCaptain)?.nickname ?? 'a1';
|
||||
const capB = snap.teams.B.players.find((p) => p.isCaptain)?.nickname ?? 'b1';
|
||||
await playVeto(call, id, { A: tokens[capA], B: tokens[capB] });
|
||||
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
|
||||
const row = app.manager.row(id);
|
||||
assert.equal(row.handle, handle, 'derselbe Server');
|
||||
assert.equal(row.knife_at, null);
|
||||
assert.ok(plans.at(-1).afterMapChange.includes('mp_give_player_c4 1'));
|
||||
assert.equal((await call('GET', `/matches/${id}`, { token: tokens.b1 })).body.server.knife, undefined);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user