dok/sockets/arena_socket.js
2026-04-12 11:21:44 +01:00

547 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
sockets/arena.js
1v1 Matchmaking + 2v2 Team-Lobby + 4v4 Team-Lobby
inkl. Kick-Funktion für Team-Leader wurde getestet
============================================================ */
const waitingPool = new Map();
const LEVEL_RANGE = 5;
const READY_TIMEOUT = 30;
/* ── 2v2 State ── */
const teams2v2 = new Map();
const readyTeams2v2 = new Map();
/* ── 4v4 State ── */
const teams4v4 = new Map();
const readyTeams4v4 = new Map();
function generateId() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
/* ═══════════════════════════════════════════════════════════
HELPER: Generisch für 2v2 UND 4v4
═══════════════════════════════════════════════════════════ */
function getTeamMap(mode) {
return mode === "4v4" ? teams4v4 : teams2v2;
}
function getReadyMap(mode) {
return mode === "4v4" ? readyTeams4v4 : readyTeams2v2;
}
function getMaxPlayers(mode) {
return mode === "4v4" ? 4 : 2;
}
function broadcastLobbies(io, mode) {
const teams = getTeamMap(mode);
const max = getMaxPlayers(mode);
const list = [];
for (const [teamId, team] of teams) {
if (team.players.length < max) {
list.push({
teamId,
leader: team.players[0]?.player?.name || "?",
leaderLevel: team.players[0]?.player?.level || 1,
count: team.players.length,
max,
});
}
}
io.emit(`${mode}_lobbies`, list);
}
function broadcastTeamStatus(io, teamId, mode) {
const team = getTeamMap(mode).get(teamId);
if (!team) return;
const data = {
teamId,
leaderId: team.leaderId, // ← Leader-SocketId mitschicken
players: team.players.map((p) => ({
socketId: p.socketId, // ← für Kick-Button im Frontend
name: p.player.name,
level: p.player.level,
ready: team.ready.has(p.socketId),
})),
count: team.players.length,
max: getMaxPlayers(mode),
};
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_team_update`, data);
}
}
function tryMatchmaking_nvn(io, mode) {
const readyMap = getReadyMap(mode);
const readyList = Array.from(readyMap.values());
if (readyList.length < 2) return;
const team1 = readyList[0];
const team2 = readyList[1];
readyMap.delete(team1.id);
readyMap.delete(team2.id);
getTeamMap(mode).delete(team1.id);
getTeamMap(mode).delete(team2.id);
const matchId = `${mode}_${generateId()}`;
const allPlayers = [
...team1.players.map((p) => ({ team: 1, ...p })),
...team2.players.map((p) => ({ team: 2, ...p })),
];
for (const p of allPlayers) {
const opponents = allPlayers
.filter((x) => x.team !== p.team)
.map((x) => x.player.name);
const teammates = allPlayers
.filter((x) => x.team === p.team && x.socketId !== p.socketId)
.map((x) => x.player.name);
const teamRef = p.team === 1 ? team1 : team2;
const slotIndex =
teamRef.players.findIndex((x) => x.socketId === p.socketId) + 1;
io.to(p.socketId).emit(`match_found_${mode}`, {
matchId,
myTeam: p.team,
teammates,
opponents,
mySlot: `team${p.team}_player${slotIndex}`,
});
}
console.log(
`[${mode}] Match: Team1(${team1.players.map((p) => p.player.name)})` +
` vs Team2(${team2.players.map((p) => p.player.name)}) | ${matchId}`,
);
}
function leaveAllTeams_nvn(socketId, io, mode) {
const teams = getTeamMap(mode);
const ready = getReadyMap(mode);
for (const [teamId, team] of teams) {
const idx = team.players.findIndex((p) => p.socketId === socketId);
if (idx === -1) continue;
const playerName = team.players[idx].player.name;
team.players.splice(idx, 1);
team.ready.delete(socketId);
ready.delete(teamId);
if (team.players.length === 0) {
teams.delete(teamId);
console.log(`[${mode}] Team ${teamId} aufgelöst.`);
} else {
// Falls Leader das Team verlässt → nächsten Spieler zum Leader machen
if (team.leaderId === socketId) {
team.leaderId = team.players[0].socketId;
console.log(
`[${mode}] Neuer Leader in Team ${teamId}: ${team.players[0].player.name}`,
);
}
broadcastTeamStatus(io, teamId, mode);
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_partner_left`, { name: playerName });
}
console.log(`[${mode}] ${playerName} hat Team ${teamId} verlassen.`);
}
broadcastLobbies(io, mode);
break;
}
}
/* ── 1v1 Matchmaking ── */
function tryMatchmaking(io, newSocketId) {
const challenger = waitingPool.get(newSocketId);
if (!challenger) return;
for (const [id, entry] of waitingPool) {
if (id === newSocketId) continue;
if (Math.abs(entry.player.level - challenger.player.level) <= LEVEL_RANGE) {
waitingPool.delete(newSocketId);
waitingPool.delete(id);
const matchId = `match_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
challenger.socket.emit("match_found", {
matchId,
opponent: entry.player,
mySlot: "player1",
});
entry.socket.emit("match_found", {
matchId,
opponent: challenger.player,
mySlot: "player2",
});
console.log(
`[1v1] ${challenger.player.name} vs ${entry.player.name} | ${matchId}`,
);
return;
}
}
}
/* ── Bereit-Timer (1v1) ── */
function startReadyTimer(io, matchId) {
if (!io._arenaTimers) io._arenaTimers = new Map();
if (io._arenaTimers.has(matchId)) return;
let remaining = READY_TIMEOUT;
io.to("arena_" + matchId).emit("ready_timer", { remaining });
const interval = setInterval(() => {
remaining--;
io.to("arena_" + matchId).emit("ready_timer", { remaining });
if (remaining <= 0) {
clearInterval(interval);
io._arenaTimers.delete(matchId);
console.log(`[1v1] Match ${matchId} abgebrochen Zeit abgelaufen.`);
io.to("arena_" + matchId).emit("match_cancelled", {
reason: "timeout",
message: "Zeit abgelaufen.",
});
}
}, 1000);
io._arenaTimers.set(matchId, interval);
}
function stopReadyTimer(io, matchId) {
if (!io._arenaTimers) return;
const interval = io._arenaTimers.get(matchId);
if (interval) {
clearInterval(interval);
io._arenaTimers.delete(matchId);
}
}
/* ═══════════════════════════════════════════════════════════
Handler-Generator für 2v2 und 4v4
═══════════════════════════════════════════════════════════ */
function registerTeamModeHandlers(io, socket, mode) {
const max = getMaxPlayers(mode);
/* Lobby-Liste anfordern */
socket.on(`get_${mode}_lobbies`, () => {
const teams = getTeamMap(mode);
const list = [];
for (const [teamId, team] of teams) {
if (team.players.length < max) {
list.push({
teamId,
leader: team.players[0]?.player?.name || "?",
leaderLevel: team.players[0]?.player?.level || 1,
count: team.players.length,
max,
});
}
}
socket.emit(`${mode}_lobbies`, list);
});
/* Team erstellen */
socket.on(`create_${mode}_team`, (playerData) => {
leaveAllTeams_nvn(socket.id, io, mode);
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
const teamId = `team_${mode}_${generateId()}`;
getTeamMap(mode).set(teamId, {
id: teamId,
leaderId: socket.id, // ← Ersteller wird Leader
players: [{ socketId: socket.id, player }],
ready: new Set(),
});
socket.emit(`${mode}_team_joined`, { teamId, isLeader: true });
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] Team ${teamId} erstellt von ${player.name}`);
});
/* Team beitreten */
socket.on(`join_${mode}_team`, (data) => {
const { teamId, playerData } = data;
const team = getTeamMap(mode).get(teamId);
if (!team)
return socket.emit(`${mode}_error`, {
message: "Team nicht mehr verfügbar.",
});
if (team.players.length >= max)
return socket.emit(`${mode}_error`, {
message: "Team ist bereits voll.",
});
if (team.players.find((p) => p.socketId === socket.id)) return;
leaveAllTeams_nvn(socket.id, io, mode);
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
team.players.push({ socketId: socket.id, player });
socket.emit(`${mode}_team_joined`, { teamId, isLeader: false });
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] ${player.name} hat Team ${teamId} beigetreten`);
});
/* Team verlassen */
socket.on(`leave_${mode}_team`, () => {
leaveAllTeams_nvn(socket.id, io, mode);
});
/* ── Kick (nur Leader) ── */
socket.on(`kick_from_${mode}_team`, (data) => {
const { teamId, targetSocketId } = data;
const team = getTeamMap(mode).get(teamId);
if (!team) return;
// Nur der Leader darf kicken
if (team.leaderId !== socket.id) {
return socket.emit(`${mode}_error`, {
message: "Nur der Team-Leader kann Spieler entfernen.",
});
}
// Sich selbst kicken nicht erlaubt
if (targetSocketId === socket.id) return;
const idx = team.players.findIndex((p) => p.socketId === targetSocketId);
if (idx === -1) return;
const kickedName = team.players[idx].player.name;
team.players.splice(idx, 1);
team.ready.delete(targetSocketId);
getReadyMap(mode).delete(teamId);
// Gekickten Spieler benachrichtigen
io.to(targetSocketId).emit(`${mode}_kicked`, {
message: "Du wurdest vom Team-Leader aus dem Team entfernt.",
});
broadcastTeamStatus(io, teamId, mode);
broadcastLobbies(io, mode);
console.log(`[${mode}] ${kickedName} wurde aus Team ${teamId} gekickt.`);
});
/* Bereit klicken */
socket.on(`${mode}_player_ready`, (data) => {
const { teamId } = data;
const team = getTeamMap(mode).get(teamId);
if (!team || team.players.length < max) return;
team.ready.add(socket.id);
broadcastTeamStatus(io, teamId, mode);
console.log(
`[${mode}] Bereit in Team ${teamId}: ${team.ready.size}/${max}`,
);
if (team.ready.size >= max) {
getReadyMap(mode).set(teamId, team);
for (const p of team.players) {
io.to(p.socketId).emit(`${mode}_searching`, {
message: "Suche nach Gegnerteam…",
});
}
console.log(`[${mode}] Team ${teamId} sucht Gegner.`);
tryMatchmaking_nvn(io, mode);
}
});
}
/* ── Helper: Event an beide Spieler senden (Room-Broadcast) ── */
function emitToMatch(io, matchId, event, data) {
io.to("arena_" + matchId).emit(event, data);
}
function emitToOpponent(io, matchId, senderSlot, event, data) {
const opponentSlot = senderSlot === "player1" ? "player2" : "player1";
const room = io._arenaRooms?.get(matchId);
if (room?.sockets[opponentSlot]) {
io.to(room.sockets[opponentSlot]).emit(event, data);
}
}
/* ═══════════════════════════════════════════════════════════
HAUPT-HANDLER
═══════════════════════════════════════════════════════════ */
function registerArenaHandlers(io, socket) {
/* ── 1v1 ── */
socket.on("join_1v1", (playerData) => {
if (waitingPool.has(socket.id)) return;
const player = {
id: playerData.id,
name: playerData.name,
level: Number(playerData.level) || 1,
};
waitingPool.set(socket.id, { socket, player });
socket.emit("queue_status", {
status: "waiting",
poolSize: waitingPool.size,
});
console.log(
`[1v1] ${player.name} (Lvl ${player.level}) im Pool. Größe: ${waitingPool.size}`,
);
tryMatchmaking(io, socket.id);
});
socket.on("leave_1v1", () => {
if (waitingPool.delete(socket.id))
socket.emit("queue_status", { status: "left" });
});
socket.on("arena_join", (data) => {
const { matchId, slot, playerName } = data;
console.log(
`[1v1] arena_join empfangen: matchId=${matchId}, slot=${slot}, name=${playerName}, socketId=${socket.id}`,
);
if (!matchId || !slot) {
console.warn(`[1v1] arena_join abgewiesen matchId oder slot fehlt`);
return;
}
if (!io._arenaRooms) io._arenaRooms = new Map();
if (!io._arenaRooms.has(matchId))
io._arenaRooms.set(matchId, { sockets: {}, names: {}, boardCards: [] });
const room = io._arenaRooms.get(matchId);
room.sockets[slot] = socket.id;
// Name: direkt vom Client (kommt aus /arena/me), socket.user wird ignoriert
// da es oft Default-Werte enthält die den echten Namen überschreiben würden
if (playerName && playerName !== "Spieler") {
// Guter Name vom Client → immer übernehmen
room.names[slot] = playerName;
} else if (!room.names[slot] || room.names[slot] === "Spieler") {
// Kein guter Name bekannt → Fallback
room.names[slot] = playerName || "Spieler";
}
// Sonst: bereits guter Name gespeichert → nicht überschreiben
console.log(`[1v1] Name gesetzt: slot=${slot}, name=${room.names[slot]}, playerName=${playerName}`);
socket.join("arena_" + matchId);
// Bereits gespielte Karten an neu verbundenen Spieler senden
if (room.boardCards?.length > 0) {
socket.emit("board_sync", { cards: room.boardCards });
}
const otherSlot = slot === "player1" ? "player2" : "player1";
if (room.sockets[otherSlot]) {
console.log(
`[1v1] Beide Spieler da → arena_ready senden | Match ${matchId}`,
);
emitToMatch(io, matchId, "arena_ready", {
player1: room.names["player1"] || "Spieler 1",
player2: room.names["player2"] || "Spieler 2",
boardSync: room.boardCards || [],
});
startReadyTimer(io, matchId);
} else {
console.log(
`[1v1] Erster Spieler joined, warte auf zweiten | Match ${matchId}`,
);
socket
.to("arena_" + matchId)
.emit("arena_opponent_joined", { name: room.names[slot], slot });
}
});
socket.on("player_ready", (data) => {
const { matchId, slot } = data;
if (!matchId || !slot) return;
if (!io._arenaReady) io._arenaReady = new Map();
if (!io._arenaReady.has(matchId)) io._arenaReady.set(matchId, new Set());
const readySet = io._arenaReady.get(matchId);
readySet.add(slot);
io.to("arena_" + matchId).emit("ready_status", {
readyCount: readySet.size,
readySlots: Array.from(readySet),
});
if (readySet.size >= 2) {
stopReadyTimer(io, matchId);
io._arenaReady.delete(matchId);
}
});
socket.on("player_surrender", (data) => {
const { matchId, slot } = data;
io.to("arena_" + matchId).emit("player_surrendered", { slot });
});
/* ── Zugwechsel ── */
socket.on("end_turn", (data) => {
const { matchId, slot } = data;
if (!matchId || !slot) return;
const nextSlot = slot === "player1" ? "player2" : "player1";
// Board-State mitschicken → beide Clients können Board synchronisieren
const room = io._arenaRooms?.get(matchId);
const boardCards = room?.boardCards || [];
emitToMatch(io, matchId, "turn_change", {
activeSlot: nextSlot,
boardSync: boardCards,
});
console.log(`[1v1] Zug: ${slot}${nextSlot} | boardCards=${boardCards.length} | Match ${matchId}`);
});
/* ── Karte gespielt → direkt an Gegner senden ── */
socket.on("card_played", (data) => {
const { matchId, slot } = data;
if (!matchId) return;
// Direkt an Gegner-Socket senden
emitToOpponent(io, matchId, slot, "card_played", data);
// Karte im Server-State speichern (für Reconnect/board_sync)
const roomState = io._arenaRooms?.get(matchId);
if (roomState) {
roomState.boardCards = roomState.boardCards || [];
roomState.boardCards.push(data);
}
console.log(`[1v1] card_played: ${data.card?.name}${data.boardSlot} | Match ${matchId}`);
});
socket.on("end_turn_init", (data) => {
const { matchId, starterSlot } = data;
if (!matchId || !starterSlot) return;
// Nur einmal senden (erster Empfang gewinnt)
if (!io._turnInit) io._turnInit = new Set();
if (io._turnInit.has(matchId)) return;
io._turnInit.add(matchId);
emitToMatch(io, matchId, "turn_change", { activeSlot: starterSlot });
console.log(`[1v1] Startzug: ${starterSlot} | Match ${matchId}`);
// Cleanup nach 60s (lang genug damit doppelte end_turn_init geblockt bleiben)
setTimeout(() => io._turnInit?.delete(matchId), 60000);
});
/* ── 2v2 & 4v4 ── */
registerTeamModeHandlers(io, socket, "2v2");
registerTeamModeHandlers(io, socket, "4v4");
/* ── Disconnect ── */
socket.on("disconnect", () => {
if (waitingPool.delete(socket.id)) {
console.log(`[1v1] ${socket.id} disconnected aus Pool entfernt.`);
}
leaveAllTeams_nvn(socket.id, io, "2v2");
leaveAllTeams_nvn(socket.id, io, "4v4");
});
}
module.exports = { registerArenaHandlers };