dok/sockets/1vKI_daily.socket.js
2026-04-14 10:46:01 +01:00

558 lines
20 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/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 (315)
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]
);
// Wenn nicht genug Karten → Rarity schrittweise erhöhen
let result = [...cards];
if (result.length < deckSize) {
for (let r = maxRarity + 1; r <= 6 && result.length < deckSize; r++) {
const [more] = await db.query(
`SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity
FROM cards WHERE rarity = ? ORDER BY RAND() LIMIT ?`,
[r, deckSize - result.length]
);
result = [...result, ...more];
}
}
// Letzter Fallback: alle Karten ohne Rarity-Filter
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;
}
// Noch immer zu wenig? → vorhandene Karten so oft 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 durch Wiederholung: ${base.length} unique → ${result.length} Karten`);
}
console.log(`[HT] KI-Deck Station ${station}: ${result.length} Karten, max. Rarity ${maxRarity}`);
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,
}));
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 + 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}`);
});
/* ── 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 };