This commit is contained in:
cay 2026-04-14 08:31:15 +01:00
parent f535f1f83c
commit a31431ecaf
9 changed files with 950 additions and 0 deletions

4
app.js
View File

@ -25,12 +25,14 @@ const mineRoute = require("./routes/mine.route");
const carddeckRoutes = require("./routes/carddeck.route"); const carddeckRoutes = require("./routes/carddeck.route");
const arenaRoutes = require("./routes/arena.route"); const arenaRoutes = require("./routes/arena.route");
const { registerArenaHandlers } = require("./sockets/arena.socket"); const { registerArenaHandlers } = require("./sockets/arena.socket");
const { registerHimmelstorHandlers } = require("./sockets/1vKI_daily.socket");
const { registerChatHandlers } = require("./sockets/chat"); const { registerChatHandlers } = require("./sockets/chat");
const boosterRoutes = require("./routes/booster.route"); const boosterRoutes = require("./routes/booster.route");
const pointsRoutes = require("./routes/points.route"); const pointsRoutes = require("./routes/points.route");
const combineRoutes = require("./routes/combine.route"); const combineRoutes = require("./routes/combine.route");
const bazaarRoutes = require("./routes/bazaar.route"); const bazaarRoutes = require("./routes/bazaar.route");
const himmelstorRoutes = require("./routes/himmelstor.route"); const himmelstorRoutes = require("./routes/himmelstor.route");
const himmelstorDailyRoutes = require("./routes/himmelstor-daily.route");
const compression = require("compression"); const compression = require("compression");
@ -408,6 +410,7 @@ app.use("/api/points", pointsRoutes);
app.use("/api", combineRoutes); app.use("/api", combineRoutes);
app.use("/api", bazaarRoutes); app.use("/api", bazaarRoutes);
app.use("/himmelstor", himmelstorRoutes); app.use("/himmelstor", himmelstorRoutes);
app.use("/api/himmelstor/daily", himmelstorDailyRoutes);
/* ======================== /* ========================
404 Handler 404 Handler
@ -425,6 +428,7 @@ io.on("connection", (socket) => {
console.log("Spieler verbunden:", socket.id); console.log("Spieler verbunden:", socket.id);
registerChatHandlers(io, socket); registerChatHandlers(io, socket);
registerArenaHandlers(io, socket); registerArenaHandlers(io, socket);
registerHimmelstorHandlers(io, socket);
}); });
/* ======================== /* ========================

198
public/css/daily.css Normal file
View File

@ -0,0 +1,198 @@
/* ============================================================
public/css/daily.css
Daily Herausforderung Karten-Pfad Overlay
============================================================ */
/* ── Overlay ─────────────────────────────────────────────── */
#daily-map-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
animation: dailyFadeIn 0.35s ease;
}
@keyframes dailyFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes dailyPulse { 0%, 100% { box-shadow: 0 0 14px 4px rgba(80,160,255,0.7); } 50% { box-shadow: 0 0 28px 10px rgba(80,160,255,1); } }
@keyframes dailyGlow { 0%, 100% { opacity: 1; } 50% { opacity: 0.65; } }
/* ── Map Container ───────────────────────────────────────── */
#daily-map-wrap {
position: relative;
max-width: min(95vw, 1000px);
max-height: 90vh;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 0 0 2px rgba(80, 140, 255, 0.4), 0 30px 80px rgba(0, 0, 0, 0.9);
}
#daily-map-img {
display: block;
width: 100%;
height: auto;
max-height: 90vh;
object-fit: cover;
user-select: none;
pointer-events: none;
}
/* ── Close Button ────────────────────────────────────────── */
#daily-map-close {
position: absolute;
top: 12px;
right: 14px;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
cursor: pointer;
z-index: 10;
transition: background 0.2s;
}
#daily-map-close:hover { background: rgba(200, 50, 50, 0.8); }
/* ── Title Banner ────────────────────────────────────────── */
#daily-map-title {
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.72);
border: 1px solid rgba(80, 140, 255, 0.45);
border-radius: 8px;
padding: 6px 22px;
font-family: "Cinzel", serif;
font-size: 15px;
color: #a0c8ff;
letter-spacing: 3px;
white-space: nowrap;
z-index: 10;
}
/* ── Circles ─────────────────────────────────────────────── */
.daily-circle {
position: absolute;
width: 7%; /* scales with image width */
aspect-ratio: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: "Cinzel", serif;
font-size: clamp(12px, 1.8vw, 22px);
font-weight: bold;
transform: translate(-50%, -50%);
cursor: default;
transition: transform 0.15s ease;
z-index: 5;
user-select: none;
}
/* Completed station ─ grün */
.daily-circle.done {
background: radial-gradient(circle at 40% 35%, #4ecf6a, #1a7a30);
border: 3px solid #7af09a;
color: #fff;
box-shadow: 0 0 14px rgba(78, 207, 106, 0.6);
}
.daily-circle.done::after {
content: "✓";
font-size: clamp(10px, 1.5vw, 18px);
}
/* Available station ─ blaues Pulsieren */
.daily-circle.available {
background: radial-gradient(circle at 40% 35%, #5090ff, #1a3a9a);
border: 3px solid #90c0ff;
color: #fff;
cursor: pointer;
animation: dailyPulse 2s ease-in-out infinite;
}
.daily-circle.available:hover {
transform: translate(-50%, -50%) scale(1.15);
animation: none;
box-shadow: 0 0 30px 8px rgba(80, 160, 255, 0.9);
}
/* Locked station ─ ausgegraut */
.daily-circle.locked {
background: radial-gradient(circle at 40% 35%, #2a2a3a, #141420);
border: 3px solid rgba(80, 100, 150, 0.4);
color: rgba(150, 160, 200, 0.35);
}
/* ── Progress Text ───────────────────────────────────────── */
#daily-progress-bar-wrap {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
width: 60%;
background: rgba(0, 0, 0, 0.72);
border: 1px solid rgba(80, 140, 255, 0.35);
border-radius: 8px;
padding: 8px 16px;
z-index: 10;
text-align: center;
font-family: "Cinzel", serif;
}
#daily-progress-text {
font-size: 12px;
color: #a0c8ff;
letter-spacing: 1px;
margin-bottom: 5px;
}
#daily-progress-track {
height: 6px;
background: rgba(80, 140, 255, 0.15);
border-radius: 3px;
overflow: hidden;
}
#daily-progress-fill {
height: 100%;
background: linear-gradient(90deg, #3060cc, #60a0ff);
border-radius: 3px;
transition: width 0.6s ease;
}
/* ── All Done ────────────────────────────────────────────── */
#daily-all-done {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.78);
border-radius: 14px;
z-index: 20;
animation: dailyFadeIn 0.4s ease;
}
#daily-all-done .done-icon { font-size: 56px; margin-bottom: 14px; }
#daily-all-done .done-title { font-family: "Cinzel", serif; font-size: 22px; color: #7af09a; letter-spacing: 4px; margin-bottom: 8px; }
#daily-all-done .done-sub { font-family: "Cinzel", serif; font-size: 12px; color: #a0c8a0; margin-bottom: 24px; }
#daily-all-done .done-btn {
background: linear-gradient(135deg, #1a4a28, #27ae60);
border: 2px solid rgba(100, 220, 100, 0.6);
border-radius: 10px;
color: #fff;
font-family: "Cinzel", serif;
font-size: 13px;
letter-spacing: 3px;
padding: 11px 32px;
cursor: pointer;
transition: 0.2s;
}
#daily-all-done .done-btn:hover { border-color: #7af09a; background: linear-gradient(135deg, #2a6a38, #37be70); }

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,211 @@
/* ============================================================
public/js/buildings/daily.js
Tagesherausforderung Karten-Pfad mit 7 Stationen gegen KI
============================================================ */
/* ── Stationen: Position auf dem Bild (% left, % top) ───── */
const DAILY_STATIONS = [
{ id: 1, left: 49.5, top: 86.5, label: "1" },
{ id: 2, left: 37.0, top: 73.0, label: "2" },
{ id: 3, left: 44.5, top: 62.0, label: "3" },
{ id: 4, left: 54.5, top: 53.5, label: "4" },
{ id: 5, left: 49.0, top: 46.0, label: "5" },
{ id: 6, left: 44.5, top: 37.5, label: "6" },
{ id: 7, left: 50.0, top: 22.0, label: "7" },
];
let dailyDeckId = null;
let dailyOverlay = null;
/* ── Öffnet den Karten-Pfad ──────────────────────────────── */
export async function openDailyMap(deckId) {
dailyDeckId = deckId;
// Kein Duplikat
document.getElementById("daily-map-overlay")?.remove();
/* Fortschritt vom Server laden */
let completed = [];
try {
const res = await fetch("/api/himmelstor/daily/progress");
if (res.ok) {
const data = await res.json();
completed = data.completed ?? [];
}
} catch (e) { console.error("[Daily] Fortschritt laden:", e); }
/* Overlay aufbauen */
dailyOverlay = document.createElement("div");
dailyOverlay.id = "daily-map-overlay";
dailyOverlay.innerHTML = `
<div id="daily-map-wrap">
<img id="daily-map-img" src="/images/KI_arena/daily.jpeg"
onerror="this.src='/images/items/rueckseite.png'" alt="Daily" />
<div id="daily-map-title"> TAGESHERAUSFORDERUNG</div>
<button id="daily-map-close" title="Schließen"></button>
${buildCircles(completed)}
<div id="daily-progress-bar-wrap">
<div id="daily-progress-text">${completed.length} / 7 Stationen abgeschlossen</div>
<div id="daily-progress-track">
<div id="daily-progress-fill" style="width:${Math.round((completed.length / 7) * 100)}%"></div>
</div>
</div>
${completed.length >= 7 ? buildAllDonePanel() : ""}
</div>`;
document.body.appendChild(dailyOverlay);
/* Event-Listener */
document.getElementById("daily-map-close").addEventListener("click", closeDailyMap);
dailyOverlay.addEventListener("click", e => { if (e.target === dailyOverlay) closeDailyMap(); });
/* Klickbare Kreise */
dailyOverlay.querySelectorAll(".daily-circle.available").forEach(circle => {
circle.addEventListener("click", () => {
const station = parseInt(circle.dataset.station, 10);
startDailyStation(station);
});
});
}
/* ── HTML der Kreise ─────────────────────────────────────── */
function buildCircles(completed) {
const maxDone = completed.length; // nächster freier = maxDone + 1
return DAILY_STATIONS.map(s => {
const isDone = completed.includes(s.id);
const isAvailable = !isDone && s.id === maxDone + 1;
const isLocked = !isDone && !isAvailable;
const cls = isDone ? "done" : isAvailable ? "available" : "locked";
const inner = isDone ? "" : s.label; // done zeigt ✓ per CSS ::after
return `<div
class="daily-circle ${cls}"
data-station="${s.id}"
style="left:${s.left}%;top:${s.top}%;"
title="${isDone ? "Abgeschlossen" : isAvailable ? `Station ${s.id} starten` : "Gesperrt"}"
>${inner}</div>`;
}).join("");
}
function buildAllDonePanel() {
return `
<div id="daily-all-done">
<div class="done-icon">🏆</div>
<div class="done-title">TAGESQUEST ABGESCHLOSSEN!</div>
<div class="done-sub">Du hast alle 7 Stationen gemeistert.</div>
<button class="done-btn" onclick="document.getElementById('daily-map-overlay')?.remove()">
SCHLIESSEN
</button>
</div>`;
}
/* ── Station starten ─────────────────────────────────────── */
function startDailyStation(station) {
if (!dailyDeckId) {
alert("Bitte zuerst ein Deck auswählen.");
return;
}
const socket = window._socket;
if (!socket) {
console.error("[Daily] Kein Socket.");
return;
}
/* Kreis als "lädt" markieren */
const circle = dailyOverlay.querySelector(`.daily-circle[data-station="${station}"]`);
if (circle) {
circle.style.animation = "none";
circle.style.opacity = "0.5";
circle.style.cursor = "wait";
}
/* Einmaligen Listener für Match-Gefunden */
socket.once("ht_daily_match_found", data => {
closeDailyMap();
openHtDailyPopup(
`/himmelstor/daily?match=${encodeURIComponent(data.matchId)}&slot=${encodeURIComponent(data.mySlot)}&deck=${encodeURIComponent(dailyDeckId)}&station=${station}&opponent=${encodeURIComponent("Wächter " + station)}`,
"Wächter " + station,
data.matchId
);
});
socket.once("ht_daily_error", data => {
if (circle) { circle.style.animation = ""; circle.style.opacity = ""; circle.style.cursor = ""; }
alert(data.message || "Fehler beim Starten.");
});
socket.emit("ht_join_daily", {
station,
deckId: dailyDeckId,
});
}
/* ── Popup öffnen (Iframe wie bei Arena) ─────────────────── */
function openHtDailyPopup(src, opponentName, matchId) {
document.getElementById("ht-daily-backdrop")?.remove();
document.getElementById("ht-daily-popup")?.remove();
const backdrop = document.createElement("div");
backdrop.id = "ht-daily-backdrop";
backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.82);backdrop-filter:blur(5px);z-index:9998;";
const popup = document.createElement("div");
popup.id = "ht-daily-popup";
popup.style.cssText = "position:fixed;inset:50px;z-index:9999;display:flex;flex-direction:column;border-radius:14px;overflow:hidden;box-shadow:0 0 0 1px rgba(80,140,255,.4),0 30px 90px rgba(0,0,0,.85);";
popup.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;background:rgba(5,8,20,.95);border-bottom:1px solid rgba(80,140,255,.3);padding:0 16px;height:42px;flex-shrink:0;">
<span style="font-family:'Cinzel',serif;font-size:13px;letter-spacing:4px;color:rgba(160,200,255,.85);text-transform:uppercase;"> Daily · vs ${opponentName}</span>
<span style="font-size:11px;color:rgba(255,255,255,.22);">${matchId}</span>
</div>
<iframe src="${src}" allowfullscreen style="flex:1;border:none;width:100%;display:block;"></iframe>`;
document.body.appendChild(backdrop);
document.body.appendChild(popup);
/* Cleanup wenn Iframe-Spiel beendet → closeToArena ruft parent auf */
}
/* ── Overlay schließen ───────────────────────────────────── */
function closeDailyMap() {
document.getElementById("daily-map-overlay")?.remove();
dailyOverlay = null;
}
/* ── Extern: nach gewonnenem Match Kreis aktualisieren ───── */
export function markDailyStationDone(station) {
if (!dailyOverlay) return;
const circle = dailyOverlay.querySelector(`.daily-circle[data-station="${station}"]`);
if (!circle) return;
circle.className = "daily-circle done";
circle.textContent = "";
circle.style = "";
/* Nächste Station freischalten */
const next = dailyOverlay.querySelector(`.daily-circle[data-station="${station + 1}"]`);
if (next) {
next.className = "daily-circle available";
next.textContent = String(station + 1);
next.addEventListener("click", () => startDailyStation(station + 1));
}
/* Fortschrittsbalken updaten */
const done = dailyOverlay.querySelectorAll(".daily-circle.done").length;
const pct = Math.round((done / 7) * 100);
const fill = document.getElementById("daily-progress-fill");
const text = document.getElementById("daily-progress-text");
if (fill) fill.style.width = pct + "%";
if (text) text.textContent = `${done} / 7 Stationen abgeschlossen`;
/* Alle 7 fertig? */
if (done >= 7) {
const wrap = document.getElementById("daily-map-wrap");
if (wrap && !document.getElementById("daily-all-done")) {
const panel = document.createElement("div");
panel.innerHTML = buildAllDonePanel();
wrap.appendChild(panel.firstElementChild);
}
}
}

View File

@ -1,3 +1,4 @@
import { openDailyMap, markDailyStationDone } from './daily.js';
/* ============================================================ /* ============================================================
public/js/buildings/himmelstor.js public/js/buildings/himmelstor.js
Himmelstor Tages- und Wochenherausforderung Himmelstor Tages- und Wochenherausforderung
@ -244,11 +245,29 @@ function initHimmelstorModes() {
htSelectedDeckId = select ? Number(select.value) : null; htSelectedDeckId = select ? Number(select.value) : null;
if (!htSelectedDeckId) return; if (!htSelectedDeckId) return;
sessionStorage.setItem("selectedDeckId", htSelectedDeckId); sessionStorage.setItem("selectedDeckId", htSelectedDeckId);
if (mode === 'daily') {
// Karten-Pfad öffnen statt direktes Matchmaking
openDailyMap(htSelectedDeckId);
return;
}
handleHtModeClick(card, mode); handleHtModeClick(card, mode);
}); });
}); });
} }
/* ── Station-Complete Event vom Server ──────────────────── */
function setupHtSocketListeners() {
const socket = window._socket;
if (!socket) return;
socket.off('ht_station_complete');
socket.on('ht_station_complete', data => {
markDailyStationDone(data.station);
});
}
// Listener einrichten sobald Socket verfügbar
if (window._socket) setupHtSocketListeners();
else document.addEventListener('socket_ready', setupHtSocketListeners);
/* ── Modus klicken ───────────────────────────────────────── */ /* ── Modus klicken ───────────────────────────────────────── */
async function handleHtModeClick(card, mode) { async function handleHtModeClick(card, mode) {
if (card.classList.contains("searching")) return; if (card.classList.contains("searching")) return;

View File

@ -0,0 +1,31 @@
/* ============================================================
routes/himmelstor-daily.route.js
GET /api/himmelstor/daily/progress welche Stationen heute erledigt
============================================================ */
const express = require("express");
const router = express.Router();
const db = require("../database/database");
function requireLogin(req, res, next) {
if (!req.session?.user) return res.status(401).json({ error: "Nicht eingeloggt" });
next();
}
/* ── GET /api/himmelstor/daily/progress ────────────────── */
router.get("/progress", requireLogin, async (req, res) => {
const userId = req.session.user.id;
try {
const [rows] = await db.query(
"SELECT event_id FROM daily_completions WHERE user_id = ?",
[userId]
);
const completed = rows.map(r => r.event_id);
res.json({ completed, total: 7, allDone: completed.length >= 7 });
} catch (err) {
console.error("[Daily] Fortschritt laden:", err);
res.status(500).json({ error: "DB Fehler" });
}
});
module.exports = router;

View File

@ -0,0 +1,483 @@
/* ============================================================
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 { 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 nach Station) ───────────── */
async function loadAiDeck(station) {
const maxRarity = station <= 2 ? 1 : station <= 4 ? 2 : station <= 6 ? 3 : 4;
try {
const [cards] = await db.query(
`SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity
FROM cards WHERE rarity <= ? ORDER BY RAND() LIMIT 10`,
[maxRarity]
);
if (cards.length === 0) {
// Fallback: alle Karten
const [all] = await db.query(
`SELECT id, name, image, attack, defends, cooldown, \`range\`, \`race\`, rarity
FROM cards ORDER BY RAND() LIMIT 10`
);
return all.map(c => ({ ...c, currentCd: 0 }));
}
return cards.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);
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 };

