From dea0618b69a228eb8d5729dabe587d6ba9d31cf2 Mon Sep 17 00:00:00 2001 From: SPP User Date: Mon, 26 Oct 2026 11:02:33 +0100 Subject: [PATCH] Veto-Timer: 30 Sekunden pro Schritt, danach automatische Auswahl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Läuft die Frist eines Veto-Schritts ab (VETO_STEP_SECONDS, Standard 30, 0 = aus), wählt das Backend für das Team am Zug eine zufällige Map bzw. Seite. Die Veto-Anzeige zeigt die verbleibende Zeit. Co-Authored-By: Claude Haiku 5.5 --- client/src/components/VetoBoard.jsx | 15 ++++++++ client/src/styles.css | 2 ++ server/src/app.js | 2 ++ server/src/config.js | 2 ++ server/src/matchService.js | 53 +++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+) diff --git a/client/src/components/VetoBoard.jsx b/client/src/components/VetoBoard.jsx index 99ef91a..cc07d00 100644 --- a/client/src/components/VetoBoard.jsx +++ b/client/src/components/VetoBoard.jsx @@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false }) const { current } = veto; const [selected, setSelected] = useState(null); useEffect(() => setSelected(null), [current?.index]); + const left = useCountdown(veto.remainingMs); const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m])); const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team; @@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false }) ? 'Live-Ansicht – nur Captains können wählen.' : 'Das andere Team ist am Zug.'} + {left !== null && Noch {Math.ceil(left / 1000)} s} )} {!current && veto.done && !compact &&
Veto abgeschlossen
} @@ -129,3 +131,16 @@ function statusText(m, teams) { default: return 'Verfügbar'; } } + +/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */ +function useCountdown(ms) { + const [left, setLeft] = useState(ms ?? null); + useEffect(() => { + setLeft(ms ?? null); + if (ms == null) return undefined; + const started = Date.now(); + const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250); + return () => clearInterval(t); + }, [ms]); + return left; +} diff --git a/client/src/styles.css b/client/src/styles.css index f0e657b..f2356e8 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -517,3 +517,5 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; } .admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; } .admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; } + +.turn-timer { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; } diff --git a/server/src/app.js b/server/src/app.js index 76223c4..0ac0bd8 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -23,6 +23,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console } const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() }); manager = new ServerManager({ db, service, adapter, config, logger }); manager.init(); + service.startVetoTimer(); const app = express(); if (config.trustProxy) app.set('trust proxy', 1); @@ -54,6 +55,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console } }, async close() { manager.stopLoops(); + service.stopVetoTimer(); // Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt await manager.idle(5000); io.disposeRealtime(); diff --git a/server/src/config.js b/server/src/config.js index 933bb41..6efc5dd 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -52,6 +52,8 @@ export function loadConfig(overrides = {}) { maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8), statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5), reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60), + // Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist + vetoStepSeconds: int('VETO_STEP_SECONDS', 30), match: { tvDelaySeconds: int('CS2_TV_DELAY', 90), diff --git a/server/src/matchService.js b/server/src/matchService.js index 4efbaa9..f7bfc96 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -346,6 +346,58 @@ export class MatchService extends EventEmitter { this.changed(viewer.matchId); } + /** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */ + vetoDeadline(match) { + if (!this.config.vetoStepSeconds || match.status !== 'veto') return null; + const actions = this.vetoActions(match.id); + const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at; + return since == null ? null : since + this.config.vetoStepSeconds * 1000; + } + + vetoRemainingMs(match) { + const deadline = this.vetoDeadline(match); + return deadline === null ? null : Math.max(0, deadline - this.now()); + } + + /** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */ + autoVetoStep(match) { + const state = this.vetoState(match); + if (state.done || !state.current) return; + const { type, team, index } = state.current; + const pick = (list) => list[Math.floor(Math.random() * list.length)]; + const viewer = { + matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team, + }; + const action = type === 'side' + ? { stepIndex: index, side: pick(['CT', 'T']) } + : { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id }; + this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`); + this.vetoAction(viewer, action); + } + + /** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */ + autoVetoTimeouts() { + for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) { + const deadline = this.vetoDeadline(match); + if (deadline === null || this.now() < deadline) continue; + try { + this.autoVetoStep(match); + } catch (err) { + this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`); + } + } + } + + startVetoTimer() { + if (!this.config.vetoStepSeconds || this.vetoTimer) return; + this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000); + } + + stopVetoTimer() { + clearInterval(this.vetoTimer); + this.vetoTimer = null; + } + vetoAction(viewer, { stepIndex, map, side }) { this.require(viewer, 'captain'); let finished = false; @@ -812,6 +864,7 @@ ${this.config.publicUrl}/stream/match/${matchId}`; steps: veto.steps, maps: veto.maps, current: match.status === 'veto' ? veto.current : null, + remainingMs: this.vetoRemainingMs(match), done: veto.done, history: actions.map((a) => ({ index: a.idx,