/* ============================================================ 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; // KI darf maximal 1 Rarity über dem Spieler liegen const playerMaxRarity = getMaxRarity(playerLevel || 1); const maxRarity = Math.min(stationRarity, playerMaxRarity + 1); // Kartenanzahl nach Station (3–15) const deckSize = station === 1 ? 5 : station === 2 ? 7 : station === 3 ? 9 : station === 4 ? 11 : station === 5 ? 12 : station === 6 ? 14 : 15; try { // Zuerst passende Karten mit korrekter Rarity holen const [cards] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards WHERE rarity <= ? ORDER BY RAND() LIMIT ?`, [maxRarity, deckSize] ); if (cards.length > 0) { console.log(`[HT] KI-Deck Station ${station}: ${cards.length} Karten, max. Rarity ${maxRarity}`); return cards.map(c => ({ ...c, currentCd: 0 })); } // Fallback: beliebige Karten wenn keine mit passender Rarity gefunden console.warn(`[HT] Keine Karten mit Rarity <= ${maxRarity} – Fallback auf alle Karten`); const [all] = await db.query( `SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity FROM cards ORDER BY RAND() LIMIT ?`, [deckSize] ); return all.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, })); 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 Hand: ${JSON.stringify(handSummary)} Freie Slots: ${JSON.stringify(availableSlots)} Wähle eine Karte und einen freien Slot. Bevorzuge Karten mit hohem Angriff oder hoher Bewegung (race). Antworte NUR mit validem JSON: {"card_index":0,"slot":"row1-slot-9"} Wenn keine Karte oder kein Slot: {"skip":true}`; 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 ); if (decision && !decision.skip && 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, aiRoom.station] ); } catch (err) { console.error('[HT] Completion speichern Fehler:', err); } // Punkte vergeben (Platzhalter: 10 Punkte pro Station) let winResult = { awarded: 0 }; if (aiRoom.playerAccountId) { try { winResult = await pointsRoute.awardPoints(aiRoom.playerAccountId, 10); } catch {} } io.to(aiRoom.playerSocketId).emit('ht_station_complete', { station: aiRoom.station, }); io.to(aiRoom.playerSocketId).emit('match_result', { won: true, awarded: winResult.awarded ?? 0, level_up: winResult.level_up ?? false, new_level:winResult.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 = ?", [accountId, 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 aktualisieren (nach Reconnect) aiRoom.playerSocketId = socket.id; 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 }); // Spieler braucht "bereit" nicht – direkt Zug starten // (Wir simulieren ready_status so dass der Client den leftSlot setzt) setTimeout(() => { socket.emit('ready_status', { readyCount: 2, readySlots: ['player1', 'player2'], }); }, 200); setTimeout(() => { socket.emit('turn_change', { activeSlot: aiRoom.playerSlot, // Spieler fängt an boardSync : [], hp : aiRoom.hp, maxHp : aiRoom.maxHp, }); }, 600); console.log(`[HT] arena_join KI-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 };