908 lines
30 KiB
JavaScript
908 lines
30 KiB
JavaScript
/* ============================================================
|
||
sockets/arena.socket.js
|
||
1v1 Matchmaking + 2v2 Team-Lobby
|
||
inkl. Kampfphase nach end_turn
|
||
============================================================ */
|
||
|
||
const { runCombatPhase } = require('./combat');
|
||
const db = require('../database/database');
|
||
const { htAiMatchIds } = require('./1vKI_daily.socket');
|
||
const pointsRoute = require('../routes/points.route');
|
||
|
||
/* ── HP-Formel (muss mit arena.route.js übereinstimmen) ── */
|
||
function calcAvatarHp(level) {
|
||
return 20 + (Math.max(1, Math.min(50, level || 1)) - 1) * 2;
|
||
}
|
||
|
||
const waitingPool = new Map();
|
||
const LEVEL_RANGE = 5;
|
||
const READY_TIMEOUT = 30;
|
||
|
||
/* ── 2v2 State ── */
|
||
const teams2v2 = new Map();
|
||
const readyTeams2v2 = new Map();
|
||
|
||
function generateId() {
|
||
return `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
HELPER: Generisch für 2v2
|
||
═══════════════════════════════════════════════════════════ */
|
||
function getTeamMap(mode) {
|
||
return teams2v2;
|
||
}
|
||
function getReadyMap(mode) {
|
||
return readyTeams2v2;
|
||
}
|
||
function getMaxPlayers(mode) {
|
||
return 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,
|
||
players: team.players.map((p) => ({
|
||
socketId: p.socketId,
|
||
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 {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/* ── Energie prüfen und abziehen ─────────────────────────────
|
||
Gibt true zurück wenn genug Energie vorhanden, sonst false.
|
||
Zieht ENERGY_COST ab und setzt energy_reset auf heute.
|
||
────────────────────────────────────────────────────────────── */
|
||
const ENERGY_COST = 2;
|
||
const ENERGY_MAX = 40;
|
||
|
||
async function deductEnergy(accountId) {
|
||
if (!accountId) return true; // kein Account → erlauben (Fallback)
|
||
try {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
|
||
// Energie laden + ggf. tagesreset
|
||
const [[row]] = await db.query(
|
||
"SELECT energy, energy_reset FROM account_currency WHERE account_id = ?",
|
||
[accountId]
|
||
);
|
||
if (!row) return true; // kein Eintrag → erlauben
|
||
|
||
const lastReset = row.energy_reset
|
||
? new Date(row.energy_reset).toISOString().slice(0, 10)
|
||
: null;
|
||
const current = lastReset !== today ? ENERGY_MAX : (row.energy ?? ENERGY_MAX);
|
||
|
||
if (current < ENERGY_COST) return false; // nicht genug Energie
|
||
|
||
// Energie abziehen
|
||
await db.query(
|
||
`UPDATE account_currency
|
||
SET energy = ?,
|
||
energy_reset = ?
|
||
WHERE account_id = ?`,
|
||
[current - ENERGY_COST, today, accountId]
|
||
);
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[Energy] deductEnergy Fehler:', err);
|
||
return true; // bei DB-Fehler nicht blockieren
|
||
}
|
||
}
|
||
|
||
/* ── 1v1 Matchmaking ── */
|
||
async function tryMatchmaking(io, newSocketId) {
|
||
const challenger = waitingPool.get(newSocketId);
|
||
if (!challenger) return;
|
||
|
||
for (const [id, entry] of waitingPool) {
|
||
if (id === newSocketId) continue;
|
||
const diff = Math.abs(entry.player.level - challenger.player.level);
|
||
// Match nur wenn BEIDE Spieler die Leveldifferenz akzeptieren
|
||
if (diff <= challenger.levelRange && diff <= entry.levelRange) {
|
||
waitingPool.delete(newSocketId);
|
||
waitingPool.delete(id);
|
||
|
||
const matchId = `match_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
|
||
// Energie beider Spieler abziehen (2 pro Match)
|
||
const [energyOk1, energyOk2] = await Promise.all([
|
||
deductEnergy(challenger.player.accountId),
|
||
deductEnergy(entry.player.accountId),
|
||
]);
|
||
|
||
if (!energyOk1) {
|
||
challenger.socket.emit("queue_status", {
|
||
status: "error",
|
||
message: "Nicht genug Energie für einen Kampf. Warte bis morgen!",
|
||
});
|
||
waitingPool.set(newSocketId, challenger); // zurück in Pool
|
||
waitingPool.set(id, entry);
|
||
return;
|
||
}
|
||
if (!energyOk2) {
|
||
// Energie von Spieler 1 zurückgeben
|
||
if (challenger.player.accountId) {
|
||
db.query(
|
||
"UPDATE account_currency SET energy = LEAST(energy + ?, ?) WHERE account_id = ?",
|
||
[ENERGY_COST, ENERGY_MAX, challenger.player.accountId]
|
||
).catch(() => {});
|
||
}
|
||
entry.socket.emit("queue_status", {
|
||
status: "error",
|
||
message: "Nicht genug Energie für einen Kampf. Warte bis morgen!",
|
||
});
|
||
waitingPool.set(newSocketId, challenger);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
emitToMatch(io, matchId, "ready_timer", { remaining });
|
||
|
||
const interval = setInterval(() => {
|
||
remaining--;
|
||
emitToMatch(io, matchId, "ready_timer", { remaining });
|
||
if (remaining <= 0) {
|
||
clearInterval(interval);
|
||
io._arenaTimers.delete(matchId);
|
||
console.log(`[1v1] Match ${matchId} abgebrochen – Zeit abgelaufen.`);
|
||
emitToMatch(io, matchId, "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);
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
KAMPFPHASE HELPER
|
||
Berechnet wie lange die Client-Animation dauert,
|
||
damit turn_change erst NACH den Animationen gesendet wird.
|
||
═══════════════════════════════════════════════════════════ */
|
||
function calcCombatDuration(events) {
|
||
const DELAY_BANNER = 600;
|
||
const DELAY_MOVE = 350;
|
||
const DELAY_ATTACK = 450;
|
||
const DELAY_DIE = 300;
|
||
const DELAY_AVATAR_ATTACK= 500;
|
||
const DELAY_FINAL = 500;
|
||
const BUFFER = 400;
|
||
|
||
let total = DELAY_BANNER + DELAY_FINAL + BUFFER;
|
||
for (const ev of events) {
|
||
if (ev.type === 'move') total += DELAY_MOVE;
|
||
if (ev.type === 'attack') total += DELAY_ATTACK;
|
||
if (ev.type === 'die') total += DELAY_DIE;
|
||
if (ev.type === 'avatar_attack')total += DELAY_AVATAR_ATTACK;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/* ── boardState → boardCards-Array (für reconnect/boardSync) ── */
|
||
function boardStateToCards(boardState) {
|
||
return Object.entries(boardState).map(([boardSlot, entry]) => ({
|
||
boardSlot,
|
||
card : entry.card,
|
||
owner: entry.owner,
|
||
slot : entry.owner, // Rückwärtskompatibilität
|
||
}));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
Handler-Generator für 2v2
|
||
═══════════════════════════════════════════════════════════ */
|
||
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,
|
||
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;
|
||
|
||
if (team.leaderId !== socket.id) {
|
||
return socket.emit(`${mode}_error`, {
|
||
message: "Nur der Team-Leader kann Spieler entfernen.",
|
||
});
|
||
}
|
||
|
||
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);
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── Hilfsfunktionen: Direkte Socket-Zustellung ── */
|
||
function getRoom(io, matchId) {
|
||
return io._arenaRooms?.get(matchId) || null;
|
||
}
|
||
|
||
function emitToMatch(io, matchId, event, data) {
|
||
const room = getRoom(io, matchId);
|
||
if (!room) {
|
||
console.warn(`[emitToMatch] Kein Room für ${matchId}`);
|
||
return;
|
||
}
|
||
["player1", "player2"].forEach((slot) => {
|
||
if (room.sockets[slot]) {
|
||
io.to(room.sockets[slot]).emit(event, data);
|
||
console.log(`[emitToMatch] ${event} → ${slot} (${room.sockets[slot]})`);
|
||
}
|
||
});
|
||
}
|
||
|
||
function emitToOpponent(io, matchId, senderSlot, event, data) {
|
||
const room = getRoom(io, matchId);
|
||
if (!room) return;
|
||
const oppSlot = senderSlot === "player1" ? "player2" : "player1";
|
||
if (room.sockets[oppSlot]) {
|
||
io.to(room.sockets[oppSlot]).emit(event, data);
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
AVATAR-HP HELPERS
|
||
═══════════════════════════════════════════════════════════ */
|
||
|
||
/** HP für beide Spieler beim Spielstart initialisieren.
|
||
* Lookup aus arena_avatar_levels, Fallback auf Formel.
|
||
* HP-Tracking läuft rein im Speicher (room.hp / room.maxHp). */
|
||
async function initMatchHP(io, matchId, room) {
|
||
for (const slot of ['player1', 'player2']) {
|
||
const accountId = room.accountIds[slot];
|
||
let maxHp = 20; // Fallback
|
||
|
||
try {
|
||
const [[acc]] = await db.query(
|
||
"SELECT level FROM accounts WHERE id = ?",
|
||
[accountId || 0]
|
||
);
|
||
const level = acc?.level ?? 1;
|
||
|
||
// Aus Lookup-Tabelle lesen, Formel als Fallback
|
||
const [[row]] = await db.query(
|
||
"SELECT max_hp FROM arena_avatar_levels WHERE level = ?",
|
||
[level]
|
||
);
|
||
maxHp = row?.max_hp ?? calcAvatarHp(level);
|
||
} catch (err) {
|
||
console.error(`[HP] Level-Lookup Fehler (${slot}):`, err);
|
||
// Formel-Fallback – Spiel läuft trotzdem weiter
|
||
}
|
||
|
||
room.hp[slot] = maxHp;
|
||
room.maxHp[slot] = maxHp;
|
||
}
|
||
|
||
// Initiale HP an beide Clients senden
|
||
emitToMatch(io, matchId, 'hp_init', { hp: room.hp, maxHp: room.maxHp });
|
||
console.log(`[HP] Init ${matchId}: P1=${room.hp.player1}/${room.maxHp.player1}, P2=${room.hp.player2}/${room.maxHp.player2}`);
|
||
}
|
||
|
||
/** Avatar-Treffer aus Combat-Events verarbeiten, Match bei Tod beenden.
|
||
* HP wird nur im Speicher (room.hp) verwaltet – kein DB-Schreiben. */
|
||
async function processAvatarAttacks(io, matchId, room, events) {
|
||
const avatarEvents = events.filter(e => e.type === 'avatar_attack');
|
||
if (avatarEvents.length === 0) return false;
|
||
|
||
for (let i = 0; i < avatarEvents.length; i++) {
|
||
// Mehrere Avatar-Treffer im gleichen Zug: 500ms Abstand
|
||
if (i > 0) await new Promise(r => setTimeout(r, 500));
|
||
|
||
const ev = avatarEvents[i];
|
||
const target = ev.target;
|
||
|
||
if (room.hp[target] == null) {
|
||
console.warn(`[HP] room.hp[${target}] nicht gesetzt – überspringe`);
|
||
continue;
|
||
}
|
||
|
||
room.hp[target] = Math.max(0, room.hp[target] - (ev.damage ?? 0));
|
||
|
||
emitToMatch(io, matchId, 'avatar_damaged', {
|
||
slot : target,
|
||
damage : ev.damage,
|
||
remainingHp: room.hp[target],
|
||
maxHp : room.maxHp[target] ?? 20,
|
||
row : ev.from?.split('-slot-')[0] ?? 'row1', // Reihe für Versatz
|
||
});
|
||
|
||
console.log(`[HP] ${target} (${ev.from}) -${ev.damage} → ${room.hp[target]}/${room.maxHp[target]}`);
|
||
|
||
if (room.hp[target] <= 0) {
|
||
await handleMatchEnd(io, matchId, room, target);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Match beenden: Ergebnis emitieren + 15 Punkte an Gewinner vergeben */
|
||
async function handleMatchEnd(io, matchId, room, loserSlot) {
|
||
if (room.gameOver) return;
|
||
room.gameOver = true;
|
||
|
||
const winnerSlot = loserSlot === 'player1' ? 'player2' : 'player1';
|
||
const winnerAccId = room.accountIds[winnerSlot];
|
||
const loserAccId = room.accountIds[loserSlot];
|
||
|
||
console.log(`[Match] Ende: Gewinner=${winnerSlot} (${winnerAccId}), Verlierer=${loserSlot} (${loserAccId}) | ${matchId}`);
|
||
|
||
// Gewinner: 15 Punkte | Verlierer: 5 Punkte
|
||
let winnerResult = { awarded: 0 };
|
||
let loserResult = { awarded: 0 };
|
||
|
||
if (winnerAccId) {
|
||
try {
|
||
winnerResult = await pointsRoute.awardPoints(winnerAccId, 15);
|
||
} catch (err) {
|
||
console.error('[Match] Gewinner-Punkte Fehler:', err);
|
||
}
|
||
}
|
||
|
||
if (loserAccId) {
|
||
try {
|
||
loserResult = await pointsRoute.awardPoints(loserAccId, 5);
|
||
} catch (err) {
|
||
console.error('[Match] Verlierer-Punkte Fehler:', err);
|
||
}
|
||
}
|
||
|
||
// Ergebnis an beide Clients senden
|
||
const room_ = getRoom(io, matchId);
|
||
|
||
if (room_.sockets[winnerSlot]) {
|
||
io.to(room_.sockets[winnerSlot]).emit('match_result', {
|
||
won : true,
|
||
awarded : winnerResult.awarded ?? 0,
|
||
level_up : winnerResult.level_up ?? false,
|
||
new_level: winnerResult.new_level ?? null,
|
||
});
|
||
}
|
||
|
||
if (room_.sockets[loserSlot]) {
|
||
io.to(room_.sockets[loserSlot]).emit('match_result', {
|
||
won : false,
|
||
awarded : loserResult.awarded ?? 0,
|
||
level_up : loserResult.level_up ?? false,
|
||
new_level: loserResult.new_level ?? null,
|
||
});
|
||
}
|
||
|
||
// HP läuft nur im Speicher – kein DB-Cleanup nötig
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
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,
|
||
accountId: playerData.id || null, // für Energie-Abzug
|
||
};
|
||
// levelRange vom Client übernehmen (5 oder 10), Standard = 5
|
||
const levelRange = [5, 10].includes(Number(playerData.levelRange))
|
||
? Number(playerData.levelRange)
|
||
: LEVEL_RANGE;
|
||
waitingPool.set(socket.id, { socket, player, levelRange });
|
||
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 (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
|
||
|
||
if (!io._arenaRooms) io._arenaRooms = new Map();
|
||
if (!io._arenaRooms.has(matchId)) {
|
||
io._arenaRooms.set(matchId, {
|
||
sockets : {},
|
||
names : {},
|
||
accountIds : {}, // { player1: accountId, player2: accountId }
|
||
boardCards : [],
|
||
boardState : {},
|
||
leftSlot : null,
|
||
hp : {}, // { player1: current, player2: current } in-memory
|
||
maxHp : {}, // { player1: max, player2: max }
|
||
gameOver : false,
|
||
});
|
||
}
|
||
|
||
const room = io._arenaRooms.get(matchId);
|
||
room.sockets[slot] = socket.id;
|
||
|
||
if (playerName && playerName !== "Spieler") {
|
||
room.names[slot] = playerName;
|
||
} else if (!room.names[slot] || room.names[slot] === "Spieler") {
|
||
room.names[slot] = playerName || "Spieler";
|
||
}
|
||
|
||
// Account-ID speichern (wird für HP-Init und Punkte-Vergabe benötigt)
|
||
if (data.accountId) room.accountIds[slot] = data.accountId;
|
||
|
||
console.log(
|
||
`[1v1] Name gesetzt: slot=${slot}, name=${room.names[slot]}, playerName=${playerName}`,
|
||
);
|
||
|
||
socket.join("arena_" + matchId);
|
||
|
||
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 || [],
|
||
hp : room.hp || {},
|
||
maxHp : room.maxHp || {},
|
||
});
|
||
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);
|
||
const readyData = {
|
||
readyCount: readySet.size,
|
||
readySlots: Array.from(readySet),
|
||
};
|
||
emitToMatch(io, matchId, "ready_status", readyData);
|
||
console.log(
|
||
`[1v1] ready_status: ${readySet.size}/2 bereit | Match ${matchId}`,
|
||
);
|
||
|
||
if (readySet.size >= 2) {
|
||
stopReadyTimer(io, matchId);
|
||
io._arenaReady.delete(matchId);
|
||
console.log(
|
||
`[1v1] Beide bereit – warte auf start_turn_request | Match ${matchId}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
socket.on("player_surrender", (data) => {
|
||
const { matchId, slot } = data;
|
||
io.to("arena_" + matchId).emit("player_surrendered", { slot });
|
||
});
|
||
|
||
/* ── Karte gespielt ── */
|
||
socket.on("card_played", (data) => {
|
||
const { matchId, slot, boardSlot, card } = data;
|
||
if (!matchId) return;
|
||
|
||
// An Gegner senden
|
||
emitToOpponent(io, matchId, slot, "card_played", data);
|
||
|
||
// Im Server-State speichern
|
||
const room = io._arenaRooms?.get(matchId);
|
||
if (room) {
|
||
// boardState mit Owner speichern (für Kampfphase)
|
||
room.boardState[boardSlot] = { card, owner: slot };
|
||
|
||
// boardCards neu aufbauen (für Reconnect/boardSync)
|
||
room.boardCards = boardStateToCards(room.boardState);
|
||
}
|
||
|
||
console.log(
|
||
`[1v1] card_played: ${card?.name} → ${boardSlot} (owner: ${slot}) | Match ${matchId}`,
|
||
);
|
||
});
|
||
|
||
/* ── Startspieler festlegen ── */
|
||
socket.on("start_turn_request", async (data) => {
|
||
const { matchId, starterSlot } = data;
|
||
if (!matchId || !starterSlot) return;
|
||
if (htAiMatchIds.has(matchId)) return; // KI-Match: turn wird von himmelstor.socket gestartet
|
||
if (!io._turnInit) io._turnInit = new Set();
|
||
if (io._turnInit.has(matchId)) return;
|
||
io._turnInit.add(matchId);
|
||
setTimeout(() => io._turnInit?.delete(matchId), 60000);
|
||
|
||
// NEU: linken Spieler im Room merken (wird für Kampfphase benötigt)
|
||
const room = io._arenaRooms?.get(matchId);
|
||
if (room) room.leftSlot = starterSlot;
|
||
|
||
// Avatar-HP initialisieren
|
||
await initMatchHP(io, matchId, room);
|
||
|
||
const boardCards = room?.boardCards || [];
|
||
emitToMatch(io, matchId, "turn_change", {
|
||
activeSlot: starterSlot,
|
||
boardSync : boardCards,
|
||
hp : room.hp,
|
||
maxHp : room.maxHp,
|
||
});
|
||
console.log(
|
||
`[1v1] Spiel startet → ${starterSlot} (linker Spieler) beginnt | Match ${matchId}`,
|
||
);
|
||
});
|
||
|
||
/* ── Zug beenden → Kampfphase → Zugwechsel ── */
|
||
|
||
/* ── Karte vom Board abwerfen ────────────────────────────── */
|
||
socket.on('discard_card', (data) => {
|
||
const { matchId, slotId, slot } = data;
|
||
if (!matchId || !slotId) return;
|
||
if (htAiMatchIds.has(matchId)) return; // KI-Matches: handled by himmelstor.socket
|
||
|
||
const room = io._arenaRooms?.get(matchId);
|
||
if (!room || room.gameOver) return;
|
||
if (room.sockets[slot] !== socket.id) return; // nur eigene Karten
|
||
|
||
// Karte aus boardState entfernen
|
||
if (room.boardState) delete room.boardState[slotId];
|
||
room.boardCards = room.boardCards?.filter(c => c.boardSlot !== slotId) ?? [];
|
||
|
||
// Gegner informieren
|
||
const oppSlot = slot === 'player1' ? 'player2' : 'player1';
|
||
const oppSocket = room.sockets[oppSlot];
|
||
if (oppSocket) {
|
||
io.to(oppSocket).emit('card_discarded', { slotId });
|
||
}
|
||
console.log(`[Arena] Karte abgeworfen: ${slotId} von ${slot} | Match ${matchId}`);
|
||
});
|
||
|
||
socket.on("end_turn", async (data) => {
|
||
const { matchId, slot } = data;
|
||
if (!matchId || !slot) return;
|
||
if (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
|
||
|
||
const room = io._arenaRooms?.get(matchId);
|
||
if (!room) return;
|
||
|
||
const leftSlot = room.leftSlot || 'player1';
|
||
const boardState = room.boardState;
|
||
const nextSlot = slot === "player1" ? "player2" : "player1";
|
||
|
||
/* ── Kampfphase berechnen ── */
|
||
const combatEvents = runCombatPhase(boardState, leftSlot, slot); // slot = aktiver Spieler
|
||
|
||
// boardCards nach Kampf aktualisieren (Karten die gestorben sind fehlen jetzt)
|
||
room.boardCards = boardStateToCards(boardState);
|
||
|
||
// finalBoard: aktueller Stand nach Kampf
|
||
const finalBoard = room.boardCards;
|
||
|
||
// Kampf-Events + finales Board an beide Clients senden
|
||
emitToMatch(io, matchId, 'combat_phase', {
|
||
events : combatEvents,
|
||
finalBoard: finalBoard,
|
||
});
|
||
|
||
console.log(
|
||
`[1v1] Kampfphase: ${combatEvents.length} Events | Zug: ${slot} → ${nextSlot} | Match ${matchId}`,
|
||
);
|
||
|
||
// Avatar-Schaden aus Combat-Events verarbeiten
|
||
const matchEnded = await processAvatarAttacks(io, matchId, room, combatEvents);
|
||
if (matchEnded) return; // Match vorbei – kein turn_change mehr
|
||
|
||
// Zugwechsel NACH den Client-Animationen senden
|
||
const animDuration = calcCombatDuration(combatEvents);
|
||
setTimeout(() => {
|
||
if (room.gameOver) return;
|
||
emitToMatch(io, matchId, "turn_change", {
|
||
activeSlot: nextSlot,
|
||
boardSync : room.boardCards,
|
||
hp : room.hp,
|
||
maxHp : room.maxHp,
|
||
});
|
||
console.log(
|
||
`[1v1] turn_change gesendet nach ${animDuration}ms | ${slot} → ${nextSlot} | Match ${matchId}`,
|
||
);
|
||
}, animDuration);
|
||
});
|
||
|
||
/* ── 2v2 ── */
|
||
registerTeamModeHandlers(io, socket, "2v2");
|
||
|
||
/* ── Disconnect ── */
|
||
socket.on("disconnect", () => {
|
||
if (waitingPool.delete(socket.id)) {
|
||
console.log(`[1v1] ${socket.id} disconnected – aus Pool entfernt.`);
|
||
}
|
||
leaveAllTeams_nvn(socket.id, io, "2v2");
|
||
});
|
||
}
|
||
|
||
module.exports = { registerArenaHandlers };
|