add music, better sync, particles
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m18s
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 2m18s
This commit is contained in:
@@ -1,199 +1,377 @@
|
||||
async function sendChunk() {
|
||||
const ticksToSend = currentTick - lastSentTick;
|
||||
if (ticksToSend <= 0) return;
|
||||
// ==========================================
|
||||
// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
|
||||
// ==========================================
|
||||
|
||||
/*
|
||||
GLOBALE VARIABLEN (aus state.js):
|
||||
- socket
|
||||
- obstacleBuffer, platformBuffer
|
||||
- currentLatencyMs, pingInterval
|
||||
- isGameRunning, isGameOver
|
||||
- score, currentTick
|
||||
*/
|
||||
|
||||
const snapshotobstacles = JSON.parse(JSON.stringify(obstacles));
|
||||
function connectGame() {
|
||||
// Alte Verbindung schließen
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sessionId: sessionID,
|
||||
inputs: [...inputLog],
|
||||
totalTicks: ticksToSend
|
||||
// Ping Timer stoppen falls aktiv
|
||||
if (typeof pingInterval !== 'undefined' && pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
|
||||
// Protokoll automatisch wählen (ws:// oder wss://)
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = proto + "//" + location.host + "/ws";
|
||||
|
||||
console.log("Verbinde zu:", url);
|
||||
socket = new WebSocket(url);
|
||||
|
||||
// --- 1. VERBINDUNG GEÖFFNET ---
|
||||
socket.onopen = () => {
|
||||
console.log("🟢 WS Verbunden. Spiel startet.");
|
||||
|
||||
// Alles zurücksetzen
|
||||
obstacleBuffer = [];
|
||||
platformBuffer = [];
|
||||
obstacles = [];
|
||||
platforms = [];
|
||||
currentLatencyMs = 0; // Reset Latenz
|
||||
|
||||
isGameRunning = true;
|
||||
isGameOver = false;
|
||||
isLoaded = true;
|
||||
|
||||
// PING LOOP STARTEN (Jede Sekunde messen)
|
||||
pingInterval = setInterval(sendPing, 1000);
|
||||
|
||||
// Game Loop anwerfen
|
||||
requestAnimationFrame(gameLoop);
|
||||
};
|
||||
|
||||
inputLog = [];
|
||||
lastSentTick = currentTick;
|
||||
// --- 2. NACHRICHT VOM SERVER ---
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/validate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
// A. PONG (Latenzmessung)
|
||||
if (msg.type === "pong") {
|
||||
const now = Date.now();
|
||||
const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
|
||||
|
||||
const data = await res.json();
|
||||
// Round Trip Time (Hin + Zurück)
|
||||
const rtt = now - sentTime;
|
||||
|
||||
// Update für visuelles Debugging
|
||||
if (data.serverObs) {
|
||||
serverObstacles = data.serverObs;
|
||||
// One Way Latency (Latenz in eine Richtung)
|
||||
const latency = rtt / 2;
|
||||
|
||||
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
|
||||
compareState(snapshotobstacles, data.serverObs);
|
||||
}
|
||||
|
||||
if (data.powerups) {
|
||||
const sTick = data.serverTick;
|
||||
|
||||
if (lastPowerupTick > sTick) {
|
||||
// Glätten (Exponential Moving Average), damit Werte nicht springen
|
||||
// Wenn es der erste Wert ist, nehmen wir ihn direkt.
|
||||
if (currentLatencyMs === 0) {
|
||||
currentLatencyMs = latency;
|
||||
} else {
|
||||
godModeLives = data.powerups.godLives;
|
||||
hasBat = data.powerups.hasBat;
|
||||
bootTicks = data.powerups.bootTicks;
|
||||
// 90% alter Wert, 10% neuer Wert
|
||||
currentLatencyMs = (currentLatencyMs * 0.9) + (latency * 0.1);
|
||||
}
|
||||
|
||||
// Optional: Debugging im Log
|
||||
// console.log(`📡 Ping: ${rtt}ms | Latenz: ${currentLatencyMs.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// B. CHUNK (Objekte empfangen)
|
||||
if (msg.type === "chunk") {
|
||||
|
||||
// 1. CLOCK SYNC (Die Zeitmaschine)
|
||||
// Wenn der Server bei Tick 204 ist und wir bei 182, müssen wir aufholen!
|
||||
// Wir addieren die geschätzte Latenz (in Ticks) auf die Serverzeit.
|
||||
// 60 FPS = 16ms/Tick. 20 TPS = 50ms/Tick.
|
||||
|
||||
const msPerTick = 1000 / 20; // WICHTIG: Wir laufen auf 20 TPS Basis!
|
||||
const latencyInTicks = Math.floor(currentLatencyMs / msPerTick);
|
||||
|
||||
// Ziel-Zeit: Server-Zeit + Übertragungsweg
|
||||
const targetTick = msg.serverTick + latencyInTicks;
|
||||
const drift = targetTick - currentTick;
|
||||
|
||||
// Wenn wir mehr als 2 Ticks abweichen -> Korrigieren
|
||||
if (Math.abs(drift) > 2) {
|
||||
// console.log(`⏰ Clock Sync: ${currentTick} -> ${targetTick} (Drift: ${drift})`);
|
||||
currentTick = targetTick; // Harter Sync, damit Physik stimmt
|
||||
}
|
||||
|
||||
// 2. PIXEL KORREKTUR (Sanfter!)
|
||||
// Wir berechnen den Speed
|
||||
let sTick = msg.serverTick;
|
||||
// Formel aus logic.js (Base 15 + Zeit)
|
||||
let currentSpeedPerTick = 15.0 + (sTick / 1000.0) * 1.5;
|
||||
if (currentSpeedPerTick > 36) currentSpeedPerTick = 36;
|
||||
|
||||
const speedPerMs = currentSpeedPerTick / msPerTick; // Speed pro MS
|
||||
|
||||
// Korrektur: Latenz * Speed
|
||||
// FIX: Wir kappen die Korrektur bei max 100px, damit Objekte nicht "teleportieren".
|
||||
let dynamicCorrection = (currentLatencyMs * speedPerMs) + 5;
|
||||
if (dynamicCorrection > 100) dynamicCorrection = 100; // Limit
|
||||
|
||||
// Puffer füllen (mit Limit)
|
||||
if (msg.obstacles) {
|
||||
msg.obstacles.forEach(o => {
|
||||
o.x -= dynamicCorrection;
|
||||
// Init für Interpolation
|
||||
o.prevX = o.x;
|
||||
obstacleBuffer.push(o);
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.platforms) {
|
||||
msg.platforms.forEach(p => {
|
||||
p.x -= dynamicCorrection;
|
||||
p.prevX = p.x;
|
||||
platformBuffer.push(p);
|
||||
});
|
||||
}
|
||||
|
||||
if (msg.score !== undefined) score = msg.score;
|
||||
|
||||
// Powerups übernehmen (für Anzeige)
|
||||
if (msg.powerups) {
|
||||
godModeLives = msg.powerups.godLives;
|
||||
hasBat = msg.powerups.hasBat;
|
||||
bootTicks = msg.powerups.bootTicks;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync Spawning Timer
|
||||
if (data.NextSpawnTick) {
|
||||
if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
|
||||
console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
|
||||
nextSpawnTick = data.nextSpawnTick;
|
||||
if (msg.type === "init") {
|
||||
console.log("📩 INIT EMPFANGEN:", msg); // <--- DEBUG LOG
|
||||
|
||||
if (msg.sessionId) {
|
||||
sessionID = msg.sessionId; // Globale Variable setzen
|
||||
console.log("🔑 Session ID gesetzt auf:", sessionID);
|
||||
} else {
|
||||
console.error("❌ INIT FEHLER: Keine sessionId im Paket!", msg);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// C. TOD (Server Authoritative)
|
||||
if (msg.type === "dead") {
|
||||
console.log("💀 Server sagt: Game Over");
|
||||
|
||||
if (data.status === "dead") {
|
||||
console.error("💀 SERVER KILL", data);
|
||||
gameOver("Vom Server gestoppt");
|
||||
} else {
|
||||
const sScore = data.verifiedScore;
|
||||
// Score Korrektur
|
||||
if (Math.abs(score - sScore) > 200) {
|
||||
console.warn(`⚠️ SCORE DRIFT: Client=${score} Server=${sScore}`);
|
||||
score = sScore;
|
||||
if (msg.score) score = msg.score;
|
||||
|
||||
// Verbindung sauber trennen
|
||||
socket.close();
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
|
||||
gameOver("Vom Server gestoppt");
|
||||
}
|
||||
|
||||
if (msg.type === "debug_sync") {
|
||||
|
||||
|
||||
|
||||
// 1. CLIENT SPEED BERECHNEN (Formel aus logic.js)
|
||||
// Wir nutzen hier 'score', da logic.js das auch tut
|
||||
let clientSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
|
||||
if (clientSpeed > 36.0) clientSpeed = 36.0;
|
||||
|
||||
// 2. SERVER SPEED HOLEN
|
||||
let serverSpeed = msg.currentSpeed || 0;
|
||||
|
||||
// 3. DIFF BERECHNEN
|
||||
let diffSpeed = clientSpeed - serverSpeed;
|
||||
let speedIcon = Math.abs(diffSpeed) < 0.01 ? "✅" : "❌";
|
||||
|
||||
console.group(`📊 SYNC REPORT (Tick: ${currentTick} vs Server: ${msg.serverTick})`);
|
||||
|
||||
// --- DER NEUE SPEED CHECK ---
|
||||
console.log(`🚀 SPEED CHECK: ${speedIcon}`);
|
||||
console.log(` Client: ${clientSpeed.toFixed(4)} px/tick (Basis: Tick ${currentTick})`);
|
||||
console.log(` Server: ${serverSpeed.toFixed(4)} px/tick (Basis: Tick ${msg.serverTick})`);
|
||||
|
||||
if (Math.abs(diffSpeed) > 0.01) {
|
||||
console.warn(`⚠️ ACHTUNG: Geschwindigkeiten weichen ab! Diff: ${diffSpeed.toFixed(4)}`);
|
||||
console.warn("Ursache: Client nutzt 'Score', Server nutzt 'Ticks'. Sind diese synchron?");
|
||||
}
|
||||
// -----------------------------
|
||||
|
||||
// 1. Hindernisse vergleichen
|
||||
generateSyncTable("Obstacles", obstacles, msg.obstacles);
|
||||
|
||||
// 2. Plattformen vergleichen
|
||||
generateSyncTable("Platforms", platforms, msg.platforms);
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Verarbeiten der Nachricht:", e);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error("Netzwerkfehler:", e);
|
||||
// --- 3. VERBINDUNG GETRENNT ---
|
||||
socket.onclose = () => {
|
||||
console.log("🔴 WS Verbindung getrennt.");
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error("WS Fehler:", error);
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PING SENDEN
|
||||
// ==========================================
|
||||
function sendPing() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
// Wir senden den aktuellen Zeitstempel
|
||||
// Der Server muss diesen im "tick" Feld zurückschicken (siehe websocket.go)
|
||||
socket.send(JSON.stringify({
|
||||
type: "ping",
|
||||
tick: Date.now() // Timestamp als Integer
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
window.submitScore = async function() {
|
||||
const nameInput = document.getElementById('playerNameInput');
|
||||
const name = nameInput.value;
|
||||
const btn = document.getElementById('submitBtn');
|
||||
|
||||
if (!name) return alert("Namen eingeben!");
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit-name', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ sessionId: sessionID, name: name })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
|
||||
myClaims.push({
|
||||
name: name, score: Math.floor(score / 10), code: data.claimCode,
|
||||
date: new Date().toLocaleString('de-DE'), sessionId: sessionID
|
||||
});
|
||||
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
|
||||
|
||||
document.getElementById('inputSection').style.display = 'none';
|
||||
loadLeaderboard();
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
|
||||
const entries = await res.json();
|
||||
let html = "<h3>BESTENLISTE</h3>";
|
||||
entries.forEach(e => {
|
||||
const color = e.isMe ? "yellow" : "white";
|
||||
html += `<div style="display:flex; justify-content:space-between; color:${color}; margin-bottom:5px;">
|
||||
<span>#${e.rank} ${e.name}</span><span>${Math.floor(e.score/10)}</span></div>`;
|
||||
});
|
||||
document.getElementById('leaderboard').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadStartScreenLeaderboard() {
|
||||
try {
|
||||
const listEl = document.getElementById('startLeaderboardList');
|
||||
if (!listEl) return;
|
||||
const res = await fetch('/api/leaderboard');
|
||||
const entries = await res.json();
|
||||
if (entries.length === 0) { listEl.innerHTML = "<div style='padding:20px'>Noch keine Scores.</div>"; return; }
|
||||
let html = "";
|
||||
entries.forEach(e => {
|
||||
let icon = "#" + e.rank;
|
||||
if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
|
||||
html += `<div class="hof-entry"><span><span class="hof-rank">${icon}</span> ${e.name}</span><span class="hof-score">${Math.floor(e.score / 10)}</span></div>`;
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function compareState(clientObs, serverObs) {
|
||||
// 1. Anzahl prüfen
|
||||
if (clientObs.length !== serverObs.length) {
|
||||
console.error(`🚨 ANZAHL MISMATCH! Client: ${clientObs.length}, Server: ${serverObs.length}`);
|
||||
// ==========================================
|
||||
// INPUT SENDEN
|
||||
// ==========================================
|
||||
function sendInput(type, action) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: "input",
|
||||
input: action
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper für die Tabelle
|
||||
function generateSyncTable(label, clientList, serverList) {
|
||||
if (!serverList) serverList = [];
|
||||
|
||||
console.log(`--- ${label} Analyse (Ping: ${Math.round(currentLatencyMs)}ms) ---`);
|
||||
|
||||
const report = [];
|
||||
const maxLen = Math.max(clientObs.length, serverObs.length);
|
||||
let hasMajorDrift = false;
|
||||
const matchedServerIndices = new Set();
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const cli = clientObs[i];
|
||||
const srv = serverObs[i];
|
||||
// 1. Parameter für Latenz-Korrektur berechnen
|
||||
// Damit wir wissen: "Wo MÜSSTE das Server-Objekt auf dem Client sein?"
|
||||
const msPerTick = 50; // Bei 20 TPS
|
||||
|
||||
let drift = 0;
|
||||
let status = "✅ OK";
|
||||
// Speed Schätzung (gleiche Formel wie in logic.js)
|
||||
let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
|
||||
if (debugSpeed > 36) debugSpeed = 36;
|
||||
|
||||
// Client Objekt vorbereiten
|
||||
let cID = "---";
|
||||
let cX = 0;
|
||||
if (cli) {
|
||||
cID = cli.def.id; // Struktur beachten: cli.def.id
|
||||
cX = cli.x;
|
||||
}
|
||||
const speedPerMs = debugSpeed / msPerTick;
|
||||
|
||||
// Server Objekt vorbereiten
|
||||
let sID = "---";
|
||||
let sX = 0;
|
||||
if (srv) {
|
||||
sID = srv.id; // Struktur vom Server: srv.id
|
||||
sX = srv.x;
|
||||
}
|
||||
// Pixel, die das Objekt wegen Ping weiter "links" sein müsste
|
||||
const latencyPx = currentLatencyMs * speedPerMs;
|
||||
|
||||
// Vergleich
|
||||
if (cli && srv) {
|
||||
// IDs unterschiedlich? (z.B. Tisch vs Lehrer)
|
||||
if (cID !== sID) {
|
||||
status = "❌ ID ERROR";
|
||||
hasMajorDrift = true;
|
||||
} else {
|
||||
drift = cX - sX;
|
||||
if (Math.abs(drift) > SYNC_TOLERANCE) {
|
||||
status = "⚠️ DRIFT";
|
||||
hasMajorDrift = true;
|
||||
}
|
||||
// 2. Client Objekte durchgehen
|
||||
clientList.forEach((cObj) => {
|
||||
let bestMatch = null;
|
||||
let bestDist = 9999;
|
||||
let bestSIdx = -1;
|
||||
|
||||
// ID sicherstellen
|
||||
const cID = cObj.def ? cObj.def.id : (cObj.id || "unknown");
|
||||
|
||||
// Passendes Server-Objekt suchen
|
||||
serverList.forEach((sObj, sIdx) => {
|
||||
if (matchedServerIndices.has(sIdx)) return;
|
||||
|
||||
const sID = sObj.id || "unknown";
|
||||
|
||||
// Match Kriterien:
|
||||
// 1. Gleiche ID (oder Plattform)
|
||||
// 2. Nähe (Wir vergleichen hier die korrigierte Position!)
|
||||
const sPosCorrected = sObj.x - latencyPx;
|
||||
const dist = Math.abs(cObj.x - sPosCorrected);
|
||||
|
||||
const isTypeMatch = (label === "Platforms") || (cID === sID);
|
||||
|
||||
// Toleranter Suchradius (500px), falls Drift groß ist
|
||||
if (isTypeMatch && dist < bestDist && dist < 500) {
|
||||
bestDist = dist;
|
||||
bestMatch = sObj;
|
||||
bestSIdx = sIdx;
|
||||
}
|
||||
} else {
|
||||
status = "❌ MISSING";
|
||||
hasMajorDrift = true;
|
||||
});
|
||||
|
||||
// Datenzeile bauen
|
||||
let serverXRaw = "---";
|
||||
let serverXCorrected = "---";
|
||||
let diffReal = "---";
|
||||
let status = "👻 GHOST (Client only)";
|
||||
|
||||
if (bestMatch) {
|
||||
matchedServerIndices.add(bestSIdx);
|
||||
|
||||
serverXRaw = bestMatch.x;
|
||||
serverXCorrected = bestMatch.x - latencyPx; // Hier rechnen wir den Ping raus
|
||||
|
||||
// Der "Wahrs" Drift: Differenz nach Latenz-Abzug
|
||||
diffReal = cObj.x - serverXCorrected;
|
||||
|
||||
// Status Bestimmung
|
||||
const absDiff = Math.abs(diffReal);
|
||||
if (absDiff < 20) status = "✅ PERFECT";
|
||||
else if (absDiff < 60) status = "🆗 OK";
|
||||
else if (absDiff < 150) status = "⚠️ DRIFT";
|
||||
else status = "🔥 BROKEN";
|
||||
}
|
||||
|
||||
// In Tabelle eintragen
|
||||
report.push({
|
||||
Index: i,
|
||||
Status: status,
|
||||
"C-ID": cID,
|
||||
"S-ID": sID,
|
||||
"C-Pos": cX.toFixed(1),
|
||||
"S-Pos": sX.toFixed(1),
|
||||
"Drift (px)": drift.toFixed(2)
|
||||
"ID": cID,
|
||||
"Client X": Math.round(cObj.x),
|
||||
"Server X (Raw)": Math.round(serverXRaw),
|
||||
"Server X (Sim)": Math.round(serverXCorrected), // Wo es sein sollte
|
||||
"Diff (Real)": typeof diffReal === 'number' ? Math.round(diffReal) : "---",
|
||||
"Status": status
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Nur loggen, wenn Fehler da sind oder alle 5 Sekunden (Tick 300)
|
||||
if (hasMajorDrift || currentTick % 300 === 0) {
|
||||
if (hasMajorDrift) console.warn("--- SYNC PROBLEME GEFUNDEN ---");
|
||||
else console.log("--- Sync Check (Routine) ---");
|
||||
// 3. Fehlende Server Objekte finden
|
||||
serverList.forEach((sObj, sIdx) => {
|
||||
if (!matchedServerIndices.has(sIdx)) {
|
||||
// Prüfen, ob es vielleicht einfach noch unsichtbar ist (Zukunft)
|
||||
const sPosCorrected = sObj.x - latencyPx;
|
||||
let status = "❌ MISSING";
|
||||
|
||||
console.table(report); // Das erstellt eine super lesbare Tabelle im Browser
|
||||
if (sPosCorrected > 850) status = "🔮 FUTURE (Buffer)"; // Noch rechts vom Screen
|
||||
if (sPosCorrected < -100) status = "🗑️ OLD (Server lag)"; // Schon links raus
|
||||
|
||||
report.push({
|
||||
"ID": sObj.id || "?",
|
||||
"Client X": "---",
|
||||
"Server X (Raw)": Math.round(sObj.x),
|
||||
"Server X (Sim)": Math.round(sPosCorrected),
|
||||
"Diff (Real)": "---",
|
||||
"Status": status
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Sortieren nach Position (links nach rechts)
|
||||
report.sort((a, b) => {
|
||||
const valA = (typeof a["Client X"] === 'number') ? a["Client X"] : a["Server X (Sim)"];
|
||||
const valB = (typeof b["Client X"] === 'number') ? b["Client X"] : b["Server X (Sim)"];
|
||||
return valA - valB;
|
||||
});
|
||||
|
||||
if (report.length > 0) console.table(report);
|
||||
else console.log("Leer.");
|
||||
}
|
||||
|
||||
function sendPhysicsSync(y, vy) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
type: "sync",
|
||||
y: y,
|
||||
vy: vy,
|
||||
tick: currentTick
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user