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:
1 parent
3a74e16bb8
commit
8be8b3d879
15 files changed
+244
-55
No files matched your search
@@ -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 |
|
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
||||||
| Gast | ohne Code | nur Teamnamen und Status |
|
| 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.
|
Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich aus, was die Rolle nicht darf.
|
||||||
|
|
||||||
### Schutzmaßnahmen
|
### Schutzmaßnahmen
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ export default function ServerPanel({ state, isOrg, act, busy }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
|
|||||||
const inLobby = state.status === 'lobby';
|
const inLobby = state.status === 'lobby';
|
||||||
const me = team.players.find((p) => p.isYou);
|
const me = team.players.find((p) => p.isYou);
|
||||||
const emptySlots = Math.max(0, team.teamSize - team.players.length);
|
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;
|
const { readiness } = team;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -25,14 +25,14 @@ export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }
|
|||||||
</span>
|
</span>
|
||||||
{p.isCaptain && <span className="captain" title="Captain – führt das Veto">Captain</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>}
|
{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">
|
<span className="row-actions">
|
||||||
{canManageCaptain && !p.isCaptain && (
|
{canManageCaptain && !p.isCaptain && (
|
||||||
<button className="link-btn" disabled={!!busy} onClick={() => act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
|
<button className="link-btn" disabled={!!busy} onClick={() => act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
|
||||||
Zum Captain machen
|
Zum Captain machen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isOrg && inLobby && (
|
{isOrg && inLobby && !p.isYou && (
|
||||||
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
|
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
useEffect(() => setSelected(null), [current?.index]);
|
useEffect(() => setSelected(null), [current?.index]);
|
||||||
|
|
||||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
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 choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
|
||||||
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
|
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.'
|
? 'Du bist am Zug.'
|
||||||
: viewer.team === current.team
|
: viewer.team === current.team
|
||||||
? 'Dein Captain entscheidet.'
|
? 'Dein Captain entscheidet.'
|
||||||
: viewer.role === 'spectator' || viewer.role === 'organizer'
|
: !viewer.team
|
||||||
? 'Live-Ansicht – nur Captains können wählen.'
|
? 'Live-Ansicht – nur Captains können wählen.'
|
||||||
: 'Das andere Team ist am Zug.'}
|
: 'Das andere Team ist am Zug.'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`/api${path}`, {
|
res = await fetch(`/api${path}`, {
|
||||||
@@ -14,6 +14,8 @@ export async function api(path, { method = 'GET', body, token } = {}) {
|
|||||||
headers: {
|
headers: {
|
||||||
...(body ? { 'content-type': 'application/json' } : {}),
|
...(body ? { 'content-type': 'application/json' } : {}),
|
||||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
...(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,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,51 @@
|
|||||||
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
|
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
|
||||||
// meldet sich der Client damit automatisch wieder an.
|
// 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 KEY = (matchId) => `cs2mv:match:${matchId}`;
|
||||||
|
const memory = new Map();
|
||||||
|
|
||||||
export function getAccess(matchId) {
|
export function getAccess(matchId) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null;
|
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? memory.get(matchId) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return memory.get(matchId) ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAccess(matchId, access) {
|
export function setAccess(matchId, access) {
|
||||||
|
memory.set(matchId, access);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(KEY(matchId), JSON.stringify(access));
|
localStorage.setItem(KEY(matchId), JSON.stringify(access));
|
||||||
} catch {
|
} catch {
|
||||||
/* privater Modus – Zugang gilt dann nur für diese Sitzung */
|
/* privater Modus – Zugang gilt dann nur für diese Sitzung */
|
||||||
}
|
}
|
||||||
memory.set(matchId, access);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAccess(matchId) {
|
export function clearAccess(matchId) {
|
||||||
|
memory.delete(matchId);
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(KEY(matchId));
|
localStorage.removeItem(KEY(matchId));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignorieren */
|
/* ignorieren */
|
||||||
}
|
}
|
||||||
memory.delete(matchId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const memory = new Map();
|
|
||||||
export function getToken(matchId) {
|
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);
|
||||||
}
|
}
|
||||||
@@ -9,17 +9,17 @@ export function getSocket() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */
|
/** 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 [state, setState] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
// null = Verbindung wird erstmals aufgebaut (kein Hinweis), false = Verbindung abgerissen
|
// null = Verbindung wird erstmals aufgebaut (kein Hinweis), false = Verbindung abgerissen
|
||||||
const [connected, setConnected] = useState(null);
|
const [connected, setConnected] = useState(null);
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef({ token, playerToken });
|
||||||
tokenRef.current = token;
|
tokenRef.current = { token, playerToken };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
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) => {
|
const onState = (s) => {
|
||||||
if (s.id !== matchId) return;
|
if (s.id !== matchId) return;
|
||||||
setState(s);
|
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.
|
// Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang.
|
||||||
let active = true;
|
let active = true;
|
||||||
api(`/matches/${matchId}`, { token })
|
api(`/matches/${matchId}`, { token, playerToken })
|
||||||
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
|
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
|
||||||
.catch((e) => active && setError({ error: e.message, code: e.code }));
|
.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('connect', onConnect);
|
||||||
socket.off('disconnect', onDisconnect);
|
socket.off('disconnect', onDisconnect);
|
||||||
};
|
};
|
||||||
}, [matchId, token]);
|
}, [matchId, token, playerToken]);
|
||||||
|
|
||||||
return { state, error, connected };
|
return { state, error, connected };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { getAccess, setAccess } from '../lib/session.js';
|
import { getAccess, setAccess, setPlayerToken } from '../lib/session.js';
|
||||||
|
|
||||||
function useCode() {
|
function useCode() {
|
||||||
const { code } = useParams();
|
const { code } = useParams();
|
||||||
@@ -13,6 +13,12 @@ function useCode() {
|
|||||||
return { code, info, error };
|
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() {
|
export function JoinPage() {
|
||||||
const { code, info, error } = useCode();
|
const { code, info, error } = useCode();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -24,8 +30,8 @@ export function JoinPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true });
|
if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true });
|
||||||
// Schon beigetreten? Dann direkt zur Lobby.
|
// Schon als Spieler dabei? Dann direkt zur Lobby. Organisator ohne Team und Zuschauer dürfen beitreten.
|
||||||
else if (getAccess(info.matchId)) navigate(`/m/${info.matchId}`, { replace: true });
|
else if (alreadyPlayer(getAccess(info.matchId))) navigate(`/m/${info.matchId}`, { replace: true });
|
||||||
}, [info, code, navigate]);
|
}, [info, code, navigate]);
|
||||||
|
|
||||||
if (error) return <AccessError message={error} />;
|
if (error) return <AccessError message={error} />;
|
||||||
@@ -37,7 +43,10 @@ export function JoinPage() {
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api(`/matches/${info.matchId}/join`, { method: 'POST', body: { code, nickname, steamId } });
|
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}`);
|
navigate(`/m/${info.matchId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(err.message);
|
setSubmitError(err.message);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.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 { useMatch } from '../lib/useMatch.js';
|
||||||
import TeamCard from '../components/TeamCard.jsx';
|
import TeamCard from '../components/TeamCard.jsx';
|
||||||
import VetoBoard from '../components/VetoBoard.jsx';
|
import VetoBoard from '../components/VetoBoard.jsx';
|
||||||
@@ -11,6 +11,7 @@ import OrganizerPanel from '../components/OrganizerPanel.jsx';
|
|||||||
import SeriesList from '../components/SeriesList.jsx';
|
import SeriesList from '../components/SeriesList.jsx';
|
||||||
import EventLog from '../components/EventLog.jsx';
|
import EventLog from '../components/EventLog.jsx';
|
||||||
import MapImage from '../components/MapImage.jsx';
|
import MapImage from '../components/MapImage.jsx';
|
||||||
|
import SelfJoin from '../components/SelfJoin.jsx';
|
||||||
|
|
||||||
export default function MatchPage() {
|
export default function MatchPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -24,7 +25,9 @@ export default function MatchPage() {
|
|||||||
}
|
}
|
||||||
return getToken(id);
|
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 [busy, setBusy] = useState(null);
|
||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
@@ -38,6 +41,26 @@ export default function MatchPage() {
|
|||||||
}
|
}
|
||||||
}, [lostAccess, id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!toast) return undefined;
|
if (!toast) return undefined;
|
||||||
const t = setTimeout(() => setToast(null), 6000);
|
const t = setTimeout(() => setToast(null), 6000);
|
||||||
@@ -49,14 +72,14 @@ export default function MatchPage() {
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(path);
|
setBusy(path);
|
||||||
try {
|
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 });
|
if (success) setToast({ tone: 'ok', text: success });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setToast({ tone: 'error', text: err.message });
|
setToast({ tone: 'error', text: err.message });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
}, [busy, id, token]);
|
}, [busy, id, token, playerToken]);
|
||||||
|
|
||||||
if (error && !state) {
|
if (error && !state) {
|
||||||
return (
|
return (
|
||||||
@@ -70,9 +93,9 @@ export default function MatchPage() {
|
|||||||
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
|
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
|
||||||
|
|
||||||
const { viewer } = state;
|
const { viewer } = state;
|
||||||
const isOrg = viewer.role === 'organizer';
|
const isOrg = viewer.isOrganizer;
|
||||||
const server = serverLabel(state.server);
|
const server = serverLabel(state.server);
|
||||||
const ctx = { state, act, busy, isOrg, viewer };
|
const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="match">
|
<div className="match">
|
||||||
@@ -92,7 +115,7 @@ export default function MatchPage() {
|
|||||||
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
|
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
|
||||||
<div><dt>Status</dt><dd>{state.statusLabel}</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>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>
|
</dl>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -109,7 +132,7 @@ export default function MatchPage() {
|
|||||||
{viewer.role !== 'public' && (
|
{viewer.role !== 'public' && (
|
||||||
<div className="match-lower">
|
<div className="match-lower">
|
||||||
<ServerPanel {...ctx} />
|
<ServerPanel {...ctx} />
|
||||||
{isOrg && <OrganizerPanel {...ctx} matchId={id} token={token} />}
|
{isOrg && <OrganizerPanel {...ctx} token={token} />}
|
||||||
<EventLog events={state.events} />
|
<EventLog events={state.events} />
|
||||||
</div>
|
</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) {
|
function Stage(ctx) {
|
||||||
const { state, isOrg, act, busy } = ctx;
|
const { state, isOrg, act, busy } = ctx;
|
||||||
if (state.viewer.role === 'public') {
|
if (state.viewer.role === 'public') {
|
||||||
@@ -148,6 +179,7 @@ function Stage(ctx) {
|
|||||||
: 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}
|
: 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isOrg && !state.viewer.playerId && <SelfJoin state={state} matchId={ctx.matchId} onJoined={ctx.onJoined} />}
|
||||||
<MapPoolPreview maps={state.maps} />
|
<MapPoolPreview maps={state.maps} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.stage-note .btn { margin: 8px 0 8px; }
|
.stage-note .btn { margin: 8px 0 8px; }
|
||||||
.stage-note .pool-preview { margin-top: 16px; }
|
.stage-note .pool-preview { margin-top: 16px; }
|
||||||
.missing-list { margin: 0 0 12px; padding-left: 1.1em; color: var(--muted); }
|
.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; }
|
.missing-list li { margin-bottom: 4px; }
|
||||||
.pool-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
|
.pool-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
|
||||||
.pool-chip {
|
.pool-chip {
|
||||||
|
|||||||
+6
-1
@@ -8,6 +8,11 @@ function bearer(req) {
|
|||||||
return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
|
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 }) {
|
export function createApiRouter({ service, manager, config, pools, adapter }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(express.json({ limit: '32kb' }));
|
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.
|
// Ermittelt die Rolle des Aufrufers für das Match in der URL.
|
||||||
const viewerFor = (req) => {
|
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);
|
service.touch(req.params.id);
|
||||||
return viewer;
|
return viewer;
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-16
@@ -22,7 +22,6 @@ export const OPEN_STATUSES = ['lobby', 'veto'];
|
|||||||
export const RUNNING_STATUSES = ['server_setup', 'live'];
|
export const RUNNING_STATUSES = ['server_setup', 'live'];
|
||||||
export const CLOSED_STATUSES = ['finished', 'cancelled'];
|
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) {
|
function cleanName(value, field, max = 32) {
|
||||||
const name = String(value ?? '').replace(/\s+/g, ' ').trim();
|
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 match = this.getMatch(matchId);
|
||||||
const viewer = { matchId, role: 'public', playerId: null, team: null };
|
const viewer = {
|
||||||
if (!token) return viewer;
|
matchId, role: 'public', isOrganizer: false, isSpectator: false, isCaptain: false, playerId: null, team: null,
|
||||||
if (safeEqual(hashToken(token), match.organizer_token_hash)) return { ...viewer, role: 'organizer' };
|
};
|
||||||
|
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
|
const player = this.db
|
||||||
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
|
.prepare('SELECT * FROM players WHERE match_id = ? AND token_hash = ? AND active = 1')
|
||||||
.get(matchId, hashToken(token));
|
.get(matchId, hashToken(token));
|
||||||
if (player) {
|
if (player) {
|
||||||
return { ...viewer, role: player.is_captain ? 'captain' : 'player', playerId: player.id, team: player.team };
|
// 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)) return { ...viewer, role: 'spectator' };
|
if (safeEqual(normalizeCode(token), match.code_spectator)) viewer.isSpectator = true;
|
||||||
|
}
|
||||||
|
viewer.role = viewer.isOrganizer ? 'organizer'
|
||||||
|
: viewer.isCaptain ? 'captain'
|
||||||
|
: viewer.playerId ? 'player'
|
||||||
|
: viewer.isSpectator ? 'spectator' : 'public';
|
||||||
return viewer;
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
require(viewer, minimum) {
|
require(viewer, need) {
|
||||||
if (ROLE_RANK[viewer.role] < ROLE_RANK[minimum]) {
|
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' };
|
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')
|
.prepare('SELECT * FROM players WHERE id = ? AND match_id = ? AND active = 1')
|
||||||
.get(playerId, viewer.matchId);
|
.get(playerId, viewer.matchId);
|
||||||
if (!target) throw new HttpError(404, 'Spieler nicht gefunden', 'not_found');
|
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');
|
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 = 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);
|
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,
|
updatedAt: match.updated_at,
|
||||||
vetoStart: match.veto_start,
|
vetoStart: match.veto_start,
|
||||||
currentMapNumber: match.current_map_number,
|
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,
|
canStartVeto: match.status === 'lobby' && readiness.A.ready && readiness.B.ready,
|
||||||
requireFullTeams: this.config.requireFullTeams,
|
requireFullTeams: this.config.requireFullTeams,
|
||||||
maps: pool.maps,
|
maps: pool.maps,
|
||||||
@@ -561,7 +586,7 @@ export class MatchService extends EventEmitter {
|
|||||||
.all(matchId),
|
.all(matchId),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (viewer.role === 'organizer') {
|
if (viewer.isOrganizer) {
|
||||||
const base = this.config.publicUrl;
|
const base = this.config.publicUrl;
|
||||||
snapshot.organizer = {
|
snapshot.organizer = {
|
||||||
codes: { A: formatCode(match.code_a), B: formatCode(match.code_b), spectator: formatCode(match.code_spectator) },
|
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';
|
const configured = row.status === 'configured';
|
||||||
if (!configured || viewer.role === 'public') return view;
|
if (!configured || viewer.role === 'public') return view;
|
||||||
|
|
||||||
const isTeam = ['player', 'captain', 'organizer'].includes(viewer.role);
|
const isTeam = viewer.isOrganizer || !!viewer.playerId;
|
||||||
const isSpectatorView = ['spectator', 'organizer'].includes(viewer.role);
|
const isSpectatorView = viewer.isOrganizer || viewer.isSpectator;
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
view.connect = {
|
view.connect = {
|
||||||
host: row.host,
|
host: row.host,
|
||||||
|
|||||||
+12
-6
@@ -17,12 +17,17 @@ export function attachRealtime(httpServer, { service, config }) {
|
|||||||
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
|
service.presence = (matchId) => new Set((presence.get(matchId) ?? new Map()).values());
|
||||||
|
|
||||||
const sendState = (socket) => {
|
const sendState = (socket) => {
|
||||||
const { matchId, token } = socket.data;
|
const { matchId, token, playerToken } = socket.data;
|
||||||
try {
|
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));
|
socket.emit('match:state', service.snapshot(matchId, viewer));
|
||||||
// Rolle kann sich geändert haben (z. B. Spieler entfernt, neuer Captain) – Anwesenheit nachziehen.
|
if (changed) broadcastMatch(matchId);
|
||||||
presence.get(matchId)?.set(socket.id, viewer.playerId ?? `socket:${socket.id}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
socket.emit('match:error', { error: err.message, code: err.code ?? 'error' });
|
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.emit('overview', { matches: service.listMatches() });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('match:subscribe', ({ matchId, token } = {}) => {
|
socket.on('match:subscribe', ({ matchId, token, playerToken } = {}) => {
|
||||||
if (typeof matchId !== 'string') return;
|
if (typeof matchId !== 'string') return;
|
||||||
for (const room of socket.rooms) if (room.startsWith('match:')) leave(room.slice(6));
|
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 {
|
try {
|
||||||
service.getMatch(matchId);
|
service.getMatch(matchId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+37
-1
@@ -19,12 +19,13 @@ async function setup(overrides = {}) {
|
|||||||
await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve));
|
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 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, {
|
const res = await fetch(base + path, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(playerToken ? { 'x-player-token': playerToken } : {}),
|
||||||
},
|
},
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
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 () => {
|
test('Spieler verlassen die Lobby, Captain geht über', async () => {
|
||||||
const { call, close } = await setup();
|
const { call, close } = await setup();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user