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' }, ]; /** โหมดเทสต์: บังคับเกมต่อการ์ด 3 ใบ — array[3], แต่ละช่อง '' = สุ่ม หรือ key จาก DETECTIVE_MINIGAME_POOL · รับ array หรือ key เดี่ยว (เก่า) */ function normalizeForcedMinigameKeys(v) { let arr; if (Array.isArray(v)) arr = v; else if (v != null && String(v).trim()) arr = [v, v, v]; else arr = []; const one = (k) => { const s = (k == null ? '' : String(k)).trim(); return s && DETECTIVE_MINIGAME_POOL.some((e) => e.key === s) ? s : ''; }; return [one(arr[0]), one(arr[1]), one(arr[2])]; } const LOBBY_SLOT_TOTAL = 6; /* เวลานับถอยหลัง 3-2-1 ก่อนเริ่มเล่นจริง (ms) — server กำหนด liveBeginsAt = now + ค่านี้ ให้ทุก client นับไปหาเวลาเดียวกัน → GO/เริ่ม/จบ ตรงกันทุกเครื่อง (เผื่อ buffer ให้ทุกคนรับ event ทัน) */ const LIVE_COUNTDOWN_MS = 3500; 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 Battle global leaderboard (เก็บคะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ const QB_LEADERBOARD_PATH = path.join(__dirname, 'data', 'quiz-battle-leaderboard.json'); /** @type {{[roomId:string]: Array<{name:string,score:number,characterId:string,ts:number}>}} */ let qbLeaderboardStore = {}; function loadQbLeaderboard() { try { if (fs.existsSync(QB_LEADERBOARD_PATH)) { const j = JSON.parse(fs.readFileSync(QB_LEADERBOARD_PATH, 'utf8')); qbLeaderboardStore = (j && typeof j === 'object') ? j : {}; } } catch (e) { qbLeaderboardStore = {}; } } function saveQbLeaderboard() { try { const dir = path.dirname(QB_LEADERBOARD_PATH); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(QB_LEADERBOARD_PATH, JSON.stringify(qbLeaderboardStore), 'utf8'); } catch (e) { /* ignore */ } } function qbLeaderboardSubmit(roomId, name, score, characterId) { roomId = String(roomId || '').trim(); name = String(name || '').trim().slice(0, 24); score = Math.max(0, Math.floor(Number(score) || 0)); if (!roomId || !name) return; if (!Array.isArray(qbLeaderboardStore[roomId])) qbLeaderboardStore[roomId] = []; const list = qbLeaderboardStore[roomId]; const ex = list.find((e) => e && e.name === name); if (ex) { if (score > (ex.score || 0)) { ex.score = score; ex.characterId = String(characterId || ''); ex.ts = Date.now(); } } else { list.push({ name, score, characterId: String(characterId || ''), ts: Date.now() }); } list.sort((a, b) => (b.score - a.score) || (a.ts - b.ts)); if (list.length > 500) list.length = 500; saveQbLeaderboard(); } function qbLeaderboardGet(roomId, name) { roomId = String(roomId || '').trim(); const list = Array.isArray(qbLeaderboardStore[roomId]) ? qbLeaderboardStore[roomId] : []; const top = list.slice(0, 50).map((e, i) => ({ rank: i + 1, name: e.name, score: e.score || 0, characterId: e.characterId || '' })); let me = null; const nm = String(name || '').trim(); if (nm) { const idx = list.findIndex((e) => e && e.name === nm); if (idx >= 0) me = { rank: idx + 1, name: nm, score: list[idx].score || 0, characterId: list[idx].characterId || '' }; } return { top, me, total: list.length }; } loadQbLeaderboard(); /* ===== System stats (CPU / RAM) สำหรับหน้า Admin ===== */ const os = require('os'); let __prevCpuSample = null; function __cpuSample() { const cpus = os.cpus() || []; let idle = 0, total = 0; for (const c of cpus) { for (const k in c.times) total += c.times[k]; idle += c.times.idle; } return { idle, total }; } /** CPU% เฉลี่ยช่วงเวลาระหว่างสองครั้งที่เรียก (admin poll ~ทุก 5 วิ) — ครั้งแรกคืน null */ function __cpuPercent() { const cur = __cpuSample(); const prev = __prevCpuSample; __prevCpuSample = cur; if (!prev) return null; const idleD = cur.idle - prev.idle; const totalD = cur.total - prev.total; if (totalD <= 0) return null; return Math.max(0, Math.min(100, Math.round((1 - idleD / totalD) * 100))); } __cpuSample(); // เก็บ baseline แรก function getDiskStats() { try { if (typeof fs.statfsSync !== 'function') return null; const s = fs.statfsSync(__dirname); const total = s.blocks * s.bsize; const free = s.bfree * s.bsize; // ว่างจริงทั้งหมด const avail = s.bavail * s.bsize; // ว่างที่ผู้ใช้ทั่วไปใช้ได้ const used = total - free; if (!(total > 0)) return null; return { total, free: avail, used, usedPercent: Math.round((used / total) * 100) }; } catch (e) { return null; } } function getSystemStats() { const totalMem = os.totalmem(); const freeMem = os.freemem(); const usedMem = totalMem - freeMem; const load = os.loadavg(); const cores = (os.cpus() || []).length || 1; const cpuPct = __cpuPercent(); const mu = process.memoryUsage(); return { ok: true, cpu: { percent: cpuPct, // 0-100 หรือ null (ครั้งแรก) cores, load1: Math.round(load[0] * 100) / 100, load5: Math.round(load[1] * 100) / 100, load15: Math.round(load[2] * 100) / 100, loadPercent: Math.max(0, Math.min(100, Math.round((load[0] / cores) * 100))), }, mem: { total: totalMem, free: freeMem, used: usedMem, usedPercent: Math.round((usedMem / totalMem) * 100), }, disk: getDiskStats(), node: { rss: mu.rss, heapUsed: mu.heapUsed, rssPercent: Math.round((mu.rss / totalMem) * 100), version: process.version, uptime: Math.round(process.uptime()), }, uptime: { os: Math.round(os.uptime()), process: Math.round(process.uptime()) }, hostname: os.hostname(), platform: os.platform(), ts: Date.now(), }; } /** ช่องตัวเลือกบนกริด 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({}); /* ชุดคำถามพิเศษผูกกับ "เลขคดีสัมบูรณ์ 1-15" (ไม่ผูกกับโฟลเดอร์/level ที่อาจถูกจัดกลุ่มใหม่) key เดิมในแอดมิน = L{ceil(case/3)}_C{((case-1)%3)+1} ต่อคดี → คงความเข้ากันได้ */ const cid = Math.max(1, Math.min(15, Number(caseId) || 1)); const L = Math.ceil(cid / 3); const C = ((cid - 1) % 3) + 1; return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) }; } /* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */ /** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */ /** คำถามพิเศษขึ้นได้ "ทุกเกม" (7 มินิเกม) — รวม stack(mnn93hpi)+balloon(mnq1eml7) ที่จับด้วยคลิก/แตะไอคอน */ const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mno9kb07', 'mnn93hpi', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp', 'mnq1eml7']; const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police']; /** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */ /** โอกาสขึ้นคำถามพิเศษต่อรอบ (เล่นจริง) — 1/3 · โหมดเทสต์บังคับการ์ดต่อเกมจะ force ขึ้นเสมอแยกต่างหาก */ const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3; /** โหมดเทสต์ (admin): บังคับ "การ์ดพิเศษใบไหน → เกมไหน" — { mapId: cardId(1-7) } · เกมที่ตั้งไว้จะ force ขึ้นเสมอด้วยการ์ดนั้น */ function normalizeTestSpecialCardByMap(v) { const out = {}; if (v && typeof v === 'object') { SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.forEach((mid) => { const c = Math.floor(Number(v[mid])); if (c >= 1 && c <= 7) out[mid] = c; }); } return out; } /** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป 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 obj = md.objects && md.objects[y]; if (obj && obj[x] === 1) return false; /* ต้องไม่ใช่ช่อง blockPlayer ด้วย — ไม่งั้นไอคอนไปโผล่บนกำแพง/บล็อกที่ผู้เล่นเดินเข้าไม่ถึง (เช่น MG1 quiz มี 140 ช่อง) */ const bp = md.blockPlayer && md.blockPlayer[y]; if (bp && bp[x] === 1) return false; return true; } /** หลีกเลี่ยงโซน 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, occupiedLaneCount) { const w = md.width || 20; const h = md.height || 15; /* quiz_carry (แบกคำตอบ): โต๊ะ/เคาน์เตอร์ถูกวาดใน "ภาพพื้นหลัง" แต่ไม่ได้มาร์คใน blockPlayer → ช่องว่างกลางระหว่าง hub กับโต๊ะ interactive ดูเหมือนเดินได้แต่จริง ๆ มีโต๊ะบัง เก็บไอคอนไม่ได้ → เกิดไอคอน "ใกล้จุดที่ผู้เล่นเกิด/เดิน" + เว้น buffer 2 ช่องรอบ hub/interactive/option (กันโต๊ะที่วาดเลยกริด) */ if (md.gameType === 'quiz_carry') { const nearMarkedTable = (x, y) => { for (const g of [md.quizCarryHubArea, md.interactive, md.quizCarryOptionArea]) { if (!Array.isArray(g)) continue; for (let dy = -2; dy <= 2; dy++) { const gy = g[y + dy]; if (!gy) continue; for (let dx = -2; dx <= 2; dx++) { if (gy[x + dx]) return true; } } } return false; }; const seeds = (Array.isArray(md.lobbyPlayerSpawns) && md.lobbyPlayerSpawns.length) ? md.lobbyPlayerSpawns : [md.spawn || { x: Math.floor(w / 2), y: Math.floor(h / 2) }]; const qcCands = []; seeds.forEach((s) => { const bx = Math.floor(Number(s.x)); const by = Math.floor(Number(s.y)); for (let dy = -5; dy <= 5; dy++) { for (let dx = -5; dx <= 5; dx++) { const dist = Math.abs(dx) + Math.abs(dy); if (dist < 2 || dist > 6) continue; const xx = bx + dx; const yy = by + dy; if (specialQuizTileWalkable(md, xx, yy) && !specialQuizTileInGameZone(md, xx, yy) && !nearMarkedTable(xx, yy)) { qcCands.push({ x: xx, y: yy }); } } } }); if (qcCands.length) { const p = qcCands[Math.floor(Math.random() * qcCands.length)]; return { x: p.x + 0.5, y: p.y + 0.5 }; } } /* gauntlet (วิ่งหลบ): ผู้เล่นวิ่งบน "แถวเลน" เท่านั้น → ไอคอนต้องอยู่บนแถวเดียวกับ gauntletPlayerSpawns (ที่ x กลางทาง) ผู้เล่นวิ่งผ่านเก็บได้/คลิกได้ — ไม่งั้นลอยออกนอกเลนเก็บไม่ได้ */ if (md.gameType === 'gauntlet' && Array.isArray(md.gauntletPlayerSpawns) && md.gauntletPlayerSpawns.length) { /* ใช้เฉพาะเลนที่ "มีผู้เล่นจริง" (occupiedLaneCount ตัวแรกตามลำดับ P1..PN) — กันไอคอนเกิดในเลนว่าง */ const nLanes = (Number.isFinite(occupiedLaneCount) && occupiedLaneCount > 0) ? Math.min(md.gauntletPlayerSpawns.length, Math.floor(occupiedLaneCount)) : md.gauntletPlayerSpawns.length; const usableSpawns = md.gauntletPlayerSpawns.slice(0, nLanes); const rows = Array.from(new Set(usableSpawns .map((s) => Math.floor(Number(s.y))) .filter((n) => Number.isFinite(n) && n >= 0 && n < h))); const laneCands = []; const xMin = Math.max(4, Math.floor(w * 0.30)); const xMax = Math.min(w - 2, Math.floor(w * 0.72)); rows.forEach((ry) => { for (let x = xMin; x <= xMax; x++) { if (specialQuizTileWalkable(md, x, ry) && !specialQuizTileInGameZone(md, x, ry)) laneCands.push({ x, y: ry }); } }); if (laneCands.length) { const p = laneCands[Math.floor(Math.random() * laneCands.length)]; return { x: p.x + 0.5, y: p.y + 0.5 }; } } /* stack (Tower block): เล่นแบบหยอดบล็อก เดินไม่ได้ + กล้องเห็นแค่ "หอ" → วางไอคอนใกล้กึ่งกลางโซนปล่อย/ลง (ที่อยู่ในจอ) ผู้เล่นคลิก/แตะเก็บได้ — ไม่งั้นไปโผล่ขอบแมปนอกจอ */ if (md.gameType === 'stack') { const zc = []; [md.stackReleaseArea, md.stackLandArea].forEach((g) => { if (Array.isArray(g)) { for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y] && g[y][x]) zc.push({ x, y }); } }); if (zc.length) { let cx2 = 0; let cy2 = 0; zc.forEach((c) => { cx2 += c.x; cy2 += c.y; }); cx2 = Math.round(cx2 / zc.length); cy2 = Math.round(cy2 / zc.length); for (let rad = 0; rad <= 6; rad++) { for (let yy = cy2 - rad; yy <= cy2 + rad; yy++) { for (let xx = cx2 - rad; xx <= cx2 + rad; xx++) { if (specialQuizTileWalkable(md, xx, yy)) return { x: xx + 0.5, y: yy + 0.5 }; } } } /* โซนทั้งหมดเป็น object → วางกลางโซนไปเลย (จะคลิกได้แม้ไม่ walkable เพราะ stack ใช้คลิก) */ return { x: cx2 + 0.5, y: cy2 + 0.5 }; } } /* จุดเกิดที่ "กำหนดเองจาก editor" (specialQuizSpawnArea) — ใช้กับทุกเกมยกเว้น gauntlet/stack (สองเกมนั้น return ไปแล้วด้านบน) → สุ่ม 1 ช่องที่ paint ไว้ (เลือกช่องเดินได้ก่อน) */ if (Array.isArray(md.specialQuizSpawnArea) && md.specialQuizSpawnArea.length) { const painted = []; const paintedWalkable = []; for (let yy = 0; yy < h; yy++) { const row = md.specialQuizSpawnArea[yy]; if (!row) continue; for (let xx = 0; xx < w; xx++) { if (!row[xx]) continue; painted.push({ x: xx, y: yy }); if (specialQuizTileWalkable(md, xx, yy)) paintedWalkable.push({ x: xx, y: yy }); } } const pickFrom = paintedWalkable.length ? paintedWalkable : painted; if (pickFrom.length) { const p = pickFrom[Math.floor(Math.random() * pickFrom.length)]; return { x: p.x + 0.5, y: p.y + 0.5 }; } } /* 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) { /* ไม่มี spawnArea/spawn เดินได้ (เช่น stack/balloon) → seed จาก "กลางแมป" วนออก เพื่อให้ไอคอนอยู่กลางฉากที่มองเห็น/เอื้อมถึง ไม่ไปโผล่มุมนอกจอ */ const ccx = Math.floor(w / 2); const ccy = Math.floor(h / 2); const maxRad = Math.max(w, h); for (let rad = 0; rad <= maxRad && sx < 0; rad++) { for (let yy = ccy - rad; yy <= ccy + rad && sx < 0; yy++) { for (let xx = ccx - rad; xx <= ccx + rad && sx < 0; xx++) { if (specialQuizTileWalkable(md, xx, yy)) { sx = xx; sy = yy; } } } } } 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; /* เลือกช่องที่อยู่ "ใกล้ผู้เล่น/กลางฉาก" (2-8 ช่องจาก seed) ก่อน → ผู้เล่นเข้าไปจับได้แน่นอน + อยู่ในจอ · ไม่มีในระยะนั้นค่อย fallback ทั้งหมด */ const near = cands.filter((c) => { const d = Math.abs(c.x - sx) + Math.abs(c.y - sy); return d >= 2 && d <= 8; }); const pool = near.length ? near : cands; const pick = pool[Math.floor(Math.random() * pool.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 || !sq.appeared) return null; return { iconType: sq.iconType, x: sq.x, y: sq.y, expiresAt: sq.expiresAt || 0 }; } /** เริ่มมินิเกมสืบสวน: สุ่ม 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); let card = set && set.specialCard; /* โหมดเทสต์ (admin): บังคับการ์ดใบที่เลือกสำหรับเกมนี้ + force ขึ้นเสมอ (ไม่ต้องสุ่ม) */ const testCardId = (runtimeGameTiming.testSpecialCardByMap || {})[mapId]; const testForcedCard = (Number(testCardId) >= 1 && Number(testCardId) <= 7); if (testForcedCard) { const def = specialCardDef(testCardId) || {}; card = sanitizeSpecialCard({ cardId: Number(testCardId), th: def.th }); } 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 || testForcedCard; 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; } /* จำนวน "เลนที่มีผู้เล่นจริง" (คนจริง + บอท) — gauntlet เกิดไอคอนเฉพาะเลนที่ใช้จริง ไม่ใช่ทุกเลนที่ config */ const occupiedLanes = (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space); const tile = pickSpecialQuizSpawnTile(md, occupiedLanes); 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); } /** หน่วงไอคอน 2 วิ ค่อยโผล่ → emit ให้ทั้งห้อง + ตั้งเวลาให้ "หายไป" หลัง N วินาที (ถ้ายังไม่มีใครเก็บ) */ const SPECIAL_QUIZ_ICON_DELAY_MS = 2000; function scheduleSpecialQuizIconAppearAndExpiry(sid, space) { const sq = space && space.specialQuiz; if (!sq || sq.triggered || sq.done) return; const tAppear = setTimeout(() => { const sp = spaces.get(sid); if (!sp || !sp.specialQuiz || sp.specialQuiz.triggered || sp.specialQuiz.done) return; const cur = sp.specialQuiz; const sec = readSpecialQuizIconExpireSec(); cur.appeared = true; cur.expiresAt = (sec > 0) ? (Date.now() + sec * 1000) : 0; emitSpecialQuizIcon(sid, sp); if (sec > 0) { const tExpire = setTimeout(() => { const sp2 = spaces.get(sid); if (!sp2 || !sp2.specialQuiz || sp2.specialQuiz.triggered || sp2.specialQuiz.done) return; sp2.specialQuiz = null; io.to(sid).emit('special-quiz-icon-clear', {}); console.log('[special-quiz] icon EXPIRED after', sec + 's'); }, sec * 1000); if (!Array.isArray(sp.specialQuizTimers)) sp.specialQuizTimers = []; sp.specialQuizTimers.push(tExpire); } }, SPECIAL_QUIZ_ICON_DELAY_MS); if (!Array.isArray(space.specialQuizTimers)) space.specialQuizTimers = []; space.specialQuizTimers.push(tAppear); } /** 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: 1, th: 'ทุกคนรับหลักฐานฟรี +1 ใบ' }, }; 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 || '', }; } /** เพดานเวลาที่ Time Dilation สะสมได้ต่อรอบ (กันการ์ดซ้อนยืดเวลาเกินเหตุ) */ const TIME_DILATION_MAX_PENDING_SEC = 30; /** ใช้การ์ดที่ทำงาน "ทันที" (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 ขยายเอง cap กันการ์ดซ้อนหลายใบจนเวลายืดเกินเหตุ */ space.timeDilationPendingSec = Math.min(TIME_DILATION_MAX_PENDING_SEC, (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 — server แจกเองผ่าน game-award.php (มี secret + รู้ playerKey จาก join) กัน client ปลอม noScore: ไม่ให้เหรียญ Fund ไปปั่นคะแนน high-score (เป็นโชค ไม่ใช่ฝีมือ) */ const coins = def.coins || 10; const awards = [...space.peers.values()] .filter((p) => p.playerKey) .map((p) => ({ playerKey: p.playerKey, nickname: (p.nickname || '').trim() || 'ผู้เล่น', coins: coins, noScore: true })); submitGameAwards(awards); io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(card), coins: coins, coinsServerAwarded: true, }); consumePendingSpecialCard(space, card.cardId); return; } /* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next) — คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */ } /* ===== คลังการ์ดพิเศษที่สะสมในเคสนี้ (ใช้ได้พร้อมกันทุกใบ ไม่ทับกัน) ===== */ function dbgCards(space) { return JSON.stringify((space && space.pendingSpecialCards || []).map((e) => e.cardId + (e.consumed ? 'x' : ''))); } function pendingSpecialCardList(space) { if (!Array.isArray(space.pendingSpecialCards)) space.pendingSpecialCards = []; return space.pendingSpecialCards; } /** เพิ่มการ์ดเข้าคลัง — กันได้การ์ดซ้ำในเกมเดียว (ถ้ามีใบเดิมยังไม่ได้ใช้ จะไม่เพิ่มซ้ำ) */ function addPendingSpecialCard(space, card) { if (!card || !card.cardId) return null; const list = pendingSpecialCardList(space); const cid = Number(card.cardId); if (list.some((e) => Number(e.cardId) === cid && !e.consumed)) { console.log('[card-dbg] add skip dup card' + cid + ' list=' + dbgCards(space)); return null; } const entry = { cardId: cid, card: card, consumed: false }; list.push(entry); console.log('[card-dbg] +card' + cid + ' list=' + dbgCards(space)); return entry; } /** หาการ์ดในคลังตาม cardId ที่ยังไม่ถูกใช้ */ function findPendingSpecialCard(space, cardId) { const cid = Number(cardId); return pendingSpecialCardList(space).find((e) => Number(e.cardId) === cid && !e.consumed) || null; } function consumePendingSpecialCard(space, cardId) { const e = findPendingSpecialCard(space, cardId); if (e) e.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; console.log('[card-dbg] specialQuiz end: success=' + !!success + ' cardId=' + (sq.card && sq.card.cardId)); if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) { space.specialCardAwardedThisRun = sq.card; cardOut = specialCardClientPayload(sq.card); addPendingSpecialCard(space, sq.card); /* สะสมเข้าคลัง — ใช้ได้พร้อมกันทุกใบ ไม่ทับใบเดิม */ applySpecialCardImmediate(sid, space, sq.card); } io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut }); /* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด) — มี safety timeout กันค้างถ้าไม่มีใครกด */ space.specialQuizAwaitContinue = true; space.specialQuizContinueAcks = new Set(); /* รวบ ack «เล่นต่อ» จากผู้เล่นจริง — ครบทุกคนถึง resume พร้อมกัน */ /* แจ้งจำนวนเริ่มต้น 0/N ให้ทุก client (โชว์บนปุ่ม) */ io.to(sid).emit('special-quiz-continue-progress', { ready: 0, total: space.peers.size }); if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer); space.specialQuizContinueTimer = setTimeout(() => { space.specialQuizContinueTimer = null; finishSpecialQuizContinue(sid, space); /* safety: ไม่ครบใน 32s → ไปต่อทั้งห้อง */ }, 32000); } /** นับ ack «เล่นต่อ» — ถ้าผู้เล่นจริงที่ยังอยู่กดครบทุกคน → resume พร้อมกัน (ไม่นับบอท) */ function maybeFinishSpecialQuizContinue(sid, space) { if (!space || !space.specialQuizAwaitContinue) return; const acks = space.specialQuizContinueAcks || (space.specialQuizContinueAcks = new Set()); const expected = [...space.peers.keys()]; /* ผู้เล่นจริงที่ยังอยู่ในห้อง (บอทไม่ต้องกด) */ const ready = expected.filter((pid) => acks.has(pid)).length; io.to(sid).emit('special-quiz-continue-progress', { ready, total: expected.length }); if (expected.length === 0 || expected.every((pid) => acks.has(pid))) { finishSpecialQuizContinue(sid, space); } } /** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */ function finishSpecialQuizContinue(sid, space) { if (!space || !space.specialQuizAwaitContinue) return; space.specialQuizAwaitContinue = false; space.specialQuizContinueAcks = null; if (space.specialQuizContinueTimer) { clearTimeout(space.specialQuizContinueTimer); space.specialQuizContinueTimer = null; } /* แจ้งทุก client ให้ปลด freeze + ปิด overlay พร้อมกัน (กดครบ หรือ safety timeout) */ io.to(sid).emit('special-quiz-resume-all', {}); 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([ 'topic1', 'topic2', 'topic3', 'topic4', 'topic5', 'topic6', 'topic7', 'topic8', 'topic9', 'topic10', ]); /** คำถาม 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 = 'topic1'; } 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) { const suspects = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/suspect-pick/case-' + n + '-suspect-' + s + '.png'); const culprits = [1, 2, 3].map((s) => '/Main-Lobby/IMAGE/culprit-prison/case-' + n + '-suspect-' + s + '.png'); 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', ], suspects, culprits, }; } function sanitizeCaseMediaUrlList(arr, fallbackArr) { const fb = Array.isArray(fallbackArr) ? fallbackArr : []; const src = Array.isArray(arr) ? arr : []; return [0, 1, 2].map((i) => sanitizeCaseMediaUrl(src[i], fb[i] || fb[0] || '')); } /** อนุญาตเฉพาะ 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])], suspects: sanitizeCaseMediaUrlList(c.suspects, d.suspects), culprits: sanitizeCaseMediaUrlList(c.culprits, d.culprits), }; } 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: [], 'suspect-pick': [], 'culprit-prison': [] }; ['case', 'cutscene', 'suspect-pick', 'culprit-prison'].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; /* ทุกเกม: เอาคะแนนของผู้เล่นทุกคนมาเฉลี่ยเป็น % แล้วแปลงเป็นเกรด A(≥80)/B(≥60)/C quiz ใช้ % สัมบูรณ์ (ถูก/เต็ม), เกมอื่น normalize เทียบคนคะแนนสูงสุดในห้องแล้วเฉลี่ย */ 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'; } } /* ===== แจกเหรียญตามอันดับ + คะแนนสะสม (high score) แบบ server-authoritative ===== */ const GAME_AWARD_URL = process.env.GAME_AWARD_URL || 'https://srv1361159.hstgr.cloud/Admin/api/game-award.php'; const ACHIEVEMENTS_URL = process.env.ACHIEVEMENTS_URL || GAME_AWARD_URL.replace('game-award.php', 'achievements.php'); const GAME_AWARD_SECRET_PATH = path.join(__dirname, '..', 'Admin', 'private', 'game-award-secret.txt'); let __gameAwardSecret = null; function gameAwardSecret() { if (__gameAwardSecret != null) return __gameAwardSecret; try { __gameAwardSecret = String(fs.readFileSync(GAME_AWARD_SECRET_PATH, 'utf8')).trim(); } catch (e) { __gameAwardSecret = ''; } return __gameAwardSecret; } /* เหรียญตามอันดับผู้รอดชีวิต: อันดับ 1→10, 2→8, 3→6, 4→4, 5→2, 6+→0 */ const MINIGAME_RANK_COINS = [10, 8, 6, 4, 2, 0]; /* เพดานคะแนนที่ client รายงานเองได้ (stack/quiz_carry) — sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง ค่าจริงต่อรอบอยู่หลักร้อย-พัน เพดานนี้กว้างพอไม่กระทบเล่นจริง (ไม่ใช่ anti-cheat สมบูรณ์ — ดูคอมเมนต์ที่ handler) */ const REPORTED_MINI_SCORE_MAX = 50000; /* คะแนน + สถานะรอด/ตาย ของผู้เล่นต่อ gameType ตอนจบมินิเกม */ function minigamePlayerScoreSurvived(space, p, gameType) { /* Card 3 Ban — ผู้ถูกแบนเข้ามาดูเฉยๆ ไม่ได้เล่น → ไม่นับเป็นผู้รอด/ไม่ได้คะแนน */ if (p && p.bannedSpectator) return { score: 0, survived: false }; switch (gameType) { case 'quiz': { const st = space.quizSession && space.quizSession.players ? space.quizSession.players[p.id] : null; return { score: st ? Math.max(0, Number(st.score) | 0) : 0, survived: st ? !st.eliminated : true }; } case 'gauntlet': return { score: Math.max(0, p.gauntletScore | 0), survived: !p.gauntletEliminated }; case 'jump_survive': /* jump_survive ไม่มี gauntlet ticker → gauntletScore/Eliminated ฝั่ง server เป็น 0/false เสมอ ใช้ค่าที่ client รายงานตอนจบแทน (reportedMiniSurvived = ไม่ตาย) ; เดิมเข้า case gauntlet → ทุกคน survived เสมอ = coin มั่ว */ return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false }; case 'space_shooter': return { score: Math.max(0, p.spaceShooterScore | 0), survived: true }; case 'balloon_boss': return { score: Math.max(0, p.balloonBossScore | 0), survived: !p.balloonBossEliminated }; default: /* stack, quiz_carry — client รายงานคะแนน/รอดเอง ตอนจบ */ return { score: Math.max(0, p.reportedMiniScore | 0), survived: p.reportedMiniSurvived !== false }; } } /* ยิงไป PHP (มี secret) เพื่อบวกเหรียญ+คะแนนเข้าบัญชีผู้เล่นจริง (client ปลอมไม่ได้) */ function submitGameAwards(awards) { const secret = gameAwardSecret(); if (!secret || !Array.isArray(awards) || !awards.length) return; try { fetch(GAME_AWARD_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret, awards }), }).then((r) => r.json()).then((d) => { if (!d || !d.ok) console.error('[game-award] failed', d); }).catch((e) => console.error('[game-award] error', e && e.message)); } catch (e) { console.error('[game-award] throw', e && e.message); } } /* บวก progress achievement (server-authoritative ผ่าน achievements.php — secret เดียวกับ game-award) items: [{ playerKey, id, inc }] — หนึ่ง POST ต่อรายการ (achievements.php รับทีละ id) */ function submitAchievementProgress(items) { const secret = gameAwardSecret(); if (!secret || !Array.isArray(items) || !items.length) return; items.forEach((it) => { if (!it || !it.playerKey || !it.id) return; try { fetch(ACHIEVEMENTS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'progress', secret, playerKey: it.playerKey, id: it.id, inc: it.inc || 1 }), }).then((r) => r.json()).then((d) => { if (!d || !d.ok) console.error('[achv] failed', it.id, d); }).catch((e) => console.error('[achv] error', e && e.message)); } catch (e) { console.error('[achv] throw', e && e.message); } }); } /* แจกเหรียญตามอันดับคะแนน (เฉพาะผู้รอดชีวิต ผู้ตาย = 0) + คะแนนสะสมเข้า leaderboard — ครั้งเดียวต่อรอบ */ function awardMinigameRankCoins(sid, space) { if (!space || space.coinAwardDoneForRun) return; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; const gameType = md && md.gameType; if (!gameType || gameType === 'zep' || gameType === 'quiz_battle' || serverMapIsPostCaseLobbyB(space)) return; const peers = [...space.peers.values()]; if (!peers.length) return; space.coinAwardDoneForRun = true; const rows = peers.map((p) => { const ss = minigamePlayerScoreSurvived(space, p, gameType); return { id: p.id, playerKey: p.playerKey || '', nickname: (p.nickname || '').trim() || 'ผู้เล่น', score: ss.score, survived: !!ss.survived, coins: 0 }; }); /* ผู้รอดชีวิตเรียงคะแนนมาก→น้อย แล้วให้เหรียญตามอันดับ */ const survivors = rows.filter((r) => r.survived).sort((a, b) => b.score - a.score); survivors.forEach((r, i) => { r.coins = i < MINIGAME_RANK_COINS.length ? MINIGAME_RANK_COINS[i] : 0; }); const caseId = String(space.caseId || space.detectiveLobbyCaseId || ''); const awards = rows.filter((r) => r.coins > 0 && r.playerKey).map((r) => ({ playerKey: r.playerKey, nickname: r.nickname, coins: r.coins, caseId: caseId })); submitGameAwards(awards); /* Achievement: d1 Minigame Solver — รอดมินิเกมสำเร็จ (เฉพาะผู้รอดชีวิตที่เป็นผู้เล่นจริง) */ submitAchievementProgress(survivors.filter((r) => r.playerKey).map((r) => ({ playerKey: r.playerKey, id: 'd1_minigame_solver', inc: 1 }))); io.to(sid).emit('minigame-coins', { gameType, coinsTable: MINIGAME_RANK_COINS, results: rows.map((r) => ({ id: r.id, nickname: r.nickname, score: r.score, survived: r.survived, coins: r.coins })), }); } /* 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', /** จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */ balloonBossBalloonsPerPlayer: 0, /** โหมดเทสต์: เกมบังคับต่อการ์ด 3 ใบ — ['','',''] = สุ่มหมด, แต่ละช่องใส่ key เพื่อเจาะจงใบนั้น */ forcedMinigameKeys: ['', '', ''], /** โหมดเทสต์: บังคับการ์ดพิเศษต่อเกม { mapId: cardId } */ testSpecialCardByMap: {}, /** โหมดเทสต์: บังคับเสนอบทตัวป่วน (ปกติเสนอเฉพาะเมื่อมีคนจริง > 4) */ troublesomeForceOffer: false, /** เวลาที่ไอคอนคำถามพิเศษอยู่บนฉากก่อนหายไป (วินาที) — 0 = ไม่หาย */ specialQuizIconExpireSec: 10, /** เวลาโหวตเลือกผู้เล่น (การ์ด Silence/Ban) วินาที */ pickVoteSec: 25, /** เวลาโหวตชี้ตัวคนร้าย (พิจารณาคดี) วินาที */ trialVoteSec: 30, }; } /** อ่านเวลา expiry ไอคอนคำถามพิเศษ (วินาที) จาก game-timing — clamp 0..120 */ function readSpecialQuizIconExpireSec() { const v = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.specialQuizIconExpireSec)); if (!Number.isFinite(v) || v < 0) return 10; return Math.min(120, v); } 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) : '', balloonBossBalloonsPerPlayer: Object.prototype.hasOwnProperty.call(j, 'balloonBossBalloonsPerPlayer') ? Math.max(0, Math.min(12, Math.floor(Number(j.balloonBossBalloonsPerPlayer)) || 0)) : 0, forcedMinigameKeys: normalizeForcedMinigameKeys( j.forcedMinigameKeys != null ? j.forcedMinigameKeys : j.forcedMinigameKey, ), testSpecialCardByMap: normalizeTestSpecialCardByMap(j.testSpecialCardByMap), troublesomeForceOffer: !!j.troublesomeForceOffer, specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(j, 'specialQuizIconExpireSec') ? Math.max(0, Math.min(120, Math.floor(Number(j.specialQuizIconExpireSec)) || 0)) : 10, pickVoteSec: Object.prototype.hasOwnProperty.call(j, 'pickVoteSec') ? Math.max(5, Math.min(120, Math.floor(Number(j.pickVoteSec)) || 25)) : 25, trialVoteSec: Object.prototype.hasOwnProperty.call(j, 'trialVoteSec') ? Math.max(5, Math.min(180, Math.floor(Number(j.trialVoteSec)) || 30)) : 30, }; } } catch (e) { console.error('loadGameTiming', e.message); } return defaultGameTiming(); } let runtimeGameTiming = loadGameTiming(); /** จำนวนลูกโป่งต่อคน Mega Virus — แมปกำหนดก่อน, ไม่งั้นใช้ค่ากลางจาก admin, สุดท้าย default 3 (clamp 1-12) */ function balloonBossBalloonsForMap(md) { const fromMap = Math.floor(Number(md && md.balloonBossBalloonsPerPlayer)) || 0; const fromCfg = Math.floor(Number(runtimeGameTiming && runtimeGameTiming.balloonBossBalloonsPerPlayer)) || 0; return Math.max(1, Math.min(12, fromMap || fromCfg || 3)); } 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; /* จำนวนลูกโป่งต่อคน (0 = ใช้ค่าจากแมป หรือ default 3) */ const prevBbCount = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBalloonsPerPlayer') ? Math.max(0, Math.min(12, Math.floor(Number(prev.balloonBossBalloonsPerPlayer)) || 0)) : 0; const balloonBossBalloonsPerPlayer = Object.prototype.hasOwnProperty.call(d, 'balloonBossBalloonsPerPlayer') ? Math.max(0, Math.min(12, Math.floor(Number(d.balloonBossBalloonsPerPlayer)) || 0)) : prevBbCount; 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, balloonBossBalloonsPerPlayer, forcedMinigameKeys: Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKeys') ? normalizeForcedMinigameKeys(d.forcedMinigameKeys) : (Object.prototype.hasOwnProperty.call(d, 'forcedMinigameKey') ? normalizeForcedMinigameKeys(d.forcedMinigameKey) : normalizeForcedMinigameKeys(prev.forcedMinigameKeys)), testSpecialCardByMap: Object.prototype.hasOwnProperty.call(d, 'testSpecialCardByMap') ? normalizeTestSpecialCardByMap(d.testSpecialCardByMap) : normalizeTestSpecialCardByMap(prev.testSpecialCardByMap), troublesomeForceOffer: Object.prototype.hasOwnProperty.call(d, 'troublesomeForceOffer') ? !!d.troublesomeForceOffer : !!prev.troublesomeForceOffer, specialQuizIconExpireSec: Object.prototype.hasOwnProperty.call(d, 'specialQuizIconExpireSec') ? Math.max(0, Math.min(120, Math.floor(Number(d.specialQuizIconExpireSec)) || 0)) : (prev.specialQuizIconExpireSec != null ? Math.max(0, Math.min(120, Math.floor(Number(prev.specialQuizIconExpireSec)) || 0)) : 10), pickVoteSec: Object.prototype.hasOwnProperty.call(d, 'pickVoteSec') ? Math.max(5, Math.min(120, Math.floor(Number(d.pickVoteSec)) || 25)) : (prev.pickVoteSec != null ? Math.max(5, Math.min(120, Math.floor(Number(prev.pickVoteSec)) || 25)) : 25), trialVoteSec: Object.prototype.hasOwnProperty.call(d, 'trialVoteSec') ? Math.max(5, Math.min(180, Math.floor(Number(d.trialVoteSec)) || 30)) : (prev.trialVoteSec != null ? Math.max(5, Math.min(180, Math.floor(Number(prev.trialVoteSec)) || 30)) : 30), }; 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 }; } const QUIZ_BATTLE_SPRITE_COL_SCALE_W = 1.15; const QUIZ_BATTLE_SPRITE_COL_SCALE_H = 1.35; function quizBattleSpriteBoundsTileKeys(md, px, py) { const out = []; if (!md || md.gameType !== 'quiz_battle') return out; const x = Number(px), y = Number(py); if (!Number.isFinite(x) || !Number.isFinite(y)) return out; const w = md.width || 20, h = md.height || 15; const { cw, ch } = getCharacterFootprintWHForMove(md); const cx = x + cw * 0.5; const feetY = y + ch; const left = cx - (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; const right = cx + (cw * QUIZ_BATTLE_SPRITE_COL_SCALE_W) / 2; const top = feetY - ch * QUIZ_BATTLE_SPRITE_COL_SCALE_H; const bottom = feetY; if (left < 0 || right > w || top < 0 || bottom > h) return out; const minTx = Math.floor(left); const maxTx = Math.min(w - 1, Math.floor(right - 1e-6)); const minTy = Math.floor(top); const maxTy = Math.min(h - 1, Math.floor(bottom - 1e-6)); for (let ty = minTy; ty <= maxTy; ty++) { for (let tx = minTx; tx <= maxTx; tx++) out.push(`${tx},${ty}`); } return out; } function serverWallCollisionTileKeys(md, px, py) { if (md && md.gameType === 'quiz_battle') return quizBattleSpriteBoundsTileKeys(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, spawnJoinOrder: typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : null, lobbyColorThemeIndex: p.lobbyColorThemeIndex || null, lobbySkinToneIndex: p.lobbySkinToneIndex || null, bannedSpectator: !!p.bannedSpectator, }; if (extraFields && extraFields.gauntlet) { row.gauntletJumpTicks = p.gauntletJumpTicks || 0; row.gauntletScore = p.gauntletScore || 0; row.gauntletEliminated = !!p.gauntletEliminated; } return row; }); } /* payload ของ quiz-carry-lobby-sync — แนบ peers snapshot ไปด้วยเสมอ → client (โดยเฉพาะ host) upsert others ที่ขาดหายให้ครบ กัน host ไม่เห็นตัวละคร client เมื่อ join/reconnect ไม่ทันรอบ (race ตอนเปลี่ยนหน้า lobbyB→play.html) */ /* true ถ้า peer นี้คือผู้ถูกแบนรอบนี้ (Card 3 Ban) — เข้ามาดูเฉยๆ ไม่ต้อง Ready และไม่นับใน gate เริ่มเกม (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ที่ reconnect เข้า play.html) */ function isBannedPeerForRun(space, id) { if (!space) return false; if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return true; const pp = space.peers && space.peers.get(id); return !!(pp && pp.bannedSpectator); } /* สำเนา ready map โดยตัด id ผู้ถูกแบนออก — กัน client นับ banned เป็นผู้เล่นที่ต้องรอ Ready (host ค้าง N/N+1) */ function readyMapWithoutBanned(space, srcMap) { const out = {}; if (srcMap && typeof srcMap === 'object') { Object.keys(srcMap).forEach((id) => { if (!isBannedPeerForRun(space, id)) out[id] = srcMap[id]; }); } return out; } function quizCarryLobbySyncPayload(space) { return { readyMap: readyMapWithoutBanned(space, space && space.quizCarryLobbyReady), peers: buildPeersSnapForSpace(space), expectedActive: (space && space.minigameExpectedActiveCount) || 0, }; } /* payload ของ gauntlet-crown-lobby-sync — ตัด banned ออกจาก readyMap เช่นกัน แนบ peers snapshot ด้วย (เหมือน quiz_carry) → client/host เติม others ที่ขาด เห็นจำนวนผู้เล่นเท่ากัน */ function crownLobbySyncPayload(space) { return { readyMap: readyMapWithoutBanned(space, space && space.gauntletCrownLobbyReady), expectedActive: (space && space.minigameExpectedActiveCount) || 0, peers: buildPeersSnapForSpace(space), }; } /** สุ่ม 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; } /* นับผู้เล่นในคดี (caseParticipantNicknames) ที่กลับเข้า LobbyB แล้วเทียบกับทั้งหมด ใช้ตรวจว่าครบหรือยังก่อนให้ host เริ่มมินิเกมรอบถัดไป */ function detectiveLobbyBMissingParticipants(space) { const roster = space && space.caseParticipantNicknames; if (!roster || roster.size === 0) return { missing: 0, present: 0, total: 0 }; const present = new Set(); space.peers.forEach((p) => { if (!p || !p.nickname) return; const n = normalizeLobbyNickname(p.nickname); if (roster.has(n)) present.add(n); }); const total = roster.size; return { missing: Math.max(0, total - present.size), present: present.size, total }; } /* แจ้งทุกคนใน LobbyB ว่ามีผู้เล่นกลับเข้ามาแล้วกี่คน/ทั้งหมดกี่คน → host ใช้เปิด/ปิดปุ่มเริ่ม + แสดงสถานะรอ */ function emitDetectiveLobbyBPresence(sid, space) { if (!sid || !space) return; if (!serverMapIsPostCaseLobbyB(space)) return; const info = detectiveLobbyBMissingParticipants(space); if (info.total <= 0) return; io.to(sid).emit('detective-lobbyb-presence', { present: info.present, total: info.total, allHere: info.missing === 0, }); } function assignDetectiveSuspectCardMinigames(space) { const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId)); /** โหมดเทสต์ (admin game-timing): เลือกเกมต่อการ์ด 3 ใบ — ช่องที่ตั้ง key = เจาะจง, ช่องว่าง = สุ่ม */ const forcedKeys = normalizeForcedMinigameKeys(runtimeGameTiming && runtimeGameTiming.forcedMinigameKeys); const randomPool = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]); let ri = 0; const pick3 = []; for (let i = 0; i < 3; i++) { const fk = forcedKeys[i]; let entry = fk ? available.find((e) => e.key === fk) : null; if (!entry) { entry = randomPool[ri % randomPool.length]; ri += 1; } pick3.push(entry); } space.suspectCardMinigames = pick3.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 = findPendingSpecialCard(space, 7); if (pend) { const def = specialCardDef(7); freeEvidenceCount = (def && def.evidence) || 1; 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; space.coinAwardDoneForRun = false; /* รีเซ็ตคะแนนที่ client รายงาน (stack/quiz_carry) ของรอบก่อน */ space.peers.forEach((p) => { p.reportedMiniScore = 0; p.reportedMiniSurvived = true; }); /* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */ space.bannedThisRunPlayerId = space.bannedPlayerId || null; console.log('[ban-debug] startMinigame: bannedThisRunPlayerId=', space.bannedThisRunPlayerId); space.bannedPlayerId = null; /* จำนวนผู้เล่นที่ต้องเข้าเกม + กด Ready ให้ครบก่อน host เริ่มได้ (ไม่นับผู้ถูกแบน) สแนปจาก peers ใน LobbyB ตอนนี้ — กัน host เริ่มก่อน client โหลด/กด Ready ครบ (เกมไม่ sync/จบไม่พร้อมกัน) */ space.minigameExpectedActiveCount = [...space.peers.keys()].filter((id) => !isBannedPeerForRun(space, id)).length; 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); /* ไอคอนยังไม่โผล่ตอนนี้ — รอ client ส่ง 'special-quiz-arm' เมื่อเกม "live จริง" (พ้น howto/นับถอยหลัง) แล้วค่อยหน่วง 2 วิโผล่ */ space.peers.forEach((p) => { const sp = pickSpawnForJoin(md, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); p.x = sp.x; p.y = sp.y; p.direction = 'down'; p.gauntletJumpTicks = 0; p.gauntletScore = 0; p.gauntletJumpPending = false; p.gauntletEliminated = false; p.detectiveFinished = false; /* MG2: รอให้ทุกคน(ที่ยังไม่ตาย)เข้าเส้นชัยก่อนจบเกมพร้อมกัน */ }); 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(); } /* quiz_carry ใช้ ready map ของตัวเอง (ไม่ใช่ crown) — รีเซ็ตสถานะ Ready ใหม่ทุกคนเริ่มรอบ แล้วแจ้งทุก client กัน host เห็นจำนวนผู้เล่น/สถานะ ready ไม่ตรง และให้ flow READY→START ทำงานเหมือนเกมอื่น */ if (md.gameType === 'quiz_carry') { space.quizCarryLobbyReady = {}; space.peers.forEach((_p, id) => { space.quizCarryLobbyReady[id] = false; }); io.to(sid).emit('quiz-carry-lobby-sync', quizCarryLobbySyncPayload(space)); } 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(); /* แจกเหรียญตามอันดับ + คะแนนสะสม ก่อนล้างสถานะมินิเกม (คะแนน/ผู้รอดยังครบ) */ awardMinigameRankCoins(sid, space); 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 — ผู้ที่ถูกแบนรอบนี้เข้ามาดูเฉยๆ ไม่ได้เล่น จึงไม่ได้รับหลักฐาน (เช็คทั้ง id เดิมตอนโหวต และ flag bannedSpectator ของ peer ใน play.html ที่ reconnect ใหม่) */ awardIds = awardIds.filter((id) => { if (space.bannedThisRunPlayerId && id === space.bannedThisRunPlayerId) return false; const pp = space.peers.get(id); if (pp && pp.bannedSpectator) return false; return true; }); /* ไม่มอบการ์ดเมื่อกลับ 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; space.peers.forEach((p) => { const sp = pickSpawnForJoin(lb, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); 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); /* Card 3 Ban (when: lobby_next): ชนะการ์ดแบนรอบนี้ → "โหวตทันทีที่กลับ LobbyB หลังเกมจบ" ตั้ง bannedPlayerId ให้มินิเกมรอบถัดไปกันไม่ให้คนที่ถูกโหวตเล่น */ const banPend = findPendingSpecialCard(space, 3); console.log('[card-dbg] lobby return: ban(3)=' + !!banPend + ' pickVote=' + !!space.pickVote + ' list=' + dbgCards(space)); if (banPend && !space.pickVote) { banPend.consumed = true; /* ตั้ง flag — เริ่มโหวตตอนผู้เล่น "rejoin เข้า room-lobby" (ตอน return ผู้เล่นกำลัง navigate play.html→room-lobby.html อยู่ event จะหาย) */ space.pendingBanVoteCard = banPend.card; } } /* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */ 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 ครั้ง */ { /* โหมดเทสต์ (Ctrl+Alt+W): บังคับให้รอบนี้ "นับเป็นโหวตผิด" ครั้งเดียว เพื่อทริกการ์ด 5 */ const forceWrong = !!space.testForceWrongVote; space.testForceWrongVote = false; 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 = forceWrong ? anyVotes : (anyVotes && mostVoted !== culprit); const pend = findPendingSpecialCard(space, 5); if (wrong && pend && !space.trialBailUsed) { pend.consumed = true; space.trialBailUsed = true; space.trialRevoteExcluded = mostVoted; /* ผู้ต้องสงสัยที่โหวตไปแล้ว (ผิด) — ห้ามโหวตซ้ำในรอบใหม่ */ io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' }); openTrialVotingPhase(sid, space, { revote: true, excludedSuspect: mostVoted }); 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); }); /* ตัวป่วน (ถ้ามีคนรับบท) — หา socket.id ปัจจุบันจาก playerKey/nickname (id เดิมตอนเสนออาจเปลี่ยนแล้ว) */ let disruptorId = null; if (space.troublesomeAccept && (space.disruptorPlayerKey || space.disruptorNickname)) { for (const [pid, p] of space.peers) { const keyMatch = space.disruptorPlayerKey && p.playerKey === space.disruptorPlayerKey; const nickMatch = space.disruptorNickname && normalizeLobbyNickname(p.nickname) === space.disruptorNickname; if (keyMatch || nickMatch) { disruptorId = pid; break; } } } io.to(sid).emit('trial-result', { culpritIndex: culprit, counts, winners, winnerNames, cardMinigames: space.suspectCardMinigames || [], disruptorId, hasDisruptor: !!disruptorId, }); /* ===== Achievement auto-track ===== */ try { const achv = []; /* a1 First Deduction (target 1) + a5 Truth Hunter (target 20) — ผู้โหวตถูก (ผู้เล่นจริงเท่านั้น) */ winners.forEach((id) => { const p = space.peers.get(id); if (p && p.playerKey) { achv.push({ playerKey: p.playerKey, id: 'a1_first_deduction', inc: 1 }); achv.push({ playerKey: p.playerKey, id: 'a5_truth_hunter', inc: 1 }); } }); /* ตัวป่วน — นับเฉพาะผู้เล่นจริง (มี playerKey) */ if (disruptorId && space.disruptorPlayerKey) { const dKey = space.disruptorPlayerKey; achv.push({ playerKey: dKey, id: 'e2_the_impostor', inc: 1 }); /* target 1 */ achv.push({ playerKey: dKey, id: 'e4_agent_of_chaos', inc: 1 }); /* target 10 */ /* ตัวป่วนชนะ = ทีมโหวตผิดคน (ไม่จับคนร้ายตัวจริง) */ let mv = 0; for (let k = 1; k <= 2; k++) { if (counts[k] > counts[mv]) mv = k; } const anyV = (counts[0] + counts[1] + counts[2]) > 0; if (anyV && mv !== culprit) { achv.push({ playerKey: dKey, id: 'e3_master_of_doubt', inc: 1 }); /* target 1 */ achv.push({ playerKey: dKey, id: 'e5_slippery_eel', inc: 1 }); /* target 5 */ } } submitAchievementProgress(achv); } catch (e) { console.error('[achv] trial-result', e && e.message); } 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 เพิ่มให้ */ /* เวลาโหวตชี้ตัวคนร้าย (ms) — ตั้งได้ในหน้า admin (game-timing.trialVoteSec) */ function trialVoteMs() { return Math.max(5, Math.min(180, Math.floor(Number(runtimeGameTiming.trialVoteSec)) || 30)) * 1000; } 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 = {}; /* ผู้ต้องสงสัยที่ถูกตัดออก (โหวตผิดไปแล้วในรอบจับผิดตัว) — null ถ้าเป็นการโหวตปกติ */ const excludedSuspect = opts.revote ? (Number.isInteger(opts.excludedSuspect) ? opts.excludedSuspect : (Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : null)) : null; if (!opts.revote) space.trialRevoteExcluded = null; autoVoteBotsTrial(space); clearTrialVoteTimer(space); /* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */ let extraMs = 0; let extensionCard = false; const pend = findPendingSpecialCard(space, 2); if (pend) { extraMs = TRIAL_VOTE_EXTENSION_MS; extensionCard = true; pend.consumed = true; } const durMs = trialVoteMs() + 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, excludedSuspect: excludedSuspect, silenced: !!(space.silencedPlayerId && pid === space.silencedPlayerId), }); }); 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 ห้ามเล่น) ===== */ /* เวลาโหวตเลือกผู้เล่น Silence/Ban (ms) — ตั้งได้ในหน้า admin (game-timing.pickVoteSec) */ function pickVoteMs() { return Math.max(5, Math.min(120, Math.floor(Number(runtimeGameTiming.pickVoteSec)) || 25)) * 1000; } 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 PV_MS = pickVoteMs(); const candidates = pickVoteCandidates(space); space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PV_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: PV_MS, }); }); emitPickVoteUpdate(sid, space); space.pickVoteTimer = setTimeout(() => { const sp = spaces.get(sid); if (!sp || !sp.pickVote || sp.pickVote.resolved) return; resolvePlayerPickVote(sid, sp); }, PV_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 = 0; Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; }); const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max); /* เสมอ (มากกว่า 1 คนได้สูงสุดเท่ากัน) หรือไม่มีใครโหวต → ไม่มีใครโดน (เป้าหมายปลอดภัย) */ const target = (max > 0 && maxIds.length === 1) ? maxIds[0] : 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); const excluded = Number.isInteger(space.trialRevoteExcluded) ? space.trialRevoteExcluded : -1; 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); } /* รอบจับผิดตัว: ห้ามบอทโหวตผู้ต้องสงสัยที่ถูกตัดออก → เปลี่ยนไปโหวตคนร้ายจริง */ if (pick === excluded) pick = culprit; 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 = findPendingSpecialCard(space, 4); console.log('[card-dbg] trial pre-vote: silence(4)=' + !!pend + ' list=' + dbgCards(space)); if (pend) { 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) { /* MG1: ไม่ auto-return/auto-grant — ส่ง quiz-ended ให้ client โชว์ผล (รอกดปุ่มรับการ์ดเอง เหมือนเกมอื่น) เดิม returnDetectiveSpaceToLobbyB ที่นี่ = server แจกการ์ด+เด้งกลับเองทันที = "auto รับผล/การ์ด" การแจกการ์ด/กลับ LobbyB จะเกิดเมื่อ client กดปุ่ม → detective-minigame-finished (เส้นทางเดียวกับ MG2-7) */ io.to(sid).emit('quiz-ended', { message: message || 'จบเกม', returnToLobbyB: true }); /* คง quizSession (active=false แล้ว) ไว้ → ตอน client กด finish → returnDetectiveSpaceToLobbyB อ่านคะแนน quiz มาคิดเหรียญได้ (ถ้า null ที่นี่ minigamePlayerScoreSurvived('quiz') = 0 หมด → เหรียญเพี้ยน) ; จะถูกแทนเมื่อเริ่มมินิเกมรอบใหม่ */ 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; } /** โดมติดกัน = 1 ข้อ (comp id 1,2,3…) — ตรงกับ play.js */ function normalizeQuizBattleDomeCompOnMap(m) { if (!m || m.gameType !== 'quiz_battle') return; normalizeQuizBattleDomeAreaOnMap(m); const w = m.width || 20, h = m.height || 15; const grid = m.quizBattleDomeArea; const comp = Array(h).fill(0).map(() => Array(w).fill(0)); let nextId = 0; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { if (!grid[y] || grid[y][x] !== 1 || comp[y][x]) continue; nextId++; const stack = [[x, y]]; comp[y][x] = nextId; while (stack.length) { const c = stack.pop(); const cx = c[0], cy = c[1]; for (let i = 0; i < 4; i++) { const dx = (i === 0 ? 1 : i === 1 ? -1 : 0); const dy = (i === 2 ? 1 : i === 3 ? -1 : 0); const nx = cx + dx, ny = cy + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; if (!grid[ny] || grid[ny][nx] !== 1 || comp[ny][nx]) continue; comp[ny][nx] = nextId; stack.push([nx, ny]); } } } } m.quizBattleDomeComp = comp; } /** * ประตูบนเลน — ช่องเลนที่อยู่ "หลัง" โดม C ต้องตอบถูกโดม 1..C ก่อน * ยืนบนโดมได้โดยไม่ต้องตอบ (dist เท่ากัน) · ผ่านจุดหลังโดมต้องตอบก่อน */ function hasQuizBattleManualPathGates(m) { const mg = m && m.quizBattlePathGateArea; if (!mg || !Array.isArray(mg)) return false; for (let ty = 0; ty < mg.length; ty++) { const row = mg[ty]; if (!row) continue; for (let tx = 0; tx < row.length; tx++) { if ((row[tx] | 0) > 0) return true; } } return false; } function buildQuizBattlePathGateFromManualOnMap(m) { const w = m.width || 20, h = m.height || 15; const path = m.quizBattlePathArea; const dome = m.quizBattleDomeArea; const manual = m.quizBattlePathGateArea; const gate = Array(h).fill(0).map(() => Array(w).fill(0)); const bestReq = Array(h).fill(0).map(() => Array(w).fill(-1)); const queue = []; function enqueue(tx, ty, req) { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return; const onPath = path[ty] && path[ty][tx] === 1; const onDome = dome && dome[ty] && dome[ty][tx] === 1; if (!onPath && !onDome) return; /* เก็บ req ต่ำสุดที่เข้าช่องได้ — กันเส้นทางวนกลับไปยก req ทับ spawn */ if (bestReq[ty][tx] >= 0 && req >= bestReq[ty][tx]) return; bestReq[ty][tx] = req; queue.push({ tx, ty, req }); } const sp = m.spawn || { x: 1, y: 1 }; const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0)); const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0)); enqueue(stx, sty, 0); if (bestReq[sty][stx] < 0) { for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) enqueue(stx + dx, sty + dy, 0); } const sa = m.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] === 1) enqueue(tx, ty, 0); } } } while (queue.length) { const cur = queue.shift(); const { tx, ty, req } = cur; if (bestReq[ty][tx] !== req) continue; const onDome = dome && dome[ty] && dome[ty][tx] === 1; let enterReq = req; const mg = manual && manual[ty] && manual[ty][tx] ? (manual[ty][tx] | 0) : 0; if (onDome) enterReq = 0; else if (mg > 0) enterReq = Math.max(enterReq, mg); if (path[ty] && path[ty][tx] === 1) gate[ty][tx] = enterReq; let outReq = enterReq; if (mg > 0) outReq = Math.max(outReq, mg); for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { enqueue(tx + dx, ty + dy, outReq); } } m.quizBattlePathGateRequired = gate; } function buildQuizBattlePathGateRequiredOnMap(m) { if (!m || m.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(m)) { if (m) m.quizBattlePathGateRequired = null; return; } if (hasQuizBattleManualPathGates(m)) { buildQuizBattlePathGateFromManualOnMap(m); return; } normalizeQuizBattleDomeCompOnMap(m); const w = m.width || 20, h = m.height || 15; const path = m.quizBattlePathArea; const dome = m.quizBattleDomeArea; const domeComp = m.quizBattleDomeComp; const gate = Array(h).fill(0).map(() => Array(w).fill(0)); const dist = Array(h).fill(0).map(() => Array(w).fill(-1)); const queue = []; const sp = m.spawn || { x: 1, y: 1 }; const stx = Math.max(0, Math.min(w - 1, Math.floor(Number(sp.x)) || 0)); const sty = Math.max(0, Math.min(h - 1, Math.floor(Number(sp.y)) || 0)); function tryEnqueue(tx, ty, d) { if (tx < 0 || ty < 0 || tx >= w || ty >= h) return; if (dist[ty][tx] >= 0) return; const onPath = path[ty] && path[ty][tx] === 1; const onDome = dome && dome[ty] && dome[ty][tx] === 1; if (!onPath && !onDome) return; dist[ty][tx] = d; queue.push({ tx, ty, d }); } tryEnqueue(stx, sty, 0); if (dist[sty][stx] < 0) { for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) tryEnqueue(stx + dx, sty + dy, 0); } const sa = m.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] === 1) tryEnqueue(tx, ty, 0); } } } const domeMinDist = {}; let maxComp = 0; while (queue.length) { const cur = queue.shift(); const { tx, ty, d } = cur; const dc = domeComp[ty] && domeComp[ty][tx] ? domeComp[ty][tx] : 0; if (dc > 0) { maxComp = Math.max(maxComp, dc); if (domeMinDist[dc] == null || d < domeMinDist[dc]) domeMinDist[dc] = d; } for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const nx = tx + dx, ny = ty + dy; if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; if (dist[ny][nx] >= 0) continue; const onPath = path[ny] && path[ny][nx] === 1; const onDome = dome && dome[ny] && dome[ny][nx] === 1; if (onPath || onDome) tryEnqueue(nx, ny, d + 1); } } for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!path[ty] || path[ty][tx] !== 1) continue; const d = dist[ty][tx]; if (d < 0) { gate[ty][tx] = maxComp; continue; } let req = 0; for (let c = 1; c <= maxComp; c++) { if (domeMinDist[c] != null && domeMinDist[c] < d) req = Math.max(req, c); } gate[ty][tx] = req; } } m.quizBattlePathGateRequired = gate; } function finalizeQuizBattleMapLayersOnMap(m) { if (!m || m.gameType !== 'quiz_battle') return; normalizeQuizBattleDomeAreaOnMap(m); normalizeQuizBattlePathAreaOnMap(m); normalizeQuizBattleDomeCompOnMap(m); buildQuizBattlePathGateRequiredOnMap(m); } 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; const keys = quizBattleSpriteBoundsTileKeys(m, px, py); if (!keys.length) return false; for (const k of keys) { const [tx, ty] = k.split(',').map(Number); if (!g[ty] || g[ty][tx] !== 1) return false; } return true; } /** ยืน/เดินได้บนเลนม่วง หรือทุกช่องสไปรต์อยู่ใน spawnArea (ฟ้า) */ function quizBattlePositionAllowedServer(m, px, py) { if (!quizBattlePathModeActiveServer(m)) return true; if (typeof px !== 'number' || typeof py !== 'number' || !Number.isFinite(px) || !Number.isFinite(py)) return false; if (quizBattleFootprintFullyOnPathServer(m, px, py)) return true; const sa = m.spawnArea; if (!sa || !Array.isArray(sa)) return false; const keys = quizBattleSpriteBoundsTileKeys(m, px, py); if (!keys.length) return false; for (const k of keys) { const [tx, ty] = k.split(',').map(Number); if (!sa[ty] || sa[ty][tx] !== 1) return false; } return true; } function quizBattlePlayerSolvedCompsServer(space, socketId) { if (!space || !space.qbwSolved) return new Set(); const set = space.qbwSolved.get(socketId); return set || new Set(); } function quizBattlePathGateRequiredAtServer(m, px, py) { const gate = m && m.quizBattlePathGateRequired; if (!gate || !quizBattlePathModeActiveServer(m)) return 0; const keys = quizBattleSpriteBoundsTileKeys(m, px, py); const path = m.quizBattlePathArea; let maxReq = 0; for (const k of keys) { const [tx, ty] = k.split(',').map(Number); if (!path[ty] || path[ty][tx] !== 1) continue; maxReq = Math.max(maxReq, gate[ty][tx] || 0); } return maxReq; } function quizBattleFootprintOnDomeServer(m, px, py) { const dome = m && m.quizBattleDomeArea; if (!dome) return false; const keys = quizBattleSpriteBoundsTileKeys(m, px, py); for (const k of keys) { const [tx, ty] = k.split(',').map(Number); if (dome[ty] && dome[ty][tx] === 1) return true; } return false; } function quizBattlePathGateAllowsServer(m, space, socketId, px, py) { if (!m || !quizBattlePathModeActiveServer(m)) return true; if (quizBattleFootprintOnDomeServer(m, px, py)) return true; const maxReq = quizBattlePathGateRequiredAtServer(m, px, py); if (maxReq <= 0) return true; const solved = quizBattlePlayerSolvedCompsServer(space, socketId); for (let c = 1; c <= maxReq; c++) { if (!solved.has(c)) return false; } return true; } function serverFootprintClearOfWalls(md, px, py) { const w = md.width || 20, h = md.height || 15; const keys = serverWallCollisionTileKeys(md, px, py); if (!keys.length) return false; for (const k of keys) { 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 (quizBattleSnapTargetValidServer(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 nx = tx + 0.5; const ny = ty + 0.5; 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 }; const sa = md.spawnArea; if (sa && Array.isArray(sa)) { for (let ty = 0; ty < h; ty++) { const row = sa[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (row[tx] !== 1) continue; for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { if (!quizBattleSnapTargetValidServer(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 }; return { x: px, y: py }; } function collectQuizBattleValidSpawnCentersServer(md) { const out = []; if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return out; const w = md.width || 20, h = md.height || 15; const g = md.quizBattlePathArea; const seen = new Set(); for (let ty = 0; ty < h; ty++) { for (let tx = 0; tx < w; tx++) { if (!g[ty] || g[ty][tx] !== 1) continue; for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { const key = nx.toFixed(3) + ',' + ny.toFixed(3); if (seen.has(key)) continue; if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue; if (!serverFootprintClearOfWalls(md, nx, ny)) continue; seen.add(key); out.push({ tx, ty, x: nx, y: ny }); } } } out.sort((a, b) => a.ty - b.ty || a.tx - b.tx || a.x - b.x); return out; } function quizBattleSnapTargetValidServer(md, x, y) { if (!md || !md.objects) return false; if (typeof x !== 'number' || typeof y !== 'number' || !Number.isFinite(x) || !Number.isFinite(y)) return false; if (!serverFootprintClearOfWalls(md, x, y)) return false; if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, x, y)) return false; return true; } function quizBattleNearestValidSpawnWorldServer(md, prefTx, prefTy) { const ptx = Math.floor(Number(prefTx)) || 0; const pty = Math.floor(Number(prefTy)) || 0; for (const [nx, ny] of [[ptx + 0.5, pty + 0.5], [ptx + 0.01, pty + 0.01]]) { if (serverFootprintClearOfWalls(md, nx, ny)) return { x: nx, y: ny }; } const pool = collectQuizBattleSpawnPoolServer(md); if (pool.length) { let best = pool[0], bestD = Infinity; for (const c of pool) { const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); if (d < bestD) { bestD = d; best = c; } } return { x: best.x, y: best.y }; } const valid = collectQuizBattleValidSpawnCentersServer(md); if (!valid.length) return { x: ptx + 0.5, y: pty + 0.5 }; let best = valid[0], bestD = Infinity; for (const c of valid) { const d = Math.abs(c.tx - ptx) + Math.abs(c.ty - pty); if (d < bestD) { bestD = d; best = c; } } return { x: best.x, y: best.y }; } function collectQuizBattleSpawnPoolServer(md) { const out = []; if (!md) return out; const w = md.width || 20, h = md.height || 15; const grid = md.spawnArea; const seen = new Set(); const cells = []; if (grid && Array.isArray(grid)) { for (let ty = 0; ty < h; ty++) { const row = grid[ty]; if (!row) continue; for (let tx = 0; tx < w; tx++) { if (Number(row[tx]) === 1) cells.push({ tx, ty }); } } } if (!cells.length && md.spawn) { cells.push({ tx: Math.max(0, Math.min(w - 1, Math.floor(Number(md.spawn.x)) || 1)), ty: Math.max(0, Math.min(h - 1, Math.floor(Number(md.spawn.y)) || 1)), }); } for (const { tx, ty } of cells) { for (const [nx, ny] of [[tx + 0.5, ty + 0.5], [tx + 0.01, ty + 0.01]]) { const key = nx.toFixed(3) + ',' + ny.toFixed(3); if (seen.has(key)) continue; if (quizBattlePathModeActiveServer(md)) { if (!serverFootprintClearOfWalls(md, nx, ny)) continue; } else if (!isMapTileWalkableForSpawn(md, tx, ty)) { continue; } seen.add(key); out.push({ tx, ty, x: nx, y: ny }); } } if (!out.length && quizBattlePathModeActiveServer(md)) { return collectQuizBattleValidSpawnCentersServer(md); } return out; } function pickQuizBattleRandomSpawnWorldServer(md) { const pool = collectQuizBattleSpawnPoolServer(md); const fb = md.spawn || { x: 1, y: 1 }; if (!pool.length) { return quizBattleNearestValidSpawnWorldServer(md, fb.x, fb.y); } 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 }; } function quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex) { if (!md) return { x: 1.5, y: 1.5 }; const ord = joinOrderIndex | 0; const mode = md.lobbySpawnMode; if (mode === 'slots6') { const slots = parseLobbyPlayerSpawnsFromMap(md); const j = Math.min(Math.max(0, ord), 5); const slot = slots[j]; if (slot) return quizBattleNearestValidSpawnWorldServer(md, slot.x, slot.y); return pickQuizBattleRandomSpawnWorldServer(md); } if (mode === 'fixed' && md.spawn) { const sx = Number(md.spawn.x) || 1, sy = Number(md.spawn.y) || 1; return quizBattleNearestValidSpawnWorldServer(md, sx, sy); } return pickQuizBattleRandomSpawnWorldServer(md); } function pickQuizBattleSpawnFromMap(md, joinOrderIndex) { const world = quizBattleSpawnWorldFromJoinOrderServer(md, joinOrderIndex); return { x: Math.floor(world.x), y: Math.floor(world.y) }; } 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); finalizeQuizBattleMapLayersOnMap(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); finalizeQuizBattleMapLayersOnMap(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.specialQuizSpawnArea) m.specialQuizSpawnArea = []; 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); finalizeQuizBattleMapLayersOnMap(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.specialQuizSpawnArea) m.specialQuizSpawnArea = []; 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); finalizeQuizBattleMapLayersOnMap(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/system-stats') { 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(getSystemStats())); } res.writeHead(405); return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' })); } 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('?')[0].split('#')[0].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; /* นับจาก "ว่างตั้งแต่เมื่อไหร่" (emptySince) ไม่ใช่อายุห้อง — ห้องที่อยู่มานานแล้วเพิ่งว่าง (เช่นกด refresh) จะไม่โดนลบทันที */ const since = s.emptySince || s.createdAt; const stale = since == null || now - since > 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; let roomPassword = ''; if (isPrivate) { /* ห้องส่วนตัว: ใช้ "ชื่อห้อง" เป็น spaceId (canonical = trim+lowercase) → join ด้วยชื่อ+รหัสได้ (เดิมเป็นรหัสสุ่ม 6 ตัว ทำให้ join ด้วยชื่อไม่เจอ) */ const canon = String(d.name || '').trim().toLowerCase(); if (!canon) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'กรุณาใส่ชื่อห้อง' })); if (spaces.has(canon)) return res.writeHead(409), res.end(JSON.stringify({ ok: false, error: 'ชื่อห้องนี้ถูกใช้แล้ว ลองชื่ออื่น' })); sid = canon; roomPassword = String(d.password == null ? '' : d.password).replace(/\D/g, '').slice(0, 4); } 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; /* Quiz Battle (ฉากเดินตอบ แบบ ZEP) รองรับได้ถึง 50 คน · โหมดอื่นคงเพดานเดิม 10 */ const mpCap = mapData.gameType === 'quiz_battle' ? 50 : 10; maxPlayers = Math.min(mpCap, 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(), roomPassword, 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]); // production: nginx เสิร์ฟ /Game/img/* เป็น static ทั้งหมด — local node จึงต้องเสิร์ฟไฟล์ที่มีอยู่จริงในโฟลเดอร์ด้วย // (ไม่จำกัดเฉพาะไฟล์อัปโหลด gauntlet-) ไม่งั้น asset ดีไซน์ (popup-Howto, Avartar, btn-ready…) จะ 400 เฉพาะตอนรันผ่าน node ตรง ๆ const fname = safeGauntletStoredFilename(sub) || path.basename(sub); // basename กัน path traversal const fp = path.join(GAUNTLET_ASSETS_DIR, fname); if (!fname || !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'; // ตัด query string / hash ออกก่อน resolve เป็น path ไฟล์ (เช่น /play.html?space=...&map=... → /play.html) // ไม่ตัด จะหาไฟล์ชื่อ "play.html?space=..." ไม่เจอ → 404 (เข้า node ตรง ๆ พร้อม query) url = url.split('?')[0].split('#')[0] || '/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, finishPhase: false, }; } 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 ord = typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : i; const pt = pos[ord] || 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; /* หยุด run ระหว่างตอบคำถามพิเศษ (special-quiz) — เลื่อน endsAt ไปด้วยให้เวลาไม่เดิน, ไม่ไหล obstacle/ชน */ if (space.specialQuizAwaitContinue || (space.specialQuiz && space.specialQuiz.triggered && !space.specialQuiz.done)) { if (gr.endsAt != null) gr.endsAt += getGauntletTickMs(); emitGauntletSync(sid, space); return; } ensureGauntletEndsAtIfNeeded(space); if (gr.endsAt != null && Date.now() >= gr.endsAt) { endGauntletGame(sid, space, 'time'); return; } /* ถึงเส้นชัยแล้ว (client แจ้ง) — จบรอบทันที (ผู้ใช้ต้องการ: ไม่ให้เดินเลยเส้นชัย) → สรุปผล */ if (gr.finishPhase) { if (gr.obstacles.length) { gr.obstacles = []; } endGauntletGame(sid, space, 'finish'); 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: เลเซอร์ — ทุกแถว */ let _gauntletLaserThisTick = false; 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 }); _gauntletLaserThisTick = true; } /* ประเภท 1: แยกเลน — แถวเท้า · กันซ้อนคอลัมน์เดียวกับเลเซอร์ (laser+lane ไม่โผล่คอลัมน์เดียวกัน) */ if (!_gauntletLaserThisTick) { 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 }; if (md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) { const qb = pickQuizBattleSpawnFromMap(md, joinOrderIndex); if (qb) return qb; } 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); } /* spawnJoinOrder เสถียรข้ามการเปลี่ยนหน้า (lobbyA→lobbyB→minigame): จำลำดับด้วย playerKey (คงที่ต่อ browser) ไม่ใช่ peers.size ที่เปลี่ยนตามลำดับ reconnect → ตำแหน่งเกิด / สีผิว / slot ของแต่ละคนสลับกันมั่ว */ function resolveSpawnJoinOrder(space, playerKey) { if (!space.spawnOrderByKey || typeof space.spawnOrderByKey !== 'object') space.spawnOrderByKey = {}; const key = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; const used = new Set(); space.peers.forEach((p) => { if (typeof p.spawnJoinOrder === 'number' && p.spawnJoinOrder >= 0) used.add(p.spawnJoinOrder); }); /* host/ผู้เล่นเดิมกลับเข้ามา → คืนลำดับเดิมถ้ายังว่าง (ไม่มี peer อื่นถืออยู่) */ if (key && Object.prototype.hasOwnProperty.call(space.spawnOrderByKey, key)) { const prev = space.spawnOrderByKey[key]; if (typeof prev === 'number' && prev >= 0 && !used.has(prev)) return prev; } let ord = 0; while (used.has(ord)) ord++; if (key) space.spawnOrderByKey[key] = ord; return ord; } /** สุ่มจุดเกิดในช่อง 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; } /** เปิดเฟส "เลือกผู้ต้องสงสัย" ให้ทั้งห้อง — เรียกหลังตอบข้อเสนอตัวป่วน หรือเมื่อข้ามการเสนอ (คน ≤ 4) */ function openSuspectPhaseForRoom(sid, space, disruptorAccepted) { if (!space) return; 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: !!disruptorAccepted, 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), }); }); } 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; /* เสนอบทตัวป่วนเฉพาะเมื่อมี "คนจริง > 4 คน" — ยกเว้น admin บังคับ (troublesomeForceOffer) */ const forceOffer = !!(runtimeGameTiming && runtimeGameTiming.troublesomeForceOffer); if (peerIds.length <= 4 && !forceOffer) { /* ไม่เสนอบทตัวป่วน (คน ≤ 4) → ข้ามไปเปิดหน้าเลือกผู้ต้องสงสัยเลย (ไม่มีตัวป่วน) */ space.troublesomeOfferSent = true; space.troublesomeResponseReceived = true; space.troublesomeAccept = false; space.disruptorPlayerKey = ''; space.disruptorNickname = ''; if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer); if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer); space.troublesomeDebTimer = null; space.troublesomeMaxTimer = null; openSuspectPhaseForRoom(sid, space, false); 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')); } /* ============================================================ * Quiz Battle (Kahoot/ZEP-style) — ห้องตอบคำถามพร้อมกัน รับได้สูงสุด 50 คน * 10 ห้อง · auto-start (กด Start แล้วนับถอยหลังเริ่มเอง) · คำถามจาก battleQuizMcq * แยกจากระบบ spaces (โลกเดิน) ใช้ io เดียวกัน · event ขึ้นต้น qb-* * ============================================================ */ const QB_ROOM_COUNT = 10; const QB_MAX_PLAYERS = 50; const QB_QUESTIONS_PER_ROUND = 10; // จำนวนข้อต่อรอบ (สุ่มจาก pool battleQuizMcq) const QB_ANSWER_MS = 20000; // เวลาตอบต่อข้อ const QB_REVEAL_MS = 4000; // โชว์เฉลย + leaderboard ก่อนข้อต่อไป const QB_COUNTDOWN_MS = 5000; // นับถอยหลังก่อนเริ่ม (auto-start) const QB_BASE_POINTS = 1000; // คะแนนเต็มต่อข้อ (ตอบถูก + เร็วสุด) const QB_STREAK_BONUS = 100; // โบนัสต่อ streak ตอบถูกติดกัน const QB_MIN_PLAYERS_TO_START = 1; // ขั้นต่ำที่กด Start ได้ const QB_END_LINGER_MS = 12000; // โชว์อันดับจบเกมก่อนรีเซ็ตห้องกลับ lobby const QB_LOBBY_WATCH_ROOM = 'qb-lobby-watchers'; const qbRooms = new Map(); // roomId -> room state function qbRoomId(n) { return 'qb-' + n; } function qbCreateRoom(n) { return { n, id: qbRoomId(n), status: 'lobby', // lobby | countdown | question | reveal | ended players: new Map(), // socketId -> player questions: [], qIndex: -1, qStartAt: 0, qEndsAt: 0, timers: [], }; } function qbGetRoom(n) { const id = qbRoomId(n); let r = qbRooms.get(id); if (!r) { r = qbCreateRoom(n); qbRooms.set(id, r); } return r; } function qbClearTimers(room) { if (Array.isArray(room.timers)) room.timers.forEach((t) => clearTimeout(t)); room.timers = []; } function qbCountsSnapshot() { const rooms = []; for (let n = 1; n <= QB_ROOM_COUNT; n++) { const r = qbRooms.get(qbRoomId(n)); rooms.push({ n, count: r ? r.players.size : 0, status: r ? r.status : 'lobby' }); } return rooms; } function qbBroadcastCounts() { const payload = { rooms: qbCountsSnapshot() }; io.to(QB_LOBBY_WATCH_ROOM).emit('qb-counts', payload); for (let n = 1; n <= QB_ROOM_COUNT; n++) { const r = qbRooms.get(qbRoomId(n)); if (r && r.players.size) io.to(r.id).emit('qb-counts', payload); } } function qbPlayerList(room) { return [...room.players.values()] .map((p) => ({ id: p.id, name: p.name, score: p.score })) .sort((a, b) => b.score - a.score); } function qbLeaderboard(room) { return qbPlayerList(room).map((p, i) => ({ rank: i + 1, id: p.id, name: p.name, score: p.score })); } function qbEmitRoomState(room) { io.to(room.id).emit('qb-room-state', { n: room.n, status: room.status, max: QB_MAX_PLAYERS, players: qbPlayerList(room), }); } function qbShuffle(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; } /** หยิบคำถามจาก battleQuizMcq (อ่านสด) → สุ่ม → ตัด N ข้อ · คืน [] ถ้าไม่มีคำถาม */ function qbPickQuestions() { let pool = []; try { pool = loadQuizSettings().battleQuizMcq || []; } catch (e) { pool = []; } pool = pool.filter((q) => q && q.text && Array.isArray(q.choices) && q.choices.length >= 2); if (!pool.length) return []; return qbShuffle(pool).slice(0, QB_QUESTIONS_PER_ROUND); } function qbResetRoom(room) { qbClearTimers(room); room.status = 'lobby'; room.questions = []; room.qIndex = -1; for (const p of room.players.values()) { p.score = 0; p.streak = 0; p.answeredIndex = -1; } io.to(room.id).emit('qb-reset', {}); qbEmitRoomState(room); qbBroadcastCounts(); } function qbStartCountdown(room) { if (room.status !== 'lobby') return { ok: false, error: 'เกมเริ่มไปแล้ว' }; if (room.players.size < QB_MIN_PLAYERS_TO_START) return { ok: false, error: 'ผู้เล่นไม่พอ' }; const questions = qbPickQuestions(); if (!questions.length) return { ok: false, error: 'ยังไม่มีคำถามในคลัง (เพิ่มที่หน้า Admin)' }; qbClearTimers(room); room.questions = questions; room.qIndex = -1; room.status = 'countdown'; for (const p of room.players.values()) { p.score = 0; p.streak = 0; p.answeredIndex = -1; } const endsAt = Date.now() + QB_COUNTDOWN_MS; io.to(room.id).emit('qb-countdown', { endsAt, ms: QB_COUNTDOWN_MS, totalQuestions: questions.length }); qbBroadcastCounts(); room.timers.push(setTimeout(() => qbNextQuestion(room), QB_COUNTDOWN_MS)); return { ok: true }; } function qbNextQuestion(room) { qbClearTimers(room); room.qIndex += 1; if (room.qIndex >= room.questions.length) { qbEndGame(room); return; } const q = room.questions[room.qIndex]; room.status = 'question'; room.qStartAt = Date.now(); room.qEndsAt = room.qStartAt + QB_ANSWER_MS; for (const p of room.players.values()) { p.answeredIndex = -1; p.answeredAt = 0; p.lastDelta = 0; p.lastCorrect = false; } io.to(room.id).emit('qb-question', { index: room.qIndex, total: room.questions.length, text: q.text, choices: q.choices, endsAt: room.qEndsAt, durationMs: QB_ANSWER_MS, }); io.to(room.id).emit('qb-answered-count', { answered: 0, total: room.players.size }); room.timers.push(setTimeout(() => qbReveal(room), QB_ANSWER_MS)); } function qbAllAnswered(room) { for (const p of room.players.values()) { if (p.answeredIndex < 0) return false; } return room.players.size > 0; } function qbReveal(room) { qbClearTimers(room); if (room.status !== 'question') return; room.status = 'reveal'; const q = room.questions[room.qIndex]; const correctIndex = q.correctIndex; const results = [...room.players.values()].map((p) => ({ id: p.id, name: p.name, correct: !!p.lastCorrect, delta: p.lastDelta || 0, score: p.score, choice: p.answeredIndex, })); const isLast = room.qIndex >= room.questions.length - 1; io.to(room.id).emit('qb-reveal', { index: room.qIndex, correctIndex, results, leaderboard: qbLeaderboard(room), isLast, nextInMs: QB_REVEAL_MS, }); room.timers.push(setTimeout(() => qbNextQuestion(room), QB_REVEAL_MS)); } function qbEndGame(room) { qbClearTimers(room); room.status = 'ended'; io.to(room.id).emit('qb-ended', { leaderboard: qbLeaderboard(room), lingerMs: QB_END_LINGER_MS }); qbBroadcastCounts(); room.timers.push(setTimeout(() => qbResetRoom(room), QB_END_LINGER_MS)); } /** ให้คะแนนแบบ Kahoot: ตอบถูก = ฐาน × (0.5 + 0.5×เศษเวลาที่เหลือ) + โบนัส streak */ function qbScoreAnswer(room, p, choiceIndex) { const q = room.questions[room.qIndex]; const now = Date.now(); p.answeredIndex = choiceIndex; p.answeredAt = now; const correct = Number(choiceIndex) === Number(q.correctIndex); p.lastCorrect = correct; if (correct) { const remain = Math.max(0, room.qEndsAt - now); const frac = QB_ANSWER_MS > 0 ? remain / QB_ANSWER_MS : 0; p.streak = (p.streak || 0) + 1; const base = Math.round(QB_BASE_POINTS * (0.5 + 0.5 * frac)); const bonus = (p.streak - 1) * QB_STREAK_BONUS; p.lastDelta = base + bonus; p.score += p.lastDelta; } else { p.streak = 0; p.lastDelta = 0; } return p.lastDelta; } function qbLeaveRoom(socket) { const roomId = socket.data.qbRoom; if (!roomId) return; const room = qbRooms.get(roomId); socket.data.qbRoom = null; socket.leave(roomId); if (!room) return; if (room.players.delete(socket.id)) { if (room.players.size === 0) { qbClearTimers(room); room.status = 'lobby'; room.questions = []; room.qIndex = -1; } else { qbEmitRoomState(room); if (room.status === 'question' && qbAllAnswered(room)) qbReveal(room); } qbBroadcastCounts(); } } /* ============================================================ * Quiz Battle (ฉากเดินตอบ แบบ ZEP) — sync คะแนน/อันดับข้ามผู้เล่นแบบ authoritative * เก็บบน space: qbwSolved = Map(socketId -> Set(compId ที่ตอบถูก)) · score = จำนวนโดมที่ปลด * event: quizbattle-hello (ขอคะแนนปัจจุบัน) · quizbattle-solve {compId} · server ส่งกลับ quizbattle-scores * ============================================================ */ function qbwScoresSnapshot(space) { const scores = {}; if (space && space.qbwSolved) { for (const [pid, set] of space.qbwSolved) scores[pid] = set ? set.size : 0; } if (space && space.peers) { for (const id of space.peers.keys()) if (scores[id] == null) scores[id] = 0; } return scores; } function qbwBroadcastScores(sid, space) { if (!sid || !space) return; io.to(sid).emit('quizbattle-scores', { scores: qbwScoresSnapshot(space) }); } io.on('connection', (socket) => { /* ===== Quiz Battle global leaderboard (คะแนนสูงสุดต่อ room/หมวด ข้ามเซสชัน) ===== */ socket.on('qb-leaderboard-submit', (d) => { if (!d || typeof d !== 'object') return; qbLeaderboardSubmit(d.roomId, d.name, d.score, d.characterId); }); socket.on('qb-leaderboard-get', (d, cb) => { if (typeof cb !== 'function') return; try { cb(qbLeaderboardGet(d && d.roomId, d && d.name)); } catch (e) { cb({ top: [], me: null, total: 0 }); } }); /* ===== Quiz Battle ฉากเดินตอบ (ZEP) — sync คะแนน ===== */ socket.on('quizbattle-hello', (cb) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (typeof cb === 'function') cb({ ok: !!space, scores: space ? qbwScoresSnapshot(space) : {} }); }); socket.on('quizbattle-solve', (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 md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'quiz_battle') return reply({ ok: false }); const compId = parseInt(data && data.compId, 10); if (!(compId > 0)) return reply({ ok: false }); if (!space.qbwSolved) space.qbwSolved = new Map(); let set = space.qbwSolved.get(socket.id); if (!set) { set = new Set(); space.qbwSolved.set(socket.id, set); } set.add(compId); qbwBroadcastScores(sid, space); reply({ ok: true, solved: set.size }); }); /* ===== Quiz Battle (Kahoot-style 50 คน) ===== */ socket.on('qb-watch-counts', (cb) => { socket.join(QB_LOBBY_WATCH_ROOM); if (typeof cb === 'function') cb({ ok: true, rooms: qbCountsSnapshot() }); }); socket.on('qb-join', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const n = parseInt(data && data.room, 10); if (!(n >= 1 && n <= QB_ROOM_COUNT)) return reply({ ok: false, error: 'ห้องไม่ถูกต้อง' }); const room = qbGetRoom(n); if (socket.data.qbRoom && socket.data.qbRoom !== room.id) qbLeaveRoom(socket); if (!room.players.has(socket.id) && room.players.size >= QB_MAX_PLAYERS) { return reply({ ok: false, error: 'ห้องเต็ม (50 คน)' }); } const name = String((data && data.name) || 'ผู้เล่น').trim().slice(0, 24) || 'ผู้เล่น'; const characterId = String((data && data.characterId) || '').trim().slice(0, 64); let p = room.players.get(socket.id); if (!p) { p = { id: socket.id, name, characterId, score: 0, streak: 0, answeredIndex: -1, answeredAt: 0, lastDelta: 0, lastCorrect: false }; room.players.set(socket.id, p); } else { p.name = name; p.characterId = characterId; } socket.data.qbRoom = room.id; socket.join(room.id); socket.leave(QB_LOBBY_WATCH_ROOM); qbEmitRoomState(room); qbBroadcastCounts(); // ส่งสถานะปัจจุบันให้คนที่เพิ่งเข้า (เผื่อเกมกำลังเล่นอยู่) const snapshot = { n: room.n, status: room.status, max: QB_MAX_PLAYERS }; if (room.status === 'question') { const q = room.questions[room.qIndex]; snapshot.question = { index: room.qIndex, total: room.questions.length, text: q.text, choices: q.choices, endsAt: room.qEndsAt, durationMs: QB_ANSWER_MS }; } else if (room.status === 'countdown') { snapshot.totalQuestions = room.questions.length; } reply({ ok: true, ...snapshot, players: qbPlayerList(room) }); }); socket.on('qb-leave', (cb) => { qbLeaveRoom(socket); if (typeof cb === 'function') cb({ ok: true }); }); socket.on('qb-start', (cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const roomId = socket.data.qbRoom; const room = roomId ? qbRooms.get(roomId) : null; if (!room || !room.players.has(socket.id)) return reply({ ok: false, error: 'ยังไม่ได้เข้าห้อง' }); reply(qbStartCountdown(room)); }); socket.on('qb-answer', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const roomId = socket.data.qbRoom; const room = roomId ? qbRooms.get(roomId) : null; if (!room || room.status !== 'question') return reply({ ok: false }); const p = room.players.get(socket.id); if (!p || p.answeredIndex >= 0) return reply({ ok: false, already: true }); const q = room.questions[room.qIndex]; const ci = parseInt(data && data.choiceIndex, 10); if (!(ci >= 0 && ci < q.choices.length)) return reply({ ok: false }); const delta = qbScoreAnswer(room, p, ci); let answered = 0; for (const pp of room.players.values()) if (pp.answeredIndex >= 0) answered++; io.to(room.id).emit('qb-answered-count', { answered, total: room.players.size }); reply({ ok: true, accepted: true }); if (qbAllAnswered(room)) qbReveal(room); }); socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, playerKey, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex, bannedSpectator, roomPassword }, cb) => { const space = spaces.get(spaceId); if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' }); /* ห้องส่วนตัวที่ตั้งรหัส → ต้องกรอกรหัสให้ตรง ครั้งแรก แล้วจำด้วย playerKey (กันด่านรหัสพังตอนเปลี่ยนหน้า LobbyA→play→LobbyB ที่ socket.id เปลี่ยน — ไม่ต้องแนบ pass ทุกลิงก์) */ if (space.isPrivate && space.roomPassword) { space.privateVerifiedKeys = space.privateVerifiedKeys || new Set(); const pk = String(playerKey == null ? '' : playerKey); const provided = String(roomPassword == null ? '' : roomPassword).replace(/\D/g, '').slice(0, 4); const passOk = provided === space.roomPassword; const alreadyOk = (pk && space.privateVerifiedKeys.has(pk)) || space.peers.has(socket.id); if (!passOk && !alreadyOk) { return cb && cb({ ok: false, error: 'รหัสผ่านห้องไม่ถูกต้อง' }); } if (passOk && pk) space.privateVerifiedKeys.add(pk); } 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; /* Host เสถียรข้ามการเปลี่ยนหน้า (lobby→play→lobbyB): จำ host ด้วย playerKey (คงที่ต่อ browser) ไม่ใช่ socket.id (เปลี่ยนทุกครั้งที่โหลดหน้าใหม่ → host สลับไปมาแบบสุ่ม) */ const joinPlayerKey = (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : ''; const prevHostId = space.hostId; if (joinPlayerKey && space.hostPlayerKey && joinPlayerKey === space.hostPlayerKey) { space.hostId = socket.id; /* host ตัวจริงกลับเข้ามา → คืนสถานะ host เสมอ */ } else if (!space.hostId) { space.hostId = socket.id; /* ยังไม่มี host → คนนี้เป็น host (ชั่วคราวถ้ามี hostPlayerKey อยู่แล้ว) */ if (!space.hostPlayerKey && joinPlayerKey) space.hostPlayerKey = joinPlayerKey; } /* reconnect/เปลี่ยนหน้า: ผู้เล่นคนเดิม (playerKey เดิม) ได้ socket.id ใหม่ แต่ peer เก่ายังค้างใน space.peers จนกว่า disconnect เก่าจะมา (อาจช้า) → ทุกคนเห็น "ตัวละครซ้ำ 2 ตัว" (ghost) แก้: ลบ ghost peer ที่ playerKey ตรงแต่ socket.id ต่าง ก่อนเพิ่มตัวใหม่ + แจ้ง user-left ให้ client ลบทิ้ง */ if (joinPlayerKey) { const staleIds = []; space.peers.forEach((p, pid) => { if (pid !== socket.id && p && p.playerKey && p.playerKey === joinPlayerKey) staleIds.push(pid); }); staleIds.forEach((pid) => { space.peers.delete(pid); if (space.quizCarryLobbyReady) delete space.quizCarryLobbyReady[pid]; if (space.gauntletCrownLobbyReady) delete space.gauntletCrownLobbyReady[pid]; io.to(spaceId).emit('user-left', { id: pid }); }); } if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space); ensurePeerLobbyThemes(space); const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData; const spawnJoinOrder = resolveSpawnJoinOrder(space, playerKey); const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder); const bbStartBalloons = balloonBossBalloonsForMap(mdJoin); /* ใช้สีที่ 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, playerKey: (typeof playerKey === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(playerKey)) ? playerKey : '', reportedMiniScore: 0, reportedMiniSurvived: true, bannedSpectator: !!bannedSpectator, }; if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) { const sn = quizBattleSpawnWorldFromJoinOrderServer(mdJoin, spawnJoinOrder); 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', quizCarryLobbySyncPayload(space)); } if (mdJoin.gameType === 'gauntlet') { const ord = typeof peer.spawnJoinOrder === 'number' ? peer.spawnJoinOrder : [...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', crownLobbySyncPayload(space)); } 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', crownLobbySyncPayload(space)); } 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, missionLiveBeginsAt: space.missionLiveBeginsAt || null, }; if (serverMapIsPostCaseLobbyB(space)) { joinCb.detectiveLobbyLevel = space.detectiveLobbyLevel != null ? space.detectiveLobbyLevel : null; joinCb.detectiveLobbyCaseId = space.detectiveLobbyCaseId != null ? space.detectiveLobbyCaseId : null; } 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); if (space.hostId !== prevHostId) io.to(spaceId).emit('host-changed', { hostId: space.hostId }); /* host เปลี่ยน (เช่น host ตัวจริงกลับเข้ามา) → sync ทุกคน */ 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 }); } } /* Card 3 Ban: ผู้เล่นกลับเข้า LobbyB หลังเกมจบ + มีการ์ด Ban ค้าง → เริ่มโหวตแบนทันที (คน rejoin คนแรกเป็นคนเปิดโหวต; คนที่ rejoin ทีหลังขณะโหวตเปิดอยู่ → ส่งสถานะให้เห็นด้วย) */ if (serverMapIsPostCaseLobbyB(space)) { /* ผู้เล่นกลับเข้า LobbyB แล้ว → อัปเดตจำนวนคนที่พร้อมให้ทุกคน (host ใช้เปิดปุ่มเริ่มเมื่อครบ) */ emitDetectiveLobbyBPresence(spaceId, space); console.log('[ban-debug] join-space rejoin: postLobbyB=true pendingBan=', !!space.pendingBanVoteCard, 'pickVote=', !!space.pickVote, 'by', socket.id); if (space.pendingBanVoteCard && !space.pickVote) { const banCard = space.pendingBanVoteCard; space.pendingBanVoteCard = null; /* หน่วงให้ room-lobby ฝั่ง client โหลด/เซ็ตอัป UI เสร็จก่อน แล้วค่อยเปิดโหวต (ไม่งั้น overlay โดน reset/ทับตอนหน้ายังไม่พร้อม) */ setTimeout(() => { const sp = spaces.get(spaceId); if (!sp || sp.pickVote || !serverMapIsPostCaseLobbyB(sp)) { console.log('[ban-debug] setTimeout BAIL: sp=', !!sp, 'pickVote=', !!(sp && sp.pickVote), 'postLobbyB=', !!(sp && serverMapIsPostCaseLobbyB(sp))); return; } console.log('[ban-debug] OPEN ban vote (peers=', sp.peers.size, ')'); io.to(spaceId).emit('special-card-applied', { card: specialCardClientPayload(banCard), context: 'ban' }); startPlayerPickVote(spaceId, sp, 'ban', (targetId) => { const sp2 = spaces.get(spaceId); console.log('[ban-debug] ban vote RESOLVED target=', targetId); if (sp2) sp2.bannedPlayerId = targetId || null; }); }, 2000); } else if (space.pickVote && !space.pickVote.resolved) { const pv = space.pickVote; socket.emit('player-pick-vote-open', { purpose: pv.purpose, candidates: pv.candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })), endsAt: pv.endsAt, durationMs: pickVoteMs(), }); } } }); /** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */ /** 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); }); /** Test Mode (Ctrl+Alt+W): บังคับให้รอบโหวตชี้คนร้ายถัดไป "นับเป็นโหวตผิด" 1 ครั้ง → ใช้เทสต์การ์ด 5 (จับผิดตัว/โหวตใหม่) */ socket.on('test-force-wrong-vote', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space) || !space.peers.has(socket.id)) return; space.testForceWrongVote = true; console.log('[test] force-wrong-vote armed by', socket.id); }); /** client แจ้งว่าเกม "live จริง" แล้ว (พ้น howto/นับถอยหลัง) → ปล่อยไอคอนคำถามพิเศษ (หน่วง 2 วิ + นับถอยหลัง) ครั้งเดียว/รอบ */ socket.on('special-quiz-arm', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !space.peers.has(socket.id)) return; const sq = space.specialQuiz; if (!sq || sq.triggered || sq.done || sq.appeared || sq.armScheduled) return; sq.armScheduled = true; scheduleSpecialQuizIconAppearAndExpiry(sid, space); }); 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 }); /* รอบจบไปแล้ว (resume แล้ว) — ตอบ ok เฉย ๆ */ if (!space.specialQuizAwaitContinue) return reply({ ok: true }); /* เก็บ ack ของผู้เล่นคนนี้ แล้วเช็ค: ครบทุกคน → resume พร้อมกัน (ไม่ resume ทันทีคนเดียว) */ if (!space.specialQuizContinueAcks) space.specialQuizContinueAcks = new Set(); space.specialQuizContinueAcks.add(socket.id); reply({ ok: true }); maybeFinishSpecialQuizContinue(sid, space); }); /** เปลี่ยนสีเสื้อ/ผิวใน 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; } /* ชนกับ "คนจริง" คนอื่น → ปฏิเสธ; ชนกับ "บอท" เท่านั้น → ดันบอทไปสีอื่น (ให้สอดคล้องกับตอน join) กันอาการ "เปลี่ยนสีไม่ได้" เมื่อบอทยึดสีไว้ */ let humanTaken = false; space.peers.forEach((op, oid) => { if (oid === socket.id) return; if (parseInt(op.lobbyColorThemeIndex, 10) === themeIndex) humanTaken = true; }); if (humanTaken) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' }); if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') { Object.keys(space.lobbyBotThemeBySlot).forEach((k) => { const slot = space.lobbyBotThemeBySlot[k]; if (slot && parseInt(slot.themeIndex, 10) === themeIndex) slot.themeIndex = 0; /* syncLobbyBotThemeSlots จะหาสีใหม่ให้ */ }); } peer.lobbyColorThemeIndex = themeIndex; peer.lobbySkinToneIndex = skinIndex; emitLobbyTintSync(sid, space); reply({ ok: true, lobbyColorThemeIndex: themeIndex, lobbySkinToneIndex: skinIndex, lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [], }); }); /* บอท lobby เดินตรงกันทุก client: host เป็นเจ้าของตำแหน่ง → relay ให้คนอื่นในห้อง (กัน desync จากการที่แต่ละเครื่องสุ่มเดินบอทแยกกัน) */ socket.on('lobby-bot-move', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; const bots = []; for (const b of data.bots) { if (!b || typeof b.id !== 'string' || b.id.length > 40) continue; const x = Number(b.x), y = Number(b.y); if (!Number.isFinite(x) || !Number.isFinite(y)) continue; bots.push({ id: b.id, x, y, direction: typeof b.direction === 'string' ? b.direction.slice(0, 5) : 'down', isWalking: !!b.isWalking, }); } if (bots.length) socket.to(sid).emit('lobby-bot-move', { bots }); }); /* fill-bot ใน 7 มินิเกม: host เป็นเจ้าของ state ของบอท → relay ให้คนอื่นในห้อง (กัน desync จากการที่แต่ละเครื่องจำลองบอทแยกกัน) */ socket.on('fill-bot-state', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) return; /* เฉพาะ host เท่านั้น */ if (!data || !Array.isArray(data.bots) || data.bots.length > 16) return; /* พ่วง hostId — ผู้รับ (non-host เท่านั้น เพราะ socket.to ไม่ส่งกลับผู้ส่ง) ใช้ยืนยันว่า "ตัวเองไม่ใช่ host" แก้ playHostId ที่ผิดตอน race LobbyB→play (กัน 2 จอคิดว่าเป็น host → sim บอทคนละชุด) */ const humans = (Array.isArray(data.humans) ? data.humans.filter((h) => h != null).slice(0, 50).map(String) : undefined); socket.to(sid).emit('fill-bot-state', { bots: data.bots, hostId: space.hostId, humans }); }); /* MG7 balloon_boss — relay กระสุนของผู้เล่น/บอท ให้ทุกคนในห้องเห็นตรงกัน (ทุกคนยิงได้ ไม่จำกัด host) payload: { sx, sy, vx, vy, ownerId, t0 } — t0 = serverNow ตอนยิง ให้ผู้รับ fast-forward ตำแหน่งให้ตรง */ socket.on('balloon-boss-shot', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const ok = ['sx', 'sy', 'vx', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); if (!ok) return; socket.to(sid).emit('balloon-boss-shot', { sx: data.sx, sy: data.sy, vx: data.vx, vy: data.vy, ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), }); }); /* MG7 balloon_boss — host เป็นคนตัดสินจบเกม แล้ว relay ให้ทุกคนจบพร้อมกัน */ socket.on('balloon-boss-over', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const reason = (data.reason === 'victory' || data.reason === 'all_dead') ? data.reason : 'all_dead'; const scores = Array.isArray(data.scores) ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), eliminated: !!s.eliminated })) : []; socket.to(sid).emit('balloon-boss-over', { reason, scores }); }); /* MG7 balloon_boss — host เป็นเจ้าของ HP บอส: relay ดาเมจรวม + สถานะผู้เล่น (ลูกโป่ง/ตาย) เป็นระยะ → ทุกเครื่องแสดง HP บอสตรงกับ host เป๊ะ (เดิมแต่ละเครื่องรวม balloonBossBossDmg จาก move เองทยอยมา = bar เพี้ยน) */ socket.on('balloon-boss-sync', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ const bossDmg = Math.max(0, Number(data.bossDmg) || 0); const anchor = Math.max(0, Number(data.anchor) || 0); /* forward anchor host → clients adopt (มุมลูกศรวงกลม sync) */ const players = Array.isArray(data.players) ? data.players.filter((p) => p && p.id != null).slice(0, 12).map((p) => ({ id: p.id, d: Math.max(0, Number(p.d) || 0), b: Math.max(0, Number(p.b) || 0), e: !!p.e, })) : []; socket.to(sid).emit('balloon-boss-sync', { bossDmg, players, anchor }); }); /* MG3 stack (shared tower) — relay ผลการวาง (เจ้าของตา/บอทของ host) ให้ทุกคนวางบล็อกเดียวกัน + เลื่อนตาพร้อมกัน */ socket.on('stack-drop', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const num = (v) => (typeof v === 'number' && Number.isFinite(v)) ? v : 0; socket.to(sid).emit('stack-drop', { placedCx: num(data.placedCx), placedW: num(data.placedW), miss: !!data.miss, perfect: !!data.perfect, pts: num(data.pts), progressDelta: num(data.progressDelta), landOffsetTiles: num(data.landOffsetTiles), overlap: num(data.overlap), supportRatio: (data.supportRatio == null ? null : num(data.supportRatio)), delta: num(data.delta), seat: Math.max(1, Math.min(8, Math.floor(num(data.seat)) || 1)), heavy: !!data.heavy, actorId: data.actorId != null ? String(data.actorId).slice(0, 64) : '', isBot: !!data.isBot, }); }); /* MG1 quiz — host ขอจบเกมก่อนครบข้อ (ทุกคนตอบผิดหมด=ผีหมด) → endQuizGame โชว์ผล/รอกดรับการ์ด (ไม่ auto) */ socket.on('quiz-end-request', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) return; /* host เท่านั้น */ if (!space.detectiveMinigameActive || !space.quizSession || !space.quizSession.active) return; const md = (space.mapId && maps.get(space.mapId)) || space.mapData; if (!md || md.gameType !== 'quiz') return; endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดหมด'); }); /* MG3 stack — relay เฟสแกว่ง (เจ้าของตา → คนอื่น mirror บล็อกที่กำลังแกว่ง) */ socket.on('stack-swing', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const ph = Number(data.phase); if (!Number.isFinite(ph)) return; socket.to(sid).emit('stack-swing', { phase: ph }); }); /* MG3 stack — host เป็นเจ้าของลำดับตา+index relay ให้ non-host (กัน host/client มองลำดับตาไม่ตรง) */ socket.on('stack-state', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id || !data) return; /* host เท่านั้น */ const order = Array.isArray(data.order) ? data.order.filter((e) => e && (e.kind === 'human' || e.kind === 'bot')).slice(0, 8).map((e) => ({ kind: e.kind === 'bot' ? 'bot' : 'human', seat: Math.max(1, Math.min(8, Math.floor(Number(e.seat)) || 1)), id: e.id != null ? String(e.id).slice(0, 64) : null, botId: e.botId != null ? String(e.botId).slice(0, 64) : null, })) : []; const idx = Math.max(0, Math.floor(Number(data.idx)) || 0); socket.to(sid).emit('stack-state', { order, idx }); }); /* MG3 stack — host ตัดสินจบรอบ relay ให้ทุกคนจบพร้อมกัน */ socket.on('stack-over', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space) return; const outcome = (data && typeof data.outcome === 'string') ? data.outcome.slice(0, 32) : 'time_up'; socket.to(sid).emit('stack-over', { outcome }); }); /* MG6 space_shooter — host ตัดสินจบเกม + แนบคะแนนสุดท้าย → ทุกคนจบ/อันดับตรงกัน */ socket.on('space-shooter-over', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const kind = (data.kind === 'all_dead') ? 'all_dead' : 'time_up'; const scores = Array.isArray(data.scores) ? data.scores.filter((s) => s && s.id != null).map((s) => ({ id: s.id, score: Math.max(0, Number(s.score) || 0), hits: Math.max(0, Number(s.hits) || 0), eliminated: !!s.eliminated })) : []; socket.to(sid).emit('space-shooter-over', { kind, scores }); }); /* MG6 space_shooter — relay กระสุนผู้เล่น/บอท ให้ทุกคนเห็นตรงกัน (host เห็นยังไง client เห็นแบบนั้น) */ socket.on('space-shooter-shot', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !data) return; const ok = ['x', 'y', 'vy'].every((k) => typeof data[k] === 'number' && Number.isFinite(data[k])); if (!ok) return; socket.to(sid).emit('space-shooter-shot', { x: data.x, y: data.y, vy: data.vy, ownerId: data.ownerId, t0: (typeof data.t0 === 'number' ? data.t0 : Date.now()), }); }); /** เปลี่ยนชื่อที่แสดงใน 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; const claimPeer = space.peers.get(socket.id); if (claimPeer && claimPeer.playerKey) space.hostPlayerKey = claimPeer.playerKey; 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; /* เก็บตัวตนตัวป่วนแบบถาวร (playerKey/nickname) — socket.id เปลี่ยนหลังเล่นมินิเกม ใช้ id ตรง ๆ ไม่ได้ */ if (accept) { const dPeer = space.peers.get(socket.id); space.disruptorPlayerKey = (dPeer && dPeer.playerKey) ? dPeer.playerKey : ''; space.disruptorNickname = dPeer ? normalizeLobbyNickname(dPeer.nickname) : ''; } else { space.disruptorPlayerKey = ''; space.disruptorNickname = ''; } openSuspectPhaseForRoom(sid, space, accept); socket.emit('troublesome-ack', { ok: true, accept }); }); socket.on('suspect-pick-select', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return; if (space.hostId !== socket.id) return; let idx = Math.floor(Number(data && data.index)); if (Number.isNaN(idx) || idx < 0 || idx > 2) return; space.suspectPickIndex = idx; io.to(sid).emit('suspect-pick-update', { selectedIndex: idx }); }); socket.on('suspect-pick-start', (data, cb) => { const reply = typeof cb === 'function' ? cb : () => {}; const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (space && 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 กดเริ่มสืบสวนได้' }); } /* ต้องรอผู้เล่นทุกคนในคดีกลับเข้า LobbyB ให้ครบก่อน host ถึงเริ่มมินิเกมรอบถัดไปได้ (เดิม: หลังจบเกม client บางคนยังโหลด LobbyB ไม่เสร็จ แต่ host กดเริ่มได้เลย → คนนั้นหลุดรอบ) */ const waitInfo = detectiveLobbyBMissingParticipants(space); if (waitInfo.missing > 0) { return reply({ ok: false, waitingPlayers: true, present: waitInfo.present, total: waitInfo.total, error: 'รอผู้เล่นกลับเข้าห้องให้ครบก่อน (' + waitInfo.present + '/' + waitInfo.total + ')', }); } 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 = findPendingSpecialCard(space, 3); if (pend && !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: 'ไม่อยู่ในห้อง' }); } /* client รายงานคะแนน/รอด ของตัวเอง (ใช้กับเกมที่ server ไม่ได้นับคะแนน เช่น stack/quiz_carry) */ const finPeer = space.peers.get(socket.id); if (finPeer && data && typeof data === 'object') { /* คะแนน client รายงานเอง (stack/quiz_carry) — clamp ด้วย sanity cap กัน overflow/ค่าขยะ/spoof สูงเกินจริง หมายเหตุ: ยังเป็น client-trusted ภายในเพดาน (anti-cheat สมบูรณ์ต้องจำลองเกมฝั่ง server — เกินสโคปตอนนี้) */ if (Number.isFinite(Number(data.score))) { let rs = Math.max(0, Math.floor(Number(data.score))); if (rs > REPORTED_MINI_SCORE_MAX) { console.warn('[mini-score] clamp reportedMiniScore', socket.id, rs, '->', REPORTED_MINI_SCORE_MAX); rs = REPORTED_MINI_SCORE_MAX; } finPeer.reportedMiniScore = rs; } if (data.survived === false) finPeer.reportedMiniSurvived = false; } 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: 'ไม่ได้อยู่ในมินิเกมสืบสวน' }); } /* MG2 วิ่งหลบ (gauntlet): ไม่จบเกมตอนคนแรกเข้าเส้นชัย — ให้คนที่เข้าเส้นแล้ว "รอในฉาก" จนกว่าทุกคนที่ยังไม่ตาย/ไม่ถูกแบนจะเข้าเส้นครบ แล้วค่อยจบพร้อมกัน (returnDetective แจกการ์ด ทุกคนพร้อมกันตอนทุกคนยังอยู่ในห้อง) · endGauntletGame timer เป็น fallback ถ้าใครไม่เข้าเส้น */ const finMd = (space.mapId && maps.get(space.mapId)) || space.mapData; if (finMd && finMd.gameType === 'gauntlet' && space.detectiveMinigameActive) { if (finPeer) finPeer.detectiveFinished = true; const allDone = [...space.peers.values()].every((p) => p.gauntletEliminated || p.bannedSpectator || p.detectiveFinished ); if (!allDone) { /* ยังมีคนวิ่งอยู่ → ให้ client นี้รอในฉาก (ไม่เด้งออก) จะกลับ LobbyB พร้อมกันตอนเกมจบ */ return reply({ ok: true, holdInScene: true }); } /* ครบทุกคนแล้ว → จบเกมให้ทุกคนด้านล่าง */ } // เล่นมินิเกม 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: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' }); /* รอบจับผิดตัว: ห้ามโหวตผู้ต้องสงสัยที่โหวตผิดไปแล้ว */ if (Number.isInteger(space.trialRevoteExcluded) && idx === space.trialRevoteExcluded) { return reply({ ok: false, error: 'ผู้ต้องสงสัยนี้ถูกโหวตไปแล้ว (ผิด) — เลือกคนอื่น', excluded: true }); } 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' }); } /* detective LobbyA→LobbyB: ไม่บังคับยืนโซนส้ม (หน้าจอเลือกคดีบังแผนที่ ขยับตัวไม่ได้) */ if (!isLobbyADetectiveStart && !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.troublesomeAccept = false; space.disruptorPlayerKey = ''; space.disruptorNickname = ''; 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); space.peers.forEach((p) => { const sp = pickSpawnForJoin(space.mapData, typeof p.spawnJoinOrder === 'number' ? p.spawnJoinOrder : 0); 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', () => { qbLeaveRoom(socket); // Quiz Battle cleanup (รันก่อนเสมอ แม้ไม่ได้อยู่ใน space) 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', quizCarryLobbySyncPayload(space)); } } if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') { delete space.gauntletCrownLobbyReady[socket.id]; if (isCrownLobbyShellSpace(space)) { io.to(sid).emit('gauntlet-crown-lobby-sync', crownLobbySyncPayload(space)); } } if (space.qbwSolved) space.qbwSolved.delete(socket.id); // Quiz Battle (ฉากเดิน) — เอาคะแนนคนออกแล้ว sync ใหม่ด้านล่าง space.peers.delete(socket.id); if (space.peers.size === 0) { stopGauntletTicker(space); clearSpaceQuizTimers(space); space.quizSession = null; space.emptySince = Date.now(); /* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */ const keepCaseSpace = !!(space.detectiveMinigameActive || (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0)); /* ห้อง Quiz Battle — เก็บไว้เมื่อว่าง ให้ refresh กลับเข้าห้องเดิมได้ (TTL prune จะเก็บกวาดเมื่อว่างเกิน 45 วิ) */ const spaceMd = (space.mapId && maps.get(space.mapId)) || space.mapData; const keepQuizBattleRoom = !!(spaceMd && spaceMd.gameType === 'quiz_battle'); if (keepCaseSpace || keepQuizBattleRoom) { space.hostId = null; return; } spaces.delete(sid); } else { if (wasHost) { const firstPeer = space.peers.values().next().value; if (firstPeer) { space.hostId = firstPeer.id; /* ย้าย pointer host ชั่วคราวให้คนที่เหลือ (liveness) */ /* อย่าเขียนทับ hostPlayerKey ถ้ามีอยู่แล้ว — host ตัวจริงต้อง reclaim ได้เมื่อ rejoin จาก play.html (เปลี่ยนหน้า lobby→play→lobbyB ทำให้ socket disconnect ชั่วคราว ไม่ใช่ออกจากห้องจริง ถ้าเขียนทับ key → host/client สลับกันถาวรหลังเข้า minigame) */ if (!space.hostPlayerKey && firstPeer.playerKey) space.hostPlayerKey = firstPeer.playerKey; io.to(sid).emit('host-changed', { hostId: firstPeer.id }); } } io.to(sid).emit('user-left', { id: socket.id }); if (space.qbwSolved) qbwBroadcastScores(sid, space); // Quiz Battle ฉากเดิน — อัปเดตอันดับหลังมีคนออก emitDetectiveLobbyBPresence(sid, space); /* คนออกจาก LobbyB → อัปเดตจำนวนที่พร้อมให้ host */ /* กำลังรอกด «เล่นต่อ» (special-quiz) แล้วมีคนออก — เอา ack คนนั้นออกแล้วเช็คใหม่ (กันค้างรอคนที่ไม่อยู่แล้ว) */ if (space.specialQuizAwaitContinue) { if (space.specialQuizContinueAcks) space.specialQuizContinueAcks.delete(socket.id); maybeFinishSpecialQuizContinue(sid, space); } } }); /* client แจ้งว่าเส้นชัยมาถึงแล้ว (BG ถึง finish) → เข้าโหมดจบ: หยุด obstacle + collision */ socket.on('gauntlet-finish-phase', () => { 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.finishPhase = true; space.gauntletRun.obstacles = []; emitGauntletSync(sid, space); } }); 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', quizCarryLobbySyncPayload(space)); }); 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', quizCarryLobbySyncPayload(space)); }); 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()].filter((id) => !isBannedPeerForRun(space, id)); /* ต้องเข้าเกมครบก่อน (กัน host เริ่มก่อน client โหลดเสร็จ) */ const expectedActive = space.minigameExpectedActiveCount || 0; if (expectedActive > 0 && humanIds.length < expectedActive) { return reply({ ok: false, waitingPlayers: true, present: humanIds.length, total: expectedActive, error: 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + humanIds.length + '/' + expectedActive + ')' }); } const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; space.missionLiveBeginsAt = liveBeginsAt; io.to(sid).emit('quiz-carry-lobby-started', { liveBeginsAt }); reply({ ok: true }); }); /* host เลือกคำถาม quiz_carry → relay index ให้คนอื่นในห้องใช้ข้อเดียวกัน (กันสุ่มไม่ตรงกัน) */ socket.on('quiz-carry-question', (data) => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) return; /* เฉพาะ host */ const idx = Number(data && data.idx); if (!Number.isInteger(idx) || idx < 0) return; socket.to(sid).emit('quiz-carry-question', { idx }); }); /* host จบเซสชัน quiz_carry → ให้คนอื่นโชว์สรุปพร้อมกัน */ socket.on('quiz-carry-session-complete', () => { const sid = socket.data.spaceId; const space = sid ? spaces.get(sid) : null; if (!space || space.hostId !== socket.id) return; socket.to(sid).emit('quiz-carry-session-complete', {}); }); /* ตอบเวลา server ปัจจุบัน → client ใช้คำนวณ clock offset (ชดเชย latency) ให้เวลาในเกมตรงกันทุกเครื่อง */ socket.on('time-sync', (cb) => { if (typeof cb === 'function') cb({ t: Date.now() }); }); 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', crownLobbySyncPayload(space)); }); 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', crownLobbySyncPayload(space)); }); 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()].filter((id) => !isBannedPeerForRun(space, id)); /* ต้องเข้าเกมครบก่อน (กัน host เริ่มก่อน client โหลดเสร็จ → เกมไม่ sync/จบไม่พร้อมกัน) */ const expectedActive = space.minigameExpectedActiveCount || 0; if (expectedActive > 0 && humanIds.length < expectedActive) { return reply({ ok: false, waitingPlayers: true, present: humanIds.length, total: expectedActive, error: 'รอผู้เล่นเข้าเกมให้ครบก่อน (' + humanIds.length + '/' + expectedActive + ')' }); } const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]); if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' }); /* เวลาเริ่มเล่นจริงร่วมกัน (server epoch) → ทุก client นับ 3-2-1 ไปหาจุดนี้ → GO พร้อมกัน */ const liveBeginsAt = Date.now() + LIVE_COUNTDOWN_MS; space.missionLiveBeginsAt = liveBeginsAt; if (isQuizQuestionMissionShellSpace(space)) { /* quiz: เลื่อน server timer ไปเริ่มที่ liveBeginsAt (ไม่ใช่ทันที) ให้ตรงกับตอน client จบ 3-2-1 */ setTimeout(() => { const sp = spaces.get(sid); if (!sp) return; const md2 = maps.get(sp.mapId) || sp.mapData; if (md2 && md2.gameType === 'quiz') { clearSpaceQuizTimers(sp); startQuizGame(sid, sp, md2); } }, LIVE_COUNTDOWN_MS); } io.to(sid).emit('gauntlet-crown-lobby-started', { liveBeginsAt }); 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') { const txN = Number(nx); const tyN = Number(ny); if (!Number.isFinite(txN) || !Number.isFinite(tyN) || !serverFootprintClearOfWalls(md, txN, tyN)) { nx = p.x; ny = p.y; } else if (quizBattlePathModeActiveServer(md) && !quizBattlePositionAllowedServer(md, txN, tyN)) { nx = p.x; ny = p.y; } else if (quizBattlePathModeActiveServer(md) && !quizBattlePathGateAllowsServer(md, space, socket.id, txN, tyN)) { nx = p.x; ny = p.y; } if (quizBattlePathModeActiveServer(md)) { const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny)); nx = sn.x; ny = sn.y; } } let moveForceEmit = false; if (p && md && md.gameType === 'space_shooter' && data) { if (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 (data.spaceShooterHits != null) { const nh = Math.floor(Number(data.spaceShooterHits)); if (Number.isFinite(nh) && nh >= 0) { const prevH = Math.max(0, p.spaceShooterHits | 0); if (nh > prevH) { p.spaceShooterHits = nh; moveForceEmit = true; } } } /* ตาย/รอด มาจากเจ้าของยาน → ต้อง relay แม้ตำแหน่งไม่เปลี่ยน (ยานหยุดตอนตาย) ไม่งั้นยานคนตายจะค้างนิ่งบนจออีกฝั่ง (ดูเหมือนขยับไม่ได้) + ตายไม่ตรงกัน */ if (data.spaceShooterEliminated && !p.spaceShooterEliminated) { p.spaceShooterEliminated = true; moveForceEmit = true; } } if (p && md && md.gameType === 'jump_survive' && data) { /* MG5: การตายมาจากเจ้าตัว → relay แม้ตำแหน่งไม่เปลี่ยน (ตอนตายไม่ขยับ) ไม่งั้นคนอื่นไม่รู้ = "ไม่ยอมตาย" */ if (data.jumpSurviveEliminated && !p.jumpSurviveEliminated) { p.jumpSurviveEliminated = true; moveForceEmit = true; } } if (p && md && md.gameType === 'balloon_boss' && data) { const bbDefaultBalloons = balloonBossBalloonsForMap(md); 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) { p.balloonBossEliminated = true; moveForceEmit = true; } else if (data.balloonBossEliminated) p.balloonBossEliminated = true; } if (p && md && md.gameType === 'quiz_carry' && data && data.quizCarryHeld !== undefined) { /* relay ของที่ผู้เล่น (มนุษย์) ถืออยู่ → client อื่นเห็น "ใครถืออะไร"; force emit แม้ยืนนิ่ง (หยิบ/วางขณะไม่ขยับ) */ const prevHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld); const rawHeld = data.quizCarryHeld; const nh = (rawHeld == null) ? null : Math.floor(Number(rawHeld)); const newHeld = (nh == null || !Number.isFinite(nh)) ? null : nh; if (newHeld !== prevHeld) moveForceEmit = true; p.quizCarryHeld = newHeld; } 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 || moveForceEmit) { 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); out.spaceShooterHits = Math.max(0, p.spaceShooterHits | 0); out.spaceShooterEliminated = !!p.spaceShooterEliminated; } if (md && md.gameType === 'balloon_boss') { const bbDef = balloonBossBalloonsForMap(md); 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 (md && md.gameType === 'quiz_carry') { out.quizCarryHeld = (p.quizCarryHeld === undefined ? null : p.quizCarryHeld); } if (md && md.gameType === 'jump_survive') { out.jumpSurviveEliminated = !!p.jumpSurviveEliminated; } 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));