dok/sockets/1vKI_daily.socket.js
2026-04-14 11:52:19 +01:00

599 lines
22 KiB
JavaScript
Raw Permalink 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/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 (515)
const deckSize = station === 1 ? 5
: station === 2 ? 7
: station === 3 ? 9
: station === 4 ? 11
: station === 5 ? 12
: station === 6 ? 14
: 15;
// Stationen 16: 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 };