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,