tdkdtz
This commit is contained in:
parent
f535f1f83c
commit
a31431ecaf
4
app.js
4
app.js
@ -25,12 +25,14 @@ const mineRoute = require("./routes/mine.route");
|
||||
const carddeckRoutes = require("./routes/carddeck.route");
|
||||
const arenaRoutes = require("./routes/arena.route");
|
||||
const { registerArenaHandlers } = require("./sockets/arena.socket");
|
||||
const { registerHimmelstorHandlers } = require("./sockets/1vKI_daily.socket");
|
||||
const { registerChatHandlers } = require("./sockets/chat");
|
||||
const boosterRoutes = require("./routes/booster.route");
|
||||
const pointsRoutes = require("./routes/points.route");
|
||||
const combineRoutes = require("./routes/combine.route");
|
||||
const bazaarRoutes = require("./routes/bazaar.route");
|
||||
const himmelstorRoutes = require("./routes/himmelstor.route");
|
||||
const himmelstorDailyRoutes = require("./routes/himmelstor-daily.route");
|
||||
|
||||
const compression = require("compression");
|
||||
|
||||
@ -408,6 +410,7 @@ app.use("/api/points", pointsRoutes);
|
||||
app.use("/api", combineRoutes);
|
||||
app.use("/api", bazaarRoutes);
|
||||
app.use("/himmelstor", himmelstorRoutes);
|
||||
app.use("/api/himmelstor/daily", himmelstorDailyRoutes);
|
||||
|
||||
/* ========================
|
||||
404 Handler
|
||||
@ -425,6 +428,7 @@ io.on("connection", (socket) => {
|
||||
console.log("Spieler verbunden:", socket.id);
|
||||
registerChatHandlers(io, socket);
|
||||
registerArenaHandlers(io, socket);
|
||||
registerHimmelstorHandlers(io, socket);
|
||||
});
|
||||
|
||||
/* ========================
|
||||
|
||||
198
public/css/daily.css
Normal file
198
public/css/daily.css
Normal 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); }
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
211
public/js/buildings/daily.js
Normal file
211
public/js/buildings/daily.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { openDailyMap, markDailyStationDone } from './daily.js';
|
||||
/* ============================================================
|
||||
public/js/buildings/himmelstor.js
|
||||
Himmelstor – Tages- und Wochenherausforderung
|
||||
@ -244,11 +245,29 @@ function initHimmelstorModes() {
|
||||
htSelectedDeckId = select ? Number(select.value) : null;
|
||||
if (!htSelectedDeckId) return;
|
||||
sessionStorage.setItem("selectedDeckId", htSelectedDeckId);
|
||||
if (mode === 'daily') {
|
||||
// Karten-Pfad öffnen statt direktes Matchmaking
|
||||
openDailyMap(htSelectedDeckId);
|
||||
return;
|
||||
}
|
||||
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 ───────────────────────────────────────── */
|
||||
async function handleHtModeClick(card, mode) {
|
||||
if (card.classList.contains("searching")) return;
|
||||
|
||||
31
routes/himmelstor-daily.route.js
Normal file
31
routes/himmelstor-daily.route.js
Normal 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;
|
||||
483
sockets/1vKI_daily.socket.js
Normal file
483
sockets/1vKI_daily.socket.js
Normal 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 };
|
||||
@ -5,6 +5,7 @@
|
||||
============================================================ */
|
||||
|
||||
const { runCombatPhase } = require('./combat');
|
||||
const { htAiMatchIds } = require('./1vKI_daily.socket');
|
||||
const db = require('../database/database');
|
||||
const pointsRoute = require('../routes/points.route');
|
||||
|
||||
@ -596,6 +597,7 @@ function registerArenaHandlers(io, socket) {
|
||||
console.warn(`[1v1] arena_join abgewiesen – matchId oder slot fehlt`);
|
||||
return;
|
||||
}
|
||||
if (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
|
||||
|
||||
if (!io._arenaRooms) io._arenaRooms = new Map();
|
||||
if (!io._arenaRooms.has(matchId)) {
|
||||
@ -742,6 +744,7 @@ function registerArenaHandlers(io, socket) {
|
||||
socket.on("end_turn", async (data) => {
|
||||
const { matchId, slot } = data;
|
||||
if (!matchId || !slot) return;
|
||||
if (htAiMatchIds.has(matchId)) return; // wird von himmelstor.socket.js behandelt
|
||||
|
||||
const room = io._arenaRooms?.get(matchId);
|
||||
if (!room) return;
|
||||
|
||||
@ -94,6 +94,7 @@
|
||||
<link rel="stylesheet" href="/css/quickmenu.css" />
|
||||
<link rel="stylesheet" href="/css/gaststaette.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/hud.css" />
|
||||
<link
|
||||
|
||||
Loading…
Reference in New Issue
Block a user