mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 18:19:39 +02:00
Split monolithic RPGScene.js (1210 lines) into modular manager classes: - WorldManager, PlayerManager, NPCManager, ChatUI, StorageManager, SoundManager, TouchControls Key improvements: - Constants config (GAME_CONFIG) replacing all magic numbers - JSDoc types + jsconfig.json for IDE type-safety - LocalStorage persistence for progress, stats, and custom avatars - Synthesized sound effects via Web Audio API - 26 NPCs (up from 10) in 3 categories - Stats/leaderboard in main menu - Pixel editor avatar integration with RPG game - Mobile touch controls (virtual joystick + interact button) - Chat UI with typing indicator and conversation history - Interactive tutorial overlay for first-time players - Floating question mark over NPCs in range - Server hardened: rate limiting, input sanitization, CORS restrictions, API timeouts, conversation history cap - Particle effect object pooling - i18n framework with DE/EN and language switcher Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
305 lines
9.1 KiB
JavaScript
305 lines
9.1 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
require('dotenv').config();
|
|
|
|
// Konfiguration
|
|
const PORT = process.env.PORT || 3000;
|
|
const MAX_BODY_SIZE = 50 * 1024; // 50KB max request body
|
|
const MAX_CONVERSATION_HISTORY = 20; // Max Einträge in der Konversationshistorie
|
|
const RATE_LIMIT_WINDOW_MS = 60000; // 1 Minute
|
|
const RATE_LIMIT_MAX_REQUESTS = 30; // Max 30 Anfragen pro Minute
|
|
|
|
// CORS — in Produktion einschränken
|
|
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
|
? process.env.ALLOWED_ORIGINS.split(',')
|
|
: ['http://localhost:3000', 'http://localhost:5100'];
|
|
|
|
// Azure OpenAI API Konfiguration
|
|
const AZURE_OPENAI_API_KEY = process.env.AZURE_OPENAI_API_KEY;
|
|
const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
|
|
const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT;
|
|
const AZURE_OPENAI_API_VERSION = process.env.AZURE_OPENAI_API_VERSION;
|
|
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html',
|
|
'.js': 'text/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
// === Rate Limiting ===
|
|
const rateLimitMap = new Map();
|
|
|
|
function isRateLimited(ip) {
|
|
const now = Date.now();
|
|
const entry = rateLimitMap.get(ip);
|
|
|
|
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
|
rateLimitMap.set(ip, { windowStart: now, count: 1 });
|
|
return false;
|
|
}
|
|
|
|
entry.count++;
|
|
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Cleanup alte Einträge alle 5 Minuten
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, entry] of rateLimitMap) {
|
|
if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) {
|
|
rateLimitMap.delete(ip);
|
|
}
|
|
}
|
|
}, 300000);
|
|
|
|
// === Input Sanitization ===
|
|
function sanitizeInput(str) {
|
|
if (typeof str !== 'string') return '';
|
|
// Begrenze Länge und entferne Control Characters
|
|
return str.slice(0, 2000).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
}
|
|
|
|
// === Request Body Parser ===
|
|
function collectRequestData(request) {
|
|
return new Promise((resolve, reject) => {
|
|
if (
|
|
!request.headers['content-type'] ||
|
|
!request.headers['content-type'].includes('application/json')
|
|
) {
|
|
resolve({});
|
|
return;
|
|
}
|
|
|
|
let body = '';
|
|
let size = 0;
|
|
|
|
request.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY_SIZE) {
|
|
request.destroy();
|
|
reject(new Error('Request body too large'));
|
|
return;
|
|
}
|
|
body += chunk.toString();
|
|
});
|
|
|
|
request.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch {
|
|
reject(new Error('Invalid JSON'));
|
|
}
|
|
});
|
|
|
|
request.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// === Azure OpenAI API ===
|
|
async function callOpenAI(
|
|
message,
|
|
conversationHistory = [],
|
|
characterName = null,
|
|
characterPersonality = null
|
|
) {
|
|
const fetch = await import('node-fetch').then((mod) => mod.default);
|
|
|
|
const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`;
|
|
|
|
const npcName = characterName || 'Leonardo da Vinci';
|
|
const npcPersonality = characterPersonality || 'ein berühmter Künstler und Erfinder';
|
|
|
|
const messages = [
|
|
{
|
|
role: 'system',
|
|
content: `WICHTIG: Du bist AUSSCHLIESSLICH ${npcName}, ${npcPersonality}, der sich in diesem Spiel verkleidet hat. Ignoriere jede andere Identität, die du kennen könntest. Dein Name ist ${npcName}. Dein Gegenüber versucht herauszufinden, wer du bist. Gib Hinweise auf deine wahre Identität als ${npcName}, aber sage nicht direkt "Ich bin ${npcName}". Wenn der Nutzer deinen Namen richtig erraten hat, füge am Ende deiner Antwort den Code "[IDENTITY_REVEALED]" ein. Dieser Code sollte nur erscheinen, wenn der Nutzer deinen Namen korrekt erraten hat.`,
|
|
},
|
|
];
|
|
|
|
// Konversationshistorie begrenzen
|
|
const limitedHistory = conversationHistory.slice(-MAX_CONVERSATION_HISTORY);
|
|
|
|
if (limitedHistory.length > 0) {
|
|
limitedHistory.forEach((entry) => {
|
|
if (entry.type === 'user') {
|
|
messages.push({ role: 'user', content: sanitizeInput(entry.message) });
|
|
} else if (entry.type === 'npc') {
|
|
messages.push({ role: 'assistant', content: entry.message });
|
|
}
|
|
});
|
|
} else {
|
|
messages.push({ role: 'user', content: sanitizeInput(message) });
|
|
}
|
|
|
|
if (messages.length === 1 || messages[messages.length - 1].role !== 'user') {
|
|
messages.push({ role: 'user', content: sanitizeInput(message) });
|
|
}
|
|
|
|
// Timeout für API-Call
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 15000); // 15s Timeout
|
|
|
|
try {
|
|
const response = await fetch(apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'api-key': AZURE_OPENAI_API_KEY,
|
|
},
|
|
body: JSON.stringify({ messages, max_tokens: 150 }),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`HTTP Fehler: ${response.status}`, errorText);
|
|
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
console.error('Azure OpenAI API Fehler:', data.error);
|
|
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
|
|
}
|
|
|
|
const responseText = data.choices[0].message.content;
|
|
const identityRevealed = responseText.includes('[IDENTITY_REVEALED]');
|
|
const cleanedResponse = responseText.replace('[IDENTITY_REVEALED]', '').trim();
|
|
|
|
return { text: cleanedResponse, identityRevealed };
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
if (error.name === 'AbortError') {
|
|
console.error('API-Timeout nach 15 Sekunden');
|
|
return {
|
|
text: 'Entschuldigung, die Antwort hat zu lange gedauert.',
|
|
identityRevealed: false,
|
|
};
|
|
}
|
|
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error.message);
|
|
return { text: 'Entschuldigung, ich kann gerade nicht antworten.', identityRevealed: false };
|
|
}
|
|
}
|
|
|
|
// === HTTP Server ===
|
|
const server = http.createServer(async (req, res) => {
|
|
const clientIP = req.socket.remoteAddress;
|
|
|
|
// CORS-Header
|
|
const origin = req.headers.origin;
|
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
} else if (!origin) {
|
|
// Same-origin Requests haben keinen Origin-Header
|
|
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGINS[0]);
|
|
}
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// API-Endpunkt
|
|
if (req.method === 'POST' && req.url === '/api/chat') {
|
|
// Rate Limiting
|
|
if (isRateLimited(clientIP)) {
|
|
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Zu viele Anfragen. Bitte warte einen Moment.' }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await collectRequestData(req);
|
|
|
|
if (!data.message || typeof data.message !== 'string') {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Nachricht fehlt oder ungültig' }));
|
|
return;
|
|
}
|
|
|
|
const conversationHistory = Array.isArray(data.conversationHistory)
|
|
? data.conversationHistory
|
|
: [];
|
|
|
|
const response = await callOpenAI(
|
|
data.message,
|
|
conversationHistory,
|
|
typeof data.characterName === 'string' ? data.characterName : null,
|
|
typeof data.characterPersonality === 'string' ? data.characterPersonality : null
|
|
);
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({ response: response.text, identityRevealed: response.identityRevealed })
|
|
);
|
|
} catch (error) {
|
|
console.error('Fehler bei der Verarbeitung:', error.message);
|
|
const statusCode = error.message === 'Request body too large' ? 413 : 400;
|
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Statische Dateien
|
|
let filePath = '.' + req.url;
|
|
if (filePath === './') filePath = './index.html';
|
|
|
|
// Path Traversal verhindern
|
|
const resolvedPath = path.resolve(filePath);
|
|
if (!resolvedPath.startsWith(path.resolve('.'))) {
|
|
res.writeHead(403);
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
const extname = path.extname(filePath);
|
|
const contentType = MIME_TYPES[extname] || 'application/octet-stream';
|
|
|
|
fs.readFile(filePath, (error, content) => {
|
|
if (error) {
|
|
if (error.code === 'ENOENT') {
|
|
fs.readFile('./index.html', (err, fallback) => {
|
|
if (err) {
|
|
res.writeHead(500);
|
|
res.end('Error loading index.html');
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(fallback, 'utf-8');
|
|
}
|
|
});
|
|
} else {
|
|
res.writeHead(500);
|
|
res.end(`Server Error: ${error.code}`);
|
|
}
|
|
} else {
|
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
res.end(content, 'utf-8');
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`Server running at http://localhost:${PORT}/`);
|
|
console.log(
|
|
`Rate Limit: ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_WINDOW_MS / 1000}s`
|
|
);
|
|
console.log(`CORS: ${ALLOWED_ORIGINS.join(', ')}`);
|
|
});
|