Compare commits
4
Commits
148cd99856
...
934d989572
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934d989572 | ||
|
|
dc601a32fe | ||
|
|
6a922f80de | ||
|
|
d36dcacd39 |
No files matched your search
@@ -395,6 +395,14 @@ Unter `/stream` (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle lau
|
||||
- **Einzelnes Match:** Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter `/stream/match/<matchId>`. Der Link enthält keinen Zuschauercode, der Schlüssel `?key=` wird übernommen.
|
||||
- **Schlüssel:** Mit `STREAM_KEY` ist die Seite nur mit `/stream?key=<schlüssel>` erreichbar, sonst offen. `?bg=transparent` funktioniert ebenfalls.
|
||||
|
||||
### Beamer-Diashow
|
||||
|
||||
Unter `/beamer` (in der Kopfzeile verlinkt) läuft eine Diashow für den Beamer: ein laufendes Match pro Folie, groß und aus der Ferne lesbar, mit Teams, Serienstand, aktueller Map samt Rundenstand und Seiten sowie den Maps der Serie. Matches im Veto zeigen, wer gerade dran ist. Nach den Matches folgt eine Folie mit offenen Lobbys und Ergebnissen.
|
||||
|
||||
- **Weiterschalten:** alle 15 Sekunden, einstellbar mit `?sekunden=20` (5 bis 120). Ein roter Balken unten zeigt die Zeit bis zur nächsten Folie.
|
||||
- **Bedienung:** Klick schaltet Vollbild um, `F` ebenso, `←`/`→` blättern, Leertaste hält an.
|
||||
- **Schlüssel:** Mit `STREAM_KEY` als `/beamer?key=<schlüssel>` aufrufen, wie bei der Stream-Übersicht.
|
||||
|
||||
### Stream-Übersicht eines Matches
|
||||
|
||||
Unter `/stream/<Zuschauercode>` gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.
|
||||
|
||||
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
|
||||
import MatchPage from './pages/MatchPage.jsx';
|
||||
import StreamPage from './pages/StreamPage.jsx';
|
||||
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||
import BeamerPage from './pages/BeamerPage.jsx';
|
||||
import AdminPage from './pages/AdminPage.jsx';
|
||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||
|
||||
@@ -16,6 +17,8 @@ export default function App() {
|
||||
<Route path="/stream/:code" element={<StreamPage />} />
|
||||
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||
{/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */}
|
||||
<Route path="/beamer" element={<BeamerPage />} />
|
||||
<Route path="*" element={<MainLayout />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -44,6 +47,7 @@ function MainLayout() {
|
||||
<nav>
|
||||
<Link to="/" className="nav-link">Übersicht</Link>
|
||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||
<a href="/beamer" target="_blank" rel="noreferrer" className="nav-link">Beamer</a>
|
||||
<Link to="/admin" className="nav-link">Admin</Link>
|
||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* ---------- Beamer-Diashow (1920×1080, skaliert): ein Match pro Folie, groß und aus der Ferne lesbar ---------- */
|
||||
.bm-root {
|
||||
position: relative; width: 100%; height: 100%; overflow: hidden; cursor: pointer;
|
||||
display: grid; grid-template-rows: 104px 1fr 56px; padding: 0 72px;
|
||||
color: var(--text); font-family: var(--body);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 40%, rgba(52, 74, 153, .3), transparent 70%),
|
||||
radial-gradient(900px 600px at 100% 40%, rgba(167, 42, 28, .28), transparent 70%),
|
||||
#000;
|
||||
}
|
||||
.bm-root > :not(.bm-art) { position: relative; z-index: 1; }
|
||||
.bm-art { position: absolute; inset: 0; opacity: .32; }
|
||||
.bm-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .92) 70%); }
|
||||
|
||||
.bm-main { display: grid; min-height: 0; animation: bm-in .6s ease both; }
|
||||
@keyframes bm-in { from { opacity: 0; transform: translateY(18px); } }
|
||||
|
||||
.bm-slide { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; min-height: 0; padding: 20px 0; }
|
||||
.bm-meta { display: flex; align-items: center; gap: 22px; font: 600 32px var(--display); color: var(--muted); }
|
||||
.bm-meta .so-badge { font-size: 30px; padding: 6px 18px; }
|
||||
|
||||
.bm-hero { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 56px; width: 100%; }
|
||||
.bm-team { margin: 0; font: 700 128px/1 var(--display); letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||
.bm-team.a { color: var(--team-a); text-align: right; }
|
||||
.bm-team.b { color: var(--team-b); }
|
||||
.bm-series { display: flex; flex-direction: column; align-items: center; font: 700 130px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||
.bm-series span { display: flex; align-items: center; }
|
||||
.bm-series i { font-style: normal; color: var(--muted); padding: 0 14px; }
|
||||
.bm-series small { font: 600 26px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .12em; }
|
||||
.bm-vs { font-size: 90px; }
|
||||
|
||||
.bm-map { display: flex; align-items: baseline; gap: 26px; }
|
||||
.bm-map strong { font: 700 64px var(--display); }
|
||||
.bm-map span { font: 600 38px var(--display); color: var(--muted); }
|
||||
|
||||
.bm-score { display: flex; align-items: center; gap: 34px; font: 700 200px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||
.bm-score i { font-style: normal; color: var(--muted); }
|
||||
.bm-score b.a { color: var(--team-a); }
|
||||
.bm-score b.b { color: var(--team-b); }
|
||||
.bm-score b.won { text-shadow: 0 0 40px currentColor; }
|
||||
|
||||
.bm-maps { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; justify-content: center; }
|
||||
.bm-maps li {
|
||||
position: relative; overflow: hidden; width: 250px; height: 104px; border-radius: 8px; padding: 12px 16px;
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; border: 2px solid var(--line-strong);
|
||||
}
|
||||
.bm-maps li .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .85)); }
|
||||
.bm-maps li > span:not(.map-art), .bm-maps li b { position: relative; z-index: 1; font: 700 28px var(--display); text-shadow: 0 2px 8px #000; }
|
||||
.bm-maps li.is-pending { filter: grayscale(.8) brightness(.6); }
|
||||
.bm-maps li.is-current { border-color: var(--brand-red); box-shadow: 0 0 0 3px var(--brand-red); }
|
||||
|
||||
.bm-note { font: 600 44px var(--display); color: var(--muted); margin: 0; }
|
||||
.bm-turn { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 0; font: 600 52px var(--display); }
|
||||
.bm-turn .a { color: var(--team-a); }
|
||||
.bm-turn .b { color: var(--team-b); }
|
||||
.bm-turn small { font-size: 28px; color: var(--muted); }
|
||||
.bm-vmaps { width: 100%; height: 330px; }
|
||||
.bm-vmaps .so-vmap-name { font-size: 34px; }
|
||||
|
||||
.bm-summary { display: flex; flex-direction: column; justify-content: center; gap: 40px; min-height: 0; }
|
||||
.bm-idle { margin: 0; text-align: center; font: 700 80px var(--display); }
|
||||
.bm-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 72px; }
|
||||
.bm-columns h3 { margin: 0 0 18px; font: 700 40px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .1em; border-bottom: 3px solid var(--brand-red); padding-bottom: 10px; }
|
||||
.bm-columns ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.bm-columns li { display: flex; align-items: baseline; gap: 18px; font: 700 44px var(--display); }
|
||||
.bm-columns li .a { color: var(--team-a); }
|
||||
.bm-columns li .b { color: var(--team-b); }
|
||||
.bm-columns li i { font-style: normal; color: var(--muted); font-size: 32px; }
|
||||
.bm-columns li b { font-variant-numeric: tabular-nums; }
|
||||
.bm-columns li .won { text-decoration: underline; text-underline-offset: 8px; }
|
||||
.bm-columns li small { margin-left: auto; font: 600 28px var(--display); color: var(--muted); }
|
||||
.bm-columns .so-none { font-size: 32px; }
|
||||
|
||||
.bm-bottom { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.bm-dots { display: flex; gap: 12px; }
|
||||
.bm-dots span { width: 14px; height: 14px; border-radius: 50%; background: var(--line-strong); }
|
||||
.bm-dots span.on { background: var(--brand-red); }
|
||||
.bm-progress { position: absolute; left: 0; bottom: 0; height: 6px; width: 100%; background: var(--brand-red); transform-origin: left; animation: bm-progress linear both; }
|
||||
@keyframes bm-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SideIcon from '../components/SideIcon.jsx';
|
||||
import { ScaledStage, useClock } from './StreamPage.jsx';
|
||||
import { useStreamOverview } from './StreamOverviewPage.jsx';
|
||||
import '../stream.css';
|
||||
import '../stream-overview.css';
|
||||
import '../beamer.css';
|
||||
|
||||
const DEFAULT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Diashow der laufenden Matches für den Beamer: /beamer
|
||||
* Ein Match pro Folie, schaltet automatisch weiter. Am Ende eine Folie mit Lobbys und Ergebnissen.
|
||||
* Optional ?sekunden=20 (5 bis 120) und ?key=<STREAM_KEY>.
|
||||
* Tasten: ← → blättern, Leertaste anhalten, F Vollbild. Klick schaltet Vollbild um.
|
||||
*/
|
||||
export default function BeamerPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const key = params.get('key') ?? '';
|
||||
const seconds = Math.max(5, Math.min(120, Number.parseInt(params.get('sekunden'), 10) || DEFAULT_SECONDS));
|
||||
const { data, error } = useStreamOverview(key);
|
||||
const now = useClock();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('stream-mode');
|
||||
return () => document.documentElement.classList.remove('stream-mode');
|
||||
}, []);
|
||||
|
||||
const slides = buildSlides(data);
|
||||
const count = slides.length;
|
||||
const current = slides[count ? index % count : 0];
|
||||
// Folie über die ID merken, damit sich bei neuen Daten nicht die angezeigte Folie verschiebt
|
||||
const currentId = current?.id;
|
||||
|
||||
const go = useCallback((step) => setIndex((i) => (count ? (i + step + count) % count : 0)), [count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || count <= 1) return undefined;
|
||||
const t = setTimeout(() => go(1), seconds * 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [index, paused, count, seconds, go, currentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'ArrowRight') go(1);
|
||||
else if (e.key === 'ArrowLeft') go(-1);
|
||||
else if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p); } else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [go]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ScaledStage>
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<h1>Diashow nicht verfügbar</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
const live = (data?.active ?? []).filter((m) => m.status === 'live').length;
|
||||
|
||||
return (
|
||||
<ScaledStage>
|
||||
<div className="bm-root" onClick={toggleFullscreen}>
|
||||
{current?.type === 'match' && <Backdrop m={current.match} />}
|
||||
<header className="st-top bm-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<div className="so-title">
|
||||
<strong>Counter-Strike 2</strong>
|
||||
{live > 0 && <span className="so-count so-count-live">{live} live</span>}
|
||||
{count > 1 && <span className="so-count">{(index % count) + 1} / {count}</span>}
|
||||
{paused && <span className="so-count">angehalten</span>}
|
||||
</div>
|
||||
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
|
||||
</header>
|
||||
|
||||
<main className="bm-main" key={currentId ?? 'leer'}>
|
||||
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||
{current?.type === 'match' && <MatchSlide m={current.match} />}
|
||||
{current?.type === 'summary' && <SummarySlide data={data} />}
|
||||
</main>
|
||||
|
||||
<footer className="bm-bottom">
|
||||
{count > 1 && (
|
||||
<div className="bm-dots">
|
||||
{slides.map((s, i) => <span key={s.id} className={i === index % count ? 'on' : ''} />)}
|
||||
</div>
|
||||
)}
|
||||
{count > 1 && !paused && <div className="bm-progress" key={`${currentId}-${index}`} style={{ animationDuration: `${seconds}s` }} />}
|
||||
</footer>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Match pro Folie, danach eine Übersicht mit Lobbys und Ergebnissen (wenn es welche gibt). */
|
||||
function buildSlides(data) {
|
||||
if (!data) return [];
|
||||
const slides = data.active.map((m) => ({ id: m.id, type: 'match', match: m }));
|
||||
if (data.upcoming.length || data.results.length || slides.length === 0) slides.push({ id: 'summary', type: 'summary' });
|
||||
return slides;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||
else document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
function currentMap(m) {
|
||||
return m.series.find((s) => s.number === m.currentMapNumber);
|
||||
}
|
||||
|
||||
function Backdrop({ m }) {
|
||||
const map = currentMap(m);
|
||||
if (m.status === 'veto' || !map) return null;
|
||||
return <MapImage map={map} className="bm-art" />;
|
||||
}
|
||||
|
||||
function MatchSlide({ m }) {
|
||||
const map = currentMap(m);
|
||||
const isBo1 = m.format === 'bo1';
|
||||
const playing = m.status === 'live' && map?.live?.status === 'live' && map.status !== 'done';
|
||||
const badge = m.status === 'veto' ? 'Map-Veto' : m.status === 'server_setup' ? 'Server startet' : playing ? 'Live' : map?.status === 'done' ? 'Map beendet' : 'Aufwärmphase';
|
||||
|
||||
return (
|
||||
<section className={`bm-slide status-${m.status}`}>
|
||||
<div className="bm-meta">
|
||||
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||
<span className={`so-badge ${playing ? 'badge-live' : ''}`}>{badge}</span>
|
||||
</div>
|
||||
<div className="bm-hero">
|
||||
<h1 className="bm-team a">{m.teams.A}</h1>
|
||||
<div className="bm-series">
|
||||
{!isBo1 && m.status !== 'veto' ? (
|
||||
<>
|
||||
<span>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</span>
|
||||
<small>Maps</small>
|
||||
</>
|
||||
) : <i className="bm-vs">vs</i>}
|
||||
</div>
|
||||
<h1 className="bm-team b">{m.teams.B}</h1>
|
||||
</div>
|
||||
|
||||
{m.status === 'veto' && <VetoPart m={m} />}
|
||||
{m.status === 'server_setup' && map && <p className="bm-note">Server wird eingerichtet: Map {map.number}, {map.name}</p>}
|
||||
{m.status === 'live' && map && <LivePart m={m} map={map} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LivePart({ m, map }) {
|
||||
const sides = map.live?.sides;
|
||||
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
|
||||
const finished = map.status === 'done';
|
||||
const rounds = map.live?.roundsPlayed ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div className="bm-map">
|
||||
<strong>{map.name}</strong>
|
||||
<span>{finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
|
||||
</div>
|
||||
<div className="bm-score">
|
||||
{sideOf('A') && <SideIcon side={sideOf('A')} size={72} />}
|
||||
<b className={`a ${map.winner === 'A' ? 'won' : ''}`}>{map.score.A}</b>
|
||||
<i>:</i>
|
||||
<b className={`b ${map.winner === 'B' ? 'won' : ''}`}>{map.score.B}</b>
|
||||
{sideOf('B') && <SideIcon side={sideOf('B')} size={72} />}
|
||||
</div>
|
||||
{m.series.length > 1 && (
|
||||
<ol className="bm-maps">
|
||||
{m.series.map((s) => (
|
||||
<li key={s.number} className={`is-${s.status} ${s.number === map.number ? 'is-current' : ''}`} style={{ '--map': s.color ?? '#556' }}>
|
||||
<MapImage map={s} />
|
||||
<span>{s.name}</span>
|
||||
{(s.status === 'done' || s.number === map.number) && <b>{s.score.A}:{s.score.B}</b>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VetoPart({ m }) {
|
||||
const { veto, teams } = m;
|
||||
const c = veto.current;
|
||||
const action = c && ({ ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Seite' })[c.type];
|
||||
return (
|
||||
<>
|
||||
<p className="bm-turn">
|
||||
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
|
||||
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
|
||||
</p>
|
||||
<div className="so-vmaps bm-vmaps" style={{ '--count': veto.maps.length }}>
|
||||
{veto.maps.map((x) => (
|
||||
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
|
||||
<MapImage map={x} />
|
||||
<span className="so-vmap-name">{x.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySlide({ data }) {
|
||||
const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore);
|
||||
return (
|
||||
<section className="bm-summary">
|
||||
{data.active.length === 0 && <h2 className="bm-idle">Gerade läuft kein Match</h2>}
|
||||
<div className="bm-columns">
|
||||
<div>
|
||||
<h3>Gleich</h3>
|
||||
{data.upcoming.length ? (
|
||||
<ul>
|
||||
{data.upcoming.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="a">{m.teams.A}</span><i>vs</i><span className="b">{m.teams.B}</span>
|
||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="so-none">Keine offenen Lobbys</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Ergebnisse</h3>
|
||||
{data.results.length ? (
|
||||
<ul>
|
||||
{data.results.map((m) => {
|
||||
const s = score(m);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<span className={`a ${s.A > s.B ? 'won' : ''}`}>{m.teams.A}</span>
|
||||
<b>{s.A}:{s.B}</b>
|
||||
<span className={`b ${s.B > s.A ? 'won' : ''}`}>{m.teams.B}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : <p className="so-none">Noch keine Ergebnisse</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ function matchHref(id, key) {
|
||||
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||
}
|
||||
|
||||
function useStreamOverview(key) {
|
||||
export function useStreamOverview(key) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
||||
let manager;
|
||||
const adapter = adapterOverride ?? createServerAdapter(config, {
|
||||
getUsedPorts: (host) => (host ? manager.usedPortsOn(host) : manager.usedPorts()),
|
||||
getStarting: (host, exclude) => manager.startingOn(host, exclude),
|
||||
});
|
||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||
manager.init();
|
||||
|
||||
@@ -555,6 +555,9 @@ export class MatchService extends EventEmitter {
|
||||
if (!server || !safeEqual(String(key ?? ''), server.config_key)) {
|
||||
throw new HttpError(403, 'Unbekannter Log-Schlüssel', 'forbidden');
|
||||
}
|
||||
// Erst zählen, wenn die Map eingerichtet ist. Während des Einrichtens kommen noch Zeilen vom vorigen
|
||||
// Spiel auf dem Server (z. B. dem Ende der Messerrunde), die nicht zur neuen Map gehören.
|
||||
if (server.status !== 'configured') return;
|
||||
this.applyLiveLog(matchId, splitLogBody(body));
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ export class DockerServerAdapter {
|
||||
this.runDocker = runDocker; // nur für Tests
|
||||
this.lanMode = !options.gslt;
|
||||
this.startQueue = Promise.resolve();
|
||||
// Starts, die gerade warten oder deren Container noch SteamCMD laufen lässt
|
||||
this.pendingStarts = 0;
|
||||
if (!options.host) throw new Error('SERVER_ADAPTER=docker benötigt CS2_PUBLIC_HOST');
|
||||
}
|
||||
|
||||
@@ -169,8 +171,13 @@ export class DockerServerAdapter {
|
||||
async start(args) {
|
||||
// In die Warteschlange: erst starten, wenn der vorige Container seinen Update-Check hinter sich hat.
|
||||
const previous = this.startQueue;
|
||||
let release;
|
||||
this.startQueue = new Promise((resolve) => { release = resolve; });
|
||||
let releaseQueue;
|
||||
this.startQueue = new Promise((resolve) => { releaseQueue = resolve; });
|
||||
this.pendingStarts += 1;
|
||||
const release = () => {
|
||||
this.pendingStarts -= 1;
|
||||
releaseQueue();
|
||||
};
|
||||
await previous;
|
||||
let result;
|
||||
try {
|
||||
@@ -368,10 +375,11 @@ export class DockerServerAdapter {
|
||||
* immer an denselben Host. Ohne CS2_DOCKER_HOSTS gibt es genau einen Host: den bisherigen.
|
||||
*/
|
||||
export class DockerHostPool {
|
||||
constructor(options, { getUsedPorts, runDocker } = {}) {
|
||||
constructor(options, { getUsedPorts, getStarting, runDocker } = {}) {
|
||||
this.name = 'docker';
|
||||
this.simulated = false;
|
||||
this.depsGetUsedPorts = getUsedPorts;
|
||||
this.depsGetStarting = getStarting;
|
||||
this.runDocker = runDocker;
|
||||
this.options = options;
|
||||
this.diskCache = new Map();
|
||||
@@ -594,19 +602,31 @@ export class DockerHostPool {
|
||||
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
|
||||
}
|
||||
|
||||
pickHost() {
|
||||
/**
|
||||
* Wie viele Server auf einem Host gerade hochfahren: Starts in der Warteschlange dieses Prozesses, und
|
||||
* (nach einem Neustart der App) Server, die laut Datenbank noch starten. Ein Container lädt beim Start
|
||||
* per SteamCMD, nach einem großen CS2-Update dauert das lange; weitere Starts dort müssten so lange warten.
|
||||
*/
|
||||
startingOn(host, exclude) {
|
||||
const queued = this.adapters.get(host.name)?.pendingStarts ?? 0;
|
||||
const stored = this.depsGetStarting?.(host.publicHost, exclude) ?? 0;
|
||||
return Math.max(queued, stored);
|
||||
}
|
||||
|
||||
pickHost(exclude) {
|
||||
const updating = this.updatingHosts();
|
||||
const best = this.hosts
|
||||
.filter((h) => !updating.has(h.name))
|
||||
.map((h) => ({ host: h, free: this.freeSlots(h) }))
|
||||
.map((h) => ({ host: h, free: this.freeSlots(h), starting: this.startingOn(h, exclude) }))
|
||||
.filter((x) => x.free > 0)
|
||||
.sort((a, b) => b.free - a.free)[0];
|
||||
// Zuerst Hosts, auf denen nichts hochfährt, dann die mit den meisten freien Plätzen
|
||||
.sort((a, b) => a.starting - b.starting || b.free - a.free)[0];
|
||||
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
|
||||
return best.host;
|
||||
}
|
||||
|
||||
async start(args) {
|
||||
return this.adapters.get(this.pickHost().name).start(args);
|
||||
return this.adapters.get(this.pickHost(args?.matchId).name).start(args);
|
||||
}
|
||||
|
||||
adapterFor(handle) {
|
||||
|
||||
@@ -258,6 +258,14 @@ export class ServerManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Server (Matches und Messer-Server), die auf einem Host gerade hochfahren. `exclude`: der, der gerade startet. */
|
||||
startingOn(host, exclude = null) {
|
||||
return this.db.prepare(`
|
||||
SELECT (SELECT COUNT(*) FROM servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND match_id IS NOT @exclude)
|
||||
+ (SELECT COUNT(*) FROM knife_servers WHERE host = @host AND status = 'starting' AND handle IS NOT NULL AND id IS NOT @exclude) AS n
|
||||
`).get({ host, exclude }).n;
|
||||
}
|
||||
|
||||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||
usedPortsOn(host) {
|
||||
const used = new Set();
|
||||
@@ -500,7 +508,12 @@ 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];
|
||||
if (row.knife_at) {
|
||||
// Eine noch laufende Messerrunde zuerst anhalten: sonst endet sie (mp_maxrounds 1) mitten im Einrichten,
|
||||
// und ihr Ergebnis würde als Ende der ersten Map gezählt
|
||||
plan.beforeMapChange = ['mp_warmup_pausetimer 1', 'mp_warmup_start', ...plan.beforeMapChange];
|
||||
plan.afterMapChange = [...KNIFE_UNDO_COMMANDS, ...plan.afterMapChange];
|
||||
}
|
||||
try {
|
||||
await this.adapter.configure(JSON.parse(row.handle), { map: seriesMap.map, ...plan });
|
||||
} catch (err) {
|
||||
|
||||
@@ -753,7 +753,19 @@ test('Messerrunde auf dem eigenen Match-Server: Verbindungsdaten in der Lobby, d
|
||||
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] });
|
||||
// Das Ende der Messerrunde kommt während des Einrichtens an: darf nicht als Ende von Map 1 zählen
|
||||
await waitFor(() => app.manager.row(id).status === 'configuring', { label: 'Map 1 wird eingerichtet' });
|
||||
const firstMap = app.service.seriesMaps(id)[0].map;
|
||||
const knifeEnd = ['Team "CT" triggered "SFUI_Notice_Target_Saved" (CT "1") (T "0")', 'World triggered "Round_End"',
|
||||
`Game Over: scrimcomp2v2 ${firstMap} score 1:0 after 2 min`].join('\n');
|
||||
const key = app.manager.row(id).config_key;
|
||||
assert.equal((await fetch(`http://127.0.0.1:${app.server.address().port}/api/logs/${id}/${key}`, { method: 'POST', body: knifeEnd })).status, 200);
|
||||
await waitFor(() => app.manager.row(id).status === 'configured', { label: 'Match eingerichtet' });
|
||||
const map1 = app.service.seriesMaps(id)[0];
|
||||
assert.notEqual(map1.status, 'done', 'Messerrunde zählt nicht als Map-Ende');
|
||||
assert.equal(map1.score_a, 0);
|
||||
assert.equal(app.service.getMatch(id).current_map_number, 1);
|
||||
assert.deepEqual(plans.at(-1).beforeMapChange.slice(0, 2), ['mp_warmup_pausetimer 1', 'mp_warmup_start'], 'laufende Messerrunde wird zuerst angehalten');
|
||||
const row = app.manager.row(id);
|
||||
assert.equal(row.handle, handle, 'derselbe Server');
|
||||
assert.equal(row.knife_at, null);
|
||||
|
||||
@@ -107,3 +107,29 @@ test('Alter Handle ohne passenden Host-Namen wird über die Adresse dem richtige
|
||||
assert.equal(p.adapterFor({ hostName: 'default', rconHost: '10.0.0.2' }).options.name, 'b');
|
||||
assert.throws(() => p.adapterFor({ hostName: 'weg', rconHost: '10.9.9.9' }), /nicht \(mehr\) konfiguriert/);
|
||||
});
|
||||
|
||||
test('Neue Server meiden Hosts, auf denen gerade ein Server hochfährt (SteamCMD)', async () => {
|
||||
const starting = { '10.0.0.2': 1 };
|
||||
const p = new DockerHostPool(options([hostA, hostB]), {
|
||||
getUsedPorts: () => new Set(),
|
||||
getStarting: (publicHost, exclude) => (exclude === 'self' ? 0 : starting[publicHost] ?? 0),
|
||||
runDocker: fakeDocker().run,
|
||||
});
|
||||
// b hat mehr Plätze, aber dort lädt gerade ein Container: a gewinnt
|
||||
assert.equal(p.pickHost().name, 'a');
|
||||
// Der hochfahrende Server selbst zählt nicht gegen seinen eigenen Neustart
|
||||
assert.equal(p.pickHost('self').name, 'b');
|
||||
// Warteschlange im Prozess zählt genauso
|
||||
starting['10.0.0.2'] = 0;
|
||||
p.adapters.get('b').pendingStarts = 1;
|
||||
assert.equal(p.pickHost().name, 'a');
|
||||
// Starts zählen hoch und nach dem Update-Check wieder herunter
|
||||
const a = p.adapters.get('a');
|
||||
let done;
|
||||
a.waitForUpdateCheck = () => new Promise((r) => { done = r; });
|
||||
await a.start({ matchId: 'm1', mode: 'competitive', secrets });
|
||||
assert.equal(a.pendingStarts, 1);
|
||||
done();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(a.pendingStarts, 0);
|
||||
});
|
||||
Reference in new issue
Block a user