managarten/games/whopixels/server.js
Till JS c0c11c325a feat(whopixels): major refactor with 20 improvements across architecture, gameplay, UX, security, and i18n
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>
2026-03-21 15:26:40 +01:00

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(', ')}`);
});