Organisator kann im selben Browser selbst mitspielen

Ein Aufrufer darf Organisator- und Spieler-Zugang kombinieren
(Authorization + X-Player-Token). Berechtigungen prüfen jetzt einzelne
Merkmale statt einer Rangstufe. In der Lobby gibt es „Selbst mitspielen?“,
Teamlinks funktionieren auch im Organisator-Browser. Online-Status wird
direkt nach dem Beitritt aktualisiert.
This commit is contained in:
mregosar committed 2026-10-25 17:53:39 +01:00
1 parent 3a74e16bb8
commit 8be8b3d879
15 files changed
+248 -59

No files matched your search

+2
View File
@@ -77,6 +77,8 @@ Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
| Gast | ohne Code | nur Teamnamen und Status |
**Organisator spielt selbst mit:** In der Lobby kann der Organisator unter „Selbst mitspielen?“ direkt einem Team beitreten, oder er öffnet einen Teamlink im selben Browser. Er bleibt Organisator und ist zusätzlich Spieler bzw. Captain. Ein zweiter Browser ist nicht nötig. Technisch speichert der Browser dafür beide Zugänge und schickt den Spieler-Token im Header `X-Player-Token` mit. Pro Aufrufer zählt höchstens ein Spieler.
Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich aus, was die Rolle nicht darf.
### Schutzmaßnahmen
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
import { api } from '../lib/api.js';
/** Organisator tritt im selben Browser selbst einem Team bei. */
export default function SelfJoin({ state, matchId, onJoined }) {
const [nickname, setNickname] = useState('');
const [busyTeam, setBusyTeam] = useState(null);
const [error, setError] = useState(null);
const { codes } = state.organizer;
const join = async (team) => {
setBusyTeam(team);
setError(null);
try {
const res = await api(`/matches/${matchId}/join`, { method: 'POST', body: { code: codes[team], nickname } });
onJoined(res.playerToken);
} catch (err) {
setError(err.message);
} finally {
setBusyTeam(null);
}
};
const valid = nickname.trim().length >= 2;
return (
<div className="self-join">
<h3>Selbst mitspielen?</h3>
<p className="small muted">Tritt hier einem Team bei. Du bleibst dabei Organisator, ein zweiter Browser ist nicht nötig.</p>
<label className="field">
<span>Dein Spielername</span>
<input value={nickname} onChange={(e) => setNickname(e.target.value)} maxLength={24} placeholder="z. B. MrEgosar" />
</label>
<div className="self-join-buttons">
{['A', 'B'].map((key) => {
const team = state.teams[key];
const full = team.players.length >= team.teamSize;
return (
<button key={key} type="button" className={`btn self-join-${key}`} disabled={!valid || full || !!busyTeam}
onClick={() => join(key)}>
{busyTeam === key ? 'Trete bei …' : full ? `${team.name} ist voll` : `${team.name} beitreten`}
</button>
);
})}
</div>
{error && <p className="field-error">{error}</p>}
</div>
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
</p>
</div>
)}
{state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && (
{state.viewer.isSpectator && !state.viewer.isOrganizer && server?.status === 'configured' && !server.gotv && (
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
)}
+3 -3
View File
@@ -3,7 +3,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
const inLobby = state.status === 'lobby';
const me = team.players.find((p) => p.isYou);
const emptySlots = Math.max(0, team.teamSize - team.players.length);
const canManageCaptain = isOrg || (viewer.role === 'captain' && isOwnTeam);
const canManageCaptain = isOrg || (viewer.isCaptain && isOwnTeam);
const { readiness } = team;
return (
@@ -25,14 +25,14 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
</span>
{p.isCaptain && <span className="captain" title="Captain – führt das Veto">Captain</span>}
{inLobby && <span className={`ready-dot ${p.ready ? 'on' : ''}`}>{p.ready ? 'bereit' : 'wartet'}</span>}
{(canManageCaptain && !p.isCaptain) || (isOrg && inLobby) ? (
{(canManageCaptain && !p.isCaptain) || (isOrg && inLobby && !p.isYou) ? (
<span className="row-actions">
{canManageCaptain && !p.isCaptain && (
<button className="link-btn" disabled={!!busy} onClick={() => act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
Zum Captain machen
</button>
)}
{isOrg && inLobby && (
{isOrg && inLobby && !p.isYou && (
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
Entfernen
</button>
+2 -2
View File
@@ -12,7 +12,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
useEffect(() => setSelected(null), [current?.index]);
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
const yourTurn = !!current && viewer.role === 'captain' && viewer.team === current.team;
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
const choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
@@ -34,7 +34,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
? 'Du bist am Zug.'
: viewer.team === current.team
? 'Dein Captain entscheidet.'
: viewer.role === 'spectator' || viewer.role === 'organizer'
: !viewer.team
? 'Live-Ansicht – nur Captains können wählen.'
: 'Das andere Team ist am Zug.'}
</span>
+3 -1
View File
@@ -6,7 +6,7 @@ export class ApiError extends Error {
}
}
export async function api(path, { method = 'GET', body, token } = {}) {
export async function api(path, { method = 'GET', body, token, playerToken } = {}) {
let res;
try {
res = await fetch(`/api${path}`, {
@@ -14,6 +14,8 @@ export async function api(path, { method = 'GET', body, token } = {}) {
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
// Zweiter Zugang: Spieler-Token des Organisators, wenn er selbst mitspielt
...(playerToken ? { 'x-player-token': playerToken } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
+23 -6
View File
@@ -1,34 +1,51 @@
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
// meldet sich der Client damit automatisch wieder an.
//
// Aufbau: { token, kind: 'organizer' | 'player' | 'spectator', playerToken? }
// playerToken gibt es nur beim Organisator, wenn er selbst einem Team beigetreten ist.
const KEY = (matchId) => `cs2mv:match:${matchId}`;
const memory = new Map();
export function getAccess(matchId) {
try {
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null;
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? memory.get(matchId) ?? null;
} catch {
return null;
return memory.get(matchId) ?? null;
}
}
export function setAccess(matchId, access) {
memory.set(matchId, access);
try {
localStorage.setItem(KEY(matchId), JSON.stringify(access));
} catch {
/* privater Modus – Zugang gilt dann nur für diese Sitzung */
}
memory.set(matchId, access);
}
export function clearAccess(matchId) {
memory.delete(matchId);
try {
localStorage.removeItem(KEY(matchId));
} catch {
/* ignorieren */
}
memory.delete(matchId);
}
const memory = new Map();
export function getToken(matchId) {
return (getAccess(matchId) ?? memory.get(matchId))?.token ?? null;
return getAccess(matchId)?.token ?? null;
}
export function getPlayerToken(matchId) {
return getAccess(matchId)?.playerToken ?? null;
}
/** Spieler-Token neben dem Organisator-Zugang speichern bzw. entfernen (null). */
export function setPlayerToken(matchId, playerToken) {
const access = getAccess(matchId);
if (!access) return;
const next = { ...access };
if (playerToken) next.playerToken = playerToken;
else delete next.playerToken;
setAccess(matchId, next);
}
+6 -6
View File
@@ -9,17 +9,17 @@ export function getSocket() {
}
/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */
export function useMatch(matchId, token) {
export function useMatch(matchId, token, playerToken = null) {
const [state, setState] = useState(null);
const [error, setError] = useState(null);
// null = Verbindung wird erstmals aufgebaut (kein Hinweis), false = Verbindung abgerissen
const [connected, setConnected] = useState(null);
const tokenRef = useRef(token);
tokenRef.current = token;
const tokenRef = useRef({ token, playerToken });
tokenRef.current = { token, playerToken };
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('match:subscribe', { matchId, token: tokenRef.current });
const subscribe = () => socket.emit('match:subscribe', { matchId, ...tokenRef.current });
const onState = (s) => {
if (s.id !== matchId) return;
setState(s);
@@ -40,7 +40,7 @@ export function useMatch(matchId, token) {
// Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang.
let active = true;
api(`/matches/${matchId}`, { token })
api(`/matches/${matchId}`, { token, playerToken })
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
.catch((e) => active && setError({ error: e.message, code: e.code }));
@@ -51,7 +51,7 @@ export function useMatch(matchId, token) {
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [matchId, token]);
}, [matchId, token, playerToken]);
return { state, error, connected };
}
+13 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { getAccess, setAccess } from '../lib/session.js';
import { getAccess, setAccess, setPlayerToken } from '../lib/session.js';
function useCode() {
const { code } = useParams();
@@ -13,6 +13,12 @@ function useCode() {
return { code, info, error };
}
function alreadyPlayer(access) {
if (!access) return false;
if (access.kind === 'organizer') return Boolean(access.playerToken);
return access.kind === 'player';
}
export function JoinPage() {
const { code, info, error } = useCode();
const navigate = useNavigate();
@@ -24,8 +30,8 @@ export function JoinPage() {
useEffect(() => {
if (!info) return;
if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true });
// Schon beigetreten? Dann direkt zur Lobby.
else if (getAccess(info.matchId)) navigate(`/m/${info.matchId}`, { replace: true });
// Schon als Spieler dabei? Dann direkt zur Lobby. Organisator ohne Team und Zuschauer dürfen beitreten.
else if (alreadyPlayer(getAccess(info.matchId))) navigate(`/m/${info.matchId}`, { replace: true });
}, [info, code, navigate]);
if (error) return <AccessError message={error} />;
@@ -37,7 +43,10 @@ export function JoinPage() {
setSubmitError(null);
try {
const res = await api(`/matches/${info.matchId}/join`, { method: 'POST', body: { code, nickname, steamId } });
setAccess(info.matchId, { token: res.playerToken, kind: 'player' });
const existing = getAccess(info.matchId);
// Der Organisator behält seinen Zugang und bekommt den Spieler-Zugang dazu.
if (existing?.kind === 'organizer') setPlayerToken(info.matchId, res.playerToken);
else setAccess(info.matchId, { token: res.playerToken, kind: 'player' });
navigate(`/m/${info.matchId}`);
} catch (err) {
setSubmitError(err.message);
+40 -8
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js';
import { clearAccess, getToken, setAccess } from '../lib/session.js';
import { clearAccess, getPlayerToken, getToken, setAccess, setPlayerToken } from '../lib/session.js';
import { useMatch } from '../lib/useMatch.js';
import TeamCard from '../components/TeamCard.jsx';
import VetoBoard from '../components/VetoBoard.jsx';
@@ -11,6 +11,7 @@ import OrganizerPanel from '../components/OrganizerPanel.jsx';
import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx';
export default function MatchPage() {
const { id } = useParams();
@@ -24,7 +25,9 @@ export default function MatchPage() {
}
return getToken(id);
});
const { state, error, connected } = useMatch(id, token);
// Spieler-Token des Organisators, wenn er im selben Browser selbst mitspielt
const [playerToken, setPlayerTokenState] = useState(() => getPlayerToken(id));
const { state, error, connected } = useMatch(id, token, playerToken);
const [busy, setBusy] = useState(null);
const [toast, setToast] = useState(null);
@@ -38,6 +41,26 @@ export default function MatchPage() {
}
}, [lostAccess, id]);
// Organisator war Spieler, ist es aber nicht mehr (Team verlassen oder entfernt)
// justJoined: bis der erste Zustand mit Spieler ankommt, ist ein Zustand ohne Spieler noch veraltet
const [justJoined, setJustJoined] = useState(false);
useEffect(() => {
if (state?.viewer.playerId) setJustJoined(false);
}, [state]);
const lostPlayer = state && playerToken && !justJoined && state.viewer.isOrganizer && !state.viewer.playerId;
useEffect(() => {
if (lostPlayer) {
setPlayerToken(id, null);
setPlayerTokenState(null);
}
}, [lostPlayer, id]);
const joinAsOrganizer = useCallback((newPlayerToken) => {
setJustJoined(true);
setPlayerToken(id, newPlayerToken);
setPlayerTokenState(newPlayerToken);
}, [id]);
useEffect(() => {
if (!toast) return undefined;
const t = setTimeout(() => setToast(null), 6000);
@@ -49,14 +72,14 @@ export default function MatchPage() {
if (busy) return;
setBusy(path);
try {
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token });
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token, playerToken });
if (success) setToast({ tone: 'ok', text: success });
} catch (err) {
setToast({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
}, [busy, id, token]);
}, [busy, id, token, playerToken]);
if (error && !state) {
return (
@@ -70,9 +93,9 @@ export default function MatchPage() {
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
const { viewer } = state;
const isOrg = viewer.role === 'organizer';
const isOrg = viewer.isOrganizer;
const server = serverLabel(state.server);
const ctx = { state, act, busy, isOrg, viewer };
const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer };
return (
<div className="match">
@@ -92,7 +115,7 @@ export default function MatchPage() {
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
<div><dt>Status</dt><dd>{state.statusLabel}</dd></div>
<div><dt>Server</dt><dd className={`tone-${server.tone}`}>{server.text}</dd></div>
<div><dt>Du bist</dt><dd>{ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}</dd></div>
<div><dt>Du bist</dt><dd>{describeViewer(viewer, state.teams)}</dd></div>
</dl>
</header>
@@ -109,7 +132,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && (
<div className="match-lower">
<ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} matchId={id} token={token} />}
{isOrg && <OrganizerPanel {...ctx} token={token} />}
<EventLog events={state.events} />
</div>
)}
@@ -117,6 +140,14 @@ export default function MatchPage() {
);
}
/** z. B. "Organisator, Captain bei Nordlicht" oder "Spieler bei Hafenkante" */
function describeViewer(viewer, teams) {
const team = viewer.team ? teams[viewer.team].name : null;
const asPlayer = team ? `${viewer.isCaptain ? 'Captain' : 'Spieler'} bei ${team}` : null;
if (viewer.isOrganizer) return asPlayer ? `Organisator, ${asPlayer}` : 'Organisator';
return asPlayer ?? ROLE_LABEL[viewer.role];
}
function Stage(ctx) {
const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public') {
@@ -148,6 +179,7 @@ function Stage(ctx) {
: 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}
</p>
)}
{isOrg && !state.viewer.playerId && <SelfJoin state={state} matchId={ctx.matchId} onJoined={ctx.onJoined} />}
<MapPoolPreview maps={state.maps} />
</div>
);
+7
View File
@@ -192,6 +192,13 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.stage-note .btn { margin: 8px 0 8px; }
.stage-note .pool-preview { margin-top: 16px; }
.missing-list { margin: 0 0 12px; padding-left: 1.1em; color: var(--muted); }
.self-join { margin: 18px 0 4px; padding: 16px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); max-width: 560px; }
.self-join h3 { margin-bottom: 4px; }
.self-join .field { margin-bottom: 12px; }
.self-join-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
.self-join-buttons .btn { flex: 1 1 180px; margin: 0; }
.self-join-A:not(:disabled) { border-color: var(--team-a); color: var(--team-a); }
.self-join-B:not(:disabled) { border-color: var(--team-b); color: var(--team-b); }
.missing-list li { margin-bottom: 4px; }
.pool-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
.pool-chip {
+6 -1
View File
@@ -8,6 +8,11 @@ function bearer(req) {
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
}
/** Bis zu zwei Zugänge: Authorization (Organisator, Spieler oder Zuschauer) und X-Player-Token. */
function accessTokens(req) {
return [bearer(req), req.get('x-player-token')?.trim() || null];
}
export function createApiRouter({ service, manager, config, pools, adapter }) {
const router = express.Router();
router.use(express.json({ limit: '32kb' }));
@@ -35,7 +40,7 @@ export function createApiRouter({ service, manager, config, pools, adapter }) {
// Ermittelt die Rolle des Aufrufers für das Match in der URL.
const viewerFor = (req) => {
const viewer = service.resolveViewer(req.params.id, bearer(req));
const viewer = service.resolveViewer(req.params.id, accessTokens(req));
service.touch(req.params.id);
return viewer;
};
+45 -20
View File
@@ -22,7 +22,6 @@ export const OPEN_STATUSES = ['lobby', 'veto'];
export const RUNNING_STATUSES = ['server_setup', 'live'];
export const CLOSED_STATUSES = ['finished', 'cancelled'];
const ROLE_RANK = { public: 0, spectator: 1, player: 2, captain: 3, organizer: 4 };
function cleanName(value, field, max = 32) {
const name = String(value ?? '').replace(/\s+/g, ' ').trim();
@@ -91,26 +90,49 @@ export class MatchService extends EventEmitter {
});
}
/** Ermittelt die Rolle eines Tokens für ein Match. Unbekannte Tokens ergeben "public". */
resolveViewer(matchId, token) {
/**
* Ermittelt, wer der Aufrufer in einem Match ist. Ein Browser darf bis zu zwei Zugänge mitschicken
* (z. B. Organisator-Token und Spieler-Token), damit der Organisator selbst mitspielen kann.
* Unbekannte Tokens werden ignoriert; ohne gültigen Zugang ergibt sich "public".
*/
resolveViewer(matchId, tokens) {
const match = this.getMatch(matchId);
const viewer = { matchId, role: 'public', playerId: null, team: null };
if (!token) return viewer;
if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' };
const player = this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
.get(matchId, hashToken(token));
if (player) {
return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team };
const viewer = {
matchId, role: 'public', isOrganizer: false, isSpectator: false, isCaptain: false, playerId: null, team: null,
};
const list = (Array.isArray(tokens) ? tokens : [tokens]).filter((t) => typeof t === 'string' && t).slice(0, 2);
for (const token of list) {
if (safeEqual(hashToken(token), match.organizer_token_hash)) {
viewer.isOrganizer = true;
continue;
}
const player = this.db
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
.get(matchId, hashToken(token));
if (player) {
// Höchstens ein Spieler pro Aufrufer – ein zweiter Spieler-Token wird ignoriert.
if (!viewer.playerId) Object.assign(viewer, { playerId: player.id, team: player.team, isCaptain: !!player.is_captain });
continue;
}
if (safeEqual(normalizeCode(token), match.code_spectator)) viewer.isSpectator = true;
}
if (safeEqual(normalizeCode(token), match.code_spectator)) return { ...viewer, role: 'spectator' };
viewer.role = viewer.isOrganizer ? 'organizer'
: viewer.isCaptain ? 'captain'
: viewer.playerId ? 'player'
: viewer.isSpectator ? 'spectator' : 'public';
return viewer;
}
require(viewer, minimum) {
if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) {
require(viewer, need) {
const ok = {
organizer: viewer.isOrganizer,
captain: viewer.isCaptain,
player: !!viewer.playerId,
spectator: viewer.role !== 'public',
}[need];
if (!ok) {
const labels = { organizer: 'den Organisator', captain: 'Team-Captains', player: 'Spieler', spectator: 'Teilnehmer' };
throw new HttpError(403, `Diese Aktion ist nur für ${labels[minimum]} erlaubt`, 'forbidden');
throw new HttpError(403, `Diese Aktion ist nur für ${labels[need]} erlaubt`, 'forbidden');
}
}
@@ -259,7 +281,7 @@ export class MatchService extends EventEmitter {
.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1')
.get(playerId, viewer.matchId);
if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found');
const allowed = viewer.role === 'organizer' || (viewer.role === 'captain' && viewer.team === target.team);
const allowed = viewer.isOrganizer || (viewer.isCaptain && viewer.team === target.team);
if (!allowed) throw new HttpError(403, 'Nur der Organisator oder der Captain des Teams kann den Captain ändern', 'forbidden');
this.db.prepare('UPDATE players SET is_captain = 0 WHERE match_id = ? AND team = ?').run(viewer.matchId, target.team);
this.db.prepare('UPDATE players SET is_captain = 1 WHERE id = ?').run(target.id);
@@ -529,7 +551,10 @@ export class MatchService extends EventEmitter {
updatedAt: match.updated_at,
vetoStart: match.veto_start,
currentMapNumber: match.current_map_number,
viewer: { role: viewer.role, playerId: viewer.playerId, team: viewer.team },
viewer: {
role: viewer.role, playerId: viewer.playerId, team: viewer.team,
isOrganizer: viewer.isOrganizer, isCaptain: viewer.isCaptain, isSpectator: viewer.isSpectator,
},
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
requireFullTeams: this.config.requireFullTeams,
maps: pool.maps,
@@ -561,7 +586,7 @@ export class MatchService extends EventEmitter {
.all(matchId),
};
if (viewer.role === 'organizer') {
if (viewer.isOrganizer) {
const base = this.config.publicUrl;
snapshot.organizer = {
codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) },
@@ -591,8 +616,8 @@ export class MatchService extends EventEmitter {
const configured = row.status === 'configured';
if (!configured || viewer.role === 'public') return view;
const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role);
const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role);
const isTeam = viewer.isOrganizer || !!viewer.playerId;
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
if (isTeam) {
view.connect = {
host: row.host,
+12 -6
View File
@@ -17,12 +17,17 @@ export function attachRealtime(httpServer, { service, config }) {
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
const sendState = (socket) => {
const { matchId, token } = socket.data;
const { matchId, token, playerToken } = socket.data;
try {
const viewer = service.resolveViewer(matchId, token);
const viewer = service.resolveViewer(matchId, [token, playerToken]);
// Rolle kann sich geändert haben (z. B. beigetreten, entfernt) – Anwesenheit vor dem Snapshot nachziehen
// und die anderen informieren, damit der Online-Status sofort stimmt.
const map = presence.get(matchId);
const key = viewer.playerId ?? `socket:${socket.id}`;
const changed = map && map.get(socket.id) !== key;
map?.set(socket.id, key);
socket.emit('match:state', service.snapshot(matchId, viewer));
// Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen.
presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`);
if (changed) broadcastMatch(matchId);
} catch (err) {
socket.emit('match:error', { error: err.message, code: err.code ?? 'error' });
}
@@ -56,10 +61,11 @@ export function attachRealtime(httpServer, { service, config }) {
socket.emit('overview', { matches: service.listMatches() });
});
socket.on('match:subscribe', ({ matchId, token } = {}) => {
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
if (typeof matchId !== 'string') return;
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
socket.data = { matchId, token: typeof token === 'string' ? token : null };
const str = (v) => (typeof v === 'string' ? v : null);
socket.data = { matchId, token: str(token), playerToken: str(playerToken) };
try {
service.getMatch(matchId);
} catch (err) {
+37 -1
View File
@@ -19,12 +19,13 @@ async function setup(overrides = {}) {
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${app.server.address().port}/api`;
const call = async (method, path, { token, body } = {}) => {
const call = async (method, path, { token, playerToken, body } = {}) => {
const res = await fetch(base + path, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...(playerToken ? { 'x-player-token': playerToken } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
@@ -217,6 +218,41 @@ test('ungenutzte Server werden nach der Frist heruntergefahren', async () => {
}
});
test('Organisator spielt im selben Browser selbst mit (zwei Zugänge)', async () => {
const { call, close } = await setup({ requireFullTeams: false });
try {
const { id, organizerToken: org } = (await call('POST', '/matches', {
body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' },
})).body;
const codes = (await call('GET', `/matches/${id}`, { token: org })).body.organizer.codes;
const me = (await call('POST', `/matches/${id}/join`, { body: { code: codes.A, nickname: 'orga' } })).body;
const b1 = (await call('POST', `/matches/${id}/join`, { body: { code: codes.B, nickname: 'b1' } })).body;
const both = { token: org, playerToken: me.playerToken };
const view = (await call('GET', `/matches/${id}`, both)).body;
assert.equal(view.viewer.role, 'organizer');
assert.equal(view.viewer.isOrganizer, true);
assert.equal(view.viewer.isCaptain, true);
assert.equal(view.viewer.team, 'A');
assert.ok(view.organizer, 'Organisator-Daten bleiben sichtbar');
assert.equal((await call('POST', `/matches/${id}/ready`, { ...both, body: { ready: true } })).status, 200);
await call('POST', `/matches/${id}/ready`, { token: b1.playerToken, body: { ready: true } });
assert.equal((await call('POST', `/matches/${id}/veto/start`, both)).status, 200);
// Nur mit Organisator-Zugang ist er kein Captain und darf nicht bannen
const ban = { stepIndex: 0, map: 'de_inferno' };
assert.equal((await call('POST', `/matches/${id}/veto/action`, { token: org, body: ban })).status, 403);
assert.equal((await call('POST', `/matches/${id}/veto/action`, { ...both, body: ban })).status, 200);
// Ein fremder Spieler-Token macht niemanden zum Organisator
const fake = (await call('GET', `/matches/${id}`, { token: b1.playerToken, playerToken: me.playerToken })).body;
assert.equal(fake.viewer.isOrganizer, false);
} finally {
await close();
}
});
test('Spieler verlassen die Lobby, Captain geht über', async () => {
const { call, close } = await setup();
try {