/* ============================================================ sockets/1vKI_daily.socket.js KI-Matches für die Tagesherausforderung (Himmelstor Daily) – Haiku entscheidet den KI-Zug via Anthropic API ============================================================ */ 'use strict'; const db = require('../database/database'); const { getMaxRarity } = require('../utils/rarity'); const { runCombatPhase } = require('./combat'); const pointsRoute = require('../routes/points.route'); /* ── Set aller aktiven KI-Match-IDs (wird von arena.socket.js importiert) */ const htAiMatchIds = new Set(); /* ── KI-Match Rooms ───────────────────────────────────────── matchId → { playerSocketId, playerSlot, playerAccountId, aiSlot, aiHand, aiDeck, boardState, boardCards, leftSlot, hp, maxHp, station, gameOver, aiTurnInProgress } */ const htAiRooms = new Map(); /* ── HP-Formel (identisch zu arena.socket.js) ─────────────── */ function calcAvatarHp(level) { return 20 + (Math.max(1, Math.min(50, level || 1)) - 1) * 2; } /* ── Wächter-Namen pro Station ────────────────────────────── */ const GUARD_NAMES = [ "", "Torwächter", "Waldläufer", "Steinbrecher", "Schattenjäger", "Eisenherzog", "Sturmritter", "Himmelsherold", ]; /* ── KI-Deck laden (Schwierigkeit skaliert mit Station) ─────── Station 1 → 5 Karten, max. Rarity 1 Station 2 → 7 Karten, max. Rarity 1 Station 3 → 9 Karten, max. Rarity 2 Station 4 → 11 Karten, max. Rarity 2 Station 5 → 12 Karten, max. Rarity 3 Station 6 → 14 Karten, max. Rarity 3 Station 7 → 15 Karten, max. Rarity 4 ────────────────────────────────────────────────────────────── */ async function loadAiDeck(station, playerLevel) { // Rarity nach Station (Schwierigkeitsstufe) const stationRarity = station <= 2 ? 1 : station <= 4 ? 2 : station <= 6 ? 3 : 4; const playerMaxRarity = getMaxRarity(playerLevel || 1); // Kartenanzahl nach Station (5–15) const deckSize = station === 1 ? 5 : station === 2 ? 7 : station === 3 ? 9 : station === 4 ? 11 : station === 5 ? 12 : station === 6 ? 14 : 15; // Stationen 1–6: alle Karten ≤ playerMaxRarity // Station 7: 14 Karten ≤ playerMaxRarity + 1 starke Karte (exakt playerMaxRarity+1) const baseRarity = Math.min(stationRarity, playerMaxRarity); const bonusRarity = Math.min(playerMaxRarity + 1, 6); const bonusCount = station >= 7 ? 1 : 0; // nur Station 7 bekommt 1 stärkere Karte const baseCount = deckSize - bonusCount; try { // Basis-Karten laden (Rarity ≤ baseRarity) const [baseCards] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards WHERE rarity <= ? ORDER BY RAND() LIMIT ?`, [baseRarity, baseCount] ); // Bonus-Karte für Station 7 (exakt Rarity = bonusRarity) let bonusCards = []; if (bonusCount > 0) { const [bonus] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards WHERE rarity = ? ORDER BY RAND() LIMIT ?`, [bonusRarity, bonusCount] ); bonusCards = bonus; // Fallback: falls keine Karte dieser Rarity vorhanden if (bonusCards.length === 0) { const [fallback] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards WHERE rarity <= ? ORDER BY RAND() LIMIT ?`, [bonusRarity, bonusCount] ); bonusCards = fallback; } } let result = [...baseCards, ...bonusCards]; // Fallback: alle Karten ohne Filter wenn zu wenig if (result.length === 0) { const [all] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards ORDER BY RAND() LIMIT ?`, [deckSize] ); result = all; } // Zu wenig Karten → vorhandene wiederholen bis deckSize erreicht if (result.length > 0 && result.length < deckSize) { const base = [...result]; while (result.length < deckSize) { result.push({ ...base[result.length % base.length] }); } console.log(`[HT] KI-Deck aufgefüllt: ${base.length} unique → ${result.length} Karten`); } console.log(`[HT] KI-Deck Station ${station}: ${result.length} Karten (${baseCount}x ≤ Rarity ${baseRarity}${bonusCount ? `, 1x Rarity ${bonusRarity}` : ''})`); return result.map(c => ({ ...c, currentCd: 0 })); } catch (err) { console.error('[HT] loadAiDeck Fehler:', err); return []; } } /* ── boardState → boardCards Array ───────────────────────── */ function boardStateToCards(boardState) { return Object.entries(boardState).map(([boardSlot, entry]) => ({ boardSlot, card: entry.card, owner: entry.owner, slot: entry.owner, })); } /* ── Haiku API: KI-Zug entscheiden ───────────────────────── */ async function askHaiku(boardState, aiHand, availableSlots, aiSlot, leftSlot) { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { console.warn('[HT] ANTHROPIC_API_KEY fehlt'); return null; } const isLeft = aiSlot === leftSlot; // Vereinfachter Board-Überblick const boardSummary = Object.entries(boardState).map(([slot, e]) => ({ slot, card: e.card.name, owner: e.owner === aiSlot ? 'KI' : 'Spieler', atk: e.card.attack, def: e.card.defends, })); const handSummary = aiHand.map((c, i) => ({ index: i, name: c.name, attack: c.attack ?? 0, defends: c.defends ?? 0, race: c.race ?? 0, range: c.range ?? 1, })); // KI-eigene Karten auf dem Board (könnten abgeworfen werden) const myBoardCards = Object.entries(boardState) .filter(([, e]) => e.owner === aiSlot) .map(([slot, e]) => ({ slot, name: e.card.name, atk: e.card.attack ?? 0 })); const prompt = `Kartenspiel. Du bist die KI (${isLeft ? 'linke Seite, Slots 1-3, bewegst dich nach rechts' : 'rechte Seite, Slots 9-11, bewegst dich nach links'}). Board: ${JSON.stringify(boardSummary)} Deine eigenen Karten auf dem Board: ${JSON.stringify(myBoardCards)} Deine Hand: ${JSON.stringify(handSummary)} Freie Slots: ${JSON.stringify(availableSlots)} Optionen: 1. Karte ausspielen: {"card_index":0,"slot":"row1-slot-9"} 2. Eigene Boardkarte abwerfen (wenn sie schlecht positioniert ist): {"discard_slot":"row1-slot-9"} 3. Nichts tun: {"skip":true} Antworte NUR mit validem JSON.`; try { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: 'claude-haiku-4-5-20251001', max_tokens: 80, messages: [{ role: 'user', content: prompt }], }), }); const data = await res.json(); const text = data.content?.[0]?.text ?? '{}'; // JSON aus Antwort extrahieren const match = text.match(/\{[^}]+\}/); if (!match) return null; return JSON.parse(match[0]); } catch (err) { console.error('[HT] Haiku API Fehler:', err); return null; } } /* ── KI-Zug ausführen ─────────────────────────────────────── */ async function playAiTurn(io, matchId) { const aiRoom = htAiRooms.get(matchId); if (!aiRoom || aiRoom.aiTurnInProgress || aiRoom.gameOver) return; aiRoom.aiTurnInProgress = true; try { // Freie Slots auf KI-Seite const isLeft = aiRoom.aiSlot === aiRoom.leftSlot; const aiIdxs = isLeft ? [1, 2, 3] : [9, 10, 11]; const freeSlots = []; for (const row of ['row1', 'row2']) { for (const idx of aiIdxs) { const slotId = `${row}-slot-${idx}`; if (!aiRoom.boardState[slotId]) freeSlots.push(slotId); } } const readyCards = aiRoom.aiHand.filter(c => (c.currentCd ?? 0) <= 0); if (readyCards.length > 0 && freeSlots.length > 0) { const decision = await askHaiku( aiRoom.boardState, readyCards, freeSlots, aiRoom.aiSlot, aiRoom.leftSlot ); // KI wirft eigene Boardkarte ab if (decision && decision.discard_slot) { const dsSlot = decision.discard_slot; if (aiRoom.boardState[dsSlot]?.owner === aiRoom.aiSlot) { delete aiRoom.boardState[dsSlot]; const ioRoom = io._arenaRooms?.get(matchId); if (ioRoom?.boardState) delete ioRoom.boardState[dsSlot]; io.to(aiRoom.playerSocketId).emit('card_discarded', { slotId: dsSlot }); console.log(`[HT] KI wirft ab: ${dsSlot}`); await sleep(400); } } if (decision && !decision.skip && !decision.discard_slot && decision.card_index != null && decision.slot) { const card = readyCards[decision.card_index]; const slotId = decision.slot; if (card && freeSlots.includes(slotId)) { // Karte aufs Board legen aiRoom.boardState[slotId] = { card, owner: aiRoom.aiSlot }; aiRoom.boardCards = boardStateToCards(aiRoom.boardState); // Karte aus Hand entfernen, neue Karte ziehen const idx = aiRoom.aiHand.findIndex(c => c === card); if (idx > -1) aiRoom.aiHand.splice(idx, 1); if (aiRoom.aiDeck.length > 0) { const drawn = aiRoom.aiDeck.shift(); drawn.currentCd = drawn.cooldown ?? 0; aiRoom.aiHand.push(drawn); } // Spieler informieren io.to(aiRoom.playerSocketId).emit('card_played', { matchId, slot: aiRoom.aiSlot, boardSlot: slotId, row: slotId.split('-slot-')[0], slotIndex: parseInt(slotId.split('-slot-')[1]), card, }); console.log(`[HT] KI spielt: ${card.name} → ${slotId} | Match ${matchId}`); await sleep(900); // Kurze Pause damit Spieler die Karte sieht } } } // KI-Hand Cooldowns ticken aiRoom.aiHand.forEach(c => { if ((c.currentCd ?? 0) > 0) c.currentCd--; }); // Kampfphase – KI ist am Zug const combatEvents = runCombatPhase(aiRoom.boardState, aiRoom.leftSlot, aiRoom.aiSlot); aiRoom.boardCards = boardStateToCards(aiRoom.boardState); const finalBoard = aiRoom.boardCards; io.to(aiRoom.playerSocketId).emit('combat_phase', { events: combatEvents, finalBoard }); // Avatar-Schaden verarbeiten const matchEnded = await htProcessAvatarAttacks(io, matchId, aiRoom, combatEvents); if (matchEnded) return; // Zug zurück an Spieler const delay = calcCombatDuration(combatEvents) + 300; setTimeout(() => { if (aiRoom.gameOver) return; io.to(aiRoom.playerSocketId).emit('turn_change', { activeSlot: aiRoom.playerSlot, boardSync: aiRoom.boardCards, hp: aiRoom.hp, maxHp: aiRoom.maxHp, }); console.log(`[HT] Zug zurück an Spieler | Match ${matchId}`); }, delay); } finally { aiRoom.aiTurnInProgress = false; } } /* ── Avatar-Schaden (KI-Match) ────────────────────────────── */ async function htProcessAvatarAttacks(io, matchId, aiRoom, events) { const avatarEvents = events.filter(e => e.type === 'avatar_attack'); if (avatarEvents.length === 0) return false; for (const ev of avatarEvents) { const target = ev.target; if (aiRoom.hp[target] == null) continue; aiRoom.hp[target] = Math.max(0, aiRoom.hp[target] - (ev.damage ?? 0)); io.to(aiRoom.playerSocketId).emit('avatar_damaged', { slot: target, damage: ev.damage, remainingHp: aiRoom.hp[target], maxHp: aiRoom.maxHp[target] ?? 20, }); if (aiRoom.hp[target] <= 0) { await htHandleMatchEnd(io, matchId, aiRoom, target); return true; } } return false; } /* ── Match beenden (KI-Match) ──────────────────────────────── */ async function htHandleMatchEnd(io, matchId, aiRoom, loserSlot) { if (aiRoom.gameOver) return; aiRoom.gameOver = true; htAiMatchIds.delete(matchId); const playerWon = loserSlot === aiRoom.aiSlot; console.log(`[HT] Match Ende: ${playerWon ? 'Spieler gewinnt' : 'KI gewinnt'} | Station ${aiRoom.station} | ${matchId}`); if (playerWon) { // Station als abgeschlossen markieren try { await db.query( `INSERT IGNORE INTO daily_completions (user_id, event_id) VALUES (?, ?)`, [aiRoom.playerAccountId, 100 + aiRoom.station] ); } catch (err) { console.error('[HT] Completion speichern Fehler:', err); } io.to(aiRoom.playerSocketId).emit('ht_station_complete', { station: aiRoom.station, }); io.to(aiRoom.playerSocketId).emit('match_result', { won: true, awarded: 0, level_up: false, new_level: null, }); } else { io.to(aiRoom.playerSocketId).emit('match_result', { won: false, awarded: 0, }); } // Aufräumen nach kurzer Verzögerung setTimeout(() => { htAiRooms.delete(matchId); }, 30000); } /* ── Animation-Dauer Berechnung ──────────────────────────── */ function calcCombatDuration(events) { let total = 600 + 500 + 400; for (const ev of events) { if (ev.type === 'move') total += 350; if (ev.type === 'attack') total += 450; if (ev.type === 'die') total += 300; if (ev.type === 'avatar_attack') total += 500; } return total; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } /* ═══════════════════════════════════════════════════════════ HAUPT-HANDLER ═══════════════════════════════════════════════════════════ */ function registerHimmelstorHandlers(io, socket) { /* ── Spieler betritt Daily-Match ────────────────────────── */ socket.on('ht_join_daily', async (data) => { const { station, deckId } = data; if (!station || station < 1 || station > 7) return; // Spieler-Account laden let accountId = null, playerLevel = 1; if (socket.request?.session?.user?.id) { accountId = socket.request.session.user.id; try { const [[acc]] = await db.query( "SELECT level FROM accounts WHERE id = ?", [accountId] ); playerLevel = acc?.level ?? 1; } catch {} } // Prüfen ob Station bereits abgeschlossen if (accountId) { const [done] = await db.query( "SELECT id FROM daily_completions WHERE user_id = ? AND event_id = ? AND DATE(completed_at) = CURDATE()", [accountId, 100 + station] ); if (done.length > 0) { socket.emit('ht_daily_error', { message: `Station ${station} wurde heute bereits abgeschlossen.` }); return; } } // KI-Deck laden const aiDeck = await loadAiDeck(station, playerLevel); if (aiDeck.length === 0) { socket.emit('ht_daily_error', { message: 'Keine Karten für diese Station gefunden.' }); return; } // Match erstellen const matchId = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; const playerSlot = 'player1'; // Spieler immer links const aiSlot = 'player2'; const leftSlot = 'player1'; const maxHpPlayer = calcAvatarHp(playerLevel); const maxHpAi = calcAvatarHp(Math.min(50, station * 7)); // KI wird mit Station stärker // AI-Room anlegen const aiRoom = { playerSocketId : socket.id, playerSlot, playerAccountId : accountId, aiSlot, aiHand : aiDeck.slice(0, 3), // 3 Startkarten aiDeck : aiDeck.slice(3), boardState : {}, boardCards : [], leftSlot, hp : { player1: maxHpPlayer, player2: maxHpAi }, maxHp : { player1: maxHpPlayer, player2: maxHpAi }, station, gameOver : false, aiTurnInProgress: false, }; htAiRooms.set(matchId, aiRoom); htAiMatchIds.add(matchId); // io._arenaRooms eintragen damit arena_join/battlefield funktioniert if (!io._arenaRooms) io._arenaRooms = new Map(); io._arenaRooms.set(matchId, { sockets : { player1: socket.id, player2: null }, // player2 = KI (kein socket) names : { player1: 'Du', player2: GUARD_NAMES[station] || `Wächter ${station}` }, accountIds : { player1: accountId, player2: null }, boardCards : [], boardState : aiRoom.boardState, // Referenz! Gleicher Zustand leftSlot, hp : aiRoom.hp, maxHp : aiRoom.maxHp, gameOver : false, }); console.log(`[HT] Daily Match: Station ${station} | ${matchId} | Spieler ${socket.id}`); // Match-Gefunden an Spieler senden socket.emit('ht_daily_match_found', { matchId, mySlot: playerSlot, }); }); /* ── arena_join für KI-Matches abfangen ─────────────────── */ socket.on('arena_join', (data) => { const { matchId } = data; if (!matchId || !htAiMatchIds.has(matchId)) return; // Nur KI-Matches hier const aiRoom = htAiRooms.get(matchId); if (!aiRoom) return; // Socket + Namen + AccountId aktualisieren aiRoom.playerSocketId = socket.id; // accountId aus arena_join data setzen falls noch nicht gesetzt (Session-Fallback) if (!aiRoom.playerAccountId && data.accountId) { aiRoom.playerAccountId = data.accountId; } // Auch io._arenaRooms synchronisieren const ioRoom2 = io._arenaRooms?.get(matchId); if (ioRoom2 && !ioRoom2.accountIds?.player1 && data.accountId) { if (!ioRoom2.accountIds) ioRoom2.accountIds = {}; ioRoom2.accountIds.player1 = data.accountId; } const myIngameName = data.playerName || 'Du'; const ioRoom = io._arenaRooms?.get(matchId); if (ioRoom) ioRoom.sockets.player1 = socket.id; socket.join('arena_' + matchId); // Direkt arena_ready senden (kein warten auf Gegner) socket.emit('arena_ready', { player1 : 'Du', player2 : GUARD_NAMES[aiRoom.station] || `Wächter ${aiRoom.station}`, boardSync: [], hp : aiRoom.hp, maxHp : aiRoom.maxHp, }); // HP initialisieren socket.emit('hp_init', { hp: aiRoom.hp, maxHp: aiRoom.maxHp }); // ht_ai_setup: setzt links/rechts explizit ohne seed-basierten Flip setTimeout(() => { socket.emit('ht_ai_setup', { playerSlot : aiRoom.playerSlot, // 'player1' leftSlot : aiRoom.leftSlot, // 'player1' (Spieler immer links) playerName : myIngameName || 'Du', aiName : GUARD_NAMES[aiRoom.station] || `Wächter ${aiRoom.station}`, hp : aiRoom.hp, maxHp : aiRoom.maxHp, }); }, 250); // Zug starten (nach Setup-Animation) setTimeout(() => { socket.emit('turn_change', { activeSlot: aiRoom.playerSlot, boardSync : [], hp : aiRoom.hp, maxHp : aiRoom.maxHp, }); }, 700); console.log(`[HT] arena_join KI-Match: ${matchId}`); }); /* ── Spieler wirft Karte ab (KI-Match) ───────────────────── */ socket.on('discard_card', (data) => { const { matchId, slotId, slot } = data; if (!matchId || !htAiMatchIds.has(matchId)) return; const aiRoom = htAiRooms.get(matchId); if (!aiRoom || aiRoom.gameOver) return; if (slot !== aiRoom.playerSlot) return; // nur eigene Karten if (aiRoom.boardState) delete aiRoom.boardState[slotId]; const ioRoom = io._arenaRooms?.get(matchId); if (ioRoom?.boardState) delete ioRoom.boardState[slotId]; console.log(`[HT] Spieler wirft ab: ${slotId} | Match ${matchId}`); }); /* ── end_turn für KI-Matches ────────────────────────────── */ socket.on('end_turn', async (data) => { const { matchId, slot } = data; if (!matchId || !htAiMatchIds.has(matchId)) return; // Nur KI-Matches hier const aiRoom = htAiRooms.get(matchId); if (!aiRoom || aiRoom.gameOver) return; if (slot !== aiRoom.playerSlot) return; // Nur Spieler-Züge akzeptieren // boardState aus io._arenaRooms synchronisieren (Spieler hat Karten gelegt) const ioRoom = io._arenaRooms?.get(matchId); if (ioRoom) { aiRoom.boardState = ioRoom.boardState; aiRoom.boardCards = boardStateToCards(aiRoom.boardState); } // Kampfphase – Spieler ist am Zug const combatEvents = runCombatPhase(aiRoom.boardState, aiRoom.leftSlot, aiRoom.playerSlot); aiRoom.boardCards = boardStateToCards(aiRoom.boardState); if (ioRoom) { ioRoom.boardState = aiRoom.boardState; ioRoom.boardCards = aiRoom.boardCards; } socket.emit('combat_phase', { events: combatEvents, finalBoard: aiRoom.boardCards }); // Avatar-Schaden nach Spieler-Zug prüfen const matchEnded = await htProcessAvatarAttacks(io, matchId, aiRoom, combatEvents); if (matchEnded) return; // Nach Animationsdauer → KI-Zug const delay = calcCombatDuration(combatEvents) + 400; setTimeout(async () => { if (aiRoom.gameOver) return; await playAiTurn(io, matchId); }, delay); }); /* ── station_complete bestätigen (nach iframe-close) ────── */ socket.on('ht_station_ack', (data) => { // Kann verwendet werden um UI zu aktualisieren console.log(`[HT] Station ACK: ${data.station}`); }); } module.exports = { registerHimmelstorHandlers, htAiMatchIds };