const http = require('http'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { Server } = require('socket.io'); const PORT = parseInt(process.env.PORT, 10) || 3001; /** Windows dev: bind 127.0.0.1 (0.0.0.0 มัก EACCES ถ้าพอร์ตอยู่ใน excluded range ของ Hyper-V) */ const HOST = process.env.HOST || process.env.GAME_HOST || '0.0.0.0'; 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 เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ฉากควิซเดิม (fallback) */ const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o'; /** 7 Minigame จาก Admin — สุ่ม 3 แบบไม่ซ้ำผูกการ์ดผู้ต้องสงสัย (ล็อกจนกว่าสร้างห้องใหม่) */ /** ตรงแท็บ Admin Minigame 1–7 (ไม่รวม Quiz Battle / กบ frogger เก่า) */ const DETECTIVE_MINIGAME_POOL = [ { key: 'quiz', mapId: 'mng8a80o', labelTh: 'Minigame-1 จริงหรือไม่' }, { key: 'gauntlet', mapId: 'mno9kb07', labelTh: 'Minigame-2 วิ่งหลบสิ่งกีดขวาง' }, { key: 'stack', mapId: 'mnn93hpi', labelTh: 'Minigame-3 Tower block' }, { key: 'quiz_carry', mapId: 'mnorwqx1', labelTh: 'Minigame-4 คำศัพท์ในกระบวนการยุติธรรม' }, { key: 'jump_survive', mapId: 'mnptfts2', labelTh: 'Minigame-5 กระโดดขึ้นแท่น' }, { key: 'space_shooter', mapId: 'mnpz6rkp', labelTh: 'Minigame-6 ยิงอุกาบาต Violent Crime' }, { key: 'balloon_boss', mapId: 'mnq1eml7', labelTh: 'Minigame-7 ยิง Mega Virus' }, ]; const LOBBY_SLOT_TOTAL = 6; const LOBBY_THEME_COUNT = 8; const LOBBY_SKIN_COUNT = 3; /** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */ function getTakenLobbyThemeIndices(space, excludePeerId) { const taken = new Set(); if (!space) return taken; space.peers.forEach((p, id) => { if (excludePeerId && id === excludePeerId) return; const ti = parseInt(p.lobbyColorThemeIndex, 10); if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti); }); const bots = space.lobbyBotThemeBySlot; if (bots && typeof bots === 'object') { Object.keys(bots).forEach((k) => { const ti = parseInt(bots[k] && bots[k].themeIndex, 10); if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti); }); } return taken; } function pickFreeLobbyThemeIndex(space, excludePeerId) { const taken = getTakenLobbyThemeIndices(space, excludePeerId); for (let i = 1; i <= LOBBY_THEME_COUNT; i++) { if (!taken.has(i)) return i; } const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); return ((n - 1) % LOBBY_THEME_COUNT) + 1; } function pickLobbySkinIndexForSlot(slotIndex) { const s = Number(slotIndex); if (!Number.isFinite(s)) return 1; return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1; } /** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น */ function syncLobbyBotThemeSlots(space) { if (!space) return []; const botCount = effectiveBotSlotCount(space); if (!space.lobbyBotThemeBySlot || typeof space.lobbyBotThemeBySlot !== 'object') { space.lobbyBotThemeBySlot = {}; } const taken = getTakenLobbyThemeIndices(space); const out = []; for (let i = 0; i < botCount; i++) { let slot = space.lobbyBotThemeBySlot[i]; let themeIndex = parseInt(slot && slot.themeIndex, 10); if (!(themeIndex >= 1 && themeIndex <= LOBBY_THEME_COUNT) || taken.has(themeIndex)) { themeIndex = pickFreeLobbyThemeIndex(space); } taken.add(themeIndex); const skinToneIndex = (() => { const si = parseInt(slot && slot.skinToneIndex, 10); return (si >= 1 && si <= LOBBY_SKIN_COUNT) ? si : pickLobbySkinIndexForSlot(i); })(); space.lobbyBotThemeBySlot[i] = { themeIndex, skinToneIndex }; out.push({ themeIndex, skinToneIndex }); } Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { if (parseInt(k, 10) >= botCount) delete space.lobbyBotThemeBySlot[k]; }); return out; } function emitLobbyTintSync(spaceId, space) { const peersTint = []; space.peers.forEach((p, id) => { peersTint.push({ id, lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, lobbySkinToneIndex: p.lobbySkinToneIndex || null, }); }); io.to(spaceId).emit('lobby-tint-sync', { peers: peersTint, lobbyBotThemes: syncLobbyBotThemeSlots(space), }); } /** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */ function effectiveBotSlotCount(space) { let n = parseInt(space && space.botSlotCount, 10); if (isNaN(n) || n < 0) n = 0; const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0; if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) { n = LOBBY_SLOT_TOTAL - maxP; if (space) space.botSlotCount = n; } return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n)); } /** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */ const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi'; /** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */ const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2'; /** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก 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: [], /** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */ jumpSurvivePlatformVariantArea: [], jumpSurviveHazardArea: [], jumpSurviveRisePxPerSec: 42, jumpSurviveGravity: 3200, /** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */ jumpSurviveJumpImpulse: 0, jumpSurviveMovePxPerSec: 200, }); /* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */ const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5]; const SPECIAL_QUIZ_CASES = [1, 2, 3]; const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary']; const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/'; function specialCardTxtFilename(id) { return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`; } function specialCardImageUrl(id) { return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`; } function specialCardTxtImageUrl(id) { return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`; } function specialQuizSetKey(level, caseId) { return 'L' + level + '_C' + caseId; } /** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */ function sanitizeSpecialQuizQuestion(q) { if (!q || typeof q !== 'object') return null; const text = String(q.text == null ? '' : q.text).trim(); if (!text) return null; const type = q.type === 'mcq' ? 'mcq' : 'tf'; if (type === 'mcq') { /* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */ const choices = (Array.isArray(q.choices) ? q.choices : []) .map((c) => String(c == null ? '' : c).trim()) .slice(0, 8); while (choices.length && choices[choices.length - 1] === '') choices.pop(); if (choices.filter((c) => c).length < 2) return null; let ci = Number(q.correctIndex); if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') { ci = choices.findIndex((c) => c); if (ci < 0) ci = 0; } return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci }; } return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue }; } function sanitizeSpecialCard(c) { const src = (c && typeof c === 'object') ? c : {}; const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare'; let cardId = Number(src.cardId); if (!(cardId >= 1 && cardId <= 7)) cardId = null; let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600); let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600); if (cardId) { if (!imageUrl) imageUrl = specialCardImageUrl(cardId); if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); } else if (imageUrl) { const m = imageUrl.match(/\/card-(\d)\.png/i); if (m) { cardId = Number(m[1]); imageUrl = specialCardImageUrl(cardId); if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId); } } return { cardId: cardId || null, th: String(src.th == null ? '' : src.th).trim().slice(0, 160), en: String(src.en == null ? '' : src.en).trim().slice(0, 160), rarity, imageUrl, txtImageUrl, }; } /** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */ function sanitizeSpecialQuizSets(obj) { const out = {}; SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => { const key = specialQuizSetKey(L, C); const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {}; const questions = (Array.isArray(src.questions) ? src.questions : []) .map(sanitizeSpecialQuizQuestion) .filter(Boolean) .slice(0, 50); out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) }; })); return out; } /** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */ function getSpecialQuizSet(settings, level, caseId) { const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({}); const L = SPECIAL_QUIZ_LEVELS.indexOf(Number(level)) >= 0 ? Number(level) : 1; /* caseId เป็นเลขคดีรวม 1-15 (5 ระดับ × 3 คดี) → แปลงเป็นคดีสัมพัทธ์ในระดับ 1-3 */ const rel = ((Math.max(1, Number(caseId) || 1) - 1) % 3) + 1; const C = SPECIAL_QUIZ_CASES.indexOf(rel) >= 0 ? rel : 1; return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; } /* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ /** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp']; const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; /** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; /** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */ const SPECIAL_QUIZ_ANSWER_MS = 20000; /** หน่วงโชว์เฉลยก่อนข้อต่อไป */ const SPECIAL_QUIZ_REVEAL_MS = 2600; /** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */ function specialQuizNormalizeQuestion(q) { if (!q || typeof q !== 'object') return null; const text = String(q.text == null ? '' : q.text).trim(); if (!text) return null; if (q.type === 'mcq') { const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== ''); if (choices.length < 2) return null; let ci = Number(q.correctIndex); if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0; return { text, choices, correctIndex: ci }; } return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 }; } function specialQuizShuffle(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 specialQuizTileWalkable(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]; return !!(row && row[x] !== 1); } /** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */ function specialQuizTileInGameZone(md, x, y) { if (quizCellOn(md.quizTrueArea || [], x, y)) return true; if (quizCellOn(md.quizFalseArea || [], x, y)) return true; if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true; const oa = md.quizCarryOptionArea; if (oa && oa[y] && oa[y][x]) return true; return false; } /** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */ function pickSpecialQuizSpawnTile(md) { const w = md.width || 20; const h = md.height || 15; /* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้ fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */ let sx = -1; let sy = -1; const spawnGrid = md.spawnArea; if (Array.isArray(spawnGrid)) { for (let y = 0; y < h && sx < 0; y++) { const row = spawnGrid[y]; if (!row) continue; for (let x = 0; x < w; x++) { if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } } } } if (sx < 0) { const sp = md.spawn || { x: 1, y: 1 }; const fx = Math.floor(Number(sp.x)); const fy = Math.floor(Number(sp.y)); if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; } } if (sx < 0) { for (let y = 0; y < h && sx < 0; y++) { for (let x = 0; x < w; x++) { if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; } } } } if (sx < 0) return null; const q = [[sx, sy]]; const seen = new Set([sx + ',' + sy]); const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]]; const cands = []; 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 (!specialQuizTileWalkable(md, nx, ny)) continue; seen.add(k); q.push([nx, ny]); if (!specialQuizTileInGameZone(md, nx, ny)) { const dist = Math.abs(nx - sx) + Math.abs(ny - sy); if (dist >= 2) cands.push({ x: nx, y: ny }); } } } if (!cands.length) return null; const pick = cands[Math.floor(Math.random() * cands.length)]; return { x: pick.x + 0.5, y: pick.y + 0.5 }; } function clearSpecialQuizTimers(space) { if (Array.isArray(space.specialQuizTimers)) { space.specialQuizTimers.forEach((t) => clearTimeout(t)); } space.specialQuizTimers = []; if (space.specialQuizContinueTimer) { clearTimeout(space.specialQuizContinueTimer); space.specialQuizContinueTimer = null; } space.specialQuizAwaitContinue = false; } /** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */ function specialQuizIconPayload(space) { const sq = space && space.specialQuiz; if (!sq || sq.triggered || sq.done) return null; return { iconType: sq.iconType, x: sq.x, y: sq.y }; } /** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง */ function maybeSpawnSpecialQuizForRun(space) { clearSpecialQuizTimers(space); space.specialQuiz = null; space.specialCardAwardedThisRun = null; space.timeDilationPendingSec = 0; /* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้ หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */ if (!space || !space.detectiveMinigameActive) return; const mapId = String(space.mapId || '').trim(); if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return; const level = Number(space.detectiveLobbyLevel) || 1; const caseId = Number(space.detectiveLobbyCaseId) || 1; const settings = loadQuizSettings(); const set = getSpecialQuizSet(settings, level, caseId); const questions = (set && Array.isArray(set.questions) ? set.questions : []) .map(specialQuizNormalizeQuestion) .filter(Boolean); const card = set && set.specialCard; const hasCard = !!(card && (card.cardId || card.imageUrl)); if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; } const forced = !!space.forceSpecialQuiz; const roll = Math.random(); if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; } if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId); const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; } const tile = pickSpecialQuizSpawnTile(md); if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; } const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)]; console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y); space.specialQuiz = { iconType, x: tile.x, y: tile.y, level, caseId, card, triggered: false, done: false, session: null, }; } function emitSpecialQuizIcon(sid, space) { const payload = specialQuizIconPayload(space); if (payload) io.to(sid).emit('special-quiz-icon', payload); } /** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */ function buildSpecialQuizRoster(space) { const roster = []; space.peers.forEach((p, id) => { roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }); }); const botN = effectiveBotSlotCount(space); for (let i = 0; i < botN; i++) { roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true }); } return roster; } function specialQuizRosterPayload(sess) { return (sess && Array.isArray(sess.roster) ? sess.roster : []) .map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot })); } /** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */ function specialQuizExpectedIds(space, sess) { const realAlive = new Set(space.peers.keys()); const out = []; (sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => { if (m.isBot) out.push(m.id); else if (realAlive.has(m.id)) out.push(m.id); }); return out; } /** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ function maybeResolveSpecialQuizAllAnswered(sid, space) { const sess = space.specialQuiz && space.specialQuiz.session; if (!sess || sess.resolving) return; const expected = specialQuizExpectedIds(space, sess); const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid])); if (allAnswered) resolveSpecialQuizQuestion(sid, space, false); } /** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */ function scheduleSpecialQuizBotAnswers(sid, space, sess, q) { const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot); if (!bots.length) return; const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500); bots.forEach((b) => { const delay = 900 + Math.floor(Math.random() * windowMs); const t = setTimeout(() => { const spNow = spaces.get(sid); const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session; if (!spNow || sNow !== sess || sess.resolving) return; if (Number.isInteger(sess.answers[b.id])) return; sess.answers[b.id] = q.correctIndex; io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true }); maybeResolveSpecialQuizAllAnswered(sid, spNow); }, delay); space.specialQuizTimers.push(t); }); } function sendSpecialQuizQuestion(sid, space) { const sq = space.specialQuiz; const sess = sq && sq.session; if (!sess) return; const q = sess.questions[sess.qIndex]; if (!q) { endSpecialQuizSession(sid, space, false); return; } sess.answers = {}; sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS; io.to(sid).emit('special-quiz-question', { index: sess.qIndex, total: sess.questions.length, text: q.text, choices: q.choices, endsAt: sess.phaseEndsAt, iconType: sq.iconType, roster: specialQuizRosterPayload(sess), }); scheduleSpecialQuizBotAnswers(sid, space, sess, q); const t = setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; resolveSpecialQuizQuestion(sid, spNow, true); }, SPECIAL_QUIZ_ANSWER_MS + 80); space.specialQuizTimers.push(t); } /** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */ function resolveSpecialQuizQuestion(sid, space, fromTimeout) { const sq = space.specialQuiz; const sess = sq && sq.session; if (!sess || sess.resolving) return; sess.resolving = true; clearSpecialQuizTimers(space); const q = sess.questions[sess.qIndex]; const expected = specialQuizExpectedIds(space, sess); let allCorrect = expected.length > 0; expected.forEach((pid) => { const a = sess.answers[pid]; if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false; }); io.to(sid).emit('special-quiz-result', { index: sess.qIndex, correctIndex: q.correctIndex, answers: { ...sess.answers }, roster: specialQuizRosterPayload(sess), allCorrect, timeout: !!fromTimeout, }); if (!allCorrect) { const t = setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || spNow.specialQuiz !== sq) return; endSpecialQuizSession(sid, spNow, false); }, SPECIAL_QUIZ_REVEAL_MS); space.specialQuizTimers.push(t); return; } sess.qIndex++; if (sess.qIndex >= sess.questions.length) { const t = setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || spNow.specialQuiz !== sq) return; endSpecialQuizSession(sid, spNow, true); }, SPECIAL_QUIZ_REVEAL_MS); space.specialQuizTimers.push(t); return; } const t = setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return; sess.resolving = false; sendSpecialQuizQuestion(sid, spNow); }, SPECIAL_QUIZ_REVEAL_MS); space.specialQuizTimers.push(t); } /** * นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน: * minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation) * now = ใช้ทันทีตอนได้รับ (Fund coins) * after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence) * pre_trial = ก่อนโหวตพิจารณาคดี (Silence) * trial_vote = ระหว่างโหวตพิจารณาคดี (Extension) * trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin) * lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban) */ const SPECIAL_CARD_DEFS = { 1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' }, 2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' }, 3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' }, 4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' }, 5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' }, 6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' }, 7: { key: 'free_evidence', when: 'after_game', evidence: 2, th: 'ทุกคนรับหลักฐานฟรี +2 ใบ' }, }; function specialCardDef(cardId) { return SPECIAL_CARD_DEFS[Number(cardId)] || null; } /** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */ function specialCardClientPayload(card) { if (!card) return null; const def = specialCardDef(card.cardId) || {}; return { cardId: card.cardId || null, th: card.th || def.th || '', en: card.en || '', rarity: card.rarity || 'common', imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''), txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''), effectKey: def.key || '', effectWhen: def.when || '', addSec: def.addSec || 0, desc: def.th || '', }; } /** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */ function applySpecialCardImmediate(sid, space, card) { const def = specialCardDef(card && card.cardId); if (!def) return; if (def.when === 'minigame' && def.key === 'time_dilation') { /* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง */ space.timeDilationPendingSec = (space.timeDilationPendingSec || 0) + (def.addSec || 10); io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(card), addSec: def.addSec || 10, }); consumePendingSpecialCard(space, card.cardId); return; } if (def.when === 'now' && def.key === 'fund') { /* +COINS — แต่ละ client บวกเหรียญของตัวเองผ่าน PHP (server ไม่รู้ playerKey) */ io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(card), coins: def.coins || 10, }); consumePendingSpecialCard(space, card.cardId); return; } /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ } function consumePendingSpecialCard(space, cardId) { if (space.pendingSpecialCard && space.pendingSpecialCard.cardId === Number(cardId)) { space.pendingSpecialCard.consumed = true; } } function endSpecialQuizSession(sid, space, success) { const sq = space.specialQuiz; if (!sq) return; clearSpecialQuizTimers(space); sq.done = true; sq.session = null; let cardOut = null; if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { space.specialCardAwardedThisRun = sq.card; cardOut = specialCardClientPayload(sq.card); space.pendingSpecialCard = { cardId: Number(sq.card.cardId) || null, card: sq.card, consumed: false }; applySpecialCardImmediate(sid, space, sq.card); } io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) — มี safety timeout กันค้างถ้าไม่มีใครกด */ space.specialQuizAwaitContinue = true; if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); space.specialQuizContinueTimer = setTimeout(() => { space.specialQuizContinueTimer = null; finishSpecialQuizContinue(sid, space); }, 32000); } /** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ function finishSpecialQuizContinue(sid, space) { if (!space || !space.specialQuizAwaitContinue) return; space.specialQuizAwaitContinue = false; if (space.specialQuizContinueTimer) { clearTimeout(space.specialQuizContinueTimer); space.specialQuizContinueTimer = null; } resumeQuizAfterSpecialQuiz(sid, space); } function startSpecialQuizSession(sid, space) { const sq = space.specialQuiz; if (!sq || sq.triggered || sq.done) return false; const settings = loadQuizSettings(); const set = getSpecialQuizSet(settings, sq.level, sq.caseId); const all = (set && Array.isArray(set.questions) ? set.questions : []) .map(specialQuizNormalizeQuestion) .filter(Boolean); if (!all.length) return false; /* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */ const picked = specialQuizShuffle(all).slice(0, 1); sq.triggered = true; sq.session = { questions: picked, qIndex: 0, answers: {}, phaseEndsAt: 0, resolving: false, roster: buildSpecialQuizRoster(space), }; clearSpecialQuizTimers(space); pauseQuizForSpecialQuiz(sid, space); io.to(sid).emit('special-quiz-icon-clear', {}); sendSpecialQuizQuestion(sid, space); return true; } 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: [], /** 15 ชุดคำถามพิเศษ (Level×Case) สำหรับรับการ์ดพิเศษ */ specialQuizSets: sanitizeSpecialQuizSets({}), }; } 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(6, 8, 14, 0.52)', innerBg: 'rgba(0, 0, 0, 0.78)', innerBorder: 'rgba(94, 234, 212, 0.82)', innerBorderWpx: 1, innerRadiusPx: 14, digitColor: '#ffe066', mapDigitCqmin: 78, mapDigitCqh: 82, mapDigitMaxPx: 220, screenDigitVw: 32, screenDigitMaxPx: 200, }; } 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; } } /* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */ const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json'); const EVIDENCE_RARITIES = ['common', 'rare', 'legendary']; const EVIDENCE_CASE_COUNT = 15; /* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */ const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json'); const CASE_MEDIA_COUNT = 15; /* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */ const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE'); function caseMediaDefault(n) { return { name: 'คดีที่ ' + n, art: '/Main-Lobby/IMAGE/case/case-' + n + '.png', detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png', story: [ '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png', '/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png', ], }; } /** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */ function sanitizeCaseMediaUrl(v, fallback) { if (typeof v !== 'string') return fallback; const s = v.trim(); if (!s) return fallback; if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback; return s; } function sanitizeCaseMedia(obj) { const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {}; const cases = {}; for (let n = 1; n <= CASE_MEDIA_COUNT; n++) { const d = caseMediaDefault(n); const c = src[n] || src[String(n)] || {}; const story = Array.isArray(c.story) ? c.story : []; cases[n] = { name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name, art: sanitizeCaseMediaUrl(c.art, d.art), detail: sanitizeCaseMediaUrl(c.detail, d.detail), story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])], }; } return { cases }; } function loadCaseMedia() { try { if (fs.existsSync(CASE_MEDIA_PATH)) { return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8'))); } } catch (e) { /* ignore — คืนค่า default */ } return sanitizeCaseMedia({}); } function saveCaseMedia(d) { try { const clean = sanitizeCaseMedia(d); fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8'); return { ok: true, cases: clean.cases }; } catch (e) { let err = 'บันทึกไม่ได้'; if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)'; else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; return { ok: false, error: err }; } } /** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */ function listCaseMediaImages() { const out = { case: [], cutscene: [] }; ['case', 'cutscene'].forEach((sub) => { try { const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub); out[sub] = fs.readdirSync(p) .filter((f) => /\.(png|jpe?g|webp)$/i.test(f)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f); } catch (e) { /* โฟลเดอร์อาจไม่มี */ } }); return out; } const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3']; /* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */ const EVIDENCE_RARITY_RANGES = [ { rarity: 'common', min: 1, max: 22, stars: 1 }, { rarity: 'rare', min: 23, max: 44, stars: 2 }, { rarity: 'legendary', min: 45, max: 54, stars: 3 }, ]; function evidenceRarityForNum(n) { for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r; return EVIDENCE_RARITY_RANGES[0]; } function evidenceCardImageDefault(suspectIdx, cardIdx) { return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png'; } /* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */ let _evidencePoolCache = null; let _evidencePoolCacheAt = 0; function evidenceImagePools() { const now = Date.now(); if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache; const pools = {}; EVIDENCE_IMAGE_DIRS.forEach((dir) => { const buckets = { common: [], rare: [], legendary: [] }; try { const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir); fs.readdirSync(p).forEach((f) => { const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f); if (!m) return; const num = parseInt(m[1], 10); const rr = evidenceRarityForNum(num); buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f }); }); } catch (e) { /* โฟลเดอร์อาจไม่มี */ } Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num)); pools[dir] = buckets; }); _evidencePoolCache = pools; _evidencePoolCacheAt = now; return pools; } function evidencePoolCounts() { const pools = evidenceImagePools(); const out = {}; EVIDENCE_IMAGE_DIRS.forEach((dir) => { const b = pools[dir] || { common: [], rare: [], legendary: [] }; out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length }; }); return out; } /* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */ function rollEvidenceRarityKey(grade) { const r = Math.random(); if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common'); if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common'); return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย } /* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */ function evidenceImageDirForSuspect(space, suspectIndex) { const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0]; try { const cid = String((space && space.caseId) || '1'); const cfg = loadEvidenceCards(); const cse = cfg.cases && cfg.cases[cid]; const s = cse && cse.suspects && cse.suspects[suspectIndex]; if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir; } catch (e) { /* ใช้ default */ } return def; } /* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */ function pickEvidenceCardForSuspect(space, suspectIndex, grade) { const imageDir = evidenceImageDirForSuspect(space, suspectIndex); const pools = evidenceImagePools(); const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] }; let want = rollEvidenceRarityKey(grade || 'C'); let pool = buckets[want]; if (!pool || !pool.length) { const tryOrder = ['common', 'rare', 'legendary']; for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } } } if (!pool || !pool.length) return null; const pick = pool[Math.floor(Math.random() * pool.length)]; return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl }; } function evidenceGradeFromPct(avgPct) { return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C'; } /* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */ function evidencePlayerRawScore(space, p, gameType) { switch (gameType) { case 'gauntlet': case 'jump_survive': return Math.max(0, p.gauntletScore | 0); case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0); case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0); default: return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0); } } /* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */ function computeRunGradeForEvidence(space) { try { const md = (space.mapId && maps.get(space.mapId)) || space.mapData; const gameType = md && md.gameType; if (gameType === 'gauntlet' || gameType === 'jump_survive') { return buildGauntletCrownMissionPayload(space).grade || 'C'; } /* เกมจริงหรือไม่ (quiz TF): เฉลี่ย % ของคะแนนเทียบเต็ม (จำนวนข้อ × แต้มต่อข้อ) */ if (gameType === 'quiz' && space.quizSession && space.quizSession.players) { const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT; const ids = Object.keys(space.quizSession.players); if (total > 0 && ids.length) { const avgPct = ids.reduce((s, id) => { const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0); return s + Math.min(100, (sc / total) * 100); }, 0) / ids.length; return evidenceGradeFromPct(avgPct); } } const peers = [...space.peers.values()]; if (!peers.length) return 'C'; const raw = peers.map((p) => evidencePlayerRawScore(space, p, gameType)); const max = Math.max(1, ...raw); const avgPct = raw.reduce((s, v) => s + (v / max) * 100, 0) / raw.length; return evidenceGradeFromPct(avgPct); } catch (e) { return 'C'; } } /* card object ที่เก็บในแฟ้มหลักฐาน */ function sanitizeStoredEvidenceCard(c) { if (!c || typeof c !== 'object') return null; let rar = String(c.rarity || 'common').toLowerCase(); if (EVIDENCE_RARITIES.indexOf(rar) < 0) rar = 'common'; const img = String(c.imageUrl || '').slice(0, 400); if (!img) return null; let stars = Math.floor(Number(c.stars)); if (!(stars >= 1 && stars <= 3)) stars = rar === 'legendary' ? 3 : rar === 'rare' ? 2 : 1; let num = Math.floor(Number(c.num)); if (!(num >= 1)) num = 0; return { rarity: rar, stars, num, imageUrl: img }; } function sanitizeEvidenceCases(obj) { const src = (obj && obj.cases) ? obj.cases : {}; const out = {}; for (let i = 1; i <= EVIDENCE_CASE_COUNT; i++) { const cid = String(i); const cse = src[cid] || {}; const suspects = Array.isArray(cse.suspects) ? cse.suspects : []; const outSus = []; for (let si = 0; si < 3; si++) { const s = suspects[si] || {}; let dir = String(s.imageDir || EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0]); if (EVIDENCE_IMAGE_DIRS.indexOf(dir) < 0) dir = EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0]; outSus.push({ linkName: String(s.linkName || ('ผู้ต้องสงสัย ' + (si + 1))).slice(0, 60), imageDir: dir, }); } out[cid] = { suspects: outSus }; } return { cases: out }; } function loadEvidenceCards() { try { if (fs.existsSync(EVIDENCE_CARDS_PATH)) { return sanitizeEvidenceCases(JSON.parse(fs.readFileSync(EVIDENCE_CARDS_PATH, 'utf8'))); } } catch (e) { /* ignore — คืนค่า default */ } return sanitizeEvidenceCases({}); } /** รายการรูปการ์ดที่มีจริงในแต่ละโฟลเดอร์ผู้ต้องสงสัย (ไว้ให้ Admin เลือกรูป) */ function listEvidenceCardImages() { const out = { 'suspect-1': [], 'suspect-2': [], 'suspect-3': [] }; Object.keys(out).forEach((dir) => { try { const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir); const files = fs.readdirSync(p) .filter((f) => /\.(png|jpe?g|webp)$/i.test(f)) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); out[dir] = files.map((f) => '/Game/img/evidence-cards/' + dir + '/' + f); } catch (e) { /* โฟลเดอร์อาจไม่มี */ } }); return out; } function saveEvidenceCards(d) { try { const clean = sanitizeEvidenceCases(d); fs.writeFileSync(EVIDENCE_CARDS_PATH, JSON.stringify(clean, null, 2), 'utf8'); return { ok: true, cases: clean.cases }; } catch (e) { let err = 'บันทึกไม่ได้'; if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ evidence-cards.json (chown เป็น user ที่รันเกม)'; else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message; return { ok: false, error: err }; } } 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, specialQuizSets: sanitizeSpecialQuizSets(j.specialQuizSets), }; 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, /** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */ jumpSurvivePlatformTiles: [ { url: '', w: 0, h: 0 }, { url: '', w: 0, h: 0 }, { url: '', w: 0, h: 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))); } /** กระโดดให้รอด: รูปแพลตฟอร์ม 3 ช่อง — url ว่าง = ไคลเอนต์วาด cyan; w/h ≤0 = ใช้ขนาดไทล์ตอนรัน */ function normalizeJumpSurvivePlatformTilesFromTiming(val, legacyTileUrl) { let arr = val; if (typeof arr === 'string') { try { const p = JSON.parse(arr); if (Array.isArray(p)) arr = p; } catch (e) { arr = []; } } if (!Array.isArray(arr)) arr = []; const legacy = typeof legacyTileUrl === 'string' ? sanitizeGauntletAssetUrl(legacyTileUrl) : ''; const out = []; for (let i = 0; i < 3; i++) { const o = arr[i]; const obj = o && typeof o === 'object' ? o : {}; let url = sanitizeGauntletAssetUrl(typeof obj.url === 'string' ? obj.url : ''); if (i === 0 && !url && legacy) url = legacy; let ww = Number(obj.w); let hh = Number(obj.h); if (!Number.isFinite(ww) || ww <= 0) ww = 0; else ww = Math.max(8, Math.min(4096, Math.round(ww))); if (!Number.isFinite(hh) || hh <= 0) hh = 0; else hh = Math.max(8, Math.min(4096, Math.round(hh))); out.push({ url, w: ww, h: hh }); } return out; } 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; } /** ช่อง Admin มักใส่แค่ …/Artboard ลืม 9.png — ชี้ไป Artboard%209.png */ function fixBareMegaVirusArtboardUrl(t) { const s = String(t || '').trim().replace(/\/+$/, ''); if (/^\/Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; if (/^Game\/img\/MegaVirus\/Artboard$/i.test(s)) return '/Game/img/MegaVirus/Artboard%209.png'; return t; } function sanitizeGauntletAssetUrl(s) { const t = fixBareMegaVirusArtboardUrl( 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, jumpSurvivePlatformTiles: normalizeJumpSurvivePlatformTilesFromTiming( j.jumpSurvivePlatformTiles, j.jumpSurvivePlatformTileUrl, ), 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 prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming( prev.jumpSurvivePlatformTiles, prev.jumpSurvivePlatformTileUrl, ); let jumpSurvivePlatformTiles; if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) { jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( d.jumpSurvivePlatformTiles, d.jumpSurvivePlatformTileUrl, ); } else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) { jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming( [ { url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 }, prevJumpTiles[1], prevJumpTiles[2], ], '', ); } else { jumpSurvivePlatformTiles = prevJumpTiles; } 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, jumpSurvivePlatformTiles, 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 specialQuizSets = d.specialQuizSets != null ? sanitizeSpecialQuizSets(d.specialQuizSets) : sanitizeSpecialQuizSets(prev.specialQuizSets); const out = { readMs, answerMs, betweenMs, carryReadMs, carryAnswerMs, carrySessionLength, carryMapPanelTheme, quizMapPanelTheme, carryEmbedCountdownTheme, carryChoicePlaqueThemes, carryChoicePlaqueTheme, carryChoicePlaqueMapScale, carryWalkSpeedMultForMapId, carryWalkSpeedMult, quizRoundQuestionCount, questions, carryQuestions, battleQuizMcq, specialQuizSets, }; fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8'); return { ok: true, battleQuizMcqSaved: (out.battleQuizMcq || []).length, carryQuestionsSaved: (out.carryQuestions || []).length, specialQuizSets: out.specialQuizSets, 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 = []; } /** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */ function pauseQuizForSpecialQuiz(sid, space) { const sess = space.quizSession; if (!sess || !sess.active || space.quizFrozenForSpecial) return; const phase = sess.phase; if (phase !== 'read' && phase !== 'answer') return; const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now()); space.quizFrozenForSpecial = true; space.quizFreezePhase = phase; space.quizFreezeRemainingMs = remaining; clearSpaceQuizTimers(space); io.to(sid).emit('quiz-paused', { phase }); } /** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */ function resumeQuizAfterSpecialQuiz(sid, space) { if (!space.quizFrozenForSpecial) return; space.quizFrozenForSpecial = false; const sess = space.quizSession; const phase = space.quizFreezePhase; const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0); space.quizFreezePhase = null; space.quizFreezeRemainingMs = 0; if (!sess || !sess.active) return; const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space); if (!md) return; /* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */ let extraMs = 0; if (space.timeDilationPendingSec > 0) { extraMs = space.timeDilationPendingSec * 1000; space.timeDilationPendingSec = 0; } sess.phaseEndsAt = Date.now() + remaining + extraMs; if (phase === 'read') { sess.phase = 'read'; const q = sess.questions[sess.qIndex]; io.to(sid).emit('quiz-phase', { phase: 'read', questionIndex: sess.qIndex + 1, questionTotal: sess.questions.length, text: String((q && q.text) || '').trim(), endsAt: sess.phaseEndsAt, resumed: true, }); const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining); space.quizTimers.push(t); } else { sess.phase = 'answer'; io.to(sid).emit('quiz-phase', { phase: 'answer', questionIndex: sess.qIndex + 1, questionTotal: sess.questions.length, endsAt: sess.phaseEndsAt, resumed: true, }); const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining); space.quizTimers.push(t); } } /** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ 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 ensurePeerLobbyThemes(space) { if (!space || !space.peers) return; const seen = new Set(); space.peers.forEach((p, id) => { let ti = parseInt(p.lobbyColorThemeIndex, 10); if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) { ti = pickFreeLobbyThemeIndex(space, id); p.lobbyColorThemeIndex = ti; } seen.add(ti); if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) { p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0); } }); } function buildPeersSnapForSpace(space, extraFields) { return [...space.peers.values()].map((p) => { const row = { id: p.id, x: p.x, y: p.y, direction: p.direction || 'down', nickname: p.nickname, ready: !!p.ready, characterId: p.characterId ?? null, lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, lobbySkinToneIndex: p.lobbySkinToneIndex || null, }; if (extraFields && extraFields.gauntlet) { row.gauntletJumpTicks = p.gauntletJumpTicks || 0; row.gauntletScore = p.gauntletScore || 0; row.gauntletEliminated = !!p.gauntletEliminated; } return row; }); } /** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */ /** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */ function normalizeDetectiveCardEntry(entry) { if (!entry) return entry; const mid = entry.mapId != null ? String(entry.mapId).trim() : ''; if (mid === 'mlpecp2j' || entry.key === 'frogger') { const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet'); if (gaunt && maps.has(gaunt.mapId)) { const md = maps.get(gaunt.mapId); return { ...entry, key: gaunt.key, mapId: gaunt.mapId, labelTh: gaunt.labelTh, mapName: (md && md.name) || gaunt.mapId, gameType: (md && md.gameType) || 'gauntlet', }; } } return entry; } function assignDetectiveSuspectCardMinigames(space) { const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); const shuffled = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); space.suspectCardMinigames = shuffled.slice(0, 3).map((entry, i) => { const md = maps.get(entry.mapId); return { cardIndex: i, key: entry.key, mapId: entry.mapId, labelTh: entry.labelTh, mapName: (md && md.name) || entry.mapId, gameType: (md && md.gameType) || '', }; }); // ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้ space.playerEvidence = {}; // สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย) space.culpritIndex = Math.floor(Math.random() * 3); space.trialPhase = null; // null | 'voting' | 'revealed' space.trialVotes = {}; // socketId -> suspectIndex space.trialScores = space.trialScores || {}; } function ensureSpacePlayerEvidence(space) { if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {}; if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {}; } /* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */ function normalizeEvidenceSlotList(cards) { if (!Array.isArray(cards)) return []; const out = []; for (let i = 0; i < cards.length; i++) { const c = sanitizeStoredEvidenceCard(cards[i]); if (c) out.push(c); } return out; } /** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */ function peerNickKey(space, playerId) { if (!space || !playerId) return ''; const p = space.peers && space.peers.get ? space.peers.get(playerId) : null; if (!p || !p.nickname) return ''; return normalizeLobbyNickname(p.nickname); } /** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */ function clonePlayerEvidence(space, playerId) { ensureSpacePlayerEvidence(space); let row = space.playerEvidence[playerId]; if (!row || !row.length) { const nk = peerNickKey(space, playerId); if (nk && space.evidenceByNick[nk]) { row = space.evidenceByNick[nk]; space.playerEvidence[playerId] = row; } } if (!row) row = [[], [], []]; return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i])); } /** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */ function syncEvidenceByNick(space, playerId) { ensureSpacePlayerEvidence(space); const nk = peerNickKey(space, playerId); if (!nk) return; const row = space.playerEvidence[playerId]; if (Array.isArray(row)) space.evidenceByNick[nk] = row; } /** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */ function playerSuspectProgressFromEvidence(space, playerId) { const row = clonePlayerEvidence(space, playerId); return row.map((arr) => arr.length); } /** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */ function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) { if (suspectIndex < 0 || suspectIndex > 2) return null; ensureSpacePlayerEvidence(space); /* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */ if (!space.playerEvidence[playerId]) { const nk = peerNickKey(space, playerId); space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk]) ? space.evidenceByNick[nk] : [[], [], []]; } const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]); const card = pickEvidenceCardForSuspect(space, suspectIndex, grade); if (!card) { space.playerEvidence[playerId][suspectIndex] = list; syncEvidenceByNick(space, playerId); return null; } list.push(card); space.playerEvidence[playerId][suspectIndex] = list; syncEvidenceByNick(space, playerId); return card; } function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) { const out = {}; (playerIds || []).forEach((pid) => { if (!space.peers.has(pid)) return; const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade); if (card != null) out[pid] = card; }); return out; } function resolveDetectiveSuspectAwardIndex(space) { if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) { return space.suspectActiveIndex; } if (typeof space.lastDetectiveInvestigatedSuspect === 'number' && space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) { return space.lastDetectiveInvestigatedSuspect; } if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) { return space.suspectPickIndex; } return -1; } /** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */ function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) { const suspectIdx = resolveDetectiveSuspectAwardIndex(space); if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} }; if (space.detectiveAwardDoneForRun) { return { suspectIdx, awarded: {}, alreadyGranted: true }; } const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()]; const grade = computeRunGradeForEvidence(space); const awardedList = {}; ids.forEach((pid) => { awardedList[pid] = []; }); const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade); ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); }); /* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */ let freeEvidenceCount = 0; const pend = space.pendingSpecialCard; if (pend && Number(pend.cardId) === 7 && !pend.consumed) { const def = specialCardDef(7); freeEvidenceCount = (def && def.evidence) || 2; for (let n = 0; n < freeEvidenceCount; n++) { ids.forEach((pid) => { if (!space.peers.has(pid)) return; const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade); if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; } }); } pend.consumed = true; } space.detectiveAwardDoneForRun = true; space.lastDetectiveInvestigatedSuspect = suspectIdx; space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount }; emitLobbyEvidenceSync(sid, space); return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount }; } function emitLobbyEvidenceSync(sid, space) { if (!sid || !space) return; space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('lobby-evidence-sync', { myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), }); }); } function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) { const msg = message || 'จบมินิเกม — กลับ LobbyB'; space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; const personal = { ...basePayload, myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), }; sock.emit('quiz-ended', { message: msg, returnToLobbyB: true }); sock.emit('detective-minigame-ended', personal); sock.emit('game-start', personal); }); } /** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */ function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) { const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : ''; const md = mapId && maps.has(mapId) ? maps.get(mapId) : null; if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' }; space.suspectPhaseActive = false; space.detectiveMinigameActive = true; space.detectiveAwardDoneForRun = false; /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ space.bannedThisRunPlayerId = space.bannedPlayerId || null; space.bannedPlayerId = null; space.detectiveMinigameCardIndex = cardEntry.cardIndex; // จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2) ? selectedIndex : (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0); space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID; clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; space.quizSession = null; space.mapId = mapId; space.mapData = md; /* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */ maybeSpawnSpecialQuizForRun(space); emitSpecialQuizIcon(sid, space); let si = 0; space.peers.forEach((p) => { const sp = pickSpawnForJoin(md, si++); p.x = sp.x; p.y = sp.y; p.direction = 'down'; p.gauntletJumpTicks = 0; p.gauntletScore = 0; p.gauntletJumpPending = false; p.gauntletEliminated = false; }); const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' }); const invPayload = { selectedIndex, cardIndex: cardEntry.cardIndex, minigameKey: cardEntry.key, minigameLabel: cardEntry.labelTh, mapId, }; io.to(sid).emit('suspect-investigation-start', invPayload); if (md.gameType === 'quiz') { const qErr = validateQuizMap(md); if (qErr) { space.detectiveMinigameActive = false; space.gauntletReturnMapId = null; return { ok: false, error: qErr }; } /** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */ const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID; if (!isQuizMissionShell) { io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true, mapId, peersSnap, detectiveMinigame: true, minigameLabel: cardEntry.labelTh, bannedPlayerId: space.bannedThisRunPlayerId || null, }); setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || !spNow.peers.size) return; const mdLive = maps.get(mapId); if (!mdLive || mdLive.gameType !== 'quiz') return; startQuizGame(sid, spNow, mdLive); }, 900); return { ok: true, mapId, gameType: md.gameType }; } /* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */ } stopGauntletTicker(space); space.gauntletRun = null; if (md.gameType === 'gauntlet') { initGauntletPeersAll(space, md); ensureGauntletEndsAtIfNeeded(space); startGauntletTicker(sid, space); } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); space.gauntletRun = newBalloonBossShellGauntletRunState(); } const gameStartPayload = { mapId, peersSnap, detectiveMinigame: true, detectiveReturnLobbyB: true, minigameLabel: cardEntry.labelTh, bannedPlayerId: space.bannedThisRunPlayerId || null, }; if (md.gameType === 'gauntlet' && space.gauntletRun) { gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null; } if (space.gauntletRun && space.gauntletRun.crownRunHeld) { gameStartPayload.gauntletCrownRunHeld = true; } io.to(sid).emit('game-start', gameStartPayload); return { ok: true, mapId, gameType: md.gameType }; } function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) { ensurePostCaseLobbyMapLoaded(); clearSpaceQuizTimers(space); clearSpecialQuizTimers(space); space.specialQuiz = null; if (space.quizSession) space.quizSession.active = false; space.quizSession = null; space.detectiveMinigameActive = false; space.gauntletReturnMapId = null; stopGauntletTicker(space); space.gauntletRun = null; const suspectIdx = resolveDetectiveSuspectAwardIndex(space); if (suspectIdx >= 0) { space.lastDetectiveInvestigatedSuspect = suspectIdx; let awardIds = []; if (awardOptions && awardOptions.allPlayers) { awardIds = [...space.peers.keys()]; } else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) { awardIds = [awardOptions.playerId]; } else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) { awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id)); } /* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้ไม่ได้เล่น จึงไม่ได้รับหลักฐาน */ if (space.bannedThisRunPlayerId) { awardIds = awardIds.filter((id) => id !== space.bannedThisRunPlayerId); } /* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */ if (!space.detectiveAwardDoneForRun && awardIds.length) { /* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */ grantDetectiveEvidenceForCurrentRun(sid, space, awardIds); } } space.suspectActiveIndex = null; space.bannedThisRunPlayerId = null; const lb = maps.get(POST_CASE_LOBBY_SPACE_ID); if (!lb) { io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true }); return; } space.mapId = POST_CASE_LOBBY_SPACE_ID; space.mapData = lb; space.suspectPhaseActive = true; let gi = 0; space.peers.forEach((p) => { const sp = pickSpawnForJoin(lb, gi++); p.x = sp.x; p.y = sp.y; p.direction = 'down'; p.gauntletJumpTicks = 0; p.gauntletScore = 0; p.gauntletJumpPending = false; p.gauntletEliminated = false; }); const peersSnap = buildPeersSnapForSpace(space); const basePayload = { stayInRoomLobby: true, mapId: POST_CASE_LOBBY_SPACE_ID, peersSnap, suspectPhaseActive: true, suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0, cardMinigames: space.suspectCardMinigames || [], detectiveReturn: true, }; emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload); } /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ function emitTrialVoteUpdate(sid, space) { const counts = [0, 0, 0]; const votes = space.trialVotes || {}; Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; }); io.to(sid).emit('trial-vote-update', { counts, voted: Object.keys(votes).length, totalPlayers: trialEligibleVoterTotal(space), }); } function computeAndEmitTrialResult(sid, space) { /* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */ autoVoteBotsTrial(space); clearTrialVoteTimer(space); const counts = [0, 0, 0]; const votes = space.trialVotes || {}; const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0; /* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */ { const c2 = [0, 0, 0]; Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; }); let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; } const anyVotes = (c2[0] + c2[1] + c2[2]) > 0; const wrong = anyVotes && mostVoted !== culprit; const pend = space.pendingSpecialCard; if (wrong && pend && Number(pend.cardId) === 5 && !pend.consumed && !space.trialBailUsed) { pend.consumed = true; space.trialBailUsed = true; io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); openTrialVotingPhase(sid, space, { revote: true }); return; } } space.trialPhase = 'revealed'; const winners = []; if (!space.trialScores) space.trialScores = {}; Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; if (v === culprit) { winners.push(id); space.trialScores[id] = (space.trialScores[id] || 0) + 100; } }); const winnerNames = winners.map((id) => { const p = space.peers.get(id); if (p && p.nickname) return p.nickname; if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) { const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10); return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท'; } return String(id).slice(0, 6); }); io.to(sid).emit('trial-result', { culpritIndex: culprit, counts, winners, winnerNames, cardMinigames: space.suspectCardMinigames || [], }); space.silencedPlayerId = null; } /* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */ /** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */ const TESTIMONY_BOT_PREFIX = '__lobby_bot_'; function testimonyBotIds(space) { const n = effectiveBotSlotCount(space); const out = []; for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i); return out; } function buildTestimonyMembers(space) { const arr = []; space.peers.forEach((p, id) => { arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false }); }); testimonyBotIds(space).forEach((bid, i) => { arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true }); }); return arr; } /** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */ function testimonyTotalParticipants(space) { return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); } /** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */ function botTestimonyPickCount(space) { const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; let maxOwned = 0; space.peers.forEach((_p, pid) => { const ev = clonePlayerEvidence(space, pid); const len = Array.isArray(ev[round]) ? ev[round].length : 0; if (len > maxOwned) maxOwned = len; }); /* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */ if (maxOwned === 0) return 1; return Math.max(1, Math.min(2, maxOwned)); } /** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */ function autoSubmitBotTestimonyPicks(space) { if (!space.testimonySubmitted) space.testimonySubmitted = {}; const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0; const want = botTestimonyPickCount(space); testimonyBotIds(space).forEach((bid) => { if (space.testimonySubmitted[bid] != null) return; const picks = []; for (let n = 0; n < want; n++) { const c = pickEvidenceCardForSuspect(space, round, 'C'); if (c) picks.push(c); } space.testimonySubmitted[bid] = picks; }); } function emitTestimonyOpen(sid, space) { space.testimonySubmitted = {}; space.testimonyReadyNext = {}; space.testimonyRevealed = false; /* บอทส่งหลักฐานทันทีเมื่อเปิด round */ autoSubmitBotTestimonyPicks(space); const submittedIds = Object.keys(space.testimonySubmitted || {}); const base = { round: space.testimonyRound, suspectIndex: space.testimonyRound, members: buildTestimonyMembers(space), totalPlayers: testimonyTotalParticipants(space), hostId: space.hostId, cardMinigames: space.suspectCardMinigames || [], submitted: submittedIds, }; space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('testimony-open', { ...base, myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), }); }); /* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */ emitTestimonyStatus(sid, space); } function emitTestimonyStatus(sid, space) { io.to(sid).emit('testimony-status', { round: space.testimonyRound, submitted: Object.keys(space.testimonySubmitted || {}), totalPlayers: testimonyTotalParticipants(space), }); } function doTestimonyReveal(sid, space) { space.testimonyRevealed = true; /* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */ autoSubmitBotTestimonyPicks(space); /* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */ if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); io.to(sid).emit('testimony-reveal', { round: space.testimonyRound, picks: space.testimonySubmitted || {}, members: buildTestimonyMembers(space), ready: Object.keys(space.testimonyReadyNext), totalPlayers: testimonyTotalParticipants(space), }); } /** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */ /** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */ const TRIAL_VOTE_MS = 30000; const TRIAL_VOTE_EXTENSION_MS = 10000; function clearTrialVoteTimer(space) { if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; } } /** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */ function openTrialVotingPhase(sid, space, opts) { opts = opts || {}; space.testimonyActive = false; space.trialPhase = 'voting'; space.trialVotes = {}; autoVoteBotsTrial(space); clearTrialVoteTimer(space); /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ let extraMs = 0; let extensionCard = false; const pend = space.pendingSpecialCard; if (pend && Number(pend.cardId) === 2 && !pend.consumed) { extraMs = TRIAL_VOTE_EXTENSION_MS; extensionCard = true; pend.consumed = true; } const durMs = TRIAL_VOTE_MS + extraMs; space.trialVoteEndsAt = Date.now() + durMs; space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('trial-open', { hostId: space.hostId, cardMinigames: space.suspectCardMinigames || [], myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), voteEndsAt: space.trialVoteEndsAt, voteDurationMs: durMs, extensionCard, extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0, revote: !!opts.revote, }); }); if (extensionCard) { io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), addSec: TRIAL_VOTE_EXTENSION_MS / 1000, context: 'trial', }); } emitTrialVoteUpdate(sid, space); space.trialVoteTimer = setTimeout(() => { const spNow = spaces.get(sid); if (!spNow || spNow.trialPhase !== 'voting') return; computeAndEmitTrialResult(sid, spNow); }, durMs + 120); } /* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */ const PICK_VOTE_MS = 15000; function clearPickVoteTimer(space) { if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; } } function pickVoteCandidates(space) { const arr = []; space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false })); testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true })); return arr; } function autoVotePickBots(space) { const pv = space.pickVote; if (!pv) return; testimonyBotIds(space).forEach((bid) => { if (pv.votes[bid] != null) return; const choices = pv.candidates.filter((c) => c.id !== bid); if (!choices.length) return; pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id; }); } function emitPickVoteUpdate(sid, space) { const pv = space.pickVote; if (!pv) return; const counts = {}; Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); io.to(sid).emit('player-pick-vote-update', { voted: Object.keys(pv.votes).length, total: pv.candidates.length, counts, }); } function startPlayerPickVote(sid, space, purpose, onResolve) { clearPickVoteTimer(space); const candidates = pickVoteCandidates(space); space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PICK_VOTE_MS, resolved: false, onResolve }; autoVotePickBots(space); space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('player-pick-vote-open', { purpose, candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), endsAt: space.pickVote.endsAt, durationMs: PICK_VOTE_MS, }); }); emitPickVoteUpdate(sid, space); space.pickVoteTimer = setTimeout(() => { const sp = spaces.get(sid); if (!sp || !sp.pickVote || sp.pickVote.resolved) return; resolvePlayerPickVote(sid, sp); }, PICK_VOTE_MS + 120); maybeResolvePickVote(sid, space); } function maybeResolvePickVote(sid, space) { const pv = space.pickVote; if (!pv || pv.resolved) return; const ids = pv.candidates.map((c) => c.id); if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space); } function resolvePlayerPickVote(sid, space) { const pv = space.pickVote; if (!pv || pv.resolved) return; pv.resolved = true; clearPickVoteTimer(space); const counts = {}; Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; }); let max = -1; Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); const target = maxIds.length ? maxIds[Math.floor(Math.random() * maxIds.length)] : null; const tc = pv.candidates.find((c) => c.id === target); const targetName = tc ? tc.nickname : ''; io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts }); const cb = pv.onResolve; space.pickVote = null; if (typeof cb === 'function') cb(target, targetName); } function trialSilencedIsParticipant(space) { const s = space.silencedPlayerId; if (!s) return false; if (space.peers.has(s)) return true; return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0); } function trialEligibleVoterTotal(space) { let t = testimonyTotalParticipants(space); if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1); return t; } function autoVoteBotsTrial(space) { if (!space.trialVotes) space.trialVotes = {}; const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3); testimonyBotIds(space).forEach((bid) => { if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */ if (space.trialVotes[bid] != null) return; /* 40% โหวตถูก, 60% โหวตสุ่ม */ let pick; if (Math.random() < 0.4) { pick = culprit; } else { pick = Math.floor(Math.random() * 3); } space.trialVotes[bid] = pick; }); } function advanceTestimony(sid, space) { space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1; if (space.testimonyRound >= 3) { space.trialBailUsed = false; space.silencedPlayerId = null; /* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */ const pend = space.pendingSpecialCard; if (pend && Number(pend.cardId) === 4 && !pend.consumed) { pend.consumed = true; io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' }); startPlayerPickVote(sid, space, 'silence', (targetId) => { const sp = spaces.get(sid); if (!sp) return; sp.silencedPlayerId = targetId || null; openTrialVotingPhase(sid, sp, { revote: false }); }); return; } openTrialVotingPhase(sid, space, { revote: false }); } else { emitTestimonyOpen(sid, space); } } function endQuizGame(sid, space, message) { clearSpaceQuizTimers(space); if (space.quizSession) space.quizSession.active = false; if (space.detectiveMinigameActive) { returnDetectiveSpaceToLobbyB(sid, space, message, { allPlayers: true }); space.quizSession = null; return; } 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); /** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */ const playFullQuizRound = !!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space); if (allWrong && !playFullQuizRound) { 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 normalizeJumpSurvivePlatformVariantAreaOnMap(m) { if (!m || m.gameType !== 'jump_survive') return; const w = m.width || 20, h = m.height || 15; const pa = m.jumpSurvivePlatformArea || []; const src = m.jumpSurvivePlatformVariantArea || []; const rows = []; for (let y = 0; y < h; y++) { const row = []; for (let x = 0; x < w; x++) { const has = pa[y] && pa[y][x] === 1; let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0; if (has && (!Number.isFinite(v) || v < 1)) v = 1; if (v > 3) v = 3; if (!has) v = 0; row.push(v); } rows.push(row); } m.jumpSurvivePlatformVariantArea = rows; } function normalizeJumpSurviveHazardAreaOnMap(m) { if (!m || m.gameType !== 'jump_survive') return; const w = m.width || 20, h = m.height || 15; const src = m.jumpSurviveHazardArea || []; 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.jumpSurviveHazardArea = rows; } /** * แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON */ function applyStackTowerMissionMapPlayPolicy(safeId, m) { if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return; const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {}; const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase(); const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up'; const spNum = Number(raw.speedPxPerSec); const speedPxPerSec = Number.isFinite(spNum) ? Math.max(0, Math.min(400, Math.floor(spNum))) : 0; const sel = Math.floor(Number(raw.stepEveryLayers)); const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0; const ssp = Math.floor(Number(raw.stepScrollPx)); const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0; const sam = Math.floor(Number(raw.stepAnimMs)); const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520; const st = { enabled: raw.enabled === true, speedPxPerSec, scrollDirection, stepEveryLayers, stepScrollPx, stepAnimMs, }; if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage; if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage; const rg = Number(raw.releaseGapWorldPx); if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg)); m.stackTowerBgScroll = st; const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {}; const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12)); const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260)); m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx }; } 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); normalizeJumpSurvivePlatformVariantAreaOnMap(data); normalizeJumpSurviveHazardAreaOnMap(data); normalizeQuizBattleDomeAreaOnMap(data); normalizeQuizBattlePathAreaOnMap(data); applyStackTowerMissionMapPlayPolicy(id, data); maps.set(id, data); } catch (e) { console.error('load map', f, e.message); } } } catch (e) { console.error('loadMaps', e.message); } } /** * อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที * (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า) */ function rehydrateSingleMapFromDisk(safeId) { if (!safeId) return false; const fp = path.join(MAPS_DIR, safeId + '.json'); if (!fs.existsSync(fp)) return false; try { const data = JSON.parse(fs.readFileSync(fp, 'utf8')); normalizeQuizCarryLayersOnMap(data); normalizeJumpSurvivePlatformAreaOnMap(data); normalizeJumpSurvivePlatformVariantAreaOnMap(data); normalizeJumpSurviveHazardAreaOnMap(data); normalizeQuizBattleDomeAreaOnMap(data); normalizeQuizBattlePathAreaOnMap(data); applyStackTowerMissionMapPlayPolicy(safeId, data); maps.set(safeId, data); return true; } catch (e) { console.error('rehydrate map', safeId, e.message); return false; } } /** โหลด 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; } function applyDevCors(req, res) { const origin = req.headers.origin; if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Vary', 'Origin'); } } const server = http.createServer((req, res) => { applyDevCors(req, res); if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) { res.writeHead(204); res.end(); return; } let url = req.url; if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html'; if (url.startsWith(BASE_PATH + '/api/maps')) { let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0]; if (id.includes('?')) id = id.split('?')[0]; if (req.method === 'GET' && id) { const sid = safeMapId(decodeURIComponent(id)); if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' })); rehydrateSingleMapFromDisk(sid); const m = maps.get(sid); 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 = []; if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; normalizeQuizCarryLayersOnMap(m); normalizeJumpSurvivePlatformAreaOnMap(m); normalizeJumpSurvivePlatformVariantAreaOnMap(m); normalizeJumpSurviveHazardAreaOnMap(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 = []; if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = []; if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = []; normalizeQuizCarryLayersOnMap(m); normalizeJumpSurvivePlatformAreaOnMap(m); normalizeJumpSurvivePlatformVariantAreaOnMap(m); normalizeJumpSurviveHazardAreaOnMap(m); normalizeQuizBattleDomeAreaOnMap(m); normalizeQuizBattlePathAreaOnMap(m); applyStackTowerMissionMapPlayPolicy(id, 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, specialQuizSets: saved.specialQuizSets, 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 evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') { 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(loadEvidenceCards())); } 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 = saveEvidenceCards(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, cases: saved.cases })); } 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 evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') { 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({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS })); } res.writeHead(405); return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (caseMediaUrlPath === BASE_PATH + '/api/case-media') { 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(loadCaseMedia())); } 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 = saveCaseMedia(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, cases: saved.cases })); } 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 caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/'; if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') { 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({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT })); } 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'; let botSlotCount = parseInt(d.botSlotCount, 10); if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0; botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers }); spaces.set(sid, { mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(), previewEditorTest, botSlotCount, suspectCardMinigames: null, suspectPhaseActive: false, suspectPickIndex: 0, detectiveMinigameActive: false, }); 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' || req.method === 'HEAD')) { 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' }); if (req.method === 'HEAD') return res.end(); 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', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); 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'; /** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */ const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7'; /** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */ const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp'; function isGauntletCrownHeistMapBySpace(space) { return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID); } function isBalloonBossMissionShellSpace(space) { if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; return !!(md && md.gameType === 'balloon_boss'); } /** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */ function isQuizQuestionMissionShellSpace(space) { if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; return !!(md && md.gameType === 'quiz'); } function isStackTowerMissionShellSpace(space) { if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; return !!(md && md.gameType === 'stack'); } function isJumpSurviveMissionShellSpace(space) { if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; return !!(md && md.gameType === 'jump_survive'); } function isSpaceShooterMissionShellSpace(space) { if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; return !!(md && md.gameType === 'space_shooter'); } function isCrownLobbyShellSpace(space) { return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space); } function newBalloonBossShellGauntletRunState() { return { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 999, crownRunHeld: true, timerId: null, endsAt: null, }; } function emitBalloonBossMissionShellGauntletSync(sid, space) { if (!isBalloonBossMissionShellSpace(space)) return; const gr = space.gauntletRun; io.to(sid).emit('gauntlet-sync', { gauntletCrownRunHeld: !!(gr && gr.crownRunHeld), }); } 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; if (space.detectiveMinigameActive) { const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม'; returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true }); 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; } function mapCharacterFootprintWH(md) { const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1))); const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1))); return { cw, ch }; } /** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */ function isMapSpawnAnchorWalkable(md, x, y) { if (!isMapTileWalkableForSpawn(md, x, y)) return false; const { cw, ch } = mapCharacterFootprintWH(md); const w = md.width || 20; const h = md.height || 15; const maxX = Math.min(w, x + cw); const maxY = Math.min(h, y + ch); for (let ty = y; ty < maxY; ty++) { for (let tx = x; tx < maxX; tx++) { if (!isMapTileWalkableForSpawn(md, tx, ty)) 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; } /** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */ function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) { if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return; const g = md.shooterSpawnSlots; if (!g) return; const w = md.width || 20, h = md.height || 15; for (let slot = 1; slot <= 6; slot++) { const idx = slot - 1; if (slots6[idx]) continue; let found = false; for (let yy = 0; yy < h && !found; yy++) { const row = g[yy]; if (!row) continue; for (let xx = 0; xx < w; xx++) { if (row[xx] === slot) { slots6[idx] = { x: xx, y: yy }; found = true; break; } } } } } /** * จุดเกิดตอน 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 (isMapSpawnAnchorWalkable(md, x, y)) return { x, y }; return pickRandomSpawnFromMap(md); } if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMap(md); augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots); const pick = slots[j]; if (pick && isMapSpawnAnchorWalkable(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, playMapId, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex }, 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); ensurePeerLobbyThemes(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)); /* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */ let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10); if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) { /* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */ let takenByHuman = false; space.peers.forEach((p) => { const ti = parseInt(p.lobbyColorThemeIndex, 10); if (ti === chosenThemeIdx) takenByHuman = true; }); if (takenByHuman) { chosenThemeIdx = pickFreeLobbyThemeIndex(space); } else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { /* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */ Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { const slot = space.lobbyBotThemeBySlot[k]; if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0; }); } } else { chosenThemeIdx = pickFreeLobbyThemeIndex(space); } let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10); if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) { chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder); } const peer = { id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true, spawnJoinOrder, lobbyColorThemeIndex: chosenThemeIdx, lobbySkinToneIndex: chosenSkinIdx, gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0, balloonBossScore: 0, balloonBossBossDmg: 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); /* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */ if (space.detectiveMinigameActive) { const clientMap = playMapId != null ? String(playMapId).trim() : ''; const activeMap = space.mapId != null ? String(space.mapId).trim() : ''; if (!clientMap || !activeMap || clientMap !== activeMap) { returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB'); } } 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); } else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.gauntletCrownLobbyReady[socket.id] = false; if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState(); io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } }); } const peersList = [...space.peers.values()]; const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin; const joinCb = { ok: true, mapData: mapDataOut, 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, cardMinigames: space.suspectCardMinigames || [], myPlayerEvidence: clonePlayerEvidence(space, socket.id), suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), botSlotCount: effectiveBotSlotCount(space), lobbyBotThemes: syncLobbyBotThemeSlots(space), joinLocked: !!space.joinLocked, }; 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 ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) { joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld; } /* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */ const sqIcon = specialQuizIconPayload(space); if (sqIcon) joinCb.specialQuizIcon = sqIcon; if (typeof cb === 'function') cb(joinCb); emitLobbyTintSync(spaceId, space); 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 }); } } }); /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ /** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */ socket.on('special-quiz-force', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; space.forceSpecialQuiz = !!(data && data.on); console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id); }); /** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */ socket.on('special-quiz-testmode', (data) => { socket.data.testMode = !!(data && data.on); }); socket.on('special-quiz-collide', (_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 }); const sq = space.specialQuiz; if (!sq || sq.triggered || sq.done) return reply({ ok: false }); const started = startSpecialQuizSession(sid, space); reply({ ok: !!started }); }); /** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */ socket.on('special-quiz-answer', (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 }); const sess = space.specialQuiz && space.specialQuiz.session; if (!sess || sess.resolving) return reply({ ok: false }); const q = sess.questions[sess.qIndex]; if (!q) return reply({ ok: false }); /* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */ let choice; if (data && data.debugCorrect && socket.data.testMode) { choice = q.correctIndex; } else { choice = Number(data && data.choiceIndex); } if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) { return reply({ ok: false }); } if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' }); sess.answers[socket.id] = choice; reply({ ok: true }); /* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */ io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true }); /* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */ maybeResolveSpecialQuizAllAnswered(sid, space); }); /** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */ socket.on('special-quiz-continue', (_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 }); finishSpecialQuizContinue(sid, space); reply({ ok: true }); }); /** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */ socket.on('lobby-tint-update', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; const peer = space && space.peers.get(socket.id); if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); let themeIndex = Math.floor(Number(data && data.themeIndex)); let skinIndex = Math.floor(Number(data && data.skinToneIndex)); if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) { return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' }); } if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) { skinIndex = peer.lobbySkinToneIndex || 1; } const taken = getTakenLobbyThemeIndices(space, socket.id); if (taken.has(themeIndex)) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); peer.lobbyColorThemeIndex = themeIndex; peer.lobbySkinToneIndex = skinIndex; emitLobbyTintSync(sid, space); reply({ ok: true, lobbyColorThemeIndex: themeIndex, lobbySkinToneIndex: skinIndex, lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], }); }); /** เปลี่ยนชื่อที่แสดงใน lobby / เกม (ซิงก์ทุก client ในห้อง) */ socket.on('update-display-name', ({ nickname }, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); const p = space.peers.get(socket.id); if (!p) return reply({ ok: false, error: 'ไม่พบผู้เล่น' }); let safe = String(nickname || '').trim(); if (!safe) return reply({ ok: false, error: 'ชื่อว่าง' }); try { safe = safe.normalize('NFKC'); } catch (e) { /* ignore */ } if (safe.length > 32) safe = safe.slice(0, 32); const oldNorm = normalizeLobbyNickname(p.nickname); const newNorm = normalizeLobbyNickname(safe); p.nickname = safe; if (space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0) { if (space.caseParticipantNicknames.has(oldNorm)) { space.caseParticipantNicknames.delete(oldNorm); space.caseParticipantNicknames.add(newNorm); } } io.to(sid).emit('peer-display-name', { id: socket.id, nickname: safe }); reply({ ok: true, nickname: safe }); }); /** ห้องทดสอบจากเอดิเตอร์ (ชื่อ 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; if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { assignDetectiveSuspectCardMinigames(space); } space.suspectPhaseActive = true; const phaseBase = { hostId: space.hostId, selectedIndex: space.suspectPickIndex, disruptorAccepted: accept, cardMinigames: space.suspectCardMinigames || [], }; space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('suspect-phase-open', { ...phaseBase, myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), }); }); 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 && Array.isArray(space.suspectCardMinigames)) { space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry); } 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 กดเริ่มสืบสวนได้' }); } if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) { assignDetectiveSuspectCardMinigames(space); } const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0; const cardEntry = space.suspectCardMinigames[selectedIndex]; if (!cardEntry || !cardEntry.mapId) { return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' }); } /* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */ const pend = space.pendingSpecialCard; if (pend && Number(pend.cardId) === 3 && !pend.consumed && !space.pickVote) { pend.consumed = true; io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' }); startPlayerPickVote(sid, space, 'ban', (targetId) => { const sp = spaces.get(sid); if (!sp) return; sp.bannedPlayerId = targetId || null; const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex); if (!res || !res.ok) { io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true }); } }); return reply({ ok: true, banVote: true }); } const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex); reply(started); }); socket.on('detective-minigame-finished', (_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 (!space.detectiveMinigameActive) { if (serverMapIsPostCaseLobbyB(space)) { const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]); return reply({ ok: true, alreadyOnLobbyB: true, suspectIndex: grant.suspectIdx, awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null, awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [], grade: grant.grade || null, freeEvidenceCount: grant.freeEvidenceCount || 0, myPlayerEvidence: clonePlayerEvidence(space, socket.id), suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), }); } return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); } // เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน) returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true }); const g = space.lastEvidenceGrant || {}; reply({ ok: true, suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1, awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null, awardedCards: (g.awardedList && g.awardedList[socket.id]) || [], grade: g.grade || null, freeEvidenceCount: g.freeEvidenceCount || 0, myPlayerEvidence: clonePlayerEvidence(space, socket.id), suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), }); }); /** * DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 0–2) ทันที * (ใช้สำหรับเทสต์ Ctrl+1/2/3 — เทียบเท่าเล่นมินิเกมจบ 1 ครั้ง) */ socket.on('detective-debug-award-evidence', (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 (!serverMapIsPostCaseLobbyB(space)) return reply({ ok: false, error: 'ต้องอยู่โถง LobbyB' }); const idx = Math.floor(Number(data && data.suspectIndex)); if (!(idx >= 0 && idx <= 2)) return reply({ ok: false, error: 'suspectIndex ต้องเป็น 0–2' }); const card = awardRandomEvidenceCardToPlayer(space, socket.id, idx, 'C'); if (card == null) return reply({ ok: false, error: 'ไม่มีรูปการ์ดในพูลของ suspect นี้' }); emitLobbyEvidenceSync(sid, space); reply({ ok: true, suspectIndex: idx, awardedCard: card, myPlayerEvidence: clonePlayerEvidence(space, socket.id), suspectProgress: playerSuspectProgressFromEvidence(space, socket.id), }); }); // Host เปิดขั้นพิจารณาคดี (ต้องสืบครบ 3 ผู้ต้องสงสัย) socket.on('suspect-accuse-open', (_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.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host เปิดพิจารณาคดีได้' }); const prog = playerSuspectProgressFromEvidence(space, socket.id); if (![0, 1, 2].every((i) => (prog[i] || 0) >= 1)) return reply({ ok: false, error: 'ต้องสืบครบทั้ง 3 ผู้ต้องสงสัยก่อน (เก็บหลักฐานอย่างน้อย 1 ใบต่อคน)' }); if (typeof space.culpritIndex !== 'number') space.culpritIndex = Math.floor(Math.random() * 3); space.suspectPhaseActive = false; // เริ่ม "ห้องสรุปหลักฐาน" (ไต่สวน 3 ปากคำ) ก่อนเข้าโหวต space.testimonyActive = true; space.testimonyRound = 0; emitTestimonyOpen(sid, space); reply({ ok: true }); }); // ผู้เล่นส่งหลักฐาน 1–2 ใบในปากคำปัจจุบัน — ใบที่ส่งต้องเป็นใบที่ตัวเองเก็บได้จริง socket.on('testimony-submit', (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 (!space.testimonyActive) return reply({ ok: false, error: 'ไม่อยู่ในขั้นไต่สวน' }); if (space.testimonyRevealed) return reply({ ok: false, error: 'ปากคำนี้เปิดเผยแล้ว' }); const raw = (data && Array.isArray(data.picks)) ? data.picks : []; const owned = normalizeEvidenceSlotList(clonePlayerEvidence(space, socket.id)[space.testimonyRound] || []); const required = Math.max(1, Math.min(2, owned.length)); /* raw = ตำแหน่ง index ใน owned (0..n-1) → เก็บเป็น card object */ const usedIdx = []; const picks = []; raw.forEach((n) => { const v = Math.floor(Number(n)); if (v >= 0 && v < owned.length && usedIdx.indexOf(v) === -1) { usedIdx.push(v); picks.push(owned[v]); } }); if (owned.length === 0) { /* ไม่มีหลักฐานของ suspect รอบนี้ — ข้ามได้ (auto-submit ว่าง) */ if (!space.testimonySubmitted) space.testimonySubmitted = {}; space.testimonySubmitted[socket.id] = []; emitTestimonyStatus(sid, space); /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design */ return reply({ ok: true, skipped: true }); } if (picks.length !== required) { return reply({ ok: false, error: 'ต้องเลือกหลักฐาน ' + required + ' ใบ (ตามจำนวนที่เก็บได้)' }); } if (!space.testimonySubmitted) space.testimonySubmitted = {}; space.testimonySubmitted[socket.id] = picks.slice(0, required); emitTestimonyStatus(sid, space); /* ไม่ auto-reveal — รอ host กดปุ่ม "เปิดหลักฐานทั้งหมด" เองตาม design (UX: host ต้องเห็นปุ่มเปลี่ยนรูปจาก btn-send → btn-open-card แล้วค่อยกดเอง) */ reply({ ok: true }); }); // Host บังคับเปิดเผยหลักฐานทั้งหมด (เผื่อมีคนยังไม่ส่ง) socket.on('testimony-reveal', (_data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); if (!space.testimonyActive || space.testimonyRevealed) return reply({ ok: false, error: 'เปิดเผยไม่ได้ตอนนี้' }); doTestimonyReveal(sid, space); reply({ ok: true }); }); // ผู้เล่นกด READY ไปปากคำถัดไป (หลังเปิดเผยผล) — ไม่ auto-advance รอ host กด "เริ่มพิจารณาคดี" socket.on('testimony-ready-next', (_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 (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังเปิดเผยไม่ได้' }); if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; space.testimonyReadyNext[socket.id] = true; const totalRN = testimonyTotalParticipants(space); io.to(sid).emit('testimony-ready-update', { ready: Object.keys(space.testimonyReadyNext), totalPlayers: totalRN, hostId: space.hostId, }); /* ไม่ auto-advance — รอ host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote.png) เอง */ reply({ ok: true }); }); // Host กดปุ่ม "เริ่มพิจารณาคดี" (btn-start-vote) — ไปปากคำถัดไป/เข้าโหวต socket.on('testimony-start-vote', (_data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); if (!space.testimonyActive || !space.testimonyRevealed) return reply({ ok: false, error: 'ยังไม่ถึงเวลา' }); /* host อาจกดก่อนทุกคน READY — บังคับให้ทุกคน (รวมบอท) ready ก่อน advance เพื่อให้ flow consistent */ if (!space.testimonyReadyNext) space.testimonyReadyNext = {}; testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; }); advanceTestimony(sid, space); reply({ ok: true }); }); // ผู้เล่นโหวตชี้ตัวคนร้าย socket.on('trial-vote', (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 (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ยังไม่ถึงเวลาโหวต' }); if (socket.id === space.silencedPlayerId) return reply({ ok: false, error: 'คุณถูกปิดปาก (Silence) — โหวตรอบนี้ไม่ได้', silenced: true }); let idx = Math.floor(Number(data && data.index)); if (Number.isNaN(idx) || idx < 0 || idx > 2) return reply({ ok: false, error: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); space.trialVotes = space.trialVotes || {}; space.trialVotes[socket.id] = idx; emitTrialVoteUpdate(sid, space); const totalTV = trialEligibleVoterTotal(space); if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) { computeAndEmitTrialResult(sid, space); } reply({ ok: true, index: idx }); }); // Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต) socket.on('trial-reveal', (_data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' }); if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' }); if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' }); computeAndEmitTrialResult(sid, space); reply({ ok: true }); }); /* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */ socket.on('player-pick-vote', (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: 'ไม่อยู่ในห้อง' }); const pv = space.pickVote; if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' }); const tid = String((data && data.targetId) || ''); if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' }); pv.votes[socket.id] = tid; reply({ ok: true, targetId: tid }); emitPickVoteUpdate(sid, space); maybeResolvePickVote(sid, space); }); /** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */ socket.on('host-console-apply', (data, cb) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) { return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); } const humans = space.peers.size; let total = parseInt(data && data.totalSlots, 10); if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space); total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total)); let bots = parseInt(data && data.botSlotCount, 10); if (!Number.isFinite(bots)) bots = total - humans; bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots)); total = humans + bots; space.botSlotCount = bots; space.maxPlayers = Math.max(humans, total - bots); const lobbyBotThemes = syncLobbyBotThemeSlots(space); io.to(sid).emit('host-console-settings', { maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes, }); emitLobbyTintSync(sid, space); cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes }); }); socket.on('host-console-kick-peer', ({ targetId }, cb) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) { return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' }); } const tid = targetId != null ? String(targetId) : ''; if (!tid || tid === socket.id || tid === space.hostId) { return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' }); } if (!space.peers.has(tid)) { return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' }); } const targetSock = io.sockets.sockets.get(tid); if (targetSock) { targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' }); targetSock.leave(sid); delete targetSock.data.spaceId; } space.peers.delete(tid); io.to(sid).emit('user-left', { id: tid }); cb && cb({ 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 = ''; /* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */ if (caseId && !/^(?:[1-9]|1[0-5])$/.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) { assignDetectiveSuspectCardMinigames(space); 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; space.detectiveLobbyLevel = Number(lobbyLevel) || 1; space.detectiveLobbyCaseId = Number(caseId) || 1; space.caseId = Number(caseId) || 1; 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 })); space.peers.forEach((_p, pid) => { const sock = io.sockets.sockets.get(pid); if (!sock) return; sock.emit('game-start', { stayInRoomLobby: true, mapId: POST_CASE_LOBBY_SPACE_ID, lobbyLevel, caseId, peersSnap, cardMinigames: space.suspectCardMinigames || [], myPlayerEvidence: clonePlayerEvidence(space, pid), suspectProgress: playerSuspectProgressFromEvidence(space, pid), }); }); 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, })); } if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {}; space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; }); space.gauntletRun = newBalloonBossShellGauntletRunState(); } } const gameStartPayload = { mapId: mapId || undefined, lobbyLevel: lobbyLevel || undefined, caseId: caseId || undefined, peersSnap: peersSnap || undefined, }; if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null; if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) { const grs = space.gauntletRun; gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld); } 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 (isCrownLobbyShellSpace(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; /* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */ const keepCaseSpace = !!(space.detectiveMinigameActive || (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0)); if (keepCaseSpace) { space.hostId = null; return; } 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; const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space); const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space); if (!okGauntletCrown && !okBalloonShell) 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; if (okGauntletCrown) { ensureGauntletEndsAtIfNeeded(space); emitGauntletSync(sid, space); } else { emitBalloonBossMissionShellGauntletSync(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 (!isCrownLobbyShellSpace(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 (!isCrownLobbyShellSpace(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 (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' }); const soloRoom = space.peers.size === 1; if (!soloRoom && 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' }); if (isQuizQuestionMissionShellSpace(space)) { const mdQuiz = maps.get(space.mapId) || space.mapData; if (mdQuiz && mdQuiz.gameType === 'quiz') { clearSpaceQuizTimers(space); startQuizGame(sid, space, mdQuiz); } } 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 + 35) p.balloonBossScore = Math.max(prev, ns); } } if (data.balloonBossBossDmg != null) { const nd = Math.floor(Number(data.balloonBossBossDmg)); if (Number.isFinite(nd) && nd >= 0) { const prevD = Math.max(0, p.balloonBossBossDmg | 0); if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd); } } 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) { const prevX = Number(p.x); const prevY = Number(p.y); const prevDir = p.direction || 'down'; p.x = nx; p.y = ny; p.direction = data.direction || p.direction; const posUnchanged = Math.abs(nx - prevX) < 1e-5 && Math.abs(ny - prevY) < 1e-5 && p.direction === prevDir; if (!posUnchanged) { 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.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 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: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' }); if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' }); 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.on('error', (err) => { if (err && (err.code === 'EACCES' || err.code === 'EADDRINUSE')) { console.error(''); console.error('[Game] Cannot listen on', HOST + ':' + PORT, '(' + err.code + ').'); console.error(' Windows AppServ:'); console.error(' set HOST=127.0.0.1'); console.error(' set PORT=13010'); console.error(' node server.js'); console.error(' Or double-click start-dev.cmd'); console.error(''); process.exit(1); } throw err; }); server.listen(PORT, HOST, () => console.log('Game server listening on http://' + HOST + ':' + PORT + BASE_PATH));