diff --git a/README.md b/README.md
index b876dc2..8c8f600 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/client/src/components/SelfJoin.jsx b/client/src/components/SelfJoin.jsx
new file mode 100644
index 0000000..889c759
--- /dev/null
+++ b/client/src/components/SelfJoin.jsx
@@ -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 (
+
+
Selbst mitspielen?
+
Tritt hier einem Team bei. Du bleibst dabei Organisator, ein zweiter Browser ist nicht nötig.
+
+ Dein Spielername
+ setNickname(e.target.value)} maxLength={24} placeholder="z. B. MrEgosar" />
+
+
+ {['A', 'B'].map((key) => {
+ const team = state.teams[key];
+ const full = team.players.length >= team.teamSize;
+ return (
+ join(key)}>
+ {busyTeam === key ? 'Trete bei …' : full ? `${team.name} ist voll` : `${team.name} beitreten`}
+
+ );
+ })}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/client/src/components/ServerPanel.jsx b/client/src/components/ServerPanel.jsx
index 081fc2f..87c691c 100644
--- a/client/src/components/ServerPanel.jsx
+++ b/client/src/components/ServerPanel.jsx
@@ -59,7 +59,7 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
)}
- {state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && (
+ {state.viewer.isSpectator && !state.viewer.isOrganizer && server?.status === 'configured' && !server.gotv && (
CSTV ist auf diesem Server deaktiviert.
)}
diff --git a/client/src/components/TeamCard.jsx b/client/src/components/TeamCard.jsx
index 24041bd..dc15850 100644
--- a/client/src/components/TeamCard.jsx
+++ b/client/src/components/TeamCard.jsx
@@ -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 }
{p.isCaptain && Captain }
{inLobby && {p.ready ? 'bereit' : 'wartet'} }
- {(canManageCaptain && !p.isCaptain) || (isOrg && inLobby) ? (
+ {(canManageCaptain && !p.isCaptain) || (isOrg && inLobby && !p.isYou) ? (
{canManageCaptain && !p.isCaptain && (
act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
Zum Captain machen
)}
- {isOrg && inLobby && (
+ {isOrg && inLobby && !p.isYou && (
act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
Entfernen
diff --git a/client/src/components/VetoBoard.jsx b/client/src/components/VetoBoard.jsx
index 51d817f..99ef91a 100644
--- a/client/src/components/VetoBoard.jsx
+++ b/client/src/components/VetoBoard.jsx
@@ -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.'}
diff --git a/client/src/lib/api.js b/client/src/lib/api.js
index f98f725..c122d8c 100644
--- a/client/src/lib/api.js
+++ b/client/src/lib/api.js
@@ -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,
});
diff --git a/client/src/lib/session.js b/client/src/lib/session.js
index f1d1bbf..c92c1c4 100644
--- a/client/src/lib/session.js
+++ b/client/src/lib/session.js
@@ -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);
}
diff --git a/client/src/lib/useMatch.js b/client/src/lib/useMatch.js
index e5a987f..aea33d1 100644
--- a/client/src/lib/useMatch.js
+++ b/client/src/lib/useMatch.js
@@ -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 };
}
diff --git a/client/src/pages/AccessPages.jsx b/client/src/pages/AccessPages.jsx
index cdac953..cdcc265 100644
--- a/client/src/pages/AccessPages.jsx
+++ b/client/src/pages/AccessPages.jsx
@@ -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 ;
@@ -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);
diff --git a/client/src/pages/MatchPage.jsx b/client/src/pages/MatchPage.jsx
index b47e14f..31f60ec 100644
--- a/client/src/pages/MatchPage.jsx
+++ b/client/src/pages/MatchPage.jsx
@@ -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 Verbinde mit Lobby …
;
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 (
@@ -92,7 +115,7 @@ export default function MatchPage() {
Serie {FORMAT_LABEL[state.format]}
Status {state.statusLabel}
Server {server.text}
-
Du bist {ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}
+
Du bist {describeViewer(viewer, state.teams)}
@@ -109,7 +132,7 @@ export default function MatchPage() {
{viewer.role !== 'public' && (
- {isOrg && }
+ {isOrg && }
)}
@@ -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.'}
)}
+ {isOrg && !state.viewer.playerId &&
}
);
diff --git a/client/src/styles.css b/client/src/styles.css
index f35ea51..c84f658 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -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 {
diff --git a/server/src/http.js b/server/src/http.js
index 054d2e6..b454c93 100644
--- a/server/src/http.js
+++ b/server/src/http.js
@@ -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;
};
diff --git a/server/src/matchService.js b/server/src/matchService.js
index a394d5e..110fe4b 100644
--- a/server/src/matchService.js
+++ b/server/src/matchService.js
@@ -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,
diff --git a/server/src/realtime.js b/server/src/realtime.js
index 940d484..4ef46f0 100644
--- a/server/src/realtime.js
+++ b/server/src/realtime.js
@@ -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) {
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 1b11c88..ed92833 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -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 {