View File

@ -5,6 +5,7 @@
============================================================ */ ============================================================ */
const { runCombatPhase } = require('./combat'); const { runCombatPhase } = require('./combat');
const { htAiMatchIds } = require('./1vKI_daily.socket');
const db = require('../database/database'); const db = require('../database/database');
const pointsRoute = require('../routes/points.route'); const pointsRoute = require('../routes/points.route');
@ -596,6 +597,7 @@ function registerArenaHandlers(io, socket) {
console.warn(`[1v1] arena_join abgewiesen matchId oder slot fehlt`); console.warn(`[1v1] arena_join abgewiesen matchId oder slot fehlt`);
return; return;
} }
if (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
if (!io._arenaRooms) io._arenaRooms = new Map(); if (!io._arenaRooms) io._arenaRooms = new Map();
if (!io._arenaRooms.has(matchId)) { if (!io._arenaRooms.has(matchId)) {
@ -742,6 +744,7 @@ function registerArenaHandlers(io, socket) {
socket.on("end_turn", async (data) => { socket.on("end_turn", async (data) => {
const { matchId, slot } = data; const { matchId, slot } = data;
if (!matchId || !slot) return; if (!matchId || !slot) return;
if (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
const room = io._arenaRooms?.get(matchId); const room = io._arenaRooms?.get(matchId);
if (!room) return; if (!room) return;

View File

@ -94,6 +94,7 @@
<link rel="stylesheet" href="/css/quickmenu.css" /> <link rel="stylesheet" href="/css/quickmenu.css" />
<link rel="stylesheet" href="/css/gaststaette.css" /> <link rel="stylesheet" href="/css/gaststaette.css" />
<link rel="stylesheet" href="/css/himmelstor.css" /> <link rel="stylesheet" href="/css/himmelstor.css" />
<link rel="stylesheet" href="/css/daily.css" />
<link rel="stylesheet" href="/css/events.css" /> <link rel="stylesheet" href="/css/events.css" />
<link rel="stylesheet" href="/css/hud.css" /> <link rel="stylesheet" href="/css/hud.css" />
<link <link