Files
justice/www/html/Game/server.js
T

9212 lines
435 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 17 (ไม่รวม 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) ต่อช่อง 116 */
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; ค่าอื่น = 107200 */
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 ช่อง 16 (ตรงกับ 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 — เลข 216หาย) */
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);
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <id>_<dir>.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-<hash>) ไม่งั้น 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 02) ทันที
* (ใช้สำหรับเทสต์ 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 ต้องเป็น 02' });
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));