const http = require('http'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { Server } = require('socket.io'); const PORT = process.env.PORT || 3001; const BASE_PATH = '/Game'; const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json'); const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin'; const adminTokens = new Set(); /** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */ const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h'; /** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ย้ายทุกคนไปฉากควิซนี้และเริ่มเกม */ const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; /** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */ const SPACE_EMPTY_TTL_MS = 45000; // Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025) const AI_MODELS = [ 'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research', 'o1', 'o1-mini', 'o1-pro', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo', ]; function loadAiSettings() { try { if (fs.existsSync(AI_SETTINGS_PATH)) { const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8'); return JSON.parse(raw); } } catch (e) { console.error('loadAiSettings', e.message); } return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' }; } function saveAiSettings(settings) { try { const dir = path.dirname(AI_SETTINGS_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8'); } catch (e) { console.error('saveAiSettings', e.message); } } function getAdminToken(cookieHeader) { if (!cookieHeader) return null; const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/); return m && adminTokens.has(m[1]) ? m[1] : null; } const MAPS_DIR = path.join(__dirname, 'data', 'maps'); const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters'); /** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */ const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face']; const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right']; /** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */ function buildCharacterLayerManifest(id, files) { if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} }; const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const ext = /\.(png|jpg|jpeg|gif|webp)$/i; const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|'); const byDir = {}; for (const dir of CHAR_MANIFEST_DIRS) { const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); const multiHits = files.filter((f) => multiRe.test(f)); if (multiHits.length) { let maxFi = -1; const perFrame = new Map(); multiHits.forEach((f) => { const m = f.match(multiRe); if (!m) return; const fi = parseInt(m[1], 10); if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; const layer = m[2]; if (fi > maxFi) maxFi = fi; if (!perFrame.has(fi)) perFrame.set(fi, {}); perFrame.get(fi)[layer] = f; }); const frames = []; for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); byDir[dir] = { multi: true, frames }; } else { const frame0 = {}; files.forEach((f) => { if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return; const m = f.match(singleRe); if (m) frame0[m[1]] = f; }); if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] }; } } const byDirIdle = {}; for (const dir of CHAR_MANIFEST_DIRS) { const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i'); const multiIdleHits = files.filter((f) => multiIdleRe.test(f)); if (multiIdleHits.length) { let maxFi = -1; const perFrame = new Map(); multiIdleHits.forEach((f) => { const m = f.match(multiIdleRe); if (!m) return; const fi = parseInt(m[1], 10); if (!Number.isFinite(fi) || fi < 0 || fi > 200) return; const layer = m[2]; if (fi > maxFi) maxFi = fi; if (!perFrame.has(fi)) perFrame.set(fi, {}); perFrame.get(fi)[layer] = f; }); const frames = []; for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {}); byDirIdle[dir] = { multi: true, frames }; } else { const frame0 = {}; files.forEach((f) => { if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return; const m = f.match(singleIdleRe); if (m) frame0[m[1]] = f; }); if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] }; } } return { ok: true, id, byDir, byDirIdle }; } /** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */ const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets'); const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets'); const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json'); /** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */ const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets'); const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json'); /** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */ const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH ? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim()) : ''; const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json'); /** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */ const QUIZ_CARRY_MAX_OPTION_SLOTS = 16; const maps = new Map(); const defaultMap = () => ({ width: 20, height: 15, tileSize: 32, gameType: 'zep', characterCells: 1, characterCellsW: 1, characterCellsH: 1, /** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */ blockPlayerSeparationW: 0, blockPlayerSeparationH: 0, ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 }, gridImageLibrary: [], gridImageSprites: [], gridImageCells: [], quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [], quizCarryHubArea: [], quizCarryOptionArea: [], /** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */ carryEmbedCountdownArea: [], /** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */ quizBattleDomeArea: [], /** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */ quizBattlePathArea: [], stackReleaseArea: [], stackLandArea: [], /** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */ jumpSurvivePlatforms: [], jumpSurvivePlatformArea: [], jumpSurviveRisePxPerSec: 42, jumpSurviveGravity: 3200, /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ jumpSurviveJumpImpulse: 0, jumpSurviveMovePxPerSec: 200, }); function defaultQuizSettings() { return { readMs: 10000, answerMs: 5000, betweenMs: 3500, /** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */ carryReadMs: 3000, /** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */ carryAnswerMs: 5000, /** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */ carrySessionLength: 0, /** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */ carryMapPanelTheme: defaultCarryMapPanelTheme(), /** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */ quizMapPanelTheme: defaultCarryMapPanelTheme(), /** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */ carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(), /** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 1–16 */ carryChoicePlaqueThemes: defaultCarryChoicePlaqueThemes(), /** legacy: ช่องแรกเท่ากับ carryChoicePlaqueThemes[0] */ carryChoicePlaqueTheme: defaultCarryChoicePlaqueTheme(), /** quiz_carry: ขยายป้ายตัวเลือกบนแมป (กว้าง/สูง/ฟอนต์) — 0.85–2.5 */ carryChoicePlaqueMapScale: 1.25, /** quiz_carry: ถ้าใส่รหัสฉาก + คูณ — เดินเร็วเฉพาะแมปนั้น (เทียบกับค่าเริ่มในไคลเอนต์) */ carryWalkSpeedMultForMapId: '', carryWalkSpeedMult: null, /** ถูก/ผิด: สุ่มกี่ข้อต่อรอบจากชุดคำถาม (สูงสุดเท่าที่มีในชุด) */ quizRoundQuestionCount: 10, questions: [], carryQuestions: [], battleQuizMcq: [], }; } function defaultCarryMapPanelTheme() { return { panelBg: 'rgba(12, 14, 28, 0.88)', panelBorder: 'rgba(255, 214, 102, 0.7)', borderWidthPx: 2, textColor: '#f1f5f9', questionFontMinPx: 10, questionFontMaxPx: 24, }; } function sanitizeCssColorToken(input, fallback) { const t = String(input == null ? '' : input).trim().slice(0, 120); if (!t) return fallback; if (/url\s*\(|expression\s*\(|@import|\/\*|javascript|<|>|\\0/i.test(t)) return fallback; if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) return t; if (/^rgba?\(\s*[^)]+\)$/i.test(t)) { const inner = t.replace(/^rgba?\(\s*/i, '').replace(/\)\s*$/, ''); if (inner.length <= 96 && /^[0-9\s.,%+-]+$/.test(inner)) return t.replace(/\s+/g, ' ').trim(); } return fallback; } function sanitizeCarryMapPanelTheme(raw) { const d = defaultCarryMapPanelTheme(); if (!raw || typeof raw !== 'object') return d; let qMin = Number(raw.questionFontMinPx); let qMax = Number(raw.questionFontMaxPx); if (!Number.isFinite(qMin)) qMin = d.questionFontMinPx; if (!Number.isFinite(qMax)) qMax = d.questionFontMaxPx; qMin = Math.round(Math.max(10, Math.min(40, qMin))); qMax = Math.round(Math.max(14, Math.min(56, qMax))); if (qMax < qMin) { const t = qMin; qMin = qMax; qMax = t; } return { panelBg: sanitizeCssColorToken(raw.panelBg, d.panelBg), panelBorder: sanitizeCssColorToken(raw.panelBorder, d.panelBorder), borderWidthPx: (() => { let w = Number(raw.borderWidthPx); if (!Number.isFinite(w)) w = d.borderWidthPx; return Math.max(0, Math.min(12, Math.round(w))); })(), textColor: sanitizeCssColorToken(raw.textColor, d.textColor), questionFontMinPx: qMin, questionFontMaxPx: qMax, }; } /** ป้ายตัวเลือกบนแผนที่ (canvas) — quiz_carry */ function defaultCarryChoicePlaqueTheme() { return { borderMode: 'neon', fixedBorder: 'rgba(122, 200, 255, 0.9)', fillBg: 'rgba(12, 10, 20, 0.88)', textColor: 'rgba(248, 249, 255, 1)', borderWidthPx: 2.5, }; } function sanitizeCarryChoicePlaqueTheme(raw) { const d = defaultCarryChoicePlaqueTheme(); if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return sanitizeCarryChoicePlaqueTheme({}); const mode = String(raw.borderMode || '').toLowerCase() === 'fixed' ? 'fixed' : 'neon'; let bw = Number(raw.borderWidthPx); if (!Number.isFinite(bw)) bw = d.borderWidthPx; bw = Math.round(Math.max(0, Math.min(8, bw)) * 10) / 10; return { borderMode: mode, fixedBorder: sanitizeCssColorToken(raw.fixedBorder, d.fixedBorder), fillBg: sanitizeCssColorToken(raw.fillBg, d.fillBg), textColor: sanitizeCssColorToken(raw.textColor, d.textColor), borderWidthPx: bw, plaqueImageUrl: sanitizeCarryChoiceImageUrl(raw.plaqueImageUrl), }; } const QUIZ_CARRY_PLAQUE_THEME_SLOTS = 16; function defaultCarryChoicePlaqueThemes() { const out = []; for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { out.push(sanitizeCarryChoicePlaqueTheme({})); } return out; } /** อาร์เรย์ 16 ช่อง — รับได้ทั้ง carryChoicePlaqueThemes หรืออ็อบเจ็กต์เดียว (legacy) */ function sanitizeCarryChoicePlaqueThemes(raw) { if (Array.isArray(raw) && raw.length > 0) { const out = []; for (let i = 0; i < QUIZ_CARRY_PLAQUE_THEME_SLOTS; i++) { out.push(sanitizeCarryChoicePlaqueTheme(raw[i] || {})); } return out; } if (raw != null && typeof raw === 'object' && !Array.isArray(raw)) { const one = sanitizeCarryChoicePlaqueTheme(raw); return Array.from({ length: QUIZ_CARRY_PLAQUE_THEME_SLOTS }, () => ({ ...one })); } return defaultCarryChoicePlaqueThemes(); } function sanitizeCarryChoiceImageUrl(input) { const s = String(input == null ? '' : input).trim().slice(0, 512); if (!s) return ''; if (/[\s<>'"`]/.test(s)) return ''; if (s.startsWith('/')) { if (!/^\/[\w\-./?#=&%+]+$/i.test(s)) return ''; return s; } const low = s.toLowerCase(); if (!low.startsWith('https://') && !low.startsWith('http://')) return ''; try { const u = new URL(s); if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; return s; } catch (e) { return ''; } } function defaultCarryEmbedCountdownTheme() { return { overlayBackdrop: 'rgba(8, 10, 20, 0.42)', innerBg: 'rgba(12, 14, 28, 0.82)', innerBorder: 'rgba(122, 162, 247, 0.45)', innerBorderWpx: 1, innerRadiusPx: 12, digitColor: '#ffe066', mapDigitCqmin: 78, mapDigitCqh: 82, mapDigitMaxPx: 200, screenDigitVw: 28, screenDigitMaxPx: 132, }; } function sanitizeCarryEmbedCountdownTheme(raw) { const d = defaultCarryEmbedCountdownTheme(); if (!raw || typeof raw !== 'object') return d; const clampN = (v, lo, hi, def) => { const n = Number(v); if (!Number.isFinite(n)) return def; return Math.max(lo, Math.min(hi, Math.round(n))); }; return { overlayBackdrop: sanitizeCssColorToken(raw.overlayBackdrop, d.overlayBackdrop), innerBg: sanitizeCssColorToken(raw.innerBg, d.innerBg), innerBorder: sanitizeCssColorToken(raw.innerBorder, d.innerBorder), innerBorderWpx: clampN(raw.innerBorderWpx, 0, 12, d.innerBorderWpx), innerRadiusPx: clampN(raw.innerRadiusPx, 0, 32, d.innerRadiusPx), digitColor: sanitizeCssColorToken(raw.digitColor, d.digitColor), mapDigitCqmin: clampN(raw.mapDigitCqmin, 35, 100, d.mapDigitCqmin), mapDigitCqh: clampN(raw.mapDigitCqh, 35, 100, d.mapDigitCqh), mapDigitMaxPx: clampN(raw.mapDigitMaxPx, 48, 400, d.mapDigitMaxPx), screenDigitVw: clampN(raw.screenDigitVw, 6, 44, d.screenDigitVw), screenDigitMaxPx: clampN(raw.screenDigitMaxPx, 48, 220, d.screenDigitMaxPx), }; } /** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */ const QUIZ_BATTLE_CATEGORY_IDS = new Set([ 'cybercrime', 'cyber_law', 'privacy', 'digital_security', 'social_media', 'ethics', 'ai_data', ]); /** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */ function sanitizeBattleQuizMcq(arr) { if (!Array.isArray(arr)) return []; const out = []; for (const q of arr) { if (!q || typeof q !== 'object') continue; let categoryId = String(q.categoryId || '').trim(); if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) { categoryId = 'cybercrime'; } const text = String(q.text || '').trim().slice(0, 500); if (!text) continue; const rawChoices = Array.isArray(q.choices) ? q.choices : []; const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160)); if (choices.length !== 3 || !choices.every(Boolean)) continue; let ci = Number(q.correctIndex); if (!Number.isFinite(ci)) ci = 0; ci = Math.max(0, Math.min(2, Math.floor(ci))); out.push({ categoryId, text, choices, correctIndex: ci }); if (out.length >= 200) break; } return out; } /** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */ function sanitizeCarryQuestions(arr) { if (!Array.isArray(arr)) return []; const out = []; for (const q of arr) { if (!q || typeof q !== 'object') continue; const text = String(q.text || '').trim().slice(0, 500); if (!text) continue; let choices = Array.isArray(q.choices) ? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean) : []; if (choices.length < 2) continue; if (choices.length > 16) choices = choices.slice(0, 16); let ci = Number(q.correctIndex); if (!Number.isFinite(ci)) ci = 0; ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci))); const row = { text, choices, correctIndex: ci }; if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) { const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx])); if (urls.some((u) => u)) row.choiceImageUrls = urls; } out.push(row); if (out.length >= 80) break; } return out; } function clampQuizMs(n, def) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.max(1000, Math.min(300000, Math.floor(v))); } function clampQuizBetweenMs(n, def) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.max(0, Math.min(300000, Math.floor(v))); } function clampCarrySessionLength(n, def) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.max(0, Math.min(500, Math.floor(v))); } /** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */ function clampQuizRoundQuestionCount(n, def) { const v = Number(n); if (!Number.isFinite(v)) { const d = Number(def); if (!Number.isFinite(d)) return 10; return Math.max(1, Math.min(50, Math.floor(d))); } return Math.max(1, Math.min(50, Math.floor(v))); } function clampCarryChoicePlaqueMapScale(n, def) { const v = Number(n); if (Number.isNaN(v)) return def; return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100; } /** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */ function sanitizeCarryWalkSpeedMultForMapId(raw) { const t = String(raw == null ? '' : raw).trim().slice(0, 64); if (!t) return ''; if (!/^[a-zA-Z0-9_-]+$/.test(t)) return ''; return t; } /** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */ function clampCarryWalkSpeedMultSetting(n) { if (n === null || n === undefined || n === '') return null; const v = Number(n); if (Number.isNaN(v)) return null; return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100; } function mergeQuizCarryFromMirrorIfSet(base) { if (!QUIZ_SETTINGS_MIRROR_PATH) return base; try { if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base; const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8'); const j = JSON.parse(raw); if (!j || typeof j !== 'object') return base; if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') { base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); } if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') { base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); } if (j.carryReadMs != null) { base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs); } if (j.carryAnswerMs != null) { base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs); } if (j.carrySessionLength != null) { base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength); } if (j.carryWalkSpeedMultForMapId != null) { base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); } if (j.carryWalkSpeedMult != null) { const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); if (wm != null) base.carryWalkSpeedMult = wm; } if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') { base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); } if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) { base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes); base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; } else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') { base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0]; } if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) { base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions); } if (j.quizRoundQuestionCount != null) { base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount); } return base; } catch (e) { console.error('mergeQuizCarryFromMirrorIfSet', e.message); return base; } } function loadQuizSettings() { try { if (fs.existsSync(QUIZ_SETTINGS_PATH)) { const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8')); const d = defaultQuizSettings(); const questions = Array.isArray(j.questions) ? j.questions : []; const carryQuestions = sanitizeCarryQuestions(j.carryQuestions); const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq); const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme); const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme); const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme); const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length ? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes) : sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme); const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale); const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId); let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult); if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null; else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); const out = { readMs: clampQuizMs(j.readMs, d.readMs), answerMs: clampQuizMs(j.answerMs, d.answerMs), betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs), carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs), carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs), carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength), carryMapPanelTheme, quizMapPanelTheme, carryEmbedCountdownTheme, carryChoicePlaqueThemes, carryChoicePlaqueTheme, carryChoicePlaqueMapScale, carryWalkSpeedMultForMapId, carryWalkSpeedMult, quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount), questions, carryQuestions, battleQuizMcq, }; return mergeQuizCarryFromMirrorIfSet(out); } } catch (e) { console.error('loadQuizSettings', e.message); } return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings()); } const GAUNTLET_DEFAULT_TICK_MS = 220; const GAUNTLET_DEFAULT_JUMP_TICKS = 16; const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0; /** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */ const STACK_SWING_HZ_DEFAULT = 0.55; function defaultGameTiming() { return { gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS, gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS, gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC, ...defaultGauntletVisuals(), stackSwingHz: STACK_SWING_HZ_DEFAULT, stackBlockWidthTiles: null, /** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */ jumpSurviveJumpHeightMult: 1.5, /** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */ jumpSurviveMissionTimeSec: 0, /** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */ stackTowerMissionTimeSec: 90, /** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */ stackTeamMissesMax: 3, /** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */ stackTowerProgressBlocks: 50, stackBlockNormalImageUrls: ['', '', '', '', '', ''], stackBlockHeavyImageUrls: ['', '', '', '', '', ''], /** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */ stackHeavyBlockPercent: 35, /** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */ spaceShooterMissionTimeSec: 0, spaceShooterShipImageUrls: ['', '', '', '', '', ''], spaceShooterAsteroidSpriteUrls: [], spaceShooterAsteroidExplodeFrameMs: 70, /** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */ spaceShooterAsteroidIntervalMs: 1040, spaceShooterShipDamageOverlayUrls: ['', '', ''], /** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */ balloonBossMissionTimeSec: 0, balloonBossBossImageUrl: '', balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''], /** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */ balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png', }; } function clampStackSwingHz(n) { const v = Number(n); if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT; return Math.max(0.08, Math.min(2.8, v)); } /** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */ function clampStackBlockWidthTiles(n) { if (n === null || n === undefined || n === '') return null; const v = Number(n); if (Number.isNaN(v) || v <= 0) return null; return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100; } function clampStackTowerMissionTimeSec(n) { const v = Number(n); if (Number.isNaN(v) || v <= 0) return 90; return Math.max(10, Math.min(7200, Math.floor(v))); } function clampStackTeamMissesMax(n) { const v = Number(n); if (Number.isNaN(v)) return 3; return Math.max(1, Math.min(20, Math.floor(v))); } function clampStackTowerProgressBlocks(n) { const v = Number(n); if (Number.isNaN(v)) return 50; return Math.max(1, Math.min(500, Math.floor(v))); } /** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */ function normalizeStackBlockSixImageUrls(val) { let arr = val; if (typeof arr === 'string') { try { const p = JSON.parse(arr); if (Array.isArray(p)) arr = p; } catch (e) { arr = arr.split(/[\n\r]+/); } } if (!Array.isArray(arr)) arr = []; const out = []; for (let i = 0; i < 6; i++) { out.push(sanitizeGauntletAssetUrl(arr[i])); } return out; } function clampStackHeavyBlockPercent(n) { const v = Number(n); if (Number.isNaN(v)) return 35; return Math.max(0, Math.min(100, Math.floor(v))); } function clampJumpSurviveJumpHeightMult(n) { const v = Number(n); if (Number.isNaN(v)) return 1.5; return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100; } /** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 10–7200 */ function clampJumpSurviveMissionTimeSec(n) { const v = Number(n); if (Number.isNaN(v) || v <= 0) return 0; return Math.max(10, Math.min(7200, Math.floor(v))); } function clampSpaceShooterMissionTimeSec(n) { const v = Number(n); if (Number.isNaN(v) || v <= 0) return 0; return Math.max(10, Math.min(7200, Math.floor(v))); } /** รูปยาน space_shooter ช่อง 1–6 (ตรงกับ spawn slot บนแมป) — ว่าง = ไคลเอนต์วาดยานเวกเตอร์ */ function normalizeSpaceShooterShipImageUrls(val) { let arr = val; if (typeof arr === 'string') { try { const p = JSON.parse(arr); if (Array.isArray(p)) arr = p; } catch (e) { arr = arr.split(/[\n\r]+/); } } if (!Array.isArray(arr)) arr = []; const out = []; for (let i = 0; i < 6; i++) { out.push(sanitizeGauntletAssetUrl(arr[i])); } return out; } /** อุกาบาต space_shooter: บรรทัดแรก = ตอนตก, ถัดไป = เฟรมแตก (สูงสุด 32 URL) */ function normalizeSpaceShooterAsteroidSpriteUrls(val) { let arr = val; if (typeof arr === 'string') { try { const p = JSON.parse(arr); if (Array.isArray(p)) arr = p; } catch (e) { arr = arr.split(/[\n\r]+/); } } if (!Array.isArray(arr)) arr = []; const out = []; for (let i = 0; i < arr.length && out.length < 32; i++) { const u = sanitizeGauntletAssetUrl(arr[i]); if (u) out.push(u); } return out; } function clampSpaceShooterAsteroidExplodeFrameMs(n) { const v = Number(n); if (Number.isNaN(v)) return 70; return Math.max(30, Math.min(500, Math.round(v))); } const SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT = 1040; function clampSpaceShooterAsteroidIntervalMs(n) { const v = Number(n); if (Number.isNaN(v) || v <= 0) return SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; return Math.max(200, Math.min(10000, Math.floor(v))); } /** รูปทับยานเมื่อชนอุกาบาต ครั้งที่ 1–3 (ภารกิจ Violent Crime) */ function normalizeSpaceShooterShipDamageOverlayUrls(val) { let arr = val; if (typeof arr === 'string') { try { const p = JSON.parse(arr); if (Array.isArray(p)) arr = p; } catch (e) { arr = arr.split(/[\n\r]+/); } } if (!Array.isArray(arr)) arr = []; const out = ['', '', '']; for (let i = 0; i < 3; i++) { out[i] = sanitizeGauntletAssetUrl(arr[i]); } return out; } function clampGauntletTickMs(n) { const v = Number(n); if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS; return Math.max(80, Math.min(800, Math.floor(v))); } function clampGauntletJumpTicks(n) { const v = Number(n); if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS; return Math.max(4, Math.min(40, Math.floor(v))); } /** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */ function clampGauntletTimeLimitSec(n) { const v = Number(n); if (Number.isNaN(v) || v <= 0) return 0; return Math.max(10, Math.min(7200, Math.floor(v))); } function defaultGauntletVisuals() { return { gauntletLaneImageUrls: [], gauntletLaserTopUrl: '', gauntletLaserBottomUrl: '', gauntletLaserLineUrl: '', gauntletLaserFillColor: 'rgba(140,230,255,0.42)', gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)', gauntletLaserLineWidthPx: 2, }; } /** ช่องว่างใน path (เช่น Artboard 9.png) — encode เป็น %20 ให้ nginx/เบราว์เซอร์โหลดได้ */ function encodeSpacesInAssetUrlPath(t) { const s = String(t || ''); const q = s.indexOf('?'); const pathPart = q === -1 ? s : s.slice(0, q); const query = q === -1 ? '' : s.slice(q); return pathPart.replace(/ /g, '%20') + query; } /** รูปเสิร์ฟที่ `/Game/img/...` (alias ไป `public/img`) — อย่าใส่ `/Game/public/img/` จาก path บนดิสก์ */ function normalizeGameAssetUrlForWeb(t) { let s = String(t || ''); s = s.replace(/^\/Game\/public\/img\//i, '/Game/img/'); s = s.replace(/^Game\/public\/img\//i, 'Game/img/'); return s; } /** ลด %2520 → %20 → space (สูงสุด 2 รอบ) — กันเข้ารหัสซ้ำจากบันทึก/คัดลอก URL */ function decodeAssetUrlPercentRuns(t) { let s = String(t || ''); const q = s.indexOf('?'); let base = q === -1 ? s : s.slice(0, q); const query = q === -1 ? '' : s.slice(q); for (let i = 0; i < 2; i += 1) { try { const d = decodeURIComponent(base); if (d === base) break; base = d; } catch (e) { break; } } return base + query; } function sanitizeGauntletAssetUrl(s) { const t = normalizeGameAssetUrlForWeb(decodeAssetUrlPercentRuns(String(s || '').trim())).replace(/\/+$/, ''); if (!t || t.length > 500) return ''; /** ห้ามแท็บ/บรรทัดใหม่/ตัวอักษรอันตราย — อนุญาตช่องว่าง (U+0020) ในชื่อไฟล์ */ if (/[\t\n\r\x00-\x08\x0b\x0c\x0e-\x1f<>"'`]/.test(t)) return ''; /** placeholder จาก Admin เช่น /Game/img/....png — ไม่ใช่ path จริง */ if (/\.{4,}/.test(t)) return ''; if (/^https?:\/\//i.test(t)) return encodeSpacesInAssetUrlPath(t); if (t.startsWith('/')) return encodeSpacesInAssetUrlPath(t); /** Admin มักใส่แบบไม่มี slash นำหน้า — ให้เทียบเท่า /Game/... บน nginx */ if (/^Game\//i.test(t)) return encodeSpacesInAssetUrlPath(`/${t.replace(/^\/+/, '')}`); return ''; } function clampGauntletLaserColor(s, def) { const t = String(s || '').trim().slice(0, 100); if (!t) return def; if (/[<>"'`]/.test(t)) return def; return t; } function clampGauntletLaserLineWidthPx(n) { const v = Number(n); if (Number.isNaN(v)) return 2; return Math.max(0, Math.min(24, Math.round(v))); } function normalizeGauntletVisualsFromRequest(d) { const def = defaultGauntletVisuals(); if (!d || typeof d !== 'object') return { ...def }; let laneArr = d.gauntletLaneImageUrls; if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/); if (!Array.isArray(laneArr)) laneArr = []; const gauntletLaneImageUrls = []; for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) { const u = sanitizeGauntletAssetUrl(laneArr[i]); if (u) gauntletLaneImageUrls.push(u); } return { gauntletLaneImageUrls, gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl), gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl), gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl), gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor), gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor), gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx), }; } function getGauntletVisualsForClient() { const r = runtimeGameTiming; const d = defaultGauntletVisuals(); return { gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls, gauntletLaserTopUrl: r.gauntletLaserTopUrl || '', gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '', gauntletLaserLineUrl: r.gauntletLaserLineUrl || '', gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor, gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor, gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx, }; } function loadGameTiming() { try { if (fs.existsSync(GAME_TIMING_PATH)) { const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8')); const vis = normalizeGauntletVisualsFromRequest(j); return { gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs), gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks), gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec), ...vis, stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz') ? clampStackSwingHz(j.stackSwingHz) : STACK_SWING_HZ_DEFAULT, stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles') ? clampStackBlockWidthTiles(j.stackBlockWidthTiles) : null, stackTowerMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'stackTowerMissionTimeSec') ? clampStackTowerMissionTimeSec(j.stackTowerMissionTimeSec) : 90, stackTeamMissesMax: Object.prototype.hasOwnProperty.call(j, 'stackTeamMissesMax') ? clampStackTeamMissesMax(j.stackTeamMissesMax) : 3, stackTowerProgressBlocks: Object.prototype.hasOwnProperty.call(j, 'stackTowerProgressBlocks') ? clampStackTowerProgressBlocks(j.stackTowerProgressBlocks) : 50, stackBlockNormalImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockNormalImageUrls), stackBlockHeavyImageUrls: normalizeStackBlockSixImageUrls(j.stackBlockHeavyImageUrls), stackHeavyBlockPercent: Object.prototype.hasOwnProperty.call(j, 'stackHeavyBlockPercent') ? clampStackHeavyBlockPercent(j.stackHeavyBlockPercent) : 35, jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult') ? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult) : 1.5, jumpSurviveMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveMissionTimeSec') ? clampJumpSurviveMissionTimeSec(j.jumpSurviveMissionTimeSec) : 0, spaceShooterMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'spaceShooterMissionTimeSec') ? clampSpaceShooterMissionTimeSec(j.spaceShooterMissionTimeSec) : 0, spaceShooterShipImageUrls: normalizeSpaceShooterShipImageUrls(j.spaceShooterShipImageUrls), spaceShooterAsteroidSpriteUrls: normalizeSpaceShooterAsteroidSpriteUrls(j.spaceShooterAsteroidSpriteUrls), spaceShooterAsteroidExplodeFrameMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidExplodeFrameMs') ? clampSpaceShooterAsteroidExplodeFrameMs(j.spaceShooterAsteroidExplodeFrameMs) : 70, spaceShooterAsteroidIntervalMs: Object.prototype.hasOwnProperty.call(j, 'spaceShooterAsteroidIntervalMs') ? clampSpaceShooterAsteroidIntervalMs(j.spaceShooterAsteroidIntervalMs) : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT, spaceShooterShipDamageOverlayUrls: normalizeSpaceShooterShipDamageOverlayUrls(j.spaceShooterShipDamageOverlayUrls), balloonBossMissionTimeSec: Object.prototype.hasOwnProperty.call(j, 'balloonBossMissionTimeSec') ? clampSpaceShooterMissionTimeSec(j.balloonBossMissionTimeSec) : 0, balloonBossBossImageUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossBossImageUrl') ? sanitizeGauntletAssetUrl(j.balloonBossBossImageUrl) : '', balloonBossPlayerBalloonImageUrls: normalizeStackBlockSixImageUrls(j.balloonBossPlayerBalloonImageUrls), balloonBossPlayerBalloonFallbackUrl: Object.prototype.hasOwnProperty.call(j, 'balloonBossPlayerBalloonFallbackUrl') ? sanitizeGauntletAssetUrl(j.balloonBossPlayerBalloonFallbackUrl) : '', }; } } catch (e) { console.error('loadGameTiming', e.message); } return defaultGameTiming(); } let runtimeGameTiming = loadGameTiming(); function getGauntletTickMs() { return runtimeGameTiming.gauntletTickMs; } function getGauntletJumpTicks() { return runtimeGameTiming.gauntletJumpTicks; } function getGauntletTimeLimitSec() { return runtimeGameTiming.gauntletTimeLimitSec || 0; } function saveGameTimingToFile(d) { try { const dir = path.dirname(GAME_TIMING_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const prev = { ...runtimeGameTiming }; const vis = normalizeGauntletVisualsFromRequest(d); const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz') ? clampStackSwingHz(d.stackSwingHz) : clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT); const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles') ? clampStackBlockWidthTiles(d.stackBlockWidthTiles) : (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles') ? prev.stackBlockWidthTiles : null); const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult') ? prev.jumpSurviveJumpHeightMult : 1.5; const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult') ? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult) : clampJumpSurviveJumpHeightMult(prevMult); const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec') ? prev.jumpSurviveMissionTimeSec : 0; const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec') ? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec) : clampJumpSurviveMissionTimeSec(prevJumpMissionSec); const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec') ? prev.spaceShooterMissionTimeSec : 0; const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec') ? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec) : clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec); const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls) ? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls) : normalizeSpaceShooterShipImageUrls([]); const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls') ? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls) : prevShipUrls; const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls) ? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls) : []; const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls') ? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls) : prevAstUrls; const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs') ? prev.spaceShooterAsteroidExplodeFrameMs : 70; const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs') ? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs) : clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms); const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs') ? prev.spaceShooterAsteroidIntervalMs : SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT; const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs') ? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs) : clampSpaceShooterAsteroidIntervalMs(prevAstIv); const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls) ? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls) : normalizeSpaceShooterShipDamageOverlayUrls([]); const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls') ? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls) : prevDmg; const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec') ? prev.balloonBossMissionTimeSec : 0; const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec') ? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec) : clampSpaceShooterMissionTimeSec(prevBbMission); const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl') ? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl) : ''; const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl') ? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl) : prevBbBoss; const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls) ? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls) : normalizeStackBlockSixImageUrls([]); const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls') ? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls) : prevBbBalloons; const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl') ? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl) : ''; const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl') ? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl) : prevBbFb; const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec') ? prev.stackTowerMissionTimeSec : 90; const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec') ? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec) : clampStackTowerMissionTimeSec(prevStackTowerSec); const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax') ? prev.stackTeamMissesMax : 3; const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax') ? clampStackTeamMissesMax(d.stackTeamMissesMax) : clampStackTeamMissesMax(prevStackMisses); const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks') ? prev.stackTowerProgressBlocks : 50; const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks') ? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks) : clampStackTowerProgressBlocks(prevStackProgBlocks); const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls) ? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls) : normalizeStackBlockSixImageUrls([]); const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls) ? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls) : normalizeStackBlockSixImageUrls([]); const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls') ? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls) : prevStackNorm; const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls') ? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls) : prevStackHeavy; const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent') ? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent) : 35; const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent') ? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent) : prevHeavyPct; const out = { gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs), gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks), gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec), ...vis, stackSwingHz: stackHz, stackBlockWidthTiles: stackBlockW, stackTowerMissionTimeSec, stackTeamMissesMax, stackTowerProgressBlocks, stackBlockNormalImageUrls, stackBlockHeavyImageUrls, stackHeavyBlockPercent, jumpSurviveJumpHeightMult: jumpMult, jumpSurviveMissionTimeSec: jumpMissionSec, spaceShooterMissionTimeSec: spaceShooterMissionSec, spaceShooterShipImageUrls, spaceShooterAsteroidSpriteUrls, spaceShooterAsteroidExplodeFrameMs, spaceShooterAsteroidIntervalMs, spaceShooterShipDamageOverlayUrls, balloonBossMissionTimeSec, balloonBossBossImageUrl, balloonBossPlayerBalloonImageUrls, balloonBossPlayerBalloonFallbackUrl, }; fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8'); runtimeGameTiming = out; rescheduleAllGauntletTickers(); return { ok: true, ...out }; } catch (e) { console.error('saveGameTimingToFile', e.message); let err = 'บันทึกไม่ได้'; if (e && e.code === 'EACCES') { err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; return { ok: false, error: err }; } } function migrateGauntletAssetsFromLegacyIfNeeded() { try { if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return; if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY); names.forEach((f) => { if (!safeGauntletStoredFilename(f)) return; const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f); const dest = path.join(GAUNTLET_ASSETS_DIR, f); if (fs.existsSync(dest)) return; try { fs.copyFileSync(src, dest); console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/'); } catch (e) { console.error('[gauntlet-assets] migrate failed', f, e && e.message); } }); } catch (e) { console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message); } } function ensureGauntletAssetsDir() { if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true }); migrateGauntletAssetsFromLegacyIfNeeded(); } function loadGauntletAssetsMeta() { try { if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) { const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8')); return j && typeof j === 'object' ? j : {}; } } catch (e) { console.error('loadGauntletAssetsMeta', e.message); } return {}; } function saveGauntletAssetsMeta(meta) { const dir = path.dirname(GAUNTLET_ASSETS_META_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8'); } function safeGauntletStoredFilename(name) { const base = path.basename(String(name || '')); if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; return base; } function safeQuizCarryPlaqueStoredFilename(name) { const base = path.basename(String(name || '')); if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null; return base; } function ensureQuizCarryPlaqueAssetsDir() { if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true }); } function gauntletAssetMimeByExt(ext) { const e = String(ext || '').toLowerCase(); if (e === '.png') return 'image/png'; if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; if (e === '.gif') return 'image/gif'; if (e === '.webp') return 'image/webp'; return 'application/octet-stream'; } function listGauntletAssetsApi() { ensureGauntletAssetsDir(); const meta = loadGauntletAssetsMeta(); let files = []; try { files = fs.readdirSync(GAUNTLET_ASSETS_DIR); } catch (e) { return []; } return files .filter((f) => safeGauntletStoredFilename(f)) .map((f) => { const fp = path.join(GAUNTLET_ASSETS_DIR, f); let st; try { st = fs.statSync(fp); } catch (e) { return null; } const entry = meta[f]; const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : ''; return { filename: f, url: BASE_PATH + '/img/gauntlet-assets/' + f, label: label || f, bytes: st.size, mtime: st.mtimeMs, }; }) .filter(Boolean) .sort((a, b) => b.mtime - a.mtime); } function saveQuizSettings(d) { try { const prev = loadQuizSettings(); const dir = path.dirname(QUIZ_SETTINGS_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs; const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs; const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs; const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs; const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs; const carrySessionLength = d.carrySessionLength != null ? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength) : prev.carrySessionLength; const questions = Array.isArray(d.questions) ? (d.questions || []) .filter((q) => q && String(q.text || '').trim()) .map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue })) : prev.questions; const carryQuestions = Array.isArray(d.carryQuestions) ? sanitizeCarryQuestions(d.carryQuestions) : prev.carryQuestions; const battleQuizMcq = Array.isArray(d.battleQuizMcq) ? sanitizeBattleQuizMcq(d.battleQuizMcq) : (prev.battleQuizMcq || []); const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object' ? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme) : prev.carryMapPanelTheme; const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object' ? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme) : prev.quizMapPanelTheme; const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object' ? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme) : prev.carryEmbedCountdownTheme; let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes; if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) { carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes); } else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') { carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme); } if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) { carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme); } const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0]; const prevScale = prev.carryChoicePlaqueMapScale != null ? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25) : 1.25; const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null ? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale) : prevScale; const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId); const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null; let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined ? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId) : prevWalkId; let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult; if (!carryWalkSpeedMultForMapId) { carryWalkSpeedMult = null; } else if (carryWalkSpeedMult == null) { carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42); } const quizRoundQuestionCount = d.quizRoundQuestionCount != null ? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount) : prev.quizRoundQuestionCount; const out = { readMs, answerMs, betweenMs, carryReadMs, carryAnswerMs, carrySessionLength, carryMapPanelTheme, quizMapPanelTheme, carryEmbedCountdownTheme, carryChoicePlaqueThemes, carryChoicePlaqueTheme, carryChoicePlaqueMapScale, carryWalkSpeedMultForMapId, carryWalkSpeedMult, quizRoundQuestionCount, questions, carryQuestions, battleQuizMcq, }; fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); return { ok: true, battleQuizMcqSaved: (out.battleQuizMcq || []).length, carryQuestionsSaved: (out.carryQuestions || []).length, carryMapPanelTheme: out.carryMapPanelTheme, quizMapPanelTheme: out.quizMapPanelTheme, carryEmbedCountdownTheme: out.carryEmbedCountdownTheme, carryChoicePlaqueThemes: out.carryChoicePlaqueThemes, carryChoicePlaqueTheme: out.carryChoicePlaqueTheme, carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale, }; } catch (e) { console.error('saveQuizSettings', e.message); let err = 'บันทึกไม่ได้'; if (e && e.code === 'EACCES') { err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)'; } else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; return { ok: false, error: err }; } } function getQuizQuestionPool(md) { const g = loadQuizSettings(); const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim()); if (globalQ.length) return globalQ; return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim()); } function quizCellOn(grid, tx, ty) { return !!(grid && grid[ty] && grid[ty][tx] === 1); } function getCharacterFootprintWHForMove(md) { let cw = Math.floor(Number(md.characterCellsW)); let ch = Math.floor(Number(md.characterCellsH)); const hasW = Number.isFinite(cw) && cw >= 1; const hasH = Number.isFinite(ch) && ch >= 1; if (hasW && hasH) { return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) }; } const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1)); return { cw: leg, ch: leg }; } function getCharacterCollisionFootprintWHForMove(md) { const { cw, ch } = getCharacterFootprintWHForMove(md); let colW = Math.floor(Number(md.characterCollisionW)); let colH = Math.floor(Number(md.characterCollisionH)); if (!Number.isFinite(colW) || colW < 1) colW = cw; if (!Number.isFinite(colH) || colH < 1) colH = ch; colW = Math.max(1, Math.min(cw, colW)); colH = Math.max(1, Math.min(ch, colH)); return { cw, ch, colW, colH }; } function serverWallCollisionTileKeys(md, px, py) { const w = md.width || 20; const h = md.height || 15; const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md); const minTx = Math.floor(Number(px)); const minTy = Math.floor(Number(py)); const offX = minTx + Math.floor((cw - colW) / 2); const offY = minTy + (ch - colH); const out = []; for (let ty = offY; ty < offY + colH; ty++) { for (let tx = offX; tx < offX + colW; tx++) { if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`); } } return out; } function quizCarryFootprintTileKeys(md, px, py) { const w = md.width || 20; const h = md.height || 15; const { cw, ch } = getCharacterFootprintWHForMove(md); const minTx = Math.floor(Number(px)); const minTy = Math.floor(Number(py)); const maxTx = Math.min(w - 1, minTx + cw - 1); const maxTy = Math.min(h - 1, minTy + ch - 1); const out = []; for (let ty = minTy; ty <= maxTy; ty++) { for (let tx = minTx; tx <= maxTx; tx++) { if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`); } } return out; } function quizCarryFootprintOverlapsHubServer(md, px, py) { if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false; for (const k of quizCarryFootprintTileKeys(md, px, py)) { const [tx, ty] = k.split(',').map(Number); if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true; } return false; } /** Nearest walkable tile center not in true/false answer zones (BFS). */ function findNearestOutsideQuizAnswerZones(md, sx, sy) { const w = md.width || 20; const h = md.height || 15; const tGrid = md.quizTrueArea || []; const fGrid = md.quizFalseArea || []; const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y); const walkable = (x, y) => { if (x < 0 || x >= w || y < 0 || y >= h) return false; const row = md.objects && md.objects[y]; return row && row[x] !== 1; }; const fx = Math.floor(Number(sx)); const fy = Math.floor(Number(sy)); if (!walkable(fx, fy)) { const sp = md.spawn || { x: 1, y: 1 }; return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; } if (!inAnswer(fx, fy)) return { x: sx, y: sy }; const q = [[fx, fy]]; const seen = new Set([`${fx},${fy}`]); const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; while (q.length) { const [cx, cy] = q.shift(); for (let di = 0; di < dirs.length; di++) { const nx = cx + dirs[di][0]; const ny = cy + dirs[di][1]; const k = `${nx},${ny}`; if (seen.has(k)) continue; if (!walkable(nx, ny)) continue; seen.add(k); if (!inAnswer(nx, ny)) { return { x: nx + 0.5, y: ny + 0.5 }; } q.push([nx, ny]); } } const sp = md.spawn || { x: 1, y: 1 }; return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 }; } function validateQuizMap(md) { const pool = getQuizQuestionPool(md); if (pool.length < 1) { return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)'; } const w = md.width || 20; const h = md.height || 15; const tGrid = md.quizTrueArea || []; const fGrid = md.quizFalseArea || []; let anyT = false; let anyF = false; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (quizCellOn(tGrid, x, y)) anyT = true; if (quizCellOn(fGrid, x, y)) anyF = true; } } if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1'; return null; } function clearSpaceQuizTimers(space) { if (!space.quizTimers || !Array.isArray(space.quizTimers)) { space.quizTimers = []; return; } space.quizTimers.forEach((t) => clearTimeout(t)); space.quizTimers = []; } /** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */ const QUIZ_TF_POINTS_PER_CORRECT = 10; function shuffleQuizQuestions(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function endQuizGame(sid, space, message) { clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' }); space.quizSession = null; } function emitQuizPlayerStates(sid, space) { const sess = space.quizSession; if (!sess || !sess.players) return; space.peers.forEach((_, peerId) => { const st = sess.players[peerId]; if (st) io.to(peerId).emit('quiz-player-state', { ...st }); }); } function scheduleQuizReadPhase(sid, space, md) { const sess = space.quizSession; if (!sess || !sess.active) return; const idx = sess.qIndex; const qList = sess.questions; if (idx >= qList.length) { endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); return; } const q = qList[idx]; const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000; sess.phase = 'read'; sess.phaseEndsAt = Date.now() + readMs; io.to(sid).emit('quiz-phase', { phase: 'read', questionIndex: idx + 1, questionTotal: qList.length, text: String(q.text || '').trim(), endsAt: sess.phaseEndsAt, }); const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs); space.quizTimers.push(t); } function scheduleQuizAnswerPhase(sid, space, md) { const sess = space.quizSession; if (!sess || !sess.active) return; const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000; sess.phase = 'answer'; sess.phaseEndsAt = Date.now() + answerMs; io.to(sid).emit('quiz-phase', { phase: 'answer', questionIndex: sess.qIndex + 1, questionTotal: sess.questions.length, endsAt: sess.phaseEndsAt, }); emitQuizPlayerStates(sid, space); const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs); space.quizTimers.push(t); } function resolveQuizRound(sid, space, md) { const sess = space.quizSession; if (!sess || !sess.active) return; /* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */ const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md; if (!mdLive) return; const idx = sess.qIndex; const q = sess.questions[idx]; const correctTrue = !!q.answerTrue; const tGrid = mdLive.quizTrueArea || []; const fGrid = mdLive.quizFalseArea || []; let eligibleCount = 0; let anyRight = false; const results = []; const ejectMoves = []; space.peers.forEach((p, peerId) => { const st = sess.players[peerId]; if (!st) return; eligibleCount++; const tx = Math.floor(Number(p.x)); const ty = Math.floor(Number(p.y)); const inT = quizCellOn(tGrid, tx, ty); const inF = quizCellOn(fGrid, tx, ty); let choice = null; if (inT && !inF) choice = true; else if (inF && !inT) choice = false; else if (inT && inF) choice = true; let right = false; if (choice === null) right = false; else right = (choice === correctTrue); if (right) { st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT; anyRight = true; } /* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */ if (!right) { st.cannotTrue = true; st.cannotFalse = true; const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder) ? Math.max(0, Math.floor(p.spawnJoinOrder)) : [...space.peers.keys()].indexOf(peerId); const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd); p.x = pos.x; p.y = pos.y; ejectMoves.push({ id: peerId, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId ?? null, }); } results.push({ id: peerId, nickname: p.nickname || '', right, choice, eliminated: !!st.eliminated, score: typeof st.score === 'number' ? st.score : 0, }); }); ejectMoves.forEach((ev) => { io.to(sid).emit('user-move', ev); try { const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id); if (sock) sock.emit('user-move', ev); } catch (e) { /* ignore */ } }); const allWrong = eligibleCount > 0 && !anyRight; const scores = {}; space.peers.forEach((_, peerId) => { const st = sess.players[peerId]; scores[peerId] = st && typeof st.score === 'number' ? st.score : 0; }); io.to(sid).emit('quiz-result', { questionIndex: idx + 1, correctTrue, results, allWrong, scores, }); emitQuizPlayerStates(sid, space); if (allWrong) { endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้'); return; } sess.qIndex++; if (sess.qIndex >= sess.questions.length) { endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว'); return; } const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500; const mapRef = sess.quizMapMd || md; const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs); space.quizTimers.push(t); } function startQuizGame(sid, space, md) { clearSpaceQuizTimers(space); const settings = loadQuizSettings(); const pool = getQuizQuestionPool(md); const shuffled = shuffleQuizQuestions(pool); const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10); const picked = shuffled.slice(0, Math.min(cap, shuffled.length)); const players = {}; space.peers.forEach((_, peerId) => { players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 }; }); space.quizSession = { active: true, questions: picked, qIndex: 0, phase: 'idle', phaseEndsAt: 0, readMs: settings.readMs, answerMs: settings.answerMs, betweenMs: settings.betweenMs, players, quizMapMd: md, }; scheduleQuizReadPhase(sid, space, md); } function safeMapId(id) { return (id || '').replace(/[^a-z0-9_-]/gi, '') || null; } /** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 2–16หาย) */ function normalizeQuizCarryLayersOnMap(m) { if (!m || m.gameType !== 'quiz_carry') return; const w = m.width || 20, h = m.height || 15; const normHub = () => { const src = m.quizCarryHubArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); rows.push(row); } m.quizCarryHubArea = rows; }; const normOptions = () => { const src = m.quizCarryOptionArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; const n = typeof v === 'number' ? v : parseInt(String(v), 10); row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0); } rows.push(row); } m.quizCarryOptionArea = rows; }; const normCountdown = () => { const src = m.carryEmbedCountdownArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) { const v = r && r[x]; row.push(Number(v) === 1 ? 1 : 0); } rows.push(row); } m.carryEmbedCountdownArea = rows; }; normHub(); normOptions(); normCountdown(); } /** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */ function normalizeQuizBattleDomeAreaOnMap(m) { if (!m || m.gameType !== 'quiz_battle') return; const w = m.width || 20, h = m.height || 15; const src = m.quizBattleDomeArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); rows.push(row); } m.quizBattleDomeArea = rows; } function normalizeQuizBattlePathAreaOnMap(m) { if (!m || m.gameType !== 'quiz_battle') return; const w = m.width || 20, h = m.height || 15; const src = m.quizBattlePathArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); rows.push(row); } m.quizBattlePathArea = rows; } function quizBattlePathModeActiveServer(m) { if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false; const g = m.quizBattlePathArea; for (let y = 0; y < g.length; y++) { const row = g[y]; if (!row) continue; for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; } return false; } function quizBattleFootprintFullyOnPathServer(m, px, py) { if (!quizBattlePathModeActiveServer(m)) return true; const g = m.quizBattlePathArea; for (const k of quizCarryFootprintTileKeys(m, px, py)) { const [tx, ty] = k.split(',').map(Number); if (!g[ty] || g[ty][tx] !== 1) return false; } return true; } function serverFootprintClearOfWalls(md, px, py) { const w = md.width || 20, h = md.height || 15; for (const k of serverWallCollisionTileKeys(md, px, py)) { const [tx, ty] = k.split(',').map(Number); if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false; const row = md.objects && md.objects[ty]; if (!row || row[tx] === 1) return false; } return true; } /** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */ function snapPositionOntoQuizBattlePathServer(md, px, py) { if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py }; if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py }; const w = md.width || 20, h = md.height || 15; const g = md.quizBattlePathArea; let bestX = null, bestY = null, bestD = Infinity; for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; const nx = tx + 0.01; const ny = ty + 0.01; if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue; if (!serverFootprintClearOfWalls(md, nx, ny)) continue; const d = Math.abs(nx - px) + Math.abs(ny - py); if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } } } if (bestX != null) return { x: bestX, y: bestY }; for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; const row = md.objects && md.objects[ty]; if (!row || row[tx] === 1) continue; const nx = tx + 0.5; const ny = ty + 0.5; const d = Math.abs(nx - px) + Math.abs(ny - py); if (d < bestD) { bestD = d; bestX = nx; bestY = ny; } } } if (bestX != null) return { x: bestX, y: bestY }; return { x: px, y: py }; } function normalizeJumpSurvivePlatformAreaOnMap(m) { if (!m || m.gameType !== 'jump_survive') return; const w = m.width || 20, h = m.height || 15; const src = m.jumpSurvivePlatformArea || []; const rows = []; for (let y = 0; y < h; y++) { const r = src[y]; const row = []; for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0); rows.push(row); } m.jumpSurvivePlatformArea = rows; } function loadMaps() { try { if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true }); if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); const files = fs.readdirSync(MAPS_DIR); for (const f of files) { if (!f.endsWith('.json')) continue; const id = f.slice(0, -5); if (!safeMapId(id)) continue; try { const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8')); normalizeQuizCarryLayersOnMap(data); normalizeJumpSurvivePlatformAreaOnMap(data); normalizeQuizBattleDomeAreaOnMap(data); normalizeQuizBattlePathAreaOnMap(data); maps.set(id, data); } catch (e) { console.error('load map', f, e.message); } } } catch (e) { console.error('loadMaps', e.message); } } /** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */ function ensurePostCaseLobbyMapLoaded() { if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true; try { const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json'); if (!fs.existsSync(fp)) return false; const data = JSON.parse(fs.readFileSync(fp, 'utf8')); normalizeQuizCarryLayersOnMap(data); maps.set(POST_CASE_LOBBY_SPACE_ID, data); return true; } catch (e) { console.error('ensurePostCaseLobbyMapLoaded', e.message); return false; } } function saveMap(id, m) { const safe = safeMapId(id); if (!safe) return; try { if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true }); fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8'); } catch (e) { console.error('saveMap', id, e.message); } } function isMapNameTaken(name, excludeId) { const n = (name || '').trim().toLowerCase(); for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true; return false; } const server = http.createServer((req, res) => { let url = req.url; if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html'; if (url.startsWith(BASE_PATH + '/api/maps')) { const id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0]; if (req.method === 'GET' && id) { const m = maps.get(id); if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m)); } if (req.method === 'GET' && !id) { const list = [...maps].map(([id, m]) => ({ id, name: m.name })); return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); } if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const d = JSON.parse(body); const name = (d.name || '').trim() || 'ฉากใหม่'; if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); const id = Date.now().toString(36); const m = { ...defaultMap(), ...d, name }; if (!m.blockPlayer) m.blockPlayer = []; if (!m.interactive) m.interactive = []; if (!m.startGameArea) m.startGameArea = []; if (!m.spawnArea) m.spawnArea = []; if (!m.quizTrueArea) m.quizTrueArea = []; if (!m.quizFalseArea) m.quizFalseArea = []; if (!m.quizQuestionArea) m.quizQuestionArea = []; if (!m.quizQuestions) m.quizQuestions = []; if (!m.quizCarryHubArea) m.quizCarryHubArea = []; if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; if (!m.quizBattlePathArea) m.quizBattlePathArea = []; if (!m.stackReleaseArea) m.stackReleaseArea = []; if (!m.stackLandArea) m.stackLandArea = []; if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; normalizeQuizCarryLayersOnMap(m); normalizeJumpSurvivePlatformAreaOnMap(m); normalizeQuizBattleDomeAreaOnMap(m); normalizeQuizBattlePathAreaOnMap(m); maps.set(id, m); saveMap(id, m); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, mapId: id })); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } if (req.method === 'PUT' && id && maps.has(id)) { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const d = JSON.parse(body); const name = (d.name || '').trim() || maps.get(id).name; if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' })); const m = { ...maps.get(id), ...d, name }; if (!m.blockPlayer) m.blockPlayer = []; if (!m.interactive) m.interactive = []; if (!m.startGameArea) m.startGameArea = []; if (!m.spawnArea) m.spawnArea = []; if (!m.quizTrueArea) m.quizTrueArea = []; if (!m.quizFalseArea) m.quizFalseArea = []; if (!m.quizQuestionArea) m.quizQuestionArea = []; if (!m.quizQuestions) m.quizQuestions = []; if (!m.quizCarryHubArea) m.quizCarryHubArea = []; if (!m.quizCarryOptionArea) m.quizCarryOptionArea = []; if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = []; if (!m.quizBattleDomeArea) m.quizBattleDomeArea = []; if (!m.quizBattlePathArea) m.quizBattlePathArea = []; if (!m.stackReleaseArea) m.stackReleaseArea = []; if (!m.stackLandArea) m.stackLandArea = []; if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = []; if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = []; normalizeQuizCarryLayersOnMap(m); normalizeJumpSurvivePlatformAreaOnMap(m); normalizeQuizBattleDomeAreaOnMap(m); normalizeQuizBattlePathAreaOnMap(m); maps.set(id, m); saveMap(id, m); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, mapId: id })); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } } const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; const quizCarryDiskPaths = new Set([ BASE_PATH + '/api/quiz-carry-from-disk', BASE_PATH + '/api-quiz-carry-from-disk.php', ]); if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) { if (req.method === 'GET') { const g = loadQuizSettings(); const slice = { carryMapPanelTheme: g.carryMapPanelTheme, carryEmbedCountdownTheme: g.carryEmbedCountdownTheme, carryChoicePlaqueThemes: g.carryChoicePlaqueThemes, carryChoicePlaqueTheme: g.carryChoicePlaqueTheme, carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale, carryReadMs: g.carryReadMs, carryAnswerMs: g.carryAnswerMs, carrySessionLength: g.carrySessionLength, carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId, carryWalkSpeedMult: g.carryWalkSpeedMult, carryQuestions: g.carryQuestions, }; res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate', Pragma: 'no-cache', }); return res.end(JSON.stringify(slice)); } res.writeHead(405); return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') { if (req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate', Pragma: 'no-cache', }); return res.end(JSON.stringify(loadQuizSettings())); } if (req.method === 'PUT') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); try { const d = JSON.parse(body || '{}'); const saved = saveQuizSettings(d); if (!saved.ok) { res.writeHead(500); return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); } res.writeHead(200); return res.end(JSON.stringify({ ok: true, battleQuizMcqSaved: saved.battleQuizMcqSaved, carryQuestionsSaved: saved.carryQuestionsSaved, carryMapPanelTheme: saved.carryMapPanelTheme, carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme, carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes, carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme, carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale, })); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } res.writeHead(405); return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') { if (req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', }); return res.end(JSON.stringify({ ...runtimeGameTiming })); } if (req.method === 'PUT') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); try { const d = JSON.parse(body || '{}'); const saved = saveGameTimingToFile(d); if (!saved.ok) { res.writeHead(500); return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' })); } res.writeHead(200); const { ok: _ok, error: _err, ...timingRest } = saved; return res.end(JSON.stringify({ ok: true, ...timingRest })); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } res.writeHead(405); return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() })); } if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); try { const d = JSON.parse(body || '{}'); const dataUrl = d.imageDataUrl; if (!dataUrl || typeof dataUrl !== 'string') { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); } const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); if (!m) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); } const extRaw = m[1].toLowerCase(); const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; const b64 = m[2].replace(/\s/g, ''); let buf; try { buf = Buffer.from(b64, 'base64'); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); } if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); } ensureGauntletAssetsDir(); const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : ''; const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : ''; let fname; if (replaceFilename) { const s = safeGauntletStoredFilename(replaceFilename); if (!s) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' })); } const cur = path.join(GAUNTLET_ASSETS_DIR, s); if (!fs.existsSync(cur)) { res.writeHead(404); return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' })); } const norm = (e) => { let x = String(e || '').toLowerCase(); if (x.startsWith('.')) x = x.slice(1); return x === 'jpeg' ? 'jpg' : x; }; if (norm(path.extname(s)) !== norm(ext)) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' })); } fname = s; } else { fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`; } fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf); const meta = loadGauntletAssetsMeta(); const prev = meta[fname] || {}; meta[fname] = { ...prev, label: labelIn || prev.label || fname, updatedAt: Date.now(), }; saveGauntletAssetsMeta(meta); res.writeHead(200); return res.end(JSON.stringify({ ok: true, filename: fname, url: BASE_PATH + '/img/gauntlet-assets/' + fname, })); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); } }); return; } const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); try { const d = JSON.parse(body || '{}'); const dataUrl = d.imageDataUrl; if (!dataUrl || typeof dataUrl !== 'string') { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' })); } const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i); if (!m) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' })); } const extRaw = m[1].toLowerCase(); const ext = extRaw === 'jpeg' ? 'jpg' : extRaw; const b64 = m[2].replace(/\s/g, ''); let buf; try { buf = Buffer.from(b64, 'base64'); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' })); } if (buf.length < 16 || buf.length > 4 * 1024 * 1024) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' })); } ensureQuizCarryPlaqueAssetsDir(); const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`; fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf); res.writeHead(200); return res.end(JSON.stringify({ ok: true, filename: fname, url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname, })); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); } }); return; } if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') { let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); try { const d = JSON.parse(body || '{}'); const fname = safeGauntletStoredFilename(d.filename); if (!fname) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' })); } const fp = path.join(GAUNTLET_ASSETS_DIR, fname); if (!fs.existsSync(fp)) { res.writeHead(404); return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' })); } const meta = loadGauntletAssetsMeta(); const label = d.label != null ? String(d.label).trim().slice(0, 120) : ''; meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() }; saveGauntletAssetsMeta(meta); res.writeHead(200); return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label })); } catch (e) { res.writeHead(400); return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' })); } }); return; } if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') { try { const q = url.includes('?') ? url.split('?')[1] : ''; const sp = new URLSearchParams(q); const rawFn = sp.get('file'); const fname = safeGauntletStoredFilename(rawFn); if (!fname) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' })); } const fp = path.join(GAUNTLET_ASSETS_DIR, fname); if (fs.existsSync(fp)) fs.unlinkSync(fp); const meta = loadGauntletAssetsMeta(); delete meta[fname]; saveGauntletAssetsMeta(meta); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: true })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' })); } } if (url === BASE_PATH + '/api/characters' && req.method === 'GET') { try { if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); const files = fs.readdirSync(CHARACTERS_DIR); const ids = new Set(); const ext = /\.(png|jpg|jpeg|gif|webp)$/i; files.forEach(f => { let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); if (m) ids.add(m[1]); m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); if (m) ids.add(m[1]); m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i); if (m) ids.add(m[1]); m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i); if (m) ids.add(m[1]); }); const idArr = [...ids]; const oldestMsById = {}; idArr.forEach((id) => { let oldest = Infinity; const prefix = id + '_'; files.forEach((f) => { if (!f.startsWith(prefix) || !ext.test(f)) return; try { const st = fs.statSync(path.join(CHARACTERS_DIR, f)); const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime(); if (t < oldest) oldest = t; } catch (e) { /* ignore */ } }); oldestMsById[id] = oldest === Infinity ? 0 : oldest; }); idArr.sort((a, b) => { const ta = oldestMsById[a] || 0; const tb = oldestMsById[b] || 0; if (ta !== tb) return ta - tb; return String(a).localeCompare(String(b)); }); const list = idArr.map((id) => { const prefix = id + '_'; const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f)); const layerNameSet = new Set(); if (hasLayerFiles) { files.forEach((f) => { if (!f.startsWith(prefix) || !ext.test(f)) return; let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); if (m) { layerNameSet.add(m[1]); return; } m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i); if (m) layerNameSet.add(m[1]); }); } const layers = layerNameSet.size ? [...layerNameSet].sort() : []; const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null; return { id, name: id, hasLayerFiles, layers, layerManifest }; }); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify(list)); } catch (e) { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify([])); } } const urlPath = url.split('?')[0]; { const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$')); if (lm && req.method === 'GET') { const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, ''); if (!id) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} })); } try { if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); const files = fs.readdirSync(CHARACTERS_DIR); const manifest = buildCharacterLayerManifest(id, files); res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify(manifest)); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} })); } } } if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') { if (/\/layer-manifest$/i.test(urlPath)) { res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' })); } const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length)); const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, ''); if (!id) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' })); } try { if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); const files = fs.readdirSync(CHARACTERS_DIR); const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i'); const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i'); let removed = 0; files.forEach(f => { if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) { try { fs.unlinkSync(path.join(CHARACTERS_DIR, f)); removed++; } catch (e) { // ignore single file errors } } }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, removed })); } catch (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') })); } return; } if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { if (!body || body.length < 2) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' })); } const d = JSON.parse(body); const dirs = ['up', 'down', 'left', 'right']; const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now(); if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true }); const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 }; let walkWritten = 0; for (const dir of dirs) { let data = d[dir]; if (!data) continue; const list = Array.isArray(data) ? data : [data]; let frameIndex = 0; for (const dataUrl of list) { if (!dataUrl || typeof dataUrl !== 'string') continue; const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); if (!m) continue; const b64 = m[1].replace(/\s/g, ''); if (!b64.length) continue; let buf; try { buf = Buffer.from(b64, 'base64'); } catch (err) { continue; } if (buf.length === 0) continue; // เขียนไฟล์เฟรม (สำหรับ animation) const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png'); fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); // ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก _.png เพื่อใช้เป็น preview / fallback if (list.length > 1 && frameIndex === 0) { const baseName = id + '_' + dir + '.png'; fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); } walkWritten++; frameIndex++; } } let layerWritten = 0; // ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html) const lf = d.layerFrames; if (lf && typeof lf === 'object') { for (const dir of dirs) { const arr = lf[dir]; if (!Array.isArray(arr)) continue; const multi = arr.length > 1; arr.forEach((frameObj, frameIndex) => { if (!frameObj || typeof frameObj !== 'object') return; for (const layerName of Object.keys(frameObj)) { if (!ALLOWED_LAYER[layerName]) continue; const dataUrl = frameObj[layerName]; if (!dataUrl || typeof dataUrl !== 'string') continue; const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); if (!m) continue; const b64 = m[1].replace(/\s/g, ''); if (!b64.length) continue; let buf; try { buf = Buffer.from(b64, 'base64'); } catch (err) { continue; } if (buf.length === 0) continue; const fnameLayer = multi ? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png') : (id + '_' + dir + '_layer_' + layerName + '.png'); fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); layerWritten++; } }); } } const lfIdle = d.layerFramesIdle; if (lfIdle && typeof lfIdle === 'object') { for (const dir of dirs) { const arr = lfIdle[dir]; if (!Array.isArray(arr)) continue; const multi = arr.length > 1; arr.forEach((frameObj, frameIndex) => { if (!frameObj || typeof frameObj !== 'object') return; for (const layerName of Object.keys(frameObj)) { if (!ALLOWED_LAYER[layerName]) continue; const dataUrl = frameObj[layerName]; if (!dataUrl || typeof dataUrl !== 'string') continue; const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); if (!m) continue; const b64 = m[1].replace(/\s/g, ''); if (!b64.length) continue; let buf; try { buf = Buffer.from(b64, 'base64'); } catch (err) { continue; } if (buf.length === 0) continue; const fnameLayer = multi ? (id + '_' + dir + '_idle_' + frameIndex + '_layer_' + layerName + '.png') : (id + '_' + dir + '_idle_layer_' + layerName + '.png'); fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf); layerWritten++; } }); } } const idleObj = d.idle && typeof d.idle === 'object' ? d.idle : null; if (idleObj) { for (const dir of dirs) { let data = idleObj[dir]; if (!data) continue; const list = Array.isArray(data) ? data : [data]; let frameIndex = 0; for (const dataUrl of list) { if (!dataUrl || typeof dataUrl !== 'string') continue; const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/); if (!m) continue; const b64 = m[1].replace(/\s/g, ''); if (!b64.length) continue; let buf; try { buf = Buffer.from(b64, 'base64'); } catch (err) { continue; } if (buf.length === 0) continue; const fnameFrame = list.length > 1 ? (id + '_' + dir + '_idle_' + frameIndex + '.png') : (id + '_' + dir + '_idle.png'); fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf); if (list.length > 1 && frameIndex === 0) { const baseName = id + '_' + dir + '_idle.png'; fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf); } frameIndex++; } } } if (walkWritten === 0 && layerWritten === 0) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (ต้องมีรูปเดินหรือเลเยอร์อย่างน้อยหนึ่งทิศ)' })); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, characterId: id })); } catch (e) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') })); } }); return; } if (url.startsWith(BASE_PATH + '/api/spaces')) { const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean); const spaceId = parts[0]; if (req.method === 'GET' && !spaceId) { const now = Date.now(); for (const [id, s] of [...spaces.entries()]) { if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue; const n = s.peers ? s.peers.size : 0; if (n !== 0) continue; const created = s.createdAt; const stale = created == null || now - created > SPACE_EMPTY_TTL_MS; if (stale) spaces.delete(id); } const list = [...spaces] .filter(([, s]) => !s.isPrivate) .filter(([, s]) => (s.peers && s.peers.size > 0)) .map(([id, s]) => ({ spaceId: id, spaceName: s.spaceName || id, mapName: (s.mapData && s.mapData.name) || '—', peerCount: s.peers ? s.peers.size : 0, maxPlayers: s.maxPlayers || 10 })); return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list)); } if (req.method === 'GET' && spaceId) { const s = spaces.get(spaceId); if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' })); return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name })); } if (req.method === 'POST' && !spaceId) { let body = ''; req.on('data', c => body += c); req.on('end', () => { try { const d = JSON.parse(body || '{}'); const mapData = d.mapId ? maps.get(d.mapId) : null; if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' })); const isPrivate = !!d.isPrivate; let sid; if (isPrivate) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code; for (let i = 0; i < 20; i++) { code = ''; for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)]; if (!spaces.has(code)) { sid = code; break; } } if (!sid) sid = 'priv-' + Date.now(); } else { const base = (d.name || '').trim() || 'space'; sid = base + '-' + Date.now(); } const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid); let maxPlayers = parseInt(d.maxPlayers, 10); if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10; maxPlayers = Math.min(10, Math.max(1, maxPlayers)); if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6); const mapId = d.mapId ? String(d.mapId).trim() : null; const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview'; spaces.set(sid, { mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(), previewEditorTest, }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null })); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } } if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') { function sendIceServers(iceServers) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ iceServers: iceServers || [] })); } function fallback() { const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; const turnUser = process.env.TURN_USER; const turnCred = process.env.TURN_CRED; if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); sendIceServers(iceServers); } const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY; const meteredAppId = process.env.METERED_TURN_APP_ID; let meteredBase = process.env.METERED_TURN_URL; if (!meteredBase) { let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret'; if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live'; meteredBase = 'https://' + meteredHost; } meteredBase = meteredBase.replace(/\/$/, ''); if (meteredKey) { function tryNext(credUrl) { return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) { if (data && (data.error || data.message)) throw new Error(data.error || data.message); const list = Array.isArray(data) ? data : (data && data.iceServers); if (list && list.length) return sendIceServers(list); if (data && data.data && Array.isArray(data.data) && data.data.length) { const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca'; const built = data.data.map(function (c) { return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password }; }); return sendIceServers(built); } throw new Error('No iceServers'); }); } const urlsToTry = [ meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey), meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey), meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey) ]; let p = tryNext(urlsToTry[0]); for (let i = 1; i < urlsToTry.length; i++) { p = p.catch(function () { return tryNext(urlsToTry[i]); }); } p.catch(fallback); return; } const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }]; const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL; const turnUser = process.env.TURN_USER; const turnCred = process.env.TURN_CRED; if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred }); sendIceServers(iceServers); return; } if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') { let body = ''; req.on('data', c => body += c); req.on('end', () => { res.setHeader('Content-Type', 'application/json'); try { const d = JSON.parse(body || '{}'); const password = (d.password || '').trim(); if (password !== ADMIN_PASSWORD) { return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' })); } const token = require('crypto').randomBytes(24).toString('hex'); adminTokens.add(token); res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax'); res.writeHead(200); res.end(JSON.stringify({ ok: true })); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' })); } }); return; } if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') { const token = getAdminToken(req.headers.cookie); if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); const s = loadAiSettings(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ model: s.model || 'gpt-4o-mini', models: AI_MODELS, hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0), intent: s.intent != null ? s.intent : '', rag_enabled: !!s.rag_enabled, rag_context: s.rag_context != null ? s.rag_context : '', })); return; } if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') { const token = getAdminToken(req.headers.cookie); if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' })); let body = ''; req.on('data', c => body += c); req.on('end', () => { res.setHeader('Content-Type', 'application/json'); try { const d = JSON.parse(body || '{}'); const s = loadAiSettings(); if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim(); if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini'; if (d.intent !== undefined) s.intent = String(d.intent); if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled; if (d.rag_context !== undefined) s.rag_context = String(d.rag_context); saveAiSettings(s); res.writeHead(200); res.end(JSON.stringify({ ok: true })); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message })); } }); return; } if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') { let body = ''; req.on('data', c => body += c); req.on('end', () => { res.setHeader('Content-Type', 'application/json; charset=utf-8'); try { const d = JSON.parse(body || '{}'); const message = (d.message || d.text || '').trim(); if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' })); const s = loadAiSettings(); if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' })); const model = s.model || 'gpt-4o-mini'; const messages = []; const systemParts = []; if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim()); if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) { systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim()); } if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') }); messages.push({ role: 'user', content: message }); const payload = JSON.stringify({ model: model, messages: messages, max_tokens: 500, }); const urlObj = new URL('https://api.openai.com/v1/chat/completions'); const opts = { hostname: urlObj.hostname, path: urlObj.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + s.openai_api_key, 'Content-Length': Buffer.byteLength(payload, 'utf8'), }, }; const reqOut = require('https').request(opts, (resOut) => { let data = ''; resOut.on('data', chunk => data += chunk); resOut.on('end', () => { try { const parsed = JSON.parse(data); const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content; if (text != null) { res.writeHead(200); res.end(JSON.stringify({ response: String(text).trim() })); } else { res.writeHead(502); res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' })); } } catch (e) { res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' })); } }); }); reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); }); reqOut.write(payload); reqOut.end(); } catch (e) { res.writeHead(400), res.end(JSON.stringify({ error: e.message })); } }); return; } if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') { const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0]; const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, ''); if (fname && fname.length > 0) { const fp = path.join(CHARACTERS_DIR, fname); if (fs.existsSync(fp)) { return fs.readFile(fp, (err, data) => { if (err) return res.writeHead(500), res.end(); res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(data); }); } } } if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') { const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]); const fname = safeGauntletStoredFilename(sub); if (!fname) { res.writeHead(400); return res.end(); } const fp = path.join(GAUNTLET_ASSETS_DIR, fname); if (!fs.existsSync(fp)) { res.writeHead(404); return res.end(); } const ext = path.extname(fname); return fs.readFile(fp, (err, data) => { if (err) return res.writeHead(500), res.end(); res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) }); res.end(data); }); } if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html'; const filePath = path.join(__dirname, 'public', url); const ext = path.extname(filePath); const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' }; fs.readFile(filePath, (err, data) => { if (err) return res.writeHead(404), res.end('Not Found'); res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' }); res.end(data); }); }); const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } }); const spaces = new Map(); const GAUNTLET_MAX_PLAYERS = 6; /** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */ /** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */ const GAUNTLET_LASER_SPAWN_CHANCE = 0.14; /** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */ const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34; /** Last Light / museum gauntlet — scoring, mission UI, how-to flow */ const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07'; function isGauntletCrownHeistMapBySpace(space) { return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); } function rollGauntletCrownRewardCard(grade) { const r = Math.random(); if (grade === 'A') { if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' }; if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (grade === 'B') { if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' }; if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' }; return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' }; } function gauntletCrownRankBonus(rankOrdinal) { if (rankOrdinal === 1) return 100; if (rankOrdinal === 2) return 80; if (rankOrdinal === 3) return 60; return 0; } function buildGauntletCrownMissionPayload(space) { const peersArr = [...space.peers.values()]; const rows = peersArr.map((p) => ({ id: p.id, nickname: (p.nickname || '').trim() || 'ผู้เล่น', characterId: p.characterId ?? null, baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0), eliminated: !!p.gauntletEliminated, })); rows.sort((a, b) => { if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore; return String(a.nickname).localeCompare(String(b.nickname), 'th'); }); let lastScore = null; let rankOrdinal = 0; const ranked = []; for (let i = 0; i < rows.length; i++) { const pos = i + 1; if (lastScore !== rows[i].baseScore) { rankOrdinal = pos; lastScore = rows[i].baseScore; } const bonus = gauntletCrownRankBonus(rankOrdinal); const finalScore = rows[i].baseScore + bonus; const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal); ranked.push({ id: rows[i].id, nickname: rows[i].nickname, characterId: rows[i].characterId, baseScore: rows[i].baseScore, eliminated: rows[i].eliminated, rank: rankOrdinal, rankLabel, rankBonus: bonus, finalScore, }); } const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0); const n = ranked.length || 1; const averageScore = Math.floor(totalSum / n); const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C'; const rewardCard = rollGauntletCrownRewardCard(grade); return { ranked, totalSum, averageScore, grade, rewardCard, totalParts: ranked.map((r) => r.finalScore), }; } function gauntletSpawnYs(md, playerCount) { const h = md.height || 15; const lo = 1; const hi = Math.max(lo, h - 2); const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount)); if (hi <= lo) return Array.from({ length: n }, () => lo); const ys = []; for (let i = 0; i < n; i++) { ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1))); } return ys; } /** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */ function collectGauntletSpawnSlotsFromSpawnArea(md) { const grid = md.spawnArea; if (!grid || !Array.isArray(grid)) return []; const w = md.width || 20; const h = md.height || 15; const slots = []; for (let y = 0; y < h; y++) { const row = grid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y }); } } slots.sort((a, b) => a.y - b.y || a.x - b.x); return slots; } /** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */ function collectGauntletSpawnSlotsFromMap(md) { const explicit = md.gauntletPlayerSpawns; if (Array.isArray(explicit) && explicit.length > 0) { const w = md.width || 20; const h = md.height || 15; const slots = []; for (const raw of explicit) { if (slots.length >= GAUNTLET_MAX_PLAYERS) break; const x = Math.floor(Number(raw && raw.x)); const y = Math.floor(Number(raw && raw.y)); if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue; if (!isMapTileWalkableForSpawn(md, x, y)) continue; slots.push({ x, y }); } if (slots.length > 0) return slots; } return collectGauntletSpawnSlotsFromSpawnArea(md); } function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) { const w = md.width || 20; if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX; if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback; for (let d = 0; d < w; d++) { if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d; if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d; } return Math.max(0, Math.min(w - 1, spawnColX)); } /** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */ function gauntletSpawnPositions(md, playerCount) { const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount)); const slots = collectGauntletSpawnSlotsFromMap(md); const fallbackYs = gauntletSpawnYs(md, n); const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1; const out = []; for (let i = 0; i < n; i++) { const y = i < slots.length ? slots[i].y : (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]); const slotX = i < slots.length ? slots[i].x : null; const x = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX); out.push({ x, y }); } return out; } function stopGauntletTicker(space) { if (space.gauntletRun && space.gauntletRun.timerId) { clearInterval(space.gauntletRun.timerId); space.gauntletRun.timerId = null; } } /** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */ function ensureGauntletEndsAtIfNeeded(space) { const gr = space.gauntletRun; if (!gr) return; if (gr.crownRunHeld) return; const lim = getGauntletTimeLimitSec(); if (lim > 0 && gr.endsAt == null) { gr.endsAt = Date.now() + lim * 1000; } } function newGauntletRunState(space) { const crown = !!(space && isGauntletCrownHeistMapBySpace(space)); return { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3, crownRunHeld: crown, }; } function emitGauntletSync(sid, space) { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') return; const gr = space.gauntletRun; if (!gr) return; const h = md.height || 15; const obsOut = gr.obstacles.map((o) => { if (o.kind === 'laser') { const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0; const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1; return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 }; } return { id: o.id, kind: o.kind, x: o.x, y: o.y }; }); const playersOut = [...space.peers.values()].map((p) => ({ id: p.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId ?? null, gauntletJumpTicks: p.gauntletJumpTicks || 0, gauntletScore: p.gauntletScore || 0, gauntletEliminated: !!p.gauntletEliminated, })); io.to(sid).emit('gauntlet-sync', { obstacles: obsOut, players: playersOut, gauntletTickMs: getGauntletTickMs(), gauntletJumpTicks: getGauntletJumpTicks(), gauntletTimeLimitSec: getGauntletTimeLimitSec(), gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null, gauntletCrownRunHeld: !!gr.crownRunHeld, ...getGauntletVisualsForClient(), }); } function startGauntletTicker(sid, space) { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') return; if (space.gauntletRun && space.gauntletRun.timerId) return; if (!space.gauntletRun) { space.gauntletRun = newGauntletRunState(space); } ensureGauntletEndsAtIfNeeded(space); space.gauntletRun.timerId = setInterval(() => { runGauntletTick(sid, space); }, getGauntletTickMs()); } function initGauntletPeersAll(space, md) { const list = [...space.peers.values()]; const pos = gauntletSpawnPositions(md, list.length); const crown = isGauntletCrownHeistMapBySpace(space); list.forEach((p, i) => { const pt = pos[i] || pos[pos.length - 1]; p.x = pt.x; p.y = pt.y; p.gauntletJumpTicks = 0; p.gauntletScore = crown ? 100 : 0; p.gauntletJumpPending = false; p.gauntletEliminated = false; }); space.gauntletRun = newGauntletRunState(space); if (crown) { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); } space.gauntletPreviewRowsBySocket = new Map(); } function findFallbackLobbyMapForGauntletEnd() { for (const [id, m] of maps) { if (m && m.gameType === 'lobby') return { id, m }; } return null; } /** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */ function endGauntletGame(sid, space, reason) { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') return; stopGauntletTicker(space); const wasCrown = isGauntletCrownHeistMapBySpace(space); const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null; const rankings = crownMission ? crownMission.ranked.map((r) => ({ id: r.id, nickname: r.nickname, score: r.finalScore, })) : [...space.peers.values()].map((p) => ({ id: p.id, nickname: (p.nickname || '').trim() || 'ผู้เล่น', score: Math.max(0, p.gauntletScore | 0), })).sort((a, b) => b.score - a.score); let retId = space.gauntletReturnMapId; let retMap = retId && maps.has(retId) ? maps.get(retId) : null; if (!retMap || retMap.gameType === 'gauntlet') { const fb = findFallbackLobbyMapForGauntletEnd(); if (fb) { retId = fb.id; retMap = fb.m; } } if (retMap && retMap.gameType !== 'gauntlet') { space.mapId = retId; space.mapData = retMap; let gi = 0; space.peers.forEach((p) => { const sp = pickSpawnForJoin(retMap, gi++); p.x = sp.x; p.y = sp.y; p.gauntletJumpTicks = 0; p.gauntletScore = 0; p.gauntletJumpPending = false; p.gauntletEliminated = false; }); } else { console.warn('endGauntletGame: no lobby map to return to', sid); } space.gauntletReturnMapId = null; if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear(); space.gauntletRun = null; const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว'; io.to(sid).emit('gauntlet-ended', { reason: reason || 'time', message: msg, rankings, crownMission: crownMission || undefined, }); } /** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */ function gauntletLaserVerticalSpanFromMap(md) { const hRaw = md && md.height != null ? Number(md.height) : 15; const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15)); const rs = md && md.gauntletLaserRowStart; const re = md && md.gauntletLaserRowEnd; if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) { return { y0: 0, y1: h - 1 }; } let y0 = Math.floor(Number(rs)); let y1 = Math.floor(Number(re)); y0 = Math.max(0, Math.min(h - 1, y0)); y1 = Math.max(0, Math.min(h - 1, y1)); if (y1 < y0) { const t = y0; y0 = y1; y1 = t; } return { y0, y1 }; } /** * แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y) * — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม) */ function gauntletLaneGridRowFromPlayerTop(md, topY, h) { const { ch } = getCharacterFootprintWHForMove(md); const top = Math.floor(Number(topY)); if (!Number.isFinite(top) || top < 0 || top >= h) return null; const lr = top + ch - 1; if (lr < 0 || lr >= h) return null; return lr; } function runGauntletTick(sid, space) { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') { stopGauntletTicker(space); return; } const w = md.width || 20; const h = md.height || 15; const gr = space.gauntletRun; if (!gr) return; ensureGauntletEndsAtIfNeeded(space); if (gr.endsAt != null && Date.now() >= gr.endsAt) { endGauntletGame(sid, space, 'time'); return; } if (gr.crownRunHeld) { space.peers.forEach((p) => { p.gauntletJumpPending = false; }); emitGauntletSync(sid, space); return; } /* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */ space.peers.forEach((p) => { if (p.gauntletJumpPending) { if ((p.gauntletJumpTicks || 0) === 0) { p.gauntletJumpTicks = getGauntletJumpTicks(); } p.gauntletJumpPending = false; } }); for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1; gr.obstacles = gr.obstacles.filter((o) => o.x >= 0); const laneSpawnRows = new Set(); space.peers.forEach((peer) => { const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h); if (lr != null) laneSpawnRows.add(lr); }); /* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */ const previewRows = space.gauntletPreviewRowsBySocket; if (previewRows && previewRows.size) { previewRows.forEach((set) => { set.forEach((y) => { const lr = gauntletLaneGridRowFromPlayerTop(md, y, h); if (lr != null) laneSpawnRows.add(lr); }); }); } /* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */ gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y)); gr.spawnAcc = (gr.spawnAcc || 0) + 1; if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) { gr.spawnAcc = 0; gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6); /* ประเภท 2: เลเซอร์ — ทุกแถว */ if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) { const span = gauntletLaserVerticalSpanFromMap(md); gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 }); } /* ประเภท 1: แยกเลน — แถวเท้า (ไม่ใช่แถวหัว; ไม่ใช่แถวใต้เท้าอีกช่อง) */ laneSpawnRows.forEach((ly) => { if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) { gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly }); } }); } /* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */ const crownHeist = isGauntletCrownHeistMapBySpace(space); const { ch: gauntletCh } = getCharacterFootprintWHForMove(md); space.peers.forEach((p) => { if (crownHeist && p.gauntletEliminated) { return; } let px = Math.floor(Number(p.x)) || 0; let py = Math.floor(Number(p.y)) || 0; px = Math.max(0, Math.min(w - 1, px)); py = Math.max(0, Math.min(h - 1, py)); const air = (p.gauntletJumpTicks || 0) > 0; let advanceX = false; let hitBack = false; for (const o of gr.obstacles) { if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) { if (air) advanceX = true; else hitBack = true; } if (o.kind === 'laser' && o.x === px) { const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0; const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1; const lo = Math.min(y0, y1); const hi = Math.max(y0, y1); if (py < lo || py > hi) { /* เลเซอร์ไม่ครอบแถวนี้ */ } else if (air) { advanceX = true; } else { hitBack = true; } } } if (advanceX) { px = Math.min(w - 2, px + 1); p.gauntletJumpTicks = 0; if (!crownHeist) { p.gauntletScore = (p.gauntletScore || 0) + 1; } } else if (hitBack) { if (crownHeist) { if (px <= 0) { p.gauntletEliminated = true; p.gauntletScore = 0; } else { p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10); px = Math.max(0, px - 1); } } else { px = Math.max(0, px - 1); } } if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--; p.x = px; p.y = py; }); emitGauntletSync(sid, space); } function rescheduleAllGauntletTickers() { for (const [sid, space] of spaces) { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') continue; if (space.gauntletRun && space.gauntletRun.timerId) { clearInterval(space.gauntletRun.timerId); space.gauntletRun.timerId = null; } if (space.peers.size === 0) continue; if (!space.gauntletRun) { space.gauntletRun = newGauntletRunState(space); } ensureGauntletEndsAtIfNeeded(space); space.gauntletRun.timerId = setInterval(() => { runGauntletTick(sid, space); }, getGauntletTickMs()); } } function getLobbyLayoutMapForSpace(space) { return (space.mapId && maps.get(space.mapId)) || space.mapData; } /** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */ function serverMapIsPostCaseLobbyB(space) { if (!space) return false; if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; const md = getLobbyLayoutMapForSpace(space); if (!md || md.gameType !== 'lobby') return false; const nm = String(md.name || '').trim().toLowerCase(); return nm === 'lobbyb'; } /** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */ function normalizeLobbyNickname(nickname) { const raw = String(nickname || '').trim(); if (!raw) return 'ผู้เล่น'; try { return raw.normalize('NFKC').toLowerCase(); } catch (e) { return raw.toLowerCase(); } } /** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */ function lobbySpaceAlreadyLobbyB(space, md) { if (!md || md.gameType !== 'lobby') return false; if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true; if (String(md.name || '').trim() === 'LobbyB') return true; return false; } function lobbyMapHasStartGameArea(md) { if (!md) return false; const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya'; if (md.gameType !== 'lobby' && !namedLobbyA) return false; const area = md.startGameArea; if (!area || !Array.isArray(area)) return false; for (let y = 0; y < area.length; y++) { const row = area[y]; if (!row) continue; for (let x = 0; x < row.length; x++) if (row[x] === 1) return true; } return false; } /** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */ function lobbyHostStandingInStartArea(space, hostId) { const md = getLobbyLayoutMapForSpace(space); if (!lobbyMapHasStartGameArea(md)) return true; const p = space.peers.get(hostId); if (!p || p.x == null || p.y == null) return false; const tx = Math.floor(p.x); const ty = Math.floor(p.y); const row = md.startGameArea[ty]; return !!(row && row[tx] === 1); } function isMapTileWalkableForSpawn(md, x, y) { const w = md.width || 20; const h = md.height || 15; if (x < 0 || x >= w || y < 0 || y >= h) return false; const row = md.objects && md.objects[y]; if (row && row[x] === 1) return false; return true; } /** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */ function parseLobbyPlayerSpawnsFromMap(md) { const w = md.width || 20; const h = md.height || 15; const out = [null, null, null, null, null, null]; const raw = md && md.lobbyPlayerSpawns; if (!Array.isArray(raw)) return out; for (let i = 0; i < 6 && i < raw.length; i++) { const cell = raw[i]; if (!cell || typeof cell !== 'object') continue; const x = Math.floor(Number(cell.x)); const y = Math.floor(Number(cell.y)); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; if (x < 0 || x >= w || y < 0 || y >= h) continue; out[i] = { x, y }; } return out; } /** * จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก) * โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม */ function pickSpawnForJoin(md, joinOrderIndex) { if (!md) return { x: 1, y: 1 }; const mode = md.lobbySpawnMode; const ord = joinOrderIndex | 0; if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md); const j = Math.min(Math.max(0, ord), 5); if (mode === 'fixed' && md.spawn) { const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1; const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1; const w = md.width || 20; const h = md.height || 15; const x = Math.max(0, Math.min(w - 1, fx)); const y = Math.max(0, Math.min(h - 1, fy)); if (isMapTileWalkableForSpawn(md, x, y)) return { x, y }; return pickRandomSpawnFromMap(md); } if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMap(md); const pick = slots[j]; if (pick && isMapTileWalkableForSpawn(md, pick.x, pick.y)) return { x: pick.x, y: pick.y }; return pickRandomSpawnFromMap(md); } return pickRandomSpawnFromMap(md); } /** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */ function pickRandomSpawnFromMap(md) { const fallback = md.spawn || { x: 1, y: 1 }; const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1; const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1; const grid = md.spawnArea; if (!grid || !Array.isArray(grid)) return { x: fx, y: fy }; const w = md.width || 20; const h = md.height || 15; const pool = []; for (let y = 0; y < h; y++) { const row = grid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y }); } } if (!pool.length) return { x: fx, y: fy }; const idx = typeof crypto.randomInt === 'function' ? crypto.randomInt(0, pool.length) : Math.floor(Math.random() * pool.length); const pick = pool[idx]; return { x: pick.x, y: pick.y }; } /** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */ function quizWrongAnswerRespawnPosition(md, joinOrderIndex) { const ord = joinOrderIndex | 0; const sp = pickSpawnForJoin(md, ord); return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5); } function initTroublesomeState(space) { if (!space.troublesomeEligible) space.troublesomeEligible = new Set(); if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false; if (space.suspectPickIndex == null) space.suspectPickIndex = 0; if (space.suspectPhaseActive == null) space.suspectPhaseActive = false; } function attemptTroublesomeRoll(sid, force) { const space = spaces.get(sid); if (!space || space.troublesomeOfferSent) return; const peerIds = [...space.peers.keys()]; if (peerIds.length === 0) return; if (!force && space.troublesomeEligible.size < peerIds.length) return; space.troublesomeOfferSent = true; if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); space.troublesomeDebTimer = null; space.troublesomeMaxTimer = null; let pool = peerIds.filter((id) => space.troublesomeEligible.has(id)); if (pool.length === 0) pool = peerIds; const chosen = pool[Math.floor(Math.random() * pool.length)]; space.troublesomeTargetId = chosen; space.troublesomeResponseReceived = false; io.to(chosen).emit('troublesome-offer', { seconds: 15 }); } function scheduleTroublesomeRoll(sid) { const space = spaces.get(sid); if (!space || space.troublesomeOfferSent) return; if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450); if (!space.troublesomeMaxTimer) { space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000); } } /** * ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์ * — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview */ function spaceAllowsQuizCarryLobbyRelaxed(space) { if (!space) return false; if (space.mapId) { const byId = maps.get(space.mapId); if (byId && byId.gameType === 'quiz_carry') return true; } const md = space.mapData; if (md && md.gameType === 'quiz_carry') return true; return !!(space.previewEditorTest || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); } io.on('connection', (socket) => { socket.on('join-space', ({ spaceId, nickname, characterId }, cb) => { const space = spaces.get(spaceId); if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' }); const maxPlayers = space.maxPlayers || 10; const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData; if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); } if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); } if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) { return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' }); } if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' }); const joinNickNorm = normalizeLobbyNickname(nickname); if (space.joinLocked) { const wl = space.caseParticipantNicknames; if (wl && wl.size > 0) { if (!wl.has(joinNickNorm)) { return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); } } else { return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' }); } } socket.join(spaceId); socket.data.spaceId = spaceId; if (!space.hostId) space.hostId = socket.id; if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; const spawnJoinOrder = space.peers.size; const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3)); const peer = { id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, spawnJoinOrder, gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, balloonBossScore: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false, }; if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5); peer.x = sn.x; peer.y = sn.y; } space.peers.set(socket.id, peer); if (spaceAllowsQuizCarryLobbyRelaxed(space)) { if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; space.quizCarryLobbyReady[socket.id] = false; io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); } if (mdJoin.gameType === 'gauntlet') { const ord = [...space.peers.keys()].indexOf(socket.id); const pos = gauntletSpawnPositions(mdJoin, space.peers.size); const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1]; peer.x = pt.x; peer.y = pt.y; peer.gauntletJumpTicks = 0; if (isGauntletCrownHeistMapBySpace(space)) { peer.gauntletScore = 100; peer.gauntletEliminated = false; if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.gauntletCrownLobbyReady[socket.id] = false; io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } startGauntletTicker(spaceId, space); } const peersList = [...space.peers.values()]; const mapData = mdJoin; const joinCb = { ok: true, mapData, mapId: space.mapId || null, peers: peersList, hostId: space.hostId, spaceName: space.spaceName || spaceId, isPrivate: !!space.isPrivate, roomCode: space.isPrivate ? spaceId : null, maxPlayers: space.maxPlayers || 10, suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space), suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, }; if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) { try { const qs = loadQuizSettings(); joinCb.quizCarrySettingsSnap = { carryMapPanelTheme: qs.carryMapPanelTheme, carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme, carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes, carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme, carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale, carryReadMs: qs.carryReadMs, carryAnswerMs: qs.carryAnswerMs, carrySessionLength: qs.carrySessionLength, carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId, carryWalkSpeedMult: qs.carryWalkSpeedMult, }; } catch (e) { /* ignore */ } } if (mdJoin.gameType === 'quiz') { try { const qsQuiz = loadQuizSettings(); joinCb.quizSettingsSnap = { quizMapPanelTheme: qsQuiz.quizMapPanelTheme, }; } catch (e) { /* ignore */ } } if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) { joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; } if (typeof cb === 'function') cb(joinCb); socket.to(spaceId).emit('user-joined', peer); if (space.voiceInits) { for (const [peerId, initData] of Object.entries(space.voiceInits)) { if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData }); } } }); /** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */ socket.on('preview-claim-host', (cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); const isPreviewEditorRoom = !!(space.previewEditorTest || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' }); space.hostId = socket.id; io.to(sid).emit('host-changed', { hostId: socket.id }); return reply({ ok: true }); }); socket.on('troublesome-eligible', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return; initTroublesomeState(space); space.troublesomeEligible.add(socket.id); scheduleTroublesomeRoll(sid); }); socket.on('troublesome-response', (data) => { const accept = !!(data && data.accept); const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space)) return; if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return; if (space.troublesomeResponseReceived) return; space.troublesomeResponseReceived = true; space.troublesomeAccept = accept; let idx = Math.floor(Number(space.suspectPickIndex)); if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0; space.suspectPickIndex = idx; space.suspectPhaseActive = true; io.to(sid).emit('suspect-phase-open', { hostId: space.hostId, selectedIndex: space.suspectPickIndex, disruptorAccepted: accept }); socket.emit('troublesome-ack', { ok: true, accept }); }); socket.on('suspect-pick-select', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return; if (space.hostId !== socket.id) return; let idx = Math.floor(Number(data && data.index)); if (Number.isNaN(idx) || idx < 0 || idx > 2) return; space.suspectPickIndex = idx; io.to(sid).emit('suspect-pick-update', { selectedIndex: idx }); }); socket.on('suspect-pick-start', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space)) { return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' }); } if (!space.suspectPhaseActive) { return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' }); } if (space.hostId !== socket.id) { return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' }); } const md = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID); if (!md) { return reply({ ok: false, error: 'ไม่พบฉากเกม (mng8a80o) บนเซิร์ฟเวอร์' }); } const qErr = validateQuizMap(md); if (qErr) { return reply({ ok: false, error: qErr }); } space.suspectPhaseActive = false; clearSpaceQuizTimers(space); space.mapId = SUSPECT_INVESTIGATION_QUIZ_MAP_ID; space.mapData = md; let si = 0; space.peers.forEach((p) => { const sp = pickSpawnForJoin(md, si++); p.x = sp.x; p.y = sp.y; p.direction = 'down'; }); const peersSnap = [...space.peers.values()].map((p) => ({ id: p.id, x: p.x, y: p.y, direction: p.direction || 'down', nickname: p.nickname, ready: !!p.ready, characterId: p.characterId ?? null })); const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; io.to(sid).emit('suspect-investigation-start', { selectedIndex }); io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true, mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, peersSnap }); setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || !spNow.peers.has(socket.id)) return; const mdLive = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID); if (!mdLive) return; startQuizGame(sid, spNow, mdLive); }, 900); reply({ ok: true }); }); /* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */ socket.on('set-ready', ({ ready }) => { for (const [sid, space] of spaces) { if (space.peers.has(socket.id)) { const p = space.peers.get(socket.id); if (p) p.ready = !!ready; io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready }); break; } } }); socket.on('start-game', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; for (const [sid, space] of spaces) { if (space.peers.has(socket.id) && space.hostId === socket.id) { let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : ''; let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : ''; if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = ''; if (caseId && !/^[1-3]$/.test(caseId)) caseId = ''; ensurePostCaseLobbyMapLoaded(); const mdBefore = getLobbyLayoutMapForSpace(space); if (mdBefore && mdBefore.gameType === 'quiz') { const qErr = validateQuizMap(mdBefore); if (qErr) return reply({ ok: false, error: qErr }); clearSpaceQuizTimers(space); startQuizGame(sid, space, mdBefore); io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true }); return reply({ ok: true }); } const detectiveB = !!(data && data.detectiveLobbyBStart); if (detectiveB && (!lobbyLevel || !caseId)) { return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' }); } const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : ''; const isNamedLobbyA = mapNameNorm === 'lobbya'; const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet'); /* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */ const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore) && lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID) && ( mdBefore.gameType === 'lobby' || isNamedLobbyA || (detectiveB && notZepOrFrogger) )); if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) { return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' }); } if (!lobbyHostStandingInStartArea(space, socket.id)) { return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' }); } if (isLobbyADetectiveStart) { space.mapId = POST_CASE_LOBBY_SPACE_ID; space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID); const caseNickWl = new Set(); space.peers.forEach((p) => { caseNickWl.add(normalizeLobbyNickname(p.nickname)); }); space.caseParticipantNicknames = caseNickWl; space.joinLocked = true; space.troublesomeOfferSent = false; space.troublesomeTargetId = null; space.troublesomeResponseReceived = false; space.suspectPhaseActive = false; space.suspectPickIndex = 0; if (space.troublesomeEligible) space.troublesomeEligible.clear(); else space.troublesomeEligible = new Set(); if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); space.troublesomeDebTimer = null; space.troublesomeMaxTimer = null; initTroublesomeState(space); let li = 0; space.peers.forEach((p) => { const sp = pickSpawnForJoin(space.mapData, li++); p.x = sp.x; p.y = sp.y; p.direction = 'down'; }); const peersSnap = [...space.peers.values()].map((p) => ({ id: p.id, x: p.x, y: p.y, direction: p.direction || 'down', nickname: p.nickname, ready: !!p.ready, characterId: p.characterId ?? null })); io.to(sid).emit('game-start', { stayInRoomLobby: true, mapId: POST_CASE_LOBBY_SPACE_ID, lobbyLevel, caseId, peersSnap }); return reply({ ok: true }); } const mapId = (data && data.mapId) ? String(data.mapId).trim() : null; let peersSnap = null; let gauntletEndsAtEmit = undefined; if (mapId && maps.has(mapId)) { const prevMapIdForReturn = space.mapId; space.mapId = mapId; space.mapData = maps.get(mapId); const md = space.mapData; if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { space.peers.forEach((p) => { const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y)); p.x = sn.x; p.y = sn.y; }); } if (!md || md.gameType !== 'gauntlet') { stopGauntletTicker(space); space.gauntletReturnMapId = null; space.gauntletRun = null; } if (md && md.gameType === 'gauntlet') { stopGauntletTicker(space); space.gauntletReturnMapId = prevMapIdForReturn; initGauntletPeersAll(space, md); ensureGauntletEndsAtIfNeeded(space); startGauntletTicker(sid, space); gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; peersSnap = [...space.peers.values()].map((p) => ({ id: p.id, x: p.x, y: p.y, direction: p.direction || 'down', nickname: p.nickname, ready: !!p.ready, characterId: p.characterId ?? null, gauntletJumpTicks: p.gauntletJumpTicks || 0, gauntletScore: p.gauntletScore || 0, gauntletEliminated: !!p.gauntletEliminated, })); } } const gameStartPayload = { mapId: mapId || undefined, lobbyLevel: lobbyLevel || undefined, caseId: caseId || undefined, peersSnap: peersSnap || undefined, }; if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; io.to(sid).emit('game-start', gameStartPayload); return reply({ ok: true }); } } reply({ ok: false, error: 'ไม่ใช่ host' }); }); socket.on('disconnect', () => { let sid = socket.data.spaceId; let space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) { for (const [s, sp] of spaces) { if (sp.peers.has(socket.id)) { sid = s; space = sp; break; } } } if (!space || !space.peers.has(socket.id)) return; const wasHost = space.hostId === socket.id; if (space.voiceInits) delete space.voiceInits[socket.id]; if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id); if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id]; if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id); if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') { delete space.quizCarryLobbyReady[socket.id]; if (spaceAllowsQuizCarryLobbyRelaxed(space)) { io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); } } if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { delete space.gauntletCrownLobbyReady[socket.id]; if (isGauntletCrownHeistMapBySpace(space)) { io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } } space.peers.delete(socket.id); if (space.peers.size === 0) { stopGauntletTicker(space); clearSpaceQuizTimers(space); space.quizSession = null; spaces.delete(sid); } else { if (wasHost) { const firstPeer = space.peers.values().next().value; if (firstPeer) { space.hostId = firstPeer.id; io.to(sid).emit('host-changed', { hostId: firstPeer.id }); } } io.to(sid).emit('user-left', { id: socket.id }); } }); socket.on('gauntlet-jump', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; const p = space.peers.get(socket.id); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') return; if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return; if ((p.gauntletJumpTicks || 0) > 0) return; p.gauntletJumpPending = true; }); socket.on('gauntlet-crown-begin-run', (cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return reply({ ok: false }); const gr = space.gauntletRun; if (!gr) return reply({ ok: false }); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet' || !isGauntletCrownHeistMapBySpace(space)) return reply({ ok: false }); const previewRoom = !!(space.previewEditorTest || (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview')); if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' }); if (!gr.crownRunHeld) return reply({ ok: true, already: true }); gr.crownRunHeld = false; ensureGauntletEndsAtIfNeeded(space); emitGauntletSync(sid, space); return reply({ ok: true }); }); /** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */ socket.on('gauntlet-preview-rows', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'gauntlet') return; if (space.gauntletRun && space.gauntletRun.crownRunHeld) return; const hh = md.height || 15; if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map(); const raw = data && Array.isArray(data.ys) ? data.ys : []; const set = new Set(); const cap = Math.min(raw.length, 48); for (let i = 0; i < cap; i++) { const fy = Math.floor(Number(raw[i])); if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy); } space.gauntletPreviewRowsBySocket.set(socket.id, set); }); socket.on('quiz-carry-lobby-sync-request', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') { space.quizCarryLobbyReady = {}; } for (const id of space.peers.keys()) { if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; } socket.emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); }); socket.on('quiz-carry-lobby-ready', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return; if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {}; for (const id of space.peers.keys()) { if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false; } space.quizCarryLobbyReady[socket.id] = !!(data && data.ready); io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } }); }); socket.on('quiz-carry-lobby-start', (_data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); const rm = space.quizCarryLobbyReady || {}; const humanIds = [...space.peers.keys()]; const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); io.to(sid).emit('quiz-carry-lobby-started', {}); reply({ ok: true }); }); socket.on('gauntlet-crown-lobby-sync-request', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; if (!isGauntletCrownHeistMapBySpace(space)) return; if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') { space.gauntletCrownLobbyReady = {}; } for (const id of space.peers.keys()) { if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; } socket.emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); }); socket.on('gauntlet-crown-lobby-ready', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; if (!isGauntletCrownHeistMapBySpace(space)) return; if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; for (const id of space.peers.keys()) { if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false; } space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready); io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); }); socket.on('gauntlet-crown-lobby-start', (_data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); if (!isGauntletCrownHeistMapBySpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' }); const gr = space.gauntletRun; if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' }); const rm = space.gauntletCrownLobbyReady || {}; const humanIds = [...space.peers.keys()]; const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); io.to(sid).emit('gauntlet-crown-lobby-started', {}); reply({ ok: true }); }); socket.on('move', (data) => { for (const [sid, space] of spaces) { if (space.peers.has(socket.id)) { const p = space.peers.get(socket.id); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (md && md.gameType === 'gauntlet') { const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId }; socket.emit('user-move', out); break; } let nx = data.x; let ny = data.y; const sess = space.quizSession; const mdQuiz = (sess && sess.quizMapMd) || md; if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') { const st = sess.players && sess.players[socket.id]; if (st && !st.eliminated) { const tx = Math.floor(Number(nx)); const ty = Math.floor(Number(ny)); const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty); const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty); if (blockTrue || blockFalse) { nx = p.x; ny = p.y; } } } if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) { const txN = Number(nx); const tyN = Number(ny); if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) { nx = p.x; ny = p.y; } } if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { const txN = Number(nx); const tyN = Number(ny); if (!Number.isFinite(txN) || !Number.isFinite(tyN)) { nx = p.x; ny = p.y; } else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) { nx = p.x; ny = p.y; } const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny)); nx = sn.x; ny = sn.y; } if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) { const ns = Math.floor(Number(data.spaceShooterScore)); if (Number.isFinite(ns) && ns >= 0) { const prev = Math.max(0, p.spaceShooterScore | 0); if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns); } } if (p && md && md.gameType === 'balloon_boss' && data) { const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); if (data.balloonBossScore != null) { const ns = Math.floor(Number(data.balloonBossScore)); if (Number.isFinite(ns) && ns >= 0) { const prev = Math.max(0, p.balloonBossScore | 0); if (ns <= prev + 25) p.balloonBossScore = Math.max(prev, ns); } } if (data.balloonBossBalloons != null) { const nb = Math.floor(Number(data.balloonBossBalloons)); if (Number.isFinite(nb) && nb >= 0) { const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) ? Math.floor(p.balloonBossBalloons) : bbDefaultBalloons; if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb; } } if (data.balloonBossEliminated) p.balloonBossEliminated = true; } if (p) { p.x = nx; p.y = ny; p.direction = data.direction || p.direction; const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId }; if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0); if (md && md.gameType === 'balloon_boss') { const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3)); out.balloonBossScore = Math.max(0, p.balloonBossScore | 0); out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons) ? Math.max(0, Math.floor(p.balloonBossBalloons)) : bbDef; out.balloonBossEliminated = !!p.balloonBossEliminated; } if (nx !== data.x || ny !== data.y) socket.emit('user-move', out); socket.to(sid).emit('user-move', out); } break; } } }); socket.on('lobby-interact', (data, ack) => { const reply = typeof ack === 'function' ? ack : () => {}; const tx = Math.floor(Number(data && data.x)); const ty = Math.floor(Number(data && data.y)); for (const [sid, space] of spaces) { if (!space.peers.has(socket.id)) continue; const p = space.peers.get(socket.id); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' }); const w = md.width || 20; const h = md.height || 15; if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) { return reply({ ok: false, error: 'จุดไม่ถูกต้อง' }); } if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) { return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' }); } const px = Math.floor(Number(p.x)); const py = Math.floor(Number(p.y)); const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]]; let near = false; for (const [dx, dy] of neighbors) { if (px + dx === tx && py + dy === ty) { near = true; break; } } if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' }); const nick = (p.nickname || '').trim() || 'ผู้เล่น'; io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty }); return reply({ ok: true }); } reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' }); }); socket.on('chat', (text) => { for (const [sid, space] of spaces) { if (socket.rooms.has(sid)) { const nick = space.peers.get(socket.id)?.nickname || ''; io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick }); break; } } }); socket.on('voice-state', ({ micOn }) => { for (const [sid, space] of spaces) { if (space.peers.has(socket.id)) { const p = space.peers.get(socket.id); if (p) p.voiceMicOn = micOn !== false; if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id]; io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false }); break; } } }); socket.on('voice-activity', ({ level }) => { const sid = socket.data.spaceId; if (!sid || !spaces.get(sid)) return; const until = Date.now() + 400; io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until }); }); socket.on('voice-init', (data) => { for (const [sid, space] of spaces) { if (socket.rooms.has(sid)) { if (!space.voiceInits) space.voiceInits = {}; space.voiceInits[socket.id] = data; io.to(sid).emit('voice-init', { from: socket.id, data }); break; } } }); socket.on('voice-chunk', (data) => { for (const [sid, space] of spaces) { if (socket.rooms.has(sid)) { socket.to(sid).emit('voice-chunk', { from: socket.id, data }); break; } } }); socket.on('webrtc-signal', (data) => { const { to, type, sdp, candidate } = data; if (!to) return; for (const [sid, space] of spaces) { if (socket.rooms.has(sid) && space.peers.has(to)) { io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate }); break; } } }); }); loadMaps(); server.listen(PORT, () => console.log('Game', PORT));