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

7128 lines
318 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' },
];
const LOBBY_SLOT_TOTAL = 6;
const LOBBY_THEME_COUNT = 8;
const LOBBY_SKIN_COUNT = 3;
/** ธีมสีเสื้อที่ถูกใช้แล้วในห้อง (ผู้เล่น + bot) */
function getTakenLobbyThemeIndices(space, excludePeerId) {
const taken = new Set();
if (!space) return taken;
space.peers.forEach((p, id) => {
if (excludePeerId && id === excludePeerId) return;
const ti = parseInt(p.lobbyColorThemeIndex, 10);
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
});
const bots = space.lobbyBotThemeBySlot;
if (bots && typeof bots === 'object') {
Object.keys(bots).forEach((k) => {
const ti = parseInt(bots[k] && bots[k].themeIndex, 10);
if (ti >= 1 && ti <= LOBBY_THEME_COUNT) taken.add(ti);
});
}
return taken;
}
function pickFreeLobbyThemeIndex(space, excludePeerId) {
const taken = getTakenLobbyThemeIndices(space, excludePeerId);
for (let i = 1; i <= LOBBY_THEME_COUNT; i++) {
if (!taken.has(i)) return i;
}
const n = (space && space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
return ((n - 1) % LOBBY_THEME_COUNT) + 1;
}
function pickLobbySkinIndexForSlot(slotIndex) {
const s = Number(slotIndex);
if (!Number.isFinite(s)) return 1;
return (Math.abs(Math.floor(s)) % LOBBY_SKIN_COUNT) + 1;
}
/** จัดสี bot ตามลำดับช่อง — ไม่ซ้ำกับผู้เล่น */
function syncLobbyBotThemeSlots(space) {
if (!space) return [];
const botCount = effectiveBotSlotCount(space);
if (!space.lobbyBotThemeBySlot || typeof space.lobbyBotThemeBySlot !== 'object') {
space.lobbyBotThemeBySlot = {};
}
const taken = getTakenLobbyThemeIndices(space);
const out = [];
for (let i = 0; i < botCount; i++) {
let slot = space.lobbyBotThemeBySlot[i];
let themeIndex = parseInt(slot && slot.themeIndex, 10);
if (!(themeIndex >= 1 && themeIndex <= LOBBY_THEME_COUNT) || taken.has(themeIndex)) {
themeIndex = pickFreeLobbyThemeIndex(space);
}
taken.add(themeIndex);
const skinToneIndex = (() => {
const si = parseInt(slot && slot.skinToneIndex, 10);
return (si >= 1 && si <= LOBBY_SKIN_COUNT) ? si : pickLobbySkinIndexForSlot(i);
})();
space.lobbyBotThemeBySlot[i] = { themeIndex, skinToneIndex };
out.push({ themeIndex, skinToneIndex });
}
Object.keys(space.lobbyBotThemeBySlot).forEach((k) => {
if (parseInt(k, 10) >= botCount) delete space.lobbyBotThemeBySlot[k];
});
return out;
}
function emitLobbyTintSync(spaceId, space) {
const peersTint = [];
space.peers.forEach((p, id) => {
peersTint.push({
id,
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
});
});
io.to(spaceId).emit('lobby-tint-sync', {
peers: peersTint,
lobbyBotThemes: syncLobbyBotThemeSlots(space),
});
}
/** จำนวน Bot จาก Create Room — ถ้าไม่เคยบันทึก ใช้ 6 − maxPlayers (เช่น 3 คน → 3 Bot) */
function effectiveBotSlotCount(space) {
let n = parseInt(space && space.botSlotCount, 10);
if (isNaN(n) || n < 0) n = 0;
const maxP = space && space.maxPlayers ? parseInt(space.maxPlayers, 10) : 0;
if (n === 0 && maxP > 0 && maxP < LOBBY_SLOT_TOTAL) {
n = LOBBY_SLOT_TOTAL - maxP;
if (space) space.botSlotCount = n;
}
return Math.min(LOBBY_SLOT_TOTAL, Math.max(0, n));
}
/** Stack Tower ภารกิจ — lobby READY / START เดียวกับ quiz mission (mng8a80o) */
const STACK_TOWER_MISSION_MAP_ID = 'mnn93hpi';
/** Jump Survival ภารกิจ Jumper — lobby READY / START เดียวกับ Stack Tower */
const JUMP_SURVIVE_MISSION_MAP_ID = 'mnptfts2';
/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */
const SPACE_EMPTY_TTL_MS = 45000;
// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025)
const AI_MODELS = [
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
'o1', 'o1-mini', 'o1-pro',
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
'gpt-3.5-turbo',
];
function loadAiSettings() {
try {
if (fs.existsSync(AI_SETTINGS_PATH)) {
const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8');
return JSON.parse(raw);
}
} catch (e) { console.error('loadAiSettings', e.message); }
return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' };
}
function saveAiSettings(settings) {
try {
const dir = path.dirname(AI_SETTINGS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
} catch (e) { console.error('saveAiSettings', e.message); }
}
function getAdminToken(cookieHeader) {
if (!cookieHeader) return null;
const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/);
return m && adminTokens.has(m[1]) ? m[1] : null;
}
const MAPS_DIR = path.join(__dirname, 'data', 'maps');
const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters');
/** ชื่อเลเยอร์ตรงกับ character.html / อัปโหลด (ลำดับวาดใน client) */
const CHAR_LAYER_MANIFEST_KEYS = ['shadow', 'bodyColor', 'bodyStroke', 'headColor', 'headStroke', 'hairColor', 'hairStroke', 'face'];
const CHAR_MANIFEST_DIRS = ['up', 'down', 'left', 'right'];
/** รายการไฟล์เลเยอร์ต่อทิศ/เฟรม — ให้หน้า character แก้ไขโหลด preview ได้ */
function buildCharacterLayerManifest(id, files) {
if (!id || typeof id !== 'string') return { ok: false, error: 'bad id', byDir: {}, byDirIdle: {} };
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
const layerAlt = CHAR_LAYER_MANIFEST_KEYS.join('|');
const byDir = {};
for (const dir of CHAR_MANIFEST_DIRS) {
const multiRe = new RegExp('^' + esc + '_' + dir + '_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
const singleRe = new RegExp('^' + esc + '_' + dir + '_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
const multiHits = files.filter((f) => multiRe.test(f));
if (multiHits.length) {
let maxFi = -1;
const perFrame = new Map();
multiHits.forEach((f) => {
const m = f.match(multiRe);
if (!m) return;
const fi = parseInt(m[1], 10);
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
const layer = m[2];
if (fi > maxFi) maxFi = fi;
if (!perFrame.has(fi)) perFrame.set(fi, {});
perFrame.get(fi)[layer] = f;
});
const frames = [];
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
byDir[dir] = { multi: true, frames };
} else {
const frame0 = {};
files.forEach((f) => {
if (!f.startsWith(id + '_' + dir + '_layer_') || !ext.test(f)) return;
const m = f.match(singleRe);
if (m) frame0[m[1]] = f;
});
if (Object.keys(frame0).length) byDir[dir] = { multi: false, frames: [frame0] };
}
}
const byDirIdle = {};
for (const dir of CHAR_MANIFEST_DIRS) {
const multiIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_(\\d+)_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
const singleIdleRe = new RegExp('^' + esc + '_' + dir + '_idle_layer_(' + layerAlt + ')\\.(png|jpg|jpeg|gif|webp)$', 'i');
const multiIdleHits = files.filter((f) => multiIdleRe.test(f));
if (multiIdleHits.length) {
let maxFi = -1;
const perFrame = new Map();
multiIdleHits.forEach((f) => {
const m = f.match(multiIdleRe);
if (!m) return;
const fi = parseInt(m[1], 10);
if (!Number.isFinite(fi) || fi < 0 || fi > 200) return;
const layer = m[2];
if (fi > maxFi) maxFi = fi;
if (!perFrame.has(fi)) perFrame.set(fi, {});
perFrame.get(fi)[layer] = f;
});
const frames = [];
for (let i = 0; i <= maxFi; i++) frames.push(perFrame.get(i) || {});
byDirIdle[dir] = { multi: true, frames };
} else {
const frame0 = {};
files.forEach((f) => {
if (!f.startsWith(id + '_' + dir + '_idle_layer_') || !ext.test(f)) return;
const m = f.match(singleIdleRe);
if (m) frame0[m[1]] = f;
});
if (Object.keys(frame0).length) byDirIdle[dir] = { multi: false, frames: [frame0] };
}
}
return { ok: true, id, byDir, byDirIdle };
}
/** ต้องอยู่ใต้ public/img/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟ GET /Game/img/gauntlet-assets/* ได้ — เดิมเก็บใน data/ จะถูกคัดลอกมาครั้งแรก */
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'gauntlet-assets');
const GAUNTLET_ASSETS_DIR_LEGACY = path.join(__dirname, 'data', 'gauntlet-assets');
const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json');
/** ต้องอยู่ใต้ public/ เพื่อให้ nginx (alias /Game/ → Game/public) เสิร์ฟรูปได้ — อย่าใช้ data/ */
const QUIZ_CARRY_PLAQUE_ASSETS_DIR = path.join(__dirname, 'public', 'img', 'quiz-carry-plaque-assets');
const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json');
/** ถ้า Admin (PHP) merge ลงไฟล์ใต้ docroot แต่ Node รันจากโฟลเดอร์อื่น ให้ตั้ง absolute path ที่นี่ — carry* / ธีมแผงจะอ่านทับจาก mirror ทุกครั้งที่ loadQuizSettings */
const QUIZ_SETTINGS_MIRROR_PATH = process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH
? path.resolve(String(process.env.GAME_QUIZ_SETTINGS_MIRROR_PATH).trim())
: '';
const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json');
/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
const maps = new Map();
const defaultMap = () => ({
width: 20, height: 15, tileSize: 32, gameType: 'zep',
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
/** กล่องตรวจทับโซน «ห้ามซ้อน» (ชิดเท้า+กลางแนวนอน เหมือนชนกำแพง) · 0 = เต็มความกว้าง/สูงตัว */
blockPlayerSeparationW: 0,
blockPlayerSeparationH: 0,
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
gridImageLibrary: [],
gridImageSprites: [],
gridImageCells: [],
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
quizCarryHubArea: [], quizCarryOptionArea: [],
/** quiz_carry embed: โซนวาง UI นับ 3-2-1 (0/1) — ใช้เมื่อ carryEmbedCountdownAnchor === 'grid' */
carryEmbedCountdownArea: [],
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */
quizBattleDomeArea: [],
/** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */
quizBattlePathArea: [],
stackReleaseArea: [], stackLandArea: [],
/** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */
jumpSurvivePlatforms: [],
jumpSurvivePlatformArea: [],
/** กริดช่องละ 0 หรือ 1–3 = รูปแพลตฟอร์มจาก jumpSurvivePlatformTiles[index-1] */
jumpSurvivePlatformVariantArea: [],
jumpSurviveHazardArea: [],
jumpSurviveRisePxPerSec: 42,
jumpSurviveGravity: 3200,
/** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */
jumpSurviveJumpImpulse: 0,
jumpSurviveMovePxPerSec: 200,
});
/* ===== Special Quiz Sets (Level 1-5 × Case 1-3 = 15 ชุด) — คำถามรับการ์ดพิเศษ ใช้ได้หลายเกม ===== */
const SPECIAL_QUIZ_LEVELS = [1, 2, 3, 4, 5];
const SPECIAL_QUIZ_CASES = [1, 2, 3];
const SPECIAL_QUIZ_CARD_RARITIES = ['common', 'rare', 'legendary'];
const SPECIAL_CARD_ASSET_BASE = '/Game/img/special-cards/';
function specialCardTxtFilename(id) {
return id === 7 ? 'card--7txt.png' : `card-${id}-txt.png`;
}
function specialCardImageUrl(id) {
return `${SPECIAL_CARD_ASSET_BASE}card-${id}.png`;
}
function specialCardTxtImageUrl(id) {
return `${SPECIAL_CARD_ASSET_BASE}${specialCardTxtFilename(id)}`;
}
function specialQuizSetKey(level, caseId) {
return 'L' + level + '_C' + caseId;
}
/** คำถาม 1 ข้อ — รองรับทั้งจริง/เท็จ (tf) และตัวเลือก A/B/C (mcq) เพื่อขยายภายหลัง */
function sanitizeSpecialQuizQuestion(q) {
if (!q || typeof q !== 'object') return null;
const text = String(q.text == null ? '' : q.text).trim();
if (!text) return null;
const type = q.type === 'mcq' ? 'mcq' : 'tf';
if (type === 'mcq') {
/* รักษาตำแหน่งตัวเลือก (A=0,B=1,...) — ตัดเฉพาะช่องว่างท้ายสุด ไม่ขยับ index คำตอบ */
const choices = (Array.isArray(q.choices) ? q.choices : [])
.map((c) => String(c == null ? '' : c).trim())
.slice(0, 8);
while (choices.length && choices[choices.length - 1] === '') choices.pop();
if (choices.filter((c) => c).length < 2) return null;
let ci = Number(q.correctIndex);
if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length || choices[ci] === '') {
ci = choices.findIndex((c) => c);
if (ci < 0) ci = 0;
}
return { type: 'mcq', text: text.slice(0, 500), choices, correctIndex: ci };
}
return { type: 'tf', text: text.slice(0, 500), answerTrue: !!q.answerTrue };
}
function sanitizeSpecialCard(c) {
const src = (c && typeof c === 'object') ? c : {};
const rarity = SPECIAL_QUIZ_CARD_RARITIES.indexOf(src.rarity) >= 0 ? src.rarity : 'rare';
let cardId = Number(src.cardId);
if (!(cardId >= 1 && cardId <= 7)) cardId = null;
let imageUrl = String(src.imageUrl == null ? '' : src.imageUrl).trim().slice(0, 600);
let txtImageUrl = String(src.txtImageUrl == null ? '' : src.txtImageUrl).trim().slice(0, 600);
if (cardId) {
if (!imageUrl) imageUrl = specialCardImageUrl(cardId);
if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId);
} else if (imageUrl) {
const m = imageUrl.match(/\/card-(\d)\.png/i);
if (m) {
cardId = Number(m[1]);
imageUrl = specialCardImageUrl(cardId);
if (!txtImageUrl) txtImageUrl = specialCardTxtImageUrl(cardId);
}
}
return {
cardId: cardId || null,
th: String(src.th == null ? '' : src.th).trim().slice(0, 160),
en: String(src.en == null ? '' : src.en).trim().slice(0, 160),
rarity,
imageUrl,
txtImageUrl,
};
}
/** คืน object 15 ชุดเสมอ (ชุดที่ยังไม่ตั้ง = ว่าง) */
function sanitizeSpecialQuizSets(obj) {
const out = {};
SPECIAL_QUIZ_LEVELS.forEach((L) => SPECIAL_QUIZ_CASES.forEach((C) => {
const key = specialQuizSetKey(L, C);
const src = (obj && typeof obj === 'object' && obj[key] && typeof obj[key] === 'object') ? obj[key] : {};
const questions = (Array.isArray(src.questions) ? src.questions : [])
.map(sanitizeSpecialQuizQuestion)
.filter(Boolean)
.slice(0, 50);
out[key] = { questions, specialCard: sanitizeSpecialCard(src.specialCard) };
}));
return out;
}
/** เลือกชุดคำถามตาม level+case (fallback ชุด L1_C1) */
function getSpecialQuizSet(settings, level, caseId) {
const sets = (settings && settings.specialQuizSets) || sanitizeSpecialQuizSets({});
const L = SPECIAL_QUIZ_LEVELS.indexOf(Number(level)) >= 0 ? Number(level) : 1;
/* caseId เป็นเลขคดีรวม 1-15 (5 ระดับ × 3 คดี) → แปลงเป็นคดีสัมพัทธ์ในระดับ 1-3 */
const rel = ((Math.max(1, Number(caseId) || 1) - 1) % 3) + 1;
const C = SPECIAL_QUIZ_CASES.indexOf(rel) >= 0 ? rel : 1;
return sets[specialQuizSetKey(L, C)] || sets[specialQuizSetKey(1, 1)] || { questions: [], specialCard: sanitizeSpecialCard({}) };
}
/* ===== Special Quiz ในเกม — ไอคอนสุ่มในฉาก + เซสชันถาม-ตอบ multiplayer (ทุกคนต้องตอบถูก) ===== */
/** เฉพาะ 4 มินิเกมนี้ (ตอนสืบสวน) ที่ไอคอนพิเศษจะสุ่มโผล่ */
const SPECIAL_QUIZ_ELIGIBLE_MAP_IDS = ['mng8a80o', 'mnorwqx1', 'mnptfts2', 'mnpz6rkp'];
const SPECIAL_QUIZ_ICON_TYPES = ['lawyer', 'police'];
/** โอกาสโผล่ = 1 ใน 3 ของการเล่นแต่ละรอบ */
const SPECIAL_QUIZ_SPAWN_CHANCE = 1 / 3;
/** เวลาให้ตอบต่อข้อ (มิลลิวิ) — ค่าคงที่เริ่มต้น (ย้ายไป Admin ได้ภายหลัง) */
const SPECIAL_QUIZ_ANSWER_MS = 20000;
/** หน่วงโชว์เฉลยก่อนข้อต่อไป */
const SPECIAL_QUIZ_REVEAL_MS = 2600;
/** แปลงคำถาม 1 ข้อให้อยู่ในรูป {text, choices[], correctIndex} เสมอ (tf -> จริง/เท็จ) */
function specialQuizNormalizeQuestion(q) {
if (!q || typeof q !== 'object') return null;
const text = String(q.text == null ? '' : q.text).trim();
if (!text) return null;
if (q.type === 'mcq') {
const choices = (Array.isArray(q.choices) ? q.choices : []).map((c) => String(c == null ? '' : c)).filter((c) => c.trim() !== '');
if (choices.length < 2) return null;
let ci = Number(q.correctIndex);
if (!Number.isInteger(ci) || ci < 0 || ci >= choices.length) ci = 0;
return { text, choices, correctIndex: ci };
}
return { text, choices: ['จริง', 'เท็จ'], correctIndex: q.answerTrue ? 0 : 1 };
}
function specialQuizShuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function specialQuizTileWalkable(md, x, y) {
const w = md.width || 20;
const h = md.height || 15;
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return !!(row && row[x] !== 1);
}
/** หลีกเลี่ยงโซน gameplay (คำตอบจริง/ผิด, hub, ตัวเลือกหยิบมาวาง) เพื่อไม่ชนกลไกเกม */
function specialQuizTileInGameZone(md, x, y) {
if (quizCellOn(md.quizTrueArea || [], x, y)) return true;
if (quizCellOn(md.quizFalseArea || [], x, y)) return true;
if (quizCellOn(md.quizCarryHubArea || [], x, y)) return true;
const oa = md.quizCarryOptionArea;
if (oa && oa[y] && oa[y][x]) return true;
return false;
}
/** หา tile ที่เดินถึงได้ (BFS) และไม่อยู่ในโซน gameplay — คืนพิกัดกึ่งกลาง tile */
function pickSpecialQuizSpawnTile(md) {
const w = md.width || 20;
const h = md.height || 15;
/* seed BFS จากพื้นที่ที่ผู้เล่นเกิดจริง (spawnArea) เพื่อรับประกันว่าไอคอนเดินไปถึงได้
fallback: md.spawn (ถ้าเดินได้) แล้วค่อยสแกนหา walkable tile แรก */
let sx = -1;
let sy = -1;
const spawnGrid = md.spawnArea;
if (Array.isArray(spawnGrid)) {
for (let y = 0; y < h && sx < 0; y++) {
const row = spawnGrid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; }
}
}
}
if (sx < 0) {
const sp = md.spawn || { x: 1, y: 1 };
const fx = Math.floor(Number(sp.x));
const fy = Math.floor(Number(sp.y));
if (specialQuizTileWalkable(md, fx, fy)) { sx = fx; sy = fy; }
}
if (sx < 0) {
for (let y = 0; y < h && sx < 0; y++) {
for (let x = 0; x < w; x++) {
if (specialQuizTileWalkable(md, x, y)) { sx = x; sy = y; break; }
}
}
}
if (sx < 0) return null;
const q = [[sx, sy]];
const seen = new Set([sx + ',' + sy]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
const cands = [];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = nx + ',' + ny;
if (seen.has(k)) continue;
if (!specialQuizTileWalkable(md, nx, ny)) continue;
seen.add(k);
q.push([nx, ny]);
if (!specialQuizTileInGameZone(md, nx, ny)) {
const dist = Math.abs(nx - sx) + Math.abs(ny - sy);
if (dist >= 2) cands.push({ x: nx, y: ny });
}
}
}
if (!cands.length) return null;
const pick = cands[Math.floor(Math.random() * cands.length)];
return { x: pick.x + 0.5, y: pick.y + 0.5 };
}
function clearSpecialQuizTimers(space) {
if (Array.isArray(space.specialQuizTimers)) {
space.specialQuizTimers.forEach((t) => clearTimeout(t));
}
space.specialQuizTimers = [];
if (space.specialQuizContinueTimer) {
clearTimeout(space.specialQuizContinueTimer);
space.specialQuizContinueTimer = null;
}
space.specialQuizAwaitContinue = false;
}
/** payload ไอคอนสำหรับ client (null = ไม่มี/ถูกทริกแล้ว) */
function specialQuizIconPayload(space) {
const sq = space && space.specialQuiz;
if (!sq || sq.triggered || sq.done) return null;
return { iconType: sq.iconType, x: sq.x, y: sq.y };
}
/** เริ่มมินิเกมสืบสวน: สุ่ม 1/3 ว่ารอบนี้จะมีไอคอนพิเศษไหม + เลือกตำแหน่ง */
function maybeSpawnSpecialQuizForRun(space) {
clearSpecialQuizTimers(space);
space.specialQuiz = null;
space.specialCardAwardedThisRun = null;
space.timeDilationPendingSec = 0;
/* หมายเหตุ: ไม่ล้าง pendingSpecialCard ที่นี่ — การ์ดที่ใช้ใน trial/lobby ต้องคงอยู่จนกว่าจะถูกใช้
หรือถูกแทนด้วยการ์ดใบใหม่ (endSpecialQuizSession เขียนทับเมื่อได้ใบใหม่) */
if (!space || !space.detectiveMinigameActive) return;
const mapId = String(space.mapId || '').trim();
if (SPECIAL_QUIZ_ELIGIBLE_MAP_IDS.indexOf(mapId) < 0) return;
const level = Number(space.detectiveLobbyLevel) || 1;
const caseId = Number(space.detectiveLobbyCaseId) || 1;
const settings = loadQuizSettings();
const set = getSpecialQuizSet(settings, level, caseId);
const questions = (set && Array.isArray(set.questions) ? set.questions : [])
.map(specialQuizNormalizeQuestion)
.filter(Boolean);
const card = set && set.specialCard;
const hasCard = !!(card && (card.cardId || card.imageUrl));
if (!questions.length || !hasCard) { console.log('[special-quiz] skip:no-questions-or-card', mapId, 'L' + level + '_C' + caseId, 'q=' + questions.length, 'card=' + hasCard); return; }
const forced = !!space.forceSpecialQuiz;
const roll = Math.random();
if (!forced && roll > SPECIAL_QUIZ_SPAWN_CHANCE) { console.log('[special-quiz] skip:roll', mapId, roll.toFixed(3), '>', SPECIAL_QUIZ_SPAWN_CHANCE.toFixed(3)); return; }
if (forced) console.log('[special-quiz] FORCE-spawn (debug)', mapId);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md) { console.log('[special-quiz] skip:no-map', mapId); return; }
const tile = pickSpecialQuizSpawnTile(md);
if (!tile) { console.log('[special-quiz] skip:no-tile', mapId); return; }
const iconType = SPECIAL_QUIZ_ICON_TYPES[Math.floor(Math.random() * SPECIAL_QUIZ_ICON_TYPES.length)];
console.log('[special-quiz] SPAWN', mapId, 'L' + level + '_C' + caseId, iconType, 'tile', tile.x, tile.y);
space.specialQuiz = {
iconType,
x: tile.x,
y: tile.y,
level,
caseId,
card,
triggered: false,
done: false,
session: null,
};
}
function emitSpecialQuizIcon(sid, space) {
const payload = specialQuizIconPayload(space);
if (payload) io.to(sid).emit('special-quiz-icon', payload);
}
/** roster ของเซสชัน: ผู้เล่นจริง + บอทเติมช่อง (บอทตอบถูกเสมอ) — โชว์ในแผง "ใครตอบอะไร" */
function buildSpecialQuizRoster(space) {
const roster = [];
space.peers.forEach((p, id) => {
roster.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false });
});
const botN = effectiveBotSlotCount(space);
for (let i = 0; i < botN; i++) {
roster.push({ id: 'sqbot-' + i, nickname: 'บอท ' + (i + 1), isBot: true });
}
return roster;
}
function specialQuizRosterPayload(sess) {
return (sess && Array.isArray(sess.roster) ? sess.roster : [])
.map((m) => ({ id: m.id, nickname: m.nickname, isBot: !!m.isBot }));
}
/** id ที่ต้องตอบในข้อนี้ = ผู้เล่นจริงที่ยังอยู่ + บอททุกตัวใน roster (ใช้ตัดสิน "ทุกคนตอบถูก") */
function specialQuizExpectedIds(space, sess) {
const realAlive = new Set(space.peers.keys());
const out = [];
(sess && Array.isArray(sess.roster) ? sess.roster : []).forEach((m) => {
if (m.isBot) out.push(m.id);
else if (realAlive.has(m.id)) out.push(m.id);
});
return out;
}
/** ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */
function maybeResolveSpecialQuizAllAnswered(sid, space) {
const sess = space.specialQuiz && space.specialQuiz.session;
if (!sess || sess.resolving) return;
const expected = specialQuizExpectedIds(space, sess);
const allAnswered = expected.length > 0 && expected.every((pid) => Number.isInteger(sess.answers[pid]));
if (allAnswered) resolveSpecialQuizQuestion(sid, space, false);
}
/** บอททยอยตอบถูกภายในเวลาตอบ (ตอบถูกเสมอเพื่อให้ทีมมีโอกาสได้การ์ด) */
function scheduleSpecialQuizBotAnswers(sid, space, sess, q) {
const bots = (Array.isArray(sess.roster) ? sess.roster : []).filter((m) => m.isBot);
if (!bots.length) return;
const windowMs = Math.max(1200, SPECIAL_QUIZ_ANSWER_MS - 1500);
bots.forEach((b) => {
const delay = 900 + Math.floor(Math.random() * windowMs);
const t = setTimeout(() => {
const spNow = spaces.get(sid);
const sNow = spNow && spNow.specialQuiz && spNow.specialQuiz.session;
if (!spNow || sNow !== sess || sess.resolving) return;
if (Number.isInteger(sess.answers[b.id])) return;
sess.answers[b.id] = q.correctIndex;
io.to(sid).emit('special-quiz-answer-update', { id: b.id, answered: true });
maybeResolveSpecialQuizAllAnswered(sid, spNow);
}, delay);
space.specialQuizTimers.push(t);
});
}
function sendSpecialQuizQuestion(sid, space) {
const sq = space.specialQuiz;
const sess = sq && sq.session;
if (!sess) return;
const q = sess.questions[sess.qIndex];
if (!q) { endSpecialQuizSession(sid, space, false); return; }
sess.answers = {};
sess.phaseEndsAt = Date.now() + SPECIAL_QUIZ_ANSWER_MS;
io.to(sid).emit('special-quiz-question', {
index: sess.qIndex,
total: sess.questions.length,
text: q.text,
choices: q.choices,
endsAt: sess.phaseEndsAt,
iconType: sq.iconType,
roster: specialQuizRosterPayload(sess),
});
scheduleSpecialQuizBotAnswers(sid, space, sess, q);
const t = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return;
resolveSpecialQuizQuestion(sid, spNow, true);
}, SPECIAL_QUIZ_ANSWER_MS + 80);
space.specialQuizTimers.push(t);
}
/** ตัดสินข้อปัจจุบัน: ต้องตอบครบทุกคน และถูกทุกคน จึงผ่าน */
function resolveSpecialQuizQuestion(sid, space, fromTimeout) {
const sq = space.specialQuiz;
const sess = sq && sq.session;
if (!sess || sess.resolving) return;
sess.resolving = true;
clearSpecialQuizTimers(space);
const q = sess.questions[sess.qIndex];
const expected = specialQuizExpectedIds(space, sess);
let allCorrect = expected.length > 0;
expected.forEach((pid) => {
const a = sess.answers[pid];
if (!(Number.isInteger(a) && a === q.correctIndex)) allCorrect = false;
});
io.to(sid).emit('special-quiz-result', {
index: sess.qIndex,
correctIndex: q.correctIndex,
answers: { ...sess.answers },
roster: specialQuizRosterPayload(sess),
allCorrect,
timeout: !!fromTimeout,
});
if (!allCorrect) {
const t = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.specialQuiz !== sq) return;
endSpecialQuizSession(sid, spNow, false);
}, SPECIAL_QUIZ_REVEAL_MS);
space.specialQuizTimers.push(t);
return;
}
sess.qIndex++;
if (sess.qIndex >= sess.questions.length) {
const t = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.specialQuiz !== sq) return;
endSpecialQuizSession(sid, spNow, true);
}, SPECIAL_QUIZ_REVEAL_MS);
space.specialQuizTimers.push(t);
return;
}
const t = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.specialQuiz !== sq || sq.session !== sess) return;
sess.resolving = false;
sendSpecialQuizQuestion(sid, spNow);
}, SPECIAL_QUIZ_REVEAL_MS);
space.specialQuizTimers.push(t);
}
/**
* นิยามการ์ดพิเศษ 7 ใบ — when = ช่วงที่การ์ดทำงาน:
* minigame = ใช้ทันทีในมินิเกมที่เพิ่งเล่น (Time Dilation)
* now = ใช้ทันทีตอนได้รับ (Fund coins)
* after_game = ตอนจบมินิเกมก่อนกลับ LobbyB (Free Evidence)
* pre_trial = ก่อนโหวตพิจารณาคดี (Silence)
* trial_vote = ระหว่างโหวตพิจารณาคดี (Extension)
* trial_revote= หลังเฉลยถ้าจับผิดตัว (Bail Coin)
* lobby_next = ใน LobbyB ก่อนเลือกมินิเกมรอบหน้า (Ban)
*/
const SPECIAL_CARD_DEFS = {
1: { key: 'time_dilation', when: 'minigame', addSec: 10, th: 'เพิ่มเวลาเล่นมินิเกม +10 วินาที' },
2: { key: 'extension', when: 'trial_vote', addSec: 10, th: 'เพิ่มเวลาโหวตพิจารณาคดี +10 วินาที' },
3: { key: 'ban', when: 'lobby_next', th: 'โหวตแบนผู้เล่น 1 คน ไม่ให้เล่นมินิเกมรอบหน้า' },
4: { key: 'silence', when: 'pre_trial', th: 'โหวตปิดปากผู้เล่น 1 คน ห้ามโหวตในพิจารณาคดี' },
5: { key: 'bail', when: 'trial_revote', th: 'จับผิดตัว ให้โหวตใหม่ได้ 1 ครั้ง' },
6: { key: 'fund', when: 'now', coins: 10, th: 'ทุกคนรับ +10 COINS' },
7: { key: 'free_evidence', when: 'after_game', evidence: 2, th: 'ทุกคนรับหลักฐานฟรี +2 ใบ' },
};
function specialCardDef(cardId) {
return SPECIAL_CARD_DEFS[Number(cardId)] || null;
}
/** payload การ์ดสำหรับ client (รวม metadata การใช้งาน) */
function specialCardClientPayload(card) {
if (!card) return null;
const def = specialCardDef(card.cardId) || {};
return {
cardId: card.cardId || null,
th: card.th || def.th || '',
en: card.en || '',
rarity: card.rarity || 'common',
imageUrl: card.imageUrl || (card.cardId ? specialCardImageUrl(card.cardId) : ''),
txtImageUrl: card.txtImageUrl || (card.cardId ? specialCardTxtImageUrl(card.cardId) : ''),
effectKey: def.key || '',
effectWhen: def.when || '',
addSec: def.addSec || 0,
desc: def.th || '',
};
}
/** ใช้การ์ดที่ทำงาน "ทันที" (minigame / now) — ส่วนที่เหลือเข้าคิวรอ flow ที่ถูก */
function applySpecialCardImmediate(sid, space, card) {
const def = specialCardDef(card && card.cardId);
if (!def) return;
if (def.when === 'minigame' && def.key === 'time_dilation') {
/* +10 วิให้รอบ quiz (mng8a80o) ตอน resume — มินิเกมอื่นฝั่ง client ขยายเอง */
space.timeDilationPendingSec = (space.timeDilationPendingSec || 0) + (def.addSec || 10);
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
addSec: def.addSec || 10,
});
consumePendingSpecialCard(space, card.cardId);
return;
}
if (def.when === 'now' && def.key === 'fund') {
/* +COINS — แต่ละ client บวกเหรียญของตัวเองผ่าน PHP (server ไม่รู้ playerKey) */
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(card),
coins: def.coins || 10,
});
consumePendingSpecialCard(space, card.cardId);
return;
}
/* การ์ดที่เหลือ (after_game / pre_trial / trial_vote / trial_revote / lobby_next)
— คงไว้ใน pendingSpecialCard เพื่อใช้ภายหลัง */
}
function consumePendingSpecialCard(space, cardId) {
if (space.pendingSpecialCard && space.pendingSpecialCard.cardId === Number(cardId)) {
space.pendingSpecialCard.consumed = true;
}
}
function endSpecialQuizSession(sid, space, success) {
const sq = space.specialQuiz;
if (!sq) return;
clearSpecialQuizTimers(space);
sq.done = true;
sq.session = null;
let cardOut = null;
if (success && sq.card && (sq.card.cardId || sq.card.imageUrl)) {
space.specialCardAwardedThisRun = sq.card;
cardOut = specialCardClientPayload(sq.card);
space.pendingSpecialCard = { cardId: Number(sq.card.cardId) || null, card: sq.card, consumed: false };
applySpecialCardImmediate(sid, space, sq.card);
}
io.to(sid).emit('special-quiz-ended', { success: !!success, card: cardOut });
/* รอ client กด «เล่นต่อ» ก่อนค่อยเล่นรอบ quiz ต่อ (กันเวลาเดินตอนค้างอ่านการ์ด)
— มี safety timeout กันค้างถ้าไม่มีใครกด */
space.specialQuizAwaitContinue = true;
if (space.specialQuizContinueTimer) clearTimeout(space.specialQuizContinueTimer);
space.specialQuizContinueTimer = setTimeout(() => {
space.specialQuizContinueTimer = null;
finishSpecialQuizContinue(sid, space);
}, 32000);
}
/** เล่นรอบ quiz ต่อหลังผู้เล่นกด «เล่นต่อ» (เรียกครั้งเดียว / หรือ safety timeout) */
function finishSpecialQuizContinue(sid, space) {
if (!space || !space.specialQuizAwaitContinue) return;
space.specialQuizAwaitContinue = false;
if (space.specialQuizContinueTimer) {
clearTimeout(space.specialQuizContinueTimer);
space.specialQuizContinueTimer = null;
}
resumeQuizAfterSpecialQuiz(sid, space);
}
function startSpecialQuizSession(sid, space) {
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done) return false;
const settings = loadQuizSettings();
const set = getSpecialQuizSet(settings, sq.level, sq.caseId);
const all = (set && Array.isArray(set.questions) ? set.questions : [])
.map(specialQuizNormalizeQuestion)
.filter(Boolean);
if (!all.length) return false;
/* ควิซพิเศษ = สุ่มถามแค่ 1 ข้อ (รับการ์ดพิเศษ) */
const picked = specialQuizShuffle(all).slice(0, 1);
sq.triggered = true;
sq.session = {
questions: picked,
qIndex: 0,
answers: {},
phaseEndsAt: 0,
resolving: false,
roster: buildSpecialQuizRoster(space),
};
clearSpecialQuizTimers(space);
pauseQuizForSpecialQuiz(sid, space);
io.to(sid).emit('special-quiz-icon-clear', {});
sendSpecialQuizQuestion(sid, space);
return true;
}
function defaultQuizSettings() {
return {
readMs: 10000,
answerMs: 5000,
betweenMs: 3500,
/** quiz_carry: โชว์เฉพาะคำถามก่อน (มิลลิวิ) แล้วค่อยให้หยิบตัวเลือก — 0 = ไม่รอ */
carryReadMs: 3000,
/** quiz_carry: หลังตัวเลือกขึ้น ให้เวลาตอบ (มิลลิวิ) */
carryAnswerMs: 5000,
/** quiz_carry: จำนวนข้อต่อเซสชันก่อนจบเกม (พรีวิว) — 0 = ไม่จบอัตโนมัติ */
carrySessionLength: 0,
/** quiz_carry: สีแผง #quiz-map-question-panel ในเกม/พรีวิว (จัดใน Admin แท็บหยิบมาวาง) */
carryMapPanelTheme: defaultCarryMapPanelTheme(),
/** เกมตอบคำถามถูก/ผิด: สีแผงคำถามบนแผนที่ (#quiz-map-question-panel) — Admin แท็บคำถามเกม */
quizMapPanelTheme: defaultCarryMapPanelTheme(),
/** quiz_carry: สี/ขอบ/ขนาดตัวเลขนับ 3-2-1 (embed พรีวิว) — Admin แท็บหยิบมาวาง */
carryEmbedCountdownTheme: defaultCarryEmbedCountdownTheme(),
/** quiz_carry: สีป้ายตัวเลือกบนแผนที่ (canvas) ต่อช่อง 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([
'cybercrime',
'cyber_law',
'privacy',
'digital_security',
'social_media',
'ethics',
'ai_data',
]);
/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
function sanitizeBattleQuizMcq(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
let categoryId = String(q.categoryId || '').trim();
if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
categoryId = 'cybercrime';
}
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
const rawChoices = Array.isArray(q.choices) ? q.choices : [];
const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
if (choices.length !== 3 || !choices.every(Boolean)) continue;
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(2, Math.floor(ci)));
out.push({ categoryId, text, choices, correctIndex: ci });
if (out.length >= 200) break;
}
return out;
}
/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
function sanitizeCarryQuestions(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
let choices = Array.isArray(q.choices)
? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
: [];
if (choices.length < 2) continue;
if (choices.length > 16) choices = choices.slice(0, 16);
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
const row = { text, choices, correctIndex: ci };
if (Array.isArray(q.choiceImageUrls) && q.choiceImageUrls.length) {
const urls = choices.map((_, idx) => sanitizeCarryChoiceImageUrl(q.choiceImageUrls[idx]));
if (urls.some((u) => u)) row.choiceImageUrls = urls;
}
out.push(row);
if (out.length >= 80) break;
}
return out;
}
function clampQuizMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(1000, Math.min(300000, Math.floor(v)));
}
function clampQuizBetweenMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(300000, Math.floor(v)));
}
function clampCarrySessionLength(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(500, Math.floor(v)));
}
/** เกมตอบคำถามถูก/ผิด: จำนวนข้อที่สุ่มจากชุดต่อรอบ (เซสชัน) — 1–50 */
function clampQuizRoundQuestionCount(n, def) {
const v = Number(n);
if (!Number.isFinite(v)) {
const d = Number(def);
if (!Number.isFinite(d)) return 10;
return Math.max(1, Math.min(50, Math.floor(d)));
}
return Math.max(1, Math.min(50, Math.floor(v)));
}
function clampCarryChoicePlaqueMapScale(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.round(Math.max(0.85, Math.min(2.5, v)) * 100) / 100;
}
/** รหัสฉาก (เช่น mnorwqx1) — ว่าง = ไม่ใช้คูณเดินแยกแมป */
function sanitizeCarryWalkSpeedMultForMapId(raw) {
const t = String(raw == null ? '' : raw).trim().slice(0, 64);
if (!t) return '';
if (!/^[a-zA-Z0-9_-]+$/.test(t)) return '';
return t;
}
/** quiz_carry: คูณความเร็วเดินเมื่อ map id ตรง — null = ไม่ตั้ง */
function clampCarryWalkSpeedMultSetting(n) {
if (n === null || n === undefined || n === '') return null;
const v = Number(n);
if (Number.isNaN(v)) return null;
return Math.round(Math.max(0.5, Math.min(3, v)) * 100) / 100;
}
function mergeQuizCarryFromMirrorIfSet(base) {
if (!QUIZ_SETTINGS_MIRROR_PATH) return base;
try {
if (!fs.existsSync(QUIZ_SETTINGS_MIRROR_PATH)) return base;
const raw = fs.readFileSync(QUIZ_SETTINGS_MIRROR_PATH, 'utf8');
const j = JSON.parse(raw);
if (!j || typeof j !== 'object') return base;
if (j.carryMapPanelTheme != null && typeof j.carryMapPanelTheme === 'object') {
base.carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
}
if (j.quizMapPanelTheme != null && typeof j.quizMapPanelTheme === 'object') {
base.quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
}
if (j.carryReadMs != null) {
base.carryReadMs = clampQuizBetweenMs(j.carryReadMs, base.carryReadMs);
}
if (j.carryAnswerMs != null) {
base.carryAnswerMs = clampQuizMs(j.carryAnswerMs, base.carryAnswerMs);
}
if (j.carrySessionLength != null) {
base.carrySessionLength = clampCarrySessionLength(j.carrySessionLength, base.carrySessionLength);
}
if (j.carryWalkSpeedMultForMapId != null) {
base.carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
}
if (j.carryWalkSpeedMult != null) {
const wm = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
if (wm != null) base.carryWalkSpeedMult = wm;
}
if (j.carryEmbedCountdownTheme != null && typeof j.carryEmbedCountdownTheme === 'object') {
base.carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
}
if (Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length) {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
} else if (j.carryChoicePlaqueTheme != null && typeof j.carryChoicePlaqueTheme === 'object') {
base.carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
base.carryChoicePlaqueTheme = base.carryChoicePlaqueThemes[0];
}
if (Array.isArray(j.carryQuestions) && j.carryQuestions.length) {
base.carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
}
if (j.quizRoundQuestionCount != null) {
base.quizRoundQuestionCount = clampQuizRoundQuestionCount(j.quizRoundQuestionCount, base.quizRoundQuestionCount);
}
return base;
} catch (e) {
console.error('mergeQuizCarryFromMirrorIfSet', e.message);
return base;
}
}
/* ===== การ์ดหลักฐาน (Evidence Cards) — 3 คดี × ผู้ต้องสงสัย 3 × การ์ด 3 ใบ จัดการผ่าน Admin ===== */
const EVIDENCE_CARDS_PATH = path.join(__dirname, 'data', 'evidence-cards.json');
const EVIDENCE_RARITIES = ['common', 'rare', 'legendary'];
const EVIDENCE_CASE_COUNT = 15;
/* ===== รูปภาพคดี + Cutscene (LobbyA → LobbyB) — จัดการผ่าน Admin ===== */
const CASE_MEDIA_PATH = path.join(__dirname, 'data', 'case-media.json');
const CASE_MEDIA_COUNT = 15;
/* Main-Lobby อยู่นอกโฟลเดอร์ Game (เสิร์ฟจาก /var/www/html) — ขึ้นไป 1 ระดับ */
const MAIN_LOBBY_IMAGE_ROOT = path.join(__dirname, '..', 'Main-Lobby', 'IMAGE');
function caseMediaDefault(n) {
return {
name: 'คดีที่ ' + n,
art: '/Main-Lobby/IMAGE/case/case-' + n + '.png',
detail: '/Main-Lobby/IMAGE/case/case-detail-' + n + '.png',
story: [
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-1.png',
'/Main-Lobby/IMAGE/cutscene/case-' + n + '-story-2.png',
],
};
}
/** อนุญาตเฉพาะ path รูปภายในเว็บ (กัน URL ภายนอก/สคริปต์) — ไม่ผ่าน → คืนค่า fallback */
function sanitizeCaseMediaUrl(v, fallback) {
if (typeof v !== 'string') return fallback;
const s = v.trim();
if (!s) return fallback;
if (!/^\/[\w./-]*\.(png|jpe?g|webp)$/i.test(s)) return fallback;
return s;
}
function sanitizeCaseMedia(obj) {
const src = (obj && typeof obj === 'object' && obj.cases && typeof obj.cases === 'object') ? obj.cases : {};
const cases = {};
for (let n = 1; n <= CASE_MEDIA_COUNT; n++) {
const d = caseMediaDefault(n);
const c = src[n] || src[String(n)] || {};
const story = Array.isArray(c.story) ? c.story : [];
cases[n] = {
name: (typeof c.name === 'string' && c.name.trim()) ? c.name.trim().slice(0, 80) : d.name,
art: sanitizeCaseMediaUrl(c.art, d.art),
detail: sanitizeCaseMediaUrl(c.detail, d.detail),
story: [sanitizeCaseMediaUrl(story[0], d.story[0]), sanitizeCaseMediaUrl(story[1], d.story[1])],
};
}
return { cases };
}
function loadCaseMedia() {
try {
if (fs.existsSync(CASE_MEDIA_PATH)) {
return sanitizeCaseMedia(JSON.parse(fs.readFileSync(CASE_MEDIA_PATH, 'utf8')));
}
} catch (e) { /* ignore — คืนค่า default */ }
return sanitizeCaseMedia({});
}
function saveCaseMedia(d) {
try {
const clean = sanitizeCaseMedia(d);
fs.writeFileSync(CASE_MEDIA_PATH, JSON.stringify(clean, null, 2), 'utf8');
return { ok: true, cases: clean.cases };
} catch (e) {
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ case-media.json (chown เป็น user ที่รันเกม)';
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
/** รายการรูปที่มีจริงในโฟลเดอร์ case / cutscene (ไว้ให้ Admin เลือก) */
function listCaseMediaImages() {
const out = { case: [], cutscene: [] };
['case', 'cutscene'].forEach((sub) => {
try {
const p = path.join(MAIN_LOBBY_IMAGE_ROOT, sub);
out[sub] = fs.readdirSync(p)
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.map((f) => '/Main-Lobby/IMAGE/' + sub + '/' + f);
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
});
return out;
}
const EVIDENCE_IMAGE_DIRS = ['suspect-1', 'suspect-2', 'suspect-3'];
/* rarity คิดจากเลขไฟล์การ์ด: 01-22 = common, 23-44 = rare, 45-54 = legendary (ชี้คนร้าย) */
const EVIDENCE_RARITY_RANGES = [
{ rarity: 'common', min: 1, max: 22, stars: 1 },
{ rarity: 'rare', min: 23, max: 44, stars: 2 },
{ rarity: 'legendary', min: 45, max: 54, stars: 3 },
];
function evidenceRarityForNum(n) {
for (const r of EVIDENCE_RARITY_RANGES) if (n >= r.min && n <= r.max) return r;
return EVIDENCE_RARITY_RANGES[0];
}
function evidenceCardImageDefault(suspectIdx, cardIdx) {
return '/Game/img/evidence-cards/suspect-' + (suspectIdx + 1) + '/Card-0' + (cardIdx + 1) + '.png';
}
/* แคชพูลรูปการ์ดต่อโฟลเดอร์ผู้ต้องสงสัย แยกตาม rarity (สแกนไฟล์จริง) */
let _evidencePoolCache = null;
let _evidencePoolCacheAt = 0;
function evidenceImagePools() {
const now = Date.now();
if (_evidencePoolCache && (now - _evidencePoolCacheAt) < 30000) return _evidencePoolCache;
const pools = {};
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
const buckets = { common: [], rare: [], legendary: [] };
try {
const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
fs.readdirSync(p).forEach((f) => {
const m = /^Card-0*(\d+)\.(png|jpe?g|webp)$/i.exec(f);
if (!m) return;
const num = parseInt(m[1], 10);
const rr = evidenceRarityForNum(num);
buckets[rr.rarity].push({ num, rarity: rr.rarity, stars: rr.stars, imageUrl: '/Game/img/evidence-cards/' + dir + '/' + f });
});
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
Object.keys(buckets).forEach((k) => buckets[k].sort((a, b) => a.num - b.num));
pools[dir] = buckets;
});
_evidencePoolCache = pools;
_evidencePoolCacheAt = now;
return pools;
}
function evidencePoolCounts() {
const pools = evidenceImagePools();
const out = {};
EVIDENCE_IMAGE_DIRS.forEach((dir) => {
const b = pools[dir] || { common: [], rare: [], legendary: [] };
out[dir] = { common: b.common.length, rare: b.rare.length, legendary: b.legendary.length };
});
return out;
}
/* แปลงเกรด -> rarity key ('legendary'=ชี้คนร้าย/'rare'/'common') ตามเปอร์เซ็นต์ที่กำหนด */
function rollEvidenceRarityKey(grade) {
const r = Math.random();
if (grade === 'A') return r < 0.30 ? 'legendary' : (r < 0.80 ? 'rare' : 'common');
if (grade === 'B') return r < 0.20 ? 'legendary' : (r < 0.50 ? 'rare' : 'common');
return r < 0.20 ? 'rare' : 'common'; // C ลงไป: ไม่มีชี้คนร้าย
}
/* โฟลเดอร์รูปของผู้ต้องสงสัยตาม config คดี (fallback = suspect-(idx+1)) */
function evidenceImageDirForSuspect(space, suspectIndex) {
const def = EVIDENCE_IMAGE_DIRS[suspectIndex] || EVIDENCE_IMAGE_DIRS[0];
try {
const cid = String((space && space.caseId) || '1');
const cfg = loadEvidenceCards();
const cse = cfg.cases && cfg.cases[cid];
const s = cse && cse.suspects && cse.suspects[suspectIndex];
if (s && s.imageDir && EVIDENCE_IMAGE_DIRS.indexOf(s.imageDir) >= 0) return s.imageDir;
} catch (e) { /* ใช้ default */ }
return def;
}
/* เลือกการ์ด 1 ใบจากพูลของผู้ต้องสงสัยตามเกรด (มี fallback ถ้า rarity นั้นไม่มีรูป) */
function pickEvidenceCardForSuspect(space, suspectIndex, grade) {
const imageDir = evidenceImageDirForSuspect(space, suspectIndex);
const pools = evidenceImagePools();
const buckets = pools[imageDir] || pools[EVIDENCE_IMAGE_DIRS[0]] || { common: [], rare: [], legendary: [] };
let want = rollEvidenceRarityKey(grade || 'C');
let pool = buckets[want];
if (!pool || !pool.length) {
const tryOrder = ['common', 'rare', 'legendary'];
for (const k of tryOrder) { if (buckets[k] && buckets[k].length) { pool = buckets[k]; want = k; break; } }
}
if (!pool || !pool.length) return null;
const pick = pool[Math.floor(Math.random() * pool.length)];
return { rarity: pick.rarity, stars: pick.stars, num: pick.num, imageUrl: pick.imageUrl };
}
function evidenceGradeFromPct(avgPct) {
return avgPct >= 80 ? 'A' : avgPct >= 60 ? 'B' : 'C';
}
/* คะแนนดิบของผู้เล่นต่อ gameType (ใช้กรณี normalize เทียบ best) */
function evidencePlayerRawScore(space, p, gameType) {
switch (gameType) {
case 'gauntlet':
case 'jump_survive': return Math.max(0, p.gauntletScore | 0);
case 'space_shooter': return Math.max(0, p.spaceShooterScore | 0);
case 'balloon_boss': return Math.max(0, p.balloonBossScore | 0);
default:
return Math.max(0, p.gauntletScore | 0) || Math.max(0, p.spaceShooterScore | 0) || Math.max(0, p.balloonBossScore | 0);
}
}
/* เกรดของรอบ (A/B/C) — quiz ใช้ % สัมบูรณ์, เกมอื่น normalize เทียบ best แล้วเฉลี่ยทั้งห้อง */
function computeRunGradeForEvidence(space) {
try {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
const gameType = md && md.gameType;
if (gameType === 'gauntlet' || gameType === 'jump_survive') {
return buildGauntletCrownMissionPayload(space).grade || 'C';
}
/* เกมจริงหรือไม่ (quiz TF): เฉลี่ย % ของคะแนนเทียบเต็ม (จำนวนข้อ × แต้มต่อข้อ) */
if (gameType === 'quiz' && space.quizSession && space.quizSession.players) {
const total = (space.quizSession.questions || []).length * QUIZ_TF_POINTS_PER_CORRECT;
const ids = Object.keys(space.quizSession.players);
if (total > 0 && ids.length) {
const avgPct = ids.reduce((s, id) => {
const sc = Math.max(0, Number(space.quizSession.players[id].score) || 0);
return s + Math.min(100, (sc / total) * 100);
}, 0) / ids.length;
return evidenceGradeFromPct(avgPct);
}
}
const peers = [...space.peers.values()];
if (!peers.length) return 'C';
const raw = peers.map((p) => evidencePlayerRawScore(space, p, gameType));
const max = Math.max(1, ...raw);
const avgPct = raw.reduce((s, v) => s + (v / max) * 100, 0) / raw.length;
return evidenceGradeFromPct(avgPct);
} catch (e) { return 'C'; }
}
/* card object ที่เก็บในแฟ้มหลักฐาน */
function sanitizeStoredEvidenceCard(c) {
if (!c || typeof c !== 'object') return null;
let rar = String(c.rarity || 'common').toLowerCase();
if (EVIDENCE_RARITIES.indexOf(rar) < 0) rar = 'common';
const img = String(c.imageUrl || '').slice(0, 400);
if (!img) return null;
let stars = Math.floor(Number(c.stars));
if (!(stars >= 1 && stars <= 3)) stars = rar === 'legendary' ? 3 : rar === 'rare' ? 2 : 1;
let num = Math.floor(Number(c.num));
if (!(num >= 1)) num = 0;
return { rarity: rar, stars, num, imageUrl: img };
}
function sanitizeEvidenceCases(obj) {
const src = (obj && obj.cases) ? obj.cases : {};
const out = {};
for (let i = 1; i <= EVIDENCE_CASE_COUNT; i++) {
const cid = String(i);
const cse = src[cid] || {};
const suspects = Array.isArray(cse.suspects) ? cse.suspects : [];
const outSus = [];
for (let si = 0; si < 3; si++) {
const s = suspects[si] || {};
let dir = String(s.imageDir || EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0]);
if (EVIDENCE_IMAGE_DIRS.indexOf(dir) < 0) dir = EVIDENCE_IMAGE_DIRS[si] || EVIDENCE_IMAGE_DIRS[0];
outSus.push({
linkName: String(s.linkName || ('ผู้ต้องสงสัย ' + (si + 1))).slice(0, 60),
imageDir: dir,
});
}
out[cid] = { suspects: outSus };
}
return { cases: out };
}
function loadEvidenceCards() {
try {
if (fs.existsSync(EVIDENCE_CARDS_PATH)) {
return sanitizeEvidenceCases(JSON.parse(fs.readFileSync(EVIDENCE_CARDS_PATH, 'utf8')));
}
} catch (e) { /* ignore — คืนค่า default */ }
return sanitizeEvidenceCases({});
}
/** รายการรูปการ์ดที่มีจริงในแต่ละโฟลเดอร์ผู้ต้องสงสัย (ไว้ให้ Admin เลือกรูป) */
function listEvidenceCardImages() {
const out = { 'suspect-1': [], 'suspect-2': [], 'suspect-3': [] };
Object.keys(out).forEach((dir) => {
try {
const p = path.join(__dirname, 'public', 'img', 'evidence-cards', dir);
const files = fs.readdirSync(p)
.filter((f) => /\.(png|jpe?g|webp)$/i.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
out[dir] = files.map((f) => '/Game/img/evidence-cards/' + dir + '/' + f);
} catch (e) { /* โฟลเดอร์อาจไม่มี */ }
});
return out;
}
function saveEvidenceCards(d) {
try {
const clean = sanitizeEvidenceCases(d);
fs.writeFileSync(EVIDENCE_CARDS_PATH, JSON.stringify(clean, null, 2), 'utf8');
return { ok: true, cases: clean.cases };
} catch (e) {
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') err = 'ไม่มีสิทธิ์เขียนไฟล์ evidence-cards.json (chown เป็น user ที่รันเกม)';
else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
function loadQuizSettings() {
try {
if (fs.existsSync(QUIZ_SETTINGS_PATH)) {
const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8'));
const d = defaultQuizSettings();
const questions = Array.isArray(j.questions) ? j.questions : [];
const carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq);
const carryMapPanelTheme = sanitizeCarryMapPanelTheme(j.carryMapPanelTheme);
const quizMapPanelTheme = sanitizeCarryMapPanelTheme(j.quizMapPanelTheme);
const carryEmbedCountdownTheme = sanitizeCarryEmbedCountdownTheme(j.carryEmbedCountdownTheme);
const carryChoicePlaqueThemes = Array.isArray(j.carryChoicePlaqueThemes) && j.carryChoicePlaqueThemes.length
? sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueThemes)
: sanitizeCarryChoicePlaqueThemes(j.carryChoicePlaqueTheme);
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
const carryChoicePlaqueMapScale = clampCarryChoicePlaqueMapScale(j.carryChoicePlaqueMapScale, d.carryChoicePlaqueMapScale);
const carryWalkSpeedMultForMapId = sanitizeCarryWalkSpeedMultForMapId(j.carryWalkSpeedMultForMapId);
let carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(j.carryWalkSpeedMult);
if (!carryWalkSpeedMultForMapId) carryWalkSpeedMult = null;
else if (carryWalkSpeedMult == null) carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42);
const out = {
readMs: clampQuizMs(j.readMs, d.readMs),
answerMs: clampQuizMs(j.answerMs, d.answerMs),
betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs),
carryReadMs: clampQuizBetweenMs(j.carryReadMs, d.carryReadMs),
carryAnswerMs: clampQuizMs(j.carryAnswerMs, d.carryAnswerMs),
carrySessionLength: clampCarrySessionLength(j.carrySessionLength, d.carrySessionLength),
carryMapPanelTheme,
quizMapPanelTheme,
carryEmbedCountdownTheme,
carryChoicePlaqueThemes,
carryChoicePlaqueTheme,
carryChoicePlaqueMapScale,
carryWalkSpeedMultForMapId,
carryWalkSpeedMult,
quizRoundQuestionCount: clampQuizRoundQuestionCount(j.quizRoundQuestionCount, d.quizRoundQuestionCount),
questions,
carryQuestions,
battleQuizMcq,
specialQuizSets: sanitizeSpecialQuizSets(j.specialQuizSets),
};
return mergeQuizCarryFromMirrorIfSet(out);
}
} catch (e) { console.error('loadQuizSettings', e.message); }
return mergeQuizCarryFromMirrorIfSet(defaultQuizSettings());
}
const GAUNTLET_DEFAULT_TICK_MS = 220;
const GAUNTLET_DEFAULT_JUMP_TICKS = 16;
const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0;
/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */
const STACK_SWING_HZ_DEFAULT = 0.55;
function defaultGameTiming() {
return {
gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS,
gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS,
gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
...defaultGauntletVisuals(),
stackSwingHz: STACK_SWING_HZ_DEFAULT,
stackBlockWidthTiles: null,
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */
jumpSurviveJumpHeightMult: 1.5,
/** จำกัดเวลารอบภารกิจ/มินิเกมกระโดดขึ้นแท่น (วินาที) — 0 = ไคลเอนต์ใช้ 60 วิ; ไม่ใช่ค่าเดียวกับพรมแดง */
jumpSurviveMissionTimeSec: 0,
/** รูปแพลตฟอร์ม jump_survive ช่อง 1–3 — ว่าง = ไคลเอนต์วาด cyan; w/h 0 = ใช้ขนาดไทล์ */
jumpSurvivePlatformTiles: [
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
{ url: '', w: 0, h: 0 },
],
/** Stack ภารกิจ Tower (mnn93hpi): จำกัดเวลารอบ (วินาที) — ไคลเอนต์ใช้เมื่อเล่นภารกิจ */
stackTowerMissionTimeSec: 90,
/** Stack ภารกิจ Tower: ทีมพลาดได้กี่ครั้งก่อนจบ (ไม่รีเซ็ตหอ) */
stackTeamMissesMax: 3,
/** Stack ภารกิจ Tower: จำนวนชั้นสำเร็จ (ไม่คิดคอมโบ) ที่รวมแล้วได้ Progress 100% — แต่ละชั้น +100/N %; คอมโบ ×2 ของชั้นนั้น */
stackTowerProgressBlocks: 50,
stackBlockNormalImageUrls: ['', '', '', '', '', ''],
stackBlockHeavyImageUrls: ['', '', '', '', '', ''],
/** โอกาสใช้รูป “ใหญ่” ต่อการปล่อยหนึ่งครั้ง (0–100) — ถ้าไม่มี URL ใหญ่ช่องนั้น = ใช้ปกติ */
stackHeavyBlockPercent: 35,
/** ยิงยานอวกาศ / space_shooter: จำกัดเวลารอบ (วินาที) — 0 = ไคลเอนต์ใช้ค่าเริ่ม 90; แมป spaceShooterTimeSec > 0 ทับ */
spaceShooterMissionTimeSec: 0,
spaceShooterShipImageUrls: ['', '', '', '', '', ''],
spaceShooterAsteroidSpriteUrls: [],
spaceShooterAsteroidExplodeFrameMs: 70,
/** ระยะห่างเกิดอุกาบาต (ms) — ค่าน้อย = ถี่ขึ้น; แมป spaceShooterAsteroidIntervalMs ≥200 ทับ */
spaceShooterAsteroidIntervalMs: 1040,
spaceShooterShipDamageOverlayUrls: ['', '', ''],
/** balloon_boss / Mega Virus — 0 = ไคลเอนต์ใช้ 120 วิเมื่อแมปไม่ตั้ง balloonBossTimeSec */
balloonBossMissionTimeSec: 0,
balloonBossBossImageUrl: '',
balloonBossPlayerBalloonImageUrls: ['', '', '', '', '', ''],
/** กรอบฟองรอบผู้เล่น / ลูกโป่ง fallback เริ่มต้น — Artboard 9 (วง cyan +หาง) */
balloonBossPlayerBalloonFallbackUrl: '/Game/img/MegaVirus/Artboard 9.png',
};
}
function clampStackSwingHz(n) {
const v = Number(n);
if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT;
return Math.max(0.08, Math.min(2.8, v));
}
/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */
function clampStackBlockWidthTiles(n) {
if (n === null || n === undefined || n === '') return null;
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return null;
return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100;
}
function clampStackTowerMissionTimeSec(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return 90;
return Math.max(10, Math.min(7200, Math.floor(v)));
}
function clampStackTeamMissesMax(n) {
const v = Number(n);
if (Number.isNaN(v)) return 3;
return Math.max(1, Math.min(20, Math.floor(v)));
}
function clampStackTowerProgressBlocks(n) {
const v = Number(n);
if (Number.isNaN(v)) return 50;
return Math.max(1, Math.min(500, Math.floor(v)));
}
/** Stack: รูปบล็อกต่อที่นั่ง 1–6 (ปกติ / ใหญ่) — ว่าง = ไคลเอนต์วาดสีเดิม */
function normalizeStackBlockSixImageUrls(val) {
let arr = val;
if (typeof arr === 'string') {
try {
const p = JSON.parse(arr);
if (Array.isArray(p)) arr = p;
} catch (e) {
arr = arr.split(/[\n\r]+/);
}
}
if (!Array.isArray(arr)) arr = [];
const out = [];
for (let i = 0; i < 6; i++) {
out.push(sanitizeGauntletAssetUrl(arr[i]));
}
return out;
}
function clampStackHeavyBlockPercent(n) {
const v = Number(n);
if (Number.isNaN(v)) return 35;
return Math.max(0, Math.min(100, Math.floor(v)));
}
function clampJumpSurviveJumpHeightMult(n) {
const v = Number(n);
if (Number.isNaN(v)) return 1.5;
return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100;
}
/** กระโดดขึ้นแท่น / ภารกิจ Jumper: จำกัดเวลารอบ (วินาที) — 0 = ไม่ตั้งในไฟล์ ให้ไคลเอนต์ใช้ค่าเริ่ม 60; ค่าอื่น = 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)
: '',
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
return defaultGameTiming();
}
let runtimeGameTiming = loadGameTiming();
function getGauntletTickMs() {
return runtimeGameTiming.gauntletTickMs;
}
function getGauntletJumpTicks() {
return runtimeGameTiming.gauntletJumpTicks;
}
function getGauntletTimeLimitSec() {
return runtimeGameTiming.gauntletTimeLimitSec || 0;
}
function saveGameTimingToFile(d) {
try {
const dir = path.dirname(GAME_TIMING_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const prev = { ...runtimeGameTiming };
const vis = normalizeGauntletVisualsFromRequest(d);
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
? clampStackSwingHz(d.stackSwingHz)
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles')
? clampStackBlockWidthTiles(d.stackBlockWidthTiles)
: (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles')
? prev.stackBlockWidthTiles
: null);
const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult')
? prev.jumpSurviveJumpHeightMult
: 1.5;
const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult')
? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult)
: clampJumpSurviveJumpHeightMult(prevMult);
const prevJumpMissionSec = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveMissionTimeSec')
? prev.jumpSurviveMissionTimeSec
: 0;
const jumpMissionSec = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveMissionTimeSec')
? clampJumpSurviveMissionTimeSec(d.jumpSurviveMissionTimeSec)
: clampJumpSurviveMissionTimeSec(prevJumpMissionSec);
const prevJumpTiles = normalizeJumpSurvivePlatformTilesFromTiming(
prev.jumpSurvivePlatformTiles,
prev.jumpSurvivePlatformTileUrl,
);
let jumpSurvivePlatformTiles;
if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTiles')) {
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
d.jumpSurvivePlatformTiles,
d.jumpSurvivePlatformTileUrl,
);
} else if (Object.prototype.hasOwnProperty.call(d, 'jumpSurvivePlatformTileUrl')) {
jumpSurvivePlatformTiles = normalizeJumpSurvivePlatformTilesFromTiming(
[
{ url: d.jumpSurvivePlatformTileUrl, w: 0, h: 0 },
prevJumpTiles[1],
prevJumpTiles[2],
],
'',
);
} else {
jumpSurvivePlatformTiles = prevJumpTiles;
}
const prevSpaceShooterMissionSec = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterMissionTimeSec')
? prev.spaceShooterMissionTimeSec
: 0;
const spaceShooterMissionSec = Object.prototype.hasOwnProperty.call(d, 'spaceShooterMissionTimeSec')
? clampSpaceShooterMissionTimeSec(d.spaceShooterMissionTimeSec)
: clampSpaceShooterMissionTimeSec(prevSpaceShooterMissionSec);
const prevShipUrls = Array.isArray(prev.spaceShooterShipImageUrls)
? normalizeSpaceShooterShipImageUrls(prev.spaceShooterShipImageUrls)
: normalizeSpaceShooterShipImageUrls([]);
const spaceShooterShipImageUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipImageUrls')
? normalizeSpaceShooterShipImageUrls(d.spaceShooterShipImageUrls)
: prevShipUrls;
const prevAstUrls = Array.isArray(prev.spaceShooterAsteroidSpriteUrls)
? normalizeSpaceShooterAsteroidSpriteUrls(prev.spaceShooterAsteroidSpriteUrls)
: [];
const spaceShooterAsteroidSpriteUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidSpriteUrls')
? normalizeSpaceShooterAsteroidSpriteUrls(d.spaceShooterAsteroidSpriteUrls)
: prevAstUrls;
const prevAstFms = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidExplodeFrameMs')
? prev.spaceShooterAsteroidExplodeFrameMs
: 70;
const spaceShooterAsteroidExplodeFrameMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidExplodeFrameMs')
? clampSpaceShooterAsteroidExplodeFrameMs(d.spaceShooterAsteroidExplodeFrameMs)
: clampSpaceShooterAsteroidExplodeFrameMs(prevAstFms);
const prevAstIv = Object.prototype.hasOwnProperty.call(prev, 'spaceShooterAsteroidIntervalMs')
? prev.spaceShooterAsteroidIntervalMs
: SPACE_SHOOTER_ASTEROID_INTERVAL_MS_DEFAULT;
const spaceShooterAsteroidIntervalMs = Object.prototype.hasOwnProperty.call(d, 'spaceShooterAsteroidIntervalMs')
? clampSpaceShooterAsteroidIntervalMs(d.spaceShooterAsteroidIntervalMs)
: clampSpaceShooterAsteroidIntervalMs(prevAstIv);
const prevDmg = Array.isArray(prev.spaceShooterShipDamageOverlayUrls)
? normalizeSpaceShooterShipDamageOverlayUrls(prev.spaceShooterShipDamageOverlayUrls)
: normalizeSpaceShooterShipDamageOverlayUrls([]);
const spaceShooterShipDamageOverlayUrls = Object.prototype.hasOwnProperty.call(d, 'spaceShooterShipDamageOverlayUrls')
? normalizeSpaceShooterShipDamageOverlayUrls(d.spaceShooterShipDamageOverlayUrls)
: prevDmg;
const prevBbMission = Object.prototype.hasOwnProperty.call(prev, 'balloonBossMissionTimeSec')
? prev.balloonBossMissionTimeSec
: 0;
const balloonBossMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'balloonBossMissionTimeSec')
? clampSpaceShooterMissionTimeSec(d.balloonBossMissionTimeSec)
: clampSpaceShooterMissionTimeSec(prevBbMission);
const prevBbBoss = Object.prototype.hasOwnProperty.call(prev, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossBossImageUrl)
: '';
const balloonBossBossImageUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossBossImageUrl')
? sanitizeGauntletAssetUrl(d.balloonBossBossImageUrl)
: prevBbBoss;
const prevBbBalloons = Array.isArray(prev.balloonBossPlayerBalloonImageUrls)
? normalizeStackBlockSixImageUrls(prev.balloonBossPlayerBalloonImageUrls)
: normalizeStackBlockSixImageUrls([]);
const balloonBossPlayerBalloonImageUrls = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonImageUrls')
? normalizeStackBlockSixImageUrls(d.balloonBossPlayerBalloonImageUrls)
: prevBbBalloons;
const prevBbFb = Object.prototype.hasOwnProperty.call(prev, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(prev.balloonBossPlayerBalloonFallbackUrl)
: '';
const balloonBossPlayerBalloonFallbackUrl = Object.prototype.hasOwnProperty.call(d, 'balloonBossPlayerBalloonFallbackUrl')
? sanitizeGauntletAssetUrl(d.balloonBossPlayerBalloonFallbackUrl)
: prevBbFb;
const prevStackTowerSec = Object.prototype.hasOwnProperty.call(prev, 'stackTowerMissionTimeSec')
? prev.stackTowerMissionTimeSec
: 90;
const stackTowerMissionTimeSec = Object.prototype.hasOwnProperty.call(d, 'stackTowerMissionTimeSec')
? clampStackTowerMissionTimeSec(d.stackTowerMissionTimeSec)
: clampStackTowerMissionTimeSec(prevStackTowerSec);
const prevStackMisses = Object.prototype.hasOwnProperty.call(prev, 'stackTeamMissesMax')
? prev.stackTeamMissesMax
: 3;
const stackTeamMissesMax = Object.prototype.hasOwnProperty.call(d, 'stackTeamMissesMax')
? clampStackTeamMissesMax(d.stackTeamMissesMax)
: clampStackTeamMissesMax(prevStackMisses);
const prevStackProgBlocks = Object.prototype.hasOwnProperty.call(prev, 'stackTowerProgressBlocks')
? prev.stackTowerProgressBlocks
: 50;
const stackTowerProgressBlocks = Object.prototype.hasOwnProperty.call(d, 'stackTowerProgressBlocks')
? clampStackTowerProgressBlocks(d.stackTowerProgressBlocks)
: clampStackTowerProgressBlocks(prevStackProgBlocks);
const prevStackNorm = Array.isArray(prev.stackBlockNormalImageUrls)
? normalizeStackBlockSixImageUrls(prev.stackBlockNormalImageUrls)
: normalizeStackBlockSixImageUrls([]);
const prevStackHeavy = Array.isArray(prev.stackBlockHeavyImageUrls)
? normalizeStackBlockSixImageUrls(prev.stackBlockHeavyImageUrls)
: normalizeStackBlockSixImageUrls([]);
const stackBlockNormalImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockNormalImageUrls')
? normalizeStackBlockSixImageUrls(d.stackBlockNormalImageUrls)
: prevStackNorm;
const stackBlockHeavyImageUrls = Object.prototype.hasOwnProperty.call(d, 'stackBlockHeavyImageUrls')
? normalizeStackBlockSixImageUrls(d.stackBlockHeavyImageUrls)
: prevStackHeavy;
const prevHeavyPct = Object.prototype.hasOwnProperty.call(prev, 'stackHeavyBlockPercent')
? clampStackHeavyBlockPercent(prev.stackHeavyBlockPercent)
: 35;
const stackHeavyBlockPercent = Object.prototype.hasOwnProperty.call(d, 'stackHeavyBlockPercent')
? clampStackHeavyBlockPercent(d.stackHeavyBlockPercent)
: prevHeavyPct;
const out = {
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
...vis,
stackSwingHz: stackHz,
stackBlockWidthTiles: stackBlockW,
stackTowerMissionTimeSec,
stackTeamMissesMax,
stackTowerProgressBlocks,
stackBlockNormalImageUrls,
stackBlockHeavyImageUrls,
stackHeavyBlockPercent,
jumpSurviveJumpHeightMult: jumpMult,
jumpSurviveMissionTimeSec: jumpMissionSec,
jumpSurvivePlatformTiles,
spaceShooterMissionTimeSec: spaceShooterMissionSec,
spaceShooterShipImageUrls,
spaceShooterAsteroidSpriteUrls,
spaceShooterAsteroidExplodeFrameMs,
spaceShooterAsteroidIntervalMs,
spaceShooterShipDamageOverlayUrls,
balloonBossMissionTimeSec,
balloonBossBossImageUrl,
balloonBossPlayerBalloonImageUrls,
balloonBossPlayerBalloonFallbackUrl,
};
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
runtimeGameTiming = out;
rescheduleAllGauntletTickers();
return { ok: true, ...out };
} catch (e) {
console.error('saveGameTimingToFile', e.message);
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') {
err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
function migrateGauntletAssetsFromLegacyIfNeeded() {
try {
if (!fs.existsSync(GAUNTLET_ASSETS_DIR_LEGACY)) return;
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
const names = fs.readdirSync(GAUNTLET_ASSETS_DIR_LEGACY);
names.forEach((f) => {
if (!safeGauntletStoredFilename(f)) return;
const src = path.join(GAUNTLET_ASSETS_DIR_LEGACY, f);
const dest = path.join(GAUNTLET_ASSETS_DIR, f);
if (fs.existsSync(dest)) return;
try {
fs.copyFileSync(src, dest);
console.log('[gauntlet-assets] migrated', f, 'from data/ to public/img/gauntlet-assets/');
} catch (e) {
console.error('[gauntlet-assets] migrate failed', f, e && e.message);
}
});
} catch (e) {
console.error('migrateGauntletAssetsFromLegacyIfNeeded', e && e.message);
}
}
function ensureGauntletAssetsDir() {
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
migrateGauntletAssetsFromLegacyIfNeeded();
}
function loadGauntletAssetsMeta() {
try {
if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) {
const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8'));
return j && typeof j === 'object' ? j : {};
}
} catch (e) { console.error('loadGauntletAssetsMeta', e.message); }
return {};
}
function saveGauntletAssetsMeta(meta) {
const dir = path.dirname(GAUNTLET_ASSETS_META_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8');
}
function safeGauntletStoredFilename(name) {
const base = path.basename(String(name || ''));
if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
return base;
}
function safeQuizCarryPlaqueStoredFilename(name) {
const base = path.basename(String(name || ''));
if (!/^qcarry-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
return base;
}
function ensureQuizCarryPlaqueAssetsDir() {
if (!fs.existsSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR)) fs.mkdirSync(QUIZ_CARRY_PLAQUE_ASSETS_DIR, { recursive: true });
}
function gauntletAssetMimeByExt(ext) {
const e = String(ext || '').toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.gif') return 'image/gif';
if (e === '.webp') return 'image/webp';
return 'application/octet-stream';
}
function listGauntletAssetsApi() {
ensureGauntletAssetsDir();
const meta = loadGauntletAssetsMeta();
let files = [];
try {
files = fs.readdirSync(GAUNTLET_ASSETS_DIR);
} catch (e) {
return [];
}
return files
.filter((f) => safeGauntletStoredFilename(f))
.map((f) => {
const fp = path.join(GAUNTLET_ASSETS_DIR, f);
let st;
try {
st = fs.statSync(fp);
} catch (e) {
return null;
}
const entry = meta[f];
const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : '';
return {
filename: f,
url: BASE_PATH + '/img/gauntlet-assets/' + f,
label: label || f,
bytes: st.size,
mtime: st.mtimeMs,
};
})
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime);
}
function saveQuizSettings(d) {
try {
const prev = loadQuizSettings();
const dir = path.dirname(QUIZ_SETTINGS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs;
const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs;
const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs;
const carryReadMs = d.carryReadMs != null ? clampQuizBetweenMs(d.carryReadMs, prev.carryReadMs) : prev.carryReadMs;
const carryAnswerMs = d.carryAnswerMs != null ? clampQuizMs(d.carryAnswerMs, prev.carryAnswerMs) : prev.carryAnswerMs;
const carrySessionLength = d.carrySessionLength != null
? clampCarrySessionLength(d.carrySessionLength, prev.carrySessionLength)
: prev.carrySessionLength;
const questions = Array.isArray(d.questions)
? (d.questions || [])
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }))
: prev.questions;
const carryQuestions = Array.isArray(d.carryQuestions)
? sanitizeCarryQuestions(d.carryQuestions)
: prev.carryQuestions;
const battleQuizMcq = Array.isArray(d.battleQuizMcq)
? sanitizeBattleQuizMcq(d.battleQuizMcq)
: (prev.battleQuizMcq || []);
const carryMapPanelTheme = d.carryMapPanelTheme != null && typeof d.carryMapPanelTheme === 'object'
? sanitizeCarryMapPanelTheme(d.carryMapPanelTheme)
: prev.carryMapPanelTheme;
const quizMapPanelTheme = d.quizMapPanelTheme != null && typeof d.quizMapPanelTheme === 'object'
? sanitizeCarryMapPanelTheme(d.quizMapPanelTheme)
: prev.quizMapPanelTheme;
const carryEmbedCountdownTheme = d.carryEmbedCountdownTheme != null && typeof d.carryEmbedCountdownTheme === 'object'
? sanitizeCarryEmbedCountdownTheme(d.carryEmbedCountdownTheme)
: prev.carryEmbedCountdownTheme;
let carryChoicePlaqueThemes = prev.carryChoicePlaqueThemes;
if (Array.isArray(d.carryChoicePlaqueThemes) && d.carryChoicePlaqueThemes.length) {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueThemes);
} else if (d.carryChoicePlaqueTheme != null && typeof d.carryChoicePlaqueTheme === 'object') {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(d.carryChoicePlaqueTheme);
}
if (!Array.isArray(carryChoicePlaqueThemes) || !carryChoicePlaqueThemes.length) {
carryChoicePlaqueThemes = sanitizeCarryChoicePlaqueThemes(prev.carryChoicePlaqueTheme);
}
const carryChoicePlaqueTheme = carryChoicePlaqueThemes[0];
const prevScale = prev.carryChoicePlaqueMapScale != null
? clampCarryChoicePlaqueMapScale(prev.carryChoicePlaqueMapScale, 1.25)
: 1.25;
const carryChoicePlaqueMapScale = d.carryChoicePlaqueMapScale != null
? clampCarryChoicePlaqueMapScale(d.carryChoicePlaqueMapScale, prevScale)
: prevScale;
const prevWalkId = sanitizeCarryWalkSpeedMultForMapId(prev.carryWalkSpeedMultForMapId);
const prevWalkMult = prev.carryWalkSpeedMult != null ? clampCarryWalkSpeedMultSetting(prev.carryWalkSpeedMult) : null;
let carryWalkSpeedMultForMapId = d.carryWalkSpeedMultForMapId !== undefined
? sanitizeCarryWalkSpeedMultForMapId(d.carryWalkSpeedMultForMapId)
: prevWalkId;
let carryWalkSpeedMult = d.carryWalkSpeedMult !== undefined ? clampCarryWalkSpeedMultSetting(d.carryWalkSpeedMult) : prevWalkMult;
if (!carryWalkSpeedMultForMapId) {
carryWalkSpeedMult = null;
} else if (carryWalkSpeedMult == null) {
carryWalkSpeedMult = clampCarryWalkSpeedMultSetting(1.42);
}
const quizRoundQuestionCount = d.quizRoundQuestionCount != null
? clampQuizRoundQuestionCount(d.quizRoundQuestionCount, prev.quizRoundQuestionCount)
: prev.quizRoundQuestionCount;
const specialQuizSets = d.specialQuizSets != null
? sanitizeSpecialQuizSets(d.specialQuizSets)
: sanitizeSpecialQuizSets(prev.specialQuizSets);
const out = {
readMs,
answerMs,
betweenMs,
carryReadMs,
carryAnswerMs,
carrySessionLength,
carryMapPanelTheme,
quizMapPanelTheme,
carryEmbedCountdownTheme,
carryChoicePlaqueThemes,
carryChoicePlaqueTheme,
carryChoicePlaqueMapScale,
carryWalkSpeedMultForMapId,
carryWalkSpeedMult,
quizRoundQuestionCount,
questions,
carryQuestions,
battleQuizMcq,
specialQuizSets,
};
fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8');
return {
ok: true,
battleQuizMcqSaved: (out.battleQuizMcq || []).length,
carryQuestionsSaved: (out.carryQuestions || []).length,
specialQuizSets: out.specialQuizSets,
carryMapPanelTheme: out.carryMapPanelTheme,
quizMapPanelTheme: out.quizMapPanelTheme,
carryEmbedCountdownTheme: out.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: out.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: out.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: out.carryChoicePlaqueMapScale,
};
} catch (e) {
console.error('saveQuizSettings', e.message);
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') {
err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
function getQuizQuestionPool(md) {
const g = loadQuizSettings();
const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim());
if (globalQ.length) return globalQ;
return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim());
}
function quizCellOn(grid, tx, ty) {
return !!(grid && grid[ty] && grid[ty][tx] === 1);
}
function getCharacterFootprintWHForMove(md) {
let cw = Math.floor(Number(md.characterCellsW));
let ch = Math.floor(Number(md.characterCellsH));
const hasW = Number.isFinite(cw) && cw >= 1;
const hasH = Number.isFinite(ch) && ch >= 1;
if (hasW && hasH) {
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
}
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
return { cw: leg, ch: leg };
}
function getCharacterCollisionFootprintWHForMove(md) {
const { cw, ch } = getCharacterFootprintWHForMove(md);
let colW = Math.floor(Number(md.characterCollisionW));
let colH = Math.floor(Number(md.characterCollisionH));
if (!Number.isFinite(colW) || colW < 1) colW = cw;
if (!Number.isFinite(colH) || colH < 1) colH = ch;
colW = Math.max(1, Math.min(cw, colW));
colH = Math.max(1, Math.min(ch, colH));
return { cw, ch, colW, colH };
}
function serverWallCollisionTileKeys(md, px, py) {
const w = md.width || 20;
const h = md.height || 15;
const { cw, ch, colW, colH } = getCharacterCollisionFootprintWHForMove(md);
const minTx = Math.floor(Number(px));
const minTy = Math.floor(Number(py));
const offX = minTx + Math.floor((cw - colW) / 2);
const offY = minTy + (ch - colH);
const out = [];
for (let ty = offY; ty < offY + colH; ty++) {
for (let tx = offX; tx < offX + colW; tx++) {
if (tx >= 0 && ty >= 0 && tx < w && ty < h) out.push(`${tx},${ty}`);
}
}
return out;
}
function quizCarryFootprintTileKeys(md, px, py) {
const w = md.width || 20;
const h = md.height || 15;
const { cw, ch } = getCharacterFootprintWHForMove(md);
const minTx = Math.floor(Number(px));
const minTy = Math.floor(Number(py));
const maxTx = Math.min(w - 1, minTx + cw - 1);
const maxTy = Math.min(h - 1, minTy + ch - 1);
const out = [];
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`);
}
}
return out;
}
function quizCarryFootprintOverlapsHubServer(md, px, py) {
if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false;
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true;
}
return false;
}
/** Nearest walkable tile center not in true/false answer zones (BFS). */
function findNearestOutsideQuizAnswerZones(md, sx, sy) {
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y);
const walkable = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return row && row[x] !== 1;
};
const fx = Math.floor(Number(sx));
const fy = Math.floor(Number(sy));
if (!walkable(fx, fy)) {
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
const q = [[fx, fy]];
const seen = new Set([`${fx},${fy}`]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = `${nx},${ny}`;
if (seen.has(k)) continue;
if (!walkable(nx, ny)) continue;
seen.add(k);
if (!inAnswer(nx, ny)) {
return { x: nx + 0.5, y: ny + 0.5 };
}
q.push([nx, ny]);
}
}
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
function validateQuizMap(md) {
const pool = getQuizQuestionPool(md);
if (pool.length < 1) {
return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)';
}
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
let anyT = false;
let anyF = false;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (quizCellOn(tGrid, x, y)) anyT = true;
if (quizCellOn(fGrid, x, y)) anyF = true;
}
}
if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1';
return null;
}
function clearSpaceQuizTimers(space) {
if (!space.quizTimers || !Array.isArray(space.quizTimers)) {
space.quizTimers = [];
return;
}
space.quizTimers.forEach((t) => clearTimeout(t));
space.quizTimers = [];
}
/** หยุดรอบ quiz (mng8a80o) ชั่วคราวระหว่างตอบคำถามพิเศษ — เก็บเวลาที่เหลือไว้ */
function pauseQuizForSpecialQuiz(sid, space) {
const sess = space.quizSession;
if (!sess || !sess.active || space.quizFrozenForSpecial) return;
const phase = sess.phase;
if (phase !== 'read' && phase !== 'answer') return;
const remaining = Math.max(0, (Number(sess.phaseEndsAt) || 0) - Date.now());
space.quizFrozenForSpecial = true;
space.quizFreezePhase = phase;
space.quizFreezeRemainingMs = remaining;
clearSpaceQuizTimers(space);
io.to(sid).emit('quiz-paused', { phase });
}
/** เล่นรอบ quiz ต่อหลังตอบคำถามพิเศษเสร็จ — ตั้ง timer ใหม่ด้วยเวลาที่เหลือ */
function resumeQuizAfterSpecialQuiz(sid, space) {
if (!space.quizFrozenForSpecial) return;
space.quizFrozenForSpecial = false;
const sess = space.quizSession;
const phase = space.quizFreezePhase;
const remaining = Math.max(1200, Number(space.quizFreezeRemainingMs) || 0);
space.quizFreezePhase = null;
space.quizFreezeRemainingMs = 0;
if (!sess || !sess.active) return;
const md = sess.quizMapMd || getLobbyLayoutMapForSpace(space);
if (!md) return;
/* Time Dilation: +N วิให้รอบ quiz (mng8a80o) ตอน resume */
let extraMs = 0;
if (space.timeDilationPendingSec > 0) {
extraMs = space.timeDilationPendingSec * 1000;
space.timeDilationPendingSec = 0;
}
sess.phaseEndsAt = Date.now() + remaining + extraMs;
if (phase === 'read') {
sess.phase = 'read';
const q = sess.questions[sess.qIndex];
io.to(sid).emit('quiz-phase', {
phase: 'read',
questionIndex: sess.qIndex + 1,
questionTotal: sess.questions.length,
text: String((q && q.text) || '').trim(),
endsAt: sess.phaseEndsAt,
resumed: true,
});
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), remaining);
space.quizTimers.push(t);
} else {
sess.phase = 'answer';
io.to(sid).emit('quiz-phase', {
phase: 'answer',
questionIndex: sess.qIndex + 1,
questionTotal: sess.questions.length,
endsAt: sess.phaseEndsAt,
resumed: true,
});
const t = setTimeout(() => resolveQuizRound(sid, space, md), remaining);
space.quizTimers.push(t);
}
}
/** เกมถูก/ผิด: คะแนนต่อข้อที่ตอบถูก (ตรงกับ client + เอฟเฟกต์ score+.png) */
const QUIZ_TF_POINTS_PER_CORRECT = 10;
function shuffleQuizQuestions(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function ensurePeerLobbyThemes(space) {
if (!space || !space.peers) return;
const seen = new Set();
space.peers.forEach((p, id) => {
let ti = parseInt(p.lobbyColorThemeIndex, 10);
if (!(ti >= 1 && ti <= LOBBY_THEME_COUNT) || seen.has(ti)) {
ti = pickFreeLobbyThemeIndex(space, id);
p.lobbyColorThemeIndex = ti;
}
seen.add(ti);
if (!parseInt(p.lobbySkinToneIndex, 10) || p.lobbySkinToneIndex < 1 || p.lobbySkinToneIndex > LOBBY_SKIN_COUNT) {
p.lobbySkinToneIndex = pickLobbySkinIndexForSlot(p.spawnJoinOrder || 0);
}
});
}
function buildPeersSnapForSpace(space, extraFields) {
return [...space.peers.values()].map((p) => {
const row = {
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null,
lobbyColorThemeIndex: p.lobbyColorThemeIndex || null,
lobbySkinToneIndex: p.lobbySkinToneIndex || null,
};
if (extraFields && extraFields.gauntlet) {
row.gauntletJumpTicks = p.gauntletJumpTicks || 0;
row.gauntletScore = p.gauntletScore || 0;
row.gauntletEliminated = !!p.gauntletEliminated;
}
return row;
});
}
/** สุ่ม 3 minigame จาก pool 7 แบบ — เรียกครั้งเดียวตอนเข้า LobbyB (ล็อกบน space) */
/** แมปเก่า (กบ frogger) → Minigame-2 Gauntlet ตาม Admin */
function normalizeDetectiveCardEntry(entry) {
if (!entry) return entry;
const mid = entry.mapId != null ? String(entry.mapId).trim() : '';
if (mid === 'mlpecp2j' || entry.key === 'frogger') {
const gaunt = DETECTIVE_MINIGAME_POOL.find((e) => e.key === 'gauntlet');
if (gaunt && maps.has(gaunt.mapId)) {
const md = maps.get(gaunt.mapId);
return {
...entry,
key: gaunt.key,
mapId: gaunt.mapId,
labelTh: gaunt.labelTh,
mapName: (md && md.name) || gaunt.mapId,
gameType: (md && md.gameType) || 'gauntlet',
};
}
}
return entry;
}
function assignDetectiveSuspectCardMinigames(space) {
const available = DETECTIVE_MINIGAME_POOL.filter((e) => maps.has(e.mapId));
const shuffled = shuffleQuizQuestions(available.length ? available : [{ key: 'quiz', mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID, labelTh: 'QuestionGame' }]);
space.suspectCardMinigames = shuffled.slice(0, 3).map((entry, i) => {
const md = maps.get(entry.mapId);
return {
cardIndex: i,
key: entry.key,
mapId: entry.mapId,
labelTh: entry.labelTh,
mapName: (md && md.name) || entry.mapId,
gameType: (md && md.gameType) || '',
};
});
// ชุดการ์ดใหม่ = ล้างหลักฐานที่ผู้เล่นเก็บได้
space.playerEvidence = {};
// สุ่มคนร้ายตัวจริง 1 ใน 3 ใบ (เก็บฝั่ง server ไม่ส่งให้ client จนกว่าจะเปิดเผย)
space.culpritIndex = Math.floor(Math.random() * 3);
space.trialPhase = null; // null | 'voting' | 'revealed'
space.trialVotes = {}; // socketId -> suspectIndex
space.trialScores = space.trialScores || {};
}
function ensureSpacePlayerEvidence(space) {
if (!space.playerEvidence || typeof space.playerEvidence !== 'object') space.playerEvidence = {};
if (!space.evidenceByNick || typeof space.evidenceByNick !== 'object') space.evidenceByNick = {};
}
/* แถวหลักฐานต่อผู้ต้องสงสัย = array ของ card object (ไม่จำกัดจำนวน) — กรอง element ที่ไม่ใช่การ์ดทิ้ง */
function normalizeEvidenceSlotList(cards) {
if (!Array.isArray(cards)) return [];
const out = [];
for (let i = 0; i < cards.length; i++) {
const c = sanitizeStoredEvidenceCard(cards[i]);
if (c) out.push(c);
}
return out;
}
/** ดึง nickname normalized ของผู้เล่นบน space (จากแถว peers) — ใช้คีย์เก็บหลักฐานข้ามการ reconnect */
function peerNickKey(space, playerId) {
if (!space || !playerId) return '';
const p = space.peers && space.peers.get ? space.peers.get(playerId) : null;
if (!p || !p.nickname) return '';
return normalizeLobbyNickname(p.nickname);
}
/** สำเนาหลักฐาน — fallback ไป evidenceByNick ถ้าไม่มีตาม socket.id (กรณี reconnect socket ใหม่) */
function clonePlayerEvidence(space, playerId) {
ensureSpacePlayerEvidence(space);
let row = space.playerEvidence[playerId];
if (!row || !row.length) {
const nk = peerNickKey(space, playerId);
if (nk && space.evidenceByNick[nk]) {
row = space.evidenceByNick[nk];
space.playerEvidence[playerId] = row;
}
}
if (!row) row = [[], [], []];
return [0, 1, 2].map((i) => normalizeEvidenceSlotList(row[i]));
}
/** ซิงค์หลักฐานของผู้เล่นปัจจุบันลง evidenceByNick — เรียกหลังการ์ดเปลี่ยน */
function syncEvidenceByNick(space, playerId) {
ensureSpacePlayerEvidence(space);
const nk = peerNickKey(space, playerId);
if (!nk) return;
const row = space.playerEvidence[playerId];
if (Array.isArray(row)) space.evidenceByNick[nk] = row;
}
/** จำนวนหลักฐานที่เก็บได้ต่อผู้ต้องสงสัย (ไม่จำกัด) — ใช้แสดงติก/จำนวนบนการ์ด suspect */
function playerSuspectProgressFromEvidence(space, playerId) {
const row = clonePlayerEvidence(space, playerId);
return row.map((arr) => arr.length);
}
/** มอบการ์ดหลักฐาน 1 ใบ (สุ่ม rarity ตามเกรด → สุ่มรูปจากพูล) เก็บเป็น card object ไม่จำกัดจำนวน */
function awardRandomEvidenceCardToPlayer(space, playerId, suspectIndex, grade) {
if (suspectIndex < 0 || suspectIndex > 2) return null;
ensureSpacePlayerEvidence(space);
/* เผื่อ rejoin: ดึงหลักฐานเก่าจาก nickname ถ้ามี ก่อนเพิ่มใบใหม่ */
if (!space.playerEvidence[playerId]) {
const nk = peerNickKey(space, playerId);
space.playerEvidence[playerId] = (nk && space.evidenceByNick[nk])
? space.evidenceByNick[nk]
: [[], [], []];
}
const list = normalizeEvidenceSlotList(space.playerEvidence[playerId][suspectIndex]);
const card = pickEvidenceCardForSuspect(space, suspectIndex, grade);
if (!card) {
space.playerEvidence[playerId][suspectIndex] = list;
syncEvidenceByNick(space, playerId);
return null;
}
list.push(card);
space.playerEvidence[playerId][suspectIndex] = list;
syncEvidenceByNick(space, playerId);
return card;
}
function awardDetectiveEvidenceForMinigameEnd(space, suspectIndex, playerIds, grade) {
const out = {};
(playerIds || []).forEach((pid) => {
if (!space.peers.has(pid)) return;
const card = awardRandomEvidenceCardToPlayer(space, pid, suspectIndex, grade);
if (card != null) out[pid] = card;
});
return out;
}
function resolveDetectiveSuspectAwardIndex(space) {
if (typeof space.suspectActiveIndex === 'number' && space.suspectActiveIndex >= 0 && space.suspectActiveIndex <= 2) {
return space.suspectActiveIndex;
}
if (typeof space.lastDetectiveInvestigatedSuspect === 'number'
&& space.lastDetectiveInvestigatedSuspect >= 0 && space.lastDetectiveInvestigatedSuspect <= 2) {
return space.lastDetectiveInvestigatedSuspect;
}
if (typeof space.suspectPickIndex === 'number' && space.suspectPickIndex >= 0 && space.suspectPickIndex <= 2) {
return space.suspectPickIndex;
}
return -1;
}
/** มอบการ์ดหลักฐาน 1 ใบต่อผู้เล่นเมื่อจบมินิเกม (ครั้งเดียวต่อรอบ — ไม่ขึ้นเกรด) */
function grantDetectiveEvidenceForCurrentRun(sid, space, playerIds) {
const suspectIdx = resolveDetectiveSuspectAwardIndex(space);
if (suspectIdx < 0) return { suspectIdx: -1, awarded: {} };
if (space.detectiveAwardDoneForRun) {
return { suspectIdx, awarded: {}, alreadyGranted: true };
}
const ids = (playerIds && playerIds.length) ? playerIds : [...space.peers.keys()];
const grade = computeRunGradeForEvidence(space);
const awardedList = {};
ids.forEach((pid) => { awardedList[pid] = []; });
const awarded = awardDetectiveEvidenceForMinigameEnd(space, suspectIdx, ids, grade);
ids.forEach((pid) => { if (awarded[pid]) awardedList[pid].push(awarded[pid]); });
/* Card 7 Free Evidence — ทุกคนรับหลักฐานเพิ่ม (after_game) */
let freeEvidenceCount = 0;
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 7 && !pend.consumed) {
const def = specialCardDef(7);
freeEvidenceCount = (def && def.evidence) || 2;
for (let n = 0; n < freeEvidenceCount; n++) {
ids.forEach((pid) => {
if (!space.peers.has(pid)) return;
const extra = awardRandomEvidenceCardToPlayer(space, pid, suspectIdx, grade);
if (extra != null) { awardedList[pid].push(extra); if (awarded[pid] == null) awarded[pid] = extra; }
});
}
pend.consumed = true;
}
space.detectiveAwardDoneForRun = true;
space.lastDetectiveInvestigatedSuspect = suspectIdx;
space.lastEvidenceGrant = { suspectIdx, awarded, awardedList, grade, freeEvidenceCount };
emitLobbyEvidenceSync(sid, space);
return { suspectIdx, awarded, awardedList, grade, alreadyGranted: false, freeEvidenceCount };
}
function emitLobbyEvidenceSync(sid, space) {
if (!sid || !space) return;
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('lobby-evidence-sync', {
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
}
function emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload) {
const msg = message || 'จบมินิเกม — กลับ LobbyB';
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
const personal = {
...basePayload,
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
};
sock.emit('quiz-ended', { message: msg, returnToLobbyB: true });
sock.emit('detective-minigame-ended', personal);
sock.emit('game-start', personal);
});
}
/** เริ่มมินิเกมตามการ์ดที่เลือก (หลังกดเริ่มสืบสวน) */
function beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex) {
const mapId = cardEntry && cardEntry.mapId ? String(cardEntry.mapId).trim() : '';
const md = mapId && maps.has(mapId) ? maps.get(mapId) : null;
if (!md) return { ok: false, error: 'ไม่พบฉากเกมบนเซิร์ฟเวอร์' };
space.suspectPhaseActive = false;
space.detectiveMinigameActive = true;
space.detectiveAwardDoneForRun = false;
/* Card 3 Ban — ผู้ที่ถูกแบนไม่ได้เล่นมินิเกมรอบนี้ (1 รอบ) */
space.bannedThisRunPlayerId = space.bannedPlayerId || null;
space.bannedPlayerId = null;
space.detectiveMinigameCardIndex = cardEntry.cardIndex;
// จำว่ากำลังสืบผู้ต้องสงสัยคนไหน เพื่อเติมหลักฐาน (ติกถูก) ตอนเล่นจบ
space.suspectActiveIndex = (typeof selectedIndex === 'number' && selectedIndex >= 0 && selectedIndex <= 2)
? selectedIndex
: (cardEntry && typeof cardEntry.cardIndex === 'number' ? cardEntry.cardIndex : 0);
space.gauntletReturnMapId = POST_CASE_LOBBY_SPACE_ID;
clearSpaceQuizTimers(space);
if (space.quizSession) space.quizSession.active = false;
space.quizSession = null;
space.mapId = mapId;
space.mapData = md;
/* สุ่มไอคอนคำถามพิเศษสำหรับรอบนี้ (1 ใน 3) — ส่งจริงผ่าน joinCb เมื่อ client เข้า play.html */
maybeSpawnSpecialQuizForRun(space);
emitSpecialQuizIcon(sid, space);
let si = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(md, si++);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
p.gauntletJumpTicks = 0;
p.gauntletScore = 0;
p.gauntletJumpPending = false;
p.gauntletEliminated = false;
});
const peersSnap = buildPeersSnapForSpace(space, { gauntlet: md.gameType === 'gauntlet' });
const invPayload = {
selectedIndex,
cardIndex: cardEntry.cardIndex,
minigameKey: cardEntry.key,
minigameLabel: cardEntry.labelTh,
mapId,
};
io.to(sid).emit('suspect-investigation-start', invPayload);
if (md.gameType === 'quiz') {
const qErr = validateQuizMap(md);
if (qErr) {
space.detectiveMinigameActive = false;
space.gauntletReturnMapId = null;
return { ok: false, error: qErr };
}
/** ภารกิจ mng8a80o — HOW TO + READY + นับ 3-2-1 เหมือน preview (อย่า startQuizGame ทันที) */
const isQuizMissionShell = String(mapId).trim() === SUSPECT_INVESTIGATION_QUIZ_MAP_ID;
if (!isQuizMissionShell) {
io.to(sid).emit('game-start', {
quizMode: true,
stayInRoomLobby: true,
mapId,
peersSnap,
detectiveMinigame: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
});
setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || !spNow.peers.size) return;
const mdLive = maps.get(mapId);
if (!mdLive || mdLive.gameType !== 'quiz') return;
startQuizGame(sid, spNow, mdLive);
}, 900);
return { ok: true, mapId, gameType: md.gameType };
}
/* mng8a80o: ต่อ gauntletRun shell ด้านล่าง */
}
stopGauntletTicker(space);
space.gauntletRun = null;
if (md.gameType === 'gauntlet') {
initGauntletPeersAll(space, md);
ensureGauntletEndsAtIfNeeded(space);
startGauntletTicker(sid, space);
} else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space) || md.gameType === 'quiz_carry') {
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.peers.forEach((_p, id) => { space.gauntletCrownLobbyReady[id] = false; });
space.gauntletRun = newBalloonBossShellGauntletRunState();
}
const gameStartPayload = {
mapId,
peersSnap,
detectiveMinigame: true,
detectiveReturnLobbyB: true,
minigameLabel: cardEntry.labelTh,
bannedPlayerId: space.bannedThisRunPlayerId || null,
};
if (md.gameType === 'gauntlet' && space.gauntletRun) {
gameStartPayload.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
}
if (space.gauntletRun && space.gauntletRun.crownRunHeld) {
gameStartPayload.gauntletCrownRunHeld = true;
}
io.to(sid).emit('game-start', gameStartPayload);
return { ok: true, mapId, gameType: md.gameType };
}
function returnDetectiveSpaceToLobbyB(sid, space, message, awardOptions) {
ensurePostCaseLobbyMapLoaded();
clearSpaceQuizTimers(space);
clearSpecialQuizTimers(space);
space.specialQuiz = null;
if (space.quizSession) space.quizSession.active = false;
space.quizSession = null;
space.detectiveMinigameActive = false;
space.gauntletReturnMapId = null;
stopGauntletTicker(space);
space.gauntletRun = null;
const suspectIdx = resolveDetectiveSuspectAwardIndex(space);
if (suspectIdx >= 0) {
space.lastDetectiveInvestigatedSuspect = suspectIdx;
let awardIds = [];
if (awardOptions && awardOptions.allPlayers) {
awardIds = [...space.peers.keys()];
} else if (awardOptions && awardOptions.playerId && space.peers.has(awardOptions.playerId)) {
awardIds = [awardOptions.playerId];
} else if (awardOptions && Array.isArray(awardOptions.playerIds) && awardOptions.playerIds.length) {
awardIds = awardOptions.playerIds.filter((id) => space.peers.has(id));
}
/* Card 3 Ban — ผู้ที่ถูกแบนรอบนี้ไม่ได้เล่น จึงไม่ได้รับหลักฐาน */
if (space.bannedThisRunPlayerId) {
awardIds = awardIds.filter((id) => id !== space.bannedThisRunPlayerId);
}
/* ไม่มอบการ์ดเมื่อกลับ LobbyB โดยไม่ระบุ (เช่น refresh room-lobby ระหว่างมินิเกม) */
if (!space.detectiveAwardDoneForRun && awardIds.length) {
/* ผ่าน grant…CurrentRun เพื่อให้ set lastEvidenceGrant + ใช้ Card 7 Free Evidence + sync แฟ้ม (สำหรับหน้าเปิดเผยหลักฐาน) */
grantDetectiveEvidenceForCurrentRun(sid, space, awardIds);
}
}
space.suspectActiveIndex = null;
space.bannedThisRunPlayerId = null;
const lb = maps.get(POST_CASE_LOBBY_SPACE_ID);
if (!lb) {
io.to(sid).emit('quiz-ended', { message: message || 'จบมินิเกม — ไม่พบ LobbyB', returnToLobbyB: true });
return;
}
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = lb;
space.suspectPhaseActive = true;
let gi = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(lb, gi++);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
p.gauntletJumpTicks = 0;
p.gauntletScore = 0;
p.gauntletJumpPending = false;
p.gauntletEliminated = false;
});
const peersSnap = buildPeersSnapForSpace(space);
const basePayload = {
stayInRoomLobby: true,
mapId: POST_CASE_LOBBY_SPACE_ID,
peersSnap,
suspectPhaseActive: true,
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
cardMinigames: space.suspectCardMinigames || [],
detectiveReturn: true,
};
emitDetectiveLobbyReturnToPeers(sid, space, message, basePayload);
}
/* ===== ขั้นพิจารณาคดี — โหวตชี้ตัวคนร้าย ===== */
function emitTrialVoteUpdate(sid, space) {
const counts = [0, 0, 0];
const votes = space.trialVotes || {};
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) counts[v]++; });
io.to(sid).emit('trial-vote-update', {
counts,
voted: Object.keys(votes).length,
totalPlayers: trialEligibleVoterTotal(space),
});
}
function computeAndEmitTrialResult(sid, space) {
/* กันบอทบางตัวยังไม่โหวต — โหวตให้หมดก่อนรวมผล */
autoVoteBotsTrial(space);
clearTrialVoteTimer(space);
const counts = [0, 0, 0];
const votes = space.trialVotes || {};
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : 0;
/* Card 5 Bail Coin — จับผิดตัวให้โหวตใหม่ได้ 1 ครั้ง */
{
const c2 = [0, 0, 0];
Object.keys(votes).forEach((id) => { const v = votes[id]; if (v >= 0 && v <= 2) c2[v]++; });
let mostVoted = 0; for (let k = 1; k <= 2; k++) { if (c2[k] > c2[mostVoted]) mostVoted = k; }
const anyVotes = (c2[0] + c2[1] + c2[2]) > 0;
const wrong = anyVotes && mostVoted !== culprit;
const pend = space.pendingSpecialCard;
if (wrong && pend && Number(pend.cardId) === 5 && !pend.consumed && !space.trialBailUsed) {
pend.consumed = true;
space.trialBailUsed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'bail' });
openTrialVotingPhase(sid, space, { revote: true });
return;
}
}
space.trialPhase = 'revealed';
const winners = [];
if (!space.trialScores) space.trialScores = {};
Object.keys(votes).forEach((id) => {
const v = votes[id];
if (v >= 0 && v <= 2) counts[v]++;
if (v === culprit) {
winners.push(id);
space.trialScores[id] = (space.trialScores[id] || 0) + 100;
}
});
const winnerNames = winners.map((id) => {
const p = space.peers.get(id);
if (p && p.nickname) return p.nickname;
if (typeof id === 'string' && id.indexOf(TESTIMONY_BOT_PREFIX) === 0) {
const slot = parseInt(id.slice(TESTIMONY_BOT_PREFIX.length), 10);
return Number.isFinite(slot) ? ('บอท ' + (slot + 1)) : 'บอท';
}
return String(id).slice(0, 6);
});
io.to(sid).emit('trial-result', {
culpritIndex: culprit,
counts,
winners,
winnerNames,
cardMinigames: space.suspectCardMinigames || [],
});
space.silencedPlayerId = null;
}
/* ===== ห้องสรุปหลักฐาน — การไต่สวน (ปากคำ 3 รอบ ก่อนโหวต) ===== */
/** id บอทใน testimony/trial — สอดคล้องกับ slot บน lobby (__lobby_bot_0..N) */
const TESTIMONY_BOT_PREFIX = '__lobby_bot_';
function testimonyBotIds(space) {
const n = effectiveBotSlotCount(space);
const out = [];
for (let i = 0; i < n; i++) out.push(TESTIMONY_BOT_PREFIX + i);
return out;
}
function buildTestimonyMembers(space) {
const arr = [];
space.peers.forEach((p, id) => {
arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isHost: id === space.hostId, isBot: false });
});
testimonyBotIds(space).forEach((bid, i) => {
arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isHost: false, isBot: true });
});
return arr;
}
/** จำนวนผู้เล่นทั้งหมดที่ต้อง submit/vote (รวมบอท) */
function testimonyTotalParticipants(space) {
return (space.peers ? space.peers.size : 0) + effectiveBotSlotCount(space);
}
/** จำนวนการ์ดที่บอทควรเลือก — เท่ากับ max ของ "ผู้เล่นจริง" ในรอบ (กันบอทเลือก 2 ขณะคนเลือก 1) */
function botTestimonyPickCount(space) {
const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0;
let maxOwned = 0;
space.peers.forEach((_p, pid) => {
const ev = clonePlayerEvidence(space, pid);
const len = Array.isArray(ev[round]) ? ev[round].length : 0;
if (len > maxOwned) maxOwned = len;
});
/* ถ้าผู้เล่นจริงไม่มีหลักฐานเลย — ให้บอทเลือก 1 ใบ (กันรอบติด) */
if (maxOwned === 0) return 1;
return Math.max(1, Math.min(2, maxOwned));
}
/** สุ่ม picks ให้บอทในรอบ testimony ปัจจุบัน — เก็บเป็น card object (สุ่มจากพูล rarity 'C') */
function autoSubmitBotTestimonyPicks(space) {
if (!space.testimonySubmitted) space.testimonySubmitted = {};
const round = typeof space.testimonyRound === 'number' ? space.testimonyRound : 0;
const want = botTestimonyPickCount(space);
testimonyBotIds(space).forEach((bid) => {
if (space.testimonySubmitted[bid] != null) return;
const picks = [];
for (let n = 0; n < want; n++) {
const c = pickEvidenceCardForSuspect(space, round, 'C');
if (c) picks.push(c);
}
space.testimonySubmitted[bid] = picks;
});
}
function emitTestimonyOpen(sid, space) {
space.testimonySubmitted = {};
space.testimonyReadyNext = {};
space.testimonyRevealed = false;
/* บอทส่งหลักฐานทันทีเมื่อเปิด round */
autoSubmitBotTestimonyPicks(space);
const submittedIds = Object.keys(space.testimonySubmitted || {});
const base = {
round: space.testimonyRound,
suspectIndex: space.testimonyRound,
members: buildTestimonyMembers(space),
totalPlayers: testimonyTotalParticipants(space),
hostId: space.hostId,
cardMinigames: space.suspectCardMinigames || [],
submitted: submittedIds,
};
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('testimony-open', {
...base,
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
/* sync สถานะปุ่ม (เผื่อบอทส่งครบแล้ว — host ต้องเห็นปุ่ม "เปิดหลักฐานทั้งหมด" ทันที) */
emitTestimonyStatus(sid, space);
}
function emitTestimonyStatus(sid, space) {
io.to(sid).emit('testimony-status', {
round: space.testimonyRound,
submitted: Object.keys(space.testimonySubmitted || {}),
totalPlayers: testimonyTotalParticipants(space),
});
}
function doTestimonyReveal(sid, space) {
space.testimonyRevealed = true;
/* กันกรณีบอทยังไม่ submit (เผื่อ edge case) */
autoSubmitBotTestimonyPicks(space);
/* บอทพร้อมไปรอบถัดไปทันที (auto-ready) */
if (!space.testimonyReadyNext) space.testimonyReadyNext = {};
testimonyBotIds(space).forEach((bid) => { space.testimonyReadyNext[bid] = true; });
io.to(sid).emit('testimony-reveal', {
round: space.testimonyRound,
picks: space.testimonySubmitted || {},
members: buildTestimonyMembers(space),
ready: Object.keys(space.testimonyReadyNext),
totalPlayers: testimonyTotalParticipants(space),
});
}
/** สุ่มโหวตชี้คนร้ายให้บอท — สมจริงๆ ก็คือเลือกใครก็ได้ใน 3 คน (มี bias ให้ตัวร้ายจริง 40% เพื่อให้สนุก) */
/** เวลาโหวตพิจารณาคดี (มิลลิวิ) + เวลาที่การ์ด Extension เพิ่มให้ */
const TRIAL_VOTE_MS = 30000;
const TRIAL_VOTE_EXTENSION_MS = 10000;
function clearTrialVoteTimer(space) {
if (space.trialVoteTimer) { clearTimeout(space.trialVoteTimer); space.trialVoteTimer = null; }
}
/** เปิดเฟสโหวต — ตั้งตัวจับเวลา + ส่ง trial-open (ใช้ทั้งโหวตครั้งแรกและโหวตใหม่จาก Bail) */
function openTrialVotingPhase(sid, space, opts) {
opts = opts || {};
space.testimonyActive = false;
space.trialPhase = 'voting';
space.trialVotes = {};
autoVoteBotsTrial(space);
clearTrialVoteTimer(space);
/* Card 2 Extension — เพิ่มเวลาโหวต +10 วิ (ใช้ครั้งเดียว) */
let extraMs = 0;
let extensionCard = false;
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 2 && !pend.consumed) {
extraMs = TRIAL_VOTE_EXTENSION_MS;
extensionCard = true;
pend.consumed = true;
}
const durMs = TRIAL_VOTE_MS + extraMs;
space.trialVoteEndsAt = Date.now() + durMs;
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('trial-open', {
hostId: space.hostId,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
voteEndsAt: space.trialVoteEndsAt,
voteDurationMs: durMs,
extensionCard,
extensionSec: extensionCard ? (TRIAL_VOTE_EXTENSION_MS / 1000) : 0,
revote: !!opts.revote,
});
});
if (extensionCard) {
io.to(sid).emit('special-card-applied', {
card: specialCardClientPayload(pend.card),
addSec: TRIAL_VOTE_EXTENSION_MS / 1000,
context: 'trial',
});
}
emitTrialVoteUpdate(sid, space);
space.trialVoteTimer = setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || spNow.trialPhase !== 'voting') return;
computeAndEmitTrialResult(sid, spNow);
}, durMs + 120);
}
/* ===== โหวตเลือกผู้เล่น 1 คน (ใช้ร่วม: Silence ปิดปาก / Ban ห้ามเล่น) ===== */
const PICK_VOTE_MS = 15000;
function clearPickVoteTimer(space) {
if (space.pickVoteTimer) { clearTimeout(space.pickVoteTimer); space.pickVoteTimer = null; }
}
function pickVoteCandidates(space) {
const arr = [];
space.peers.forEach((p, id) => arr.push({ id, nickname: (p && p.nickname) ? p.nickname : String(id).slice(0, 6), isBot: false }));
testimonyBotIds(space).forEach((bid, i) => arr.push({ id: bid, nickname: 'บอท ' + (i + 1), isBot: true }));
return arr;
}
function autoVotePickBots(space) {
const pv = space.pickVote;
if (!pv) return;
testimonyBotIds(space).forEach((bid) => {
if (pv.votes[bid] != null) return;
const choices = pv.candidates.filter((c) => c.id !== bid);
if (!choices.length) return;
pv.votes[bid] = choices[Math.floor(Math.random() * choices.length)].id;
});
}
function emitPickVoteUpdate(sid, space) {
const pv = space.pickVote;
if (!pv) return;
const counts = {};
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
io.to(sid).emit('player-pick-vote-update', {
voted: Object.keys(pv.votes).length,
total: pv.candidates.length,
counts,
});
}
function startPlayerPickVote(sid, space, purpose, onResolve) {
clearPickVoteTimer(space);
const candidates = pickVoteCandidates(space);
space.pickVote = { purpose, votes: {}, candidates, endsAt: Date.now() + PICK_VOTE_MS, resolved: false, onResolve };
autoVotePickBots(space);
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('player-pick-vote-open', {
purpose,
candidates: candidates.map((c) => ({ id: c.id, nickname: c.nickname, isBot: c.isBot })),
endsAt: space.pickVote.endsAt,
durationMs: PICK_VOTE_MS,
});
});
emitPickVoteUpdate(sid, space);
space.pickVoteTimer = setTimeout(() => {
const sp = spaces.get(sid);
if (!sp || !sp.pickVote || sp.pickVote.resolved) return;
resolvePlayerPickVote(sid, sp);
}, PICK_VOTE_MS + 120);
maybeResolvePickVote(sid, space);
}
function maybeResolvePickVote(sid, space) {
const pv = space.pickVote;
if (!pv || pv.resolved) return;
const ids = pv.candidates.map((c) => c.id);
if (ids.length > 0 && ids.every((id) => pv.votes[id] != null)) resolvePlayerPickVote(sid, space);
}
function resolvePlayerPickVote(sid, space) {
const pv = space.pickVote;
if (!pv || pv.resolved) return;
pv.resolved = true;
clearPickVoteTimer(space);
const counts = {};
Object.keys(pv.votes).forEach((vid) => { const t = pv.votes[vid]; counts[t] = (counts[t] || 0) + 1; });
let max = -1;
Object.keys(counts).forEach((tid) => { if (counts[tid] > max) max = counts[tid]; });
const maxIds = Object.keys(counts).filter((tid) => counts[tid] === max);
const target = maxIds.length ? maxIds[Math.floor(Math.random() * maxIds.length)] : null;
const tc = pv.candidates.find((c) => c.id === target);
const targetName = tc ? tc.nickname : '';
io.to(sid).emit('player-pick-vote-result', { purpose: pv.purpose, targetId: target, targetName, counts });
const cb = pv.onResolve;
space.pickVote = null;
if (typeof cb === 'function') cb(target, targetName);
}
function trialSilencedIsParticipant(space) {
const s = space.silencedPlayerId;
if (!s) return false;
if (space.peers.has(s)) return true;
return (typeof s === 'string' && s.indexOf(TESTIMONY_BOT_PREFIX) === 0);
}
function trialEligibleVoterTotal(space) {
let t = testimonyTotalParticipants(space);
if (trialSilencedIsParticipant(space)) t = Math.max(0, t - 1);
return t;
}
function autoVoteBotsTrial(space) {
if (!space.trialVotes) space.trialVotes = {};
const culprit = (typeof space.culpritIndex === 'number') ? space.culpritIndex : Math.floor(Math.random() * 3);
testimonyBotIds(space).forEach((bid) => {
if (bid === space.silencedPlayerId) return; /* บอทที่ถูกปิดปากไม่โหวต */
if (space.trialVotes[bid] != null) return;
/* 40% โหวตถูก, 60% โหวตสุ่ม */
let pick;
if (Math.random() < 0.4) {
pick = culprit;
} else {
pick = Math.floor(Math.random() * 3);
}
space.trialVotes[bid] = pick;
});
}
function advanceTestimony(sid, space) {
space.testimonyRound = (typeof space.testimonyRound === 'number' ? space.testimonyRound : 0) + 1;
if (space.testimonyRound >= 3) {
space.trialBailUsed = false;
space.silencedPlayerId = null;
/* Card 4 Silence — โหวตเลือกผู้เล่น 1 คนให้ห้ามโหวต ก่อนเข้าโหวตจริง */
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 4 && !pend.consumed) {
pend.consumed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'silence' });
startPlayerPickVote(sid, space, 'silence', (targetId) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.silencedPlayerId = targetId || null;
openTrialVotingPhase(sid, sp, { revote: false });
});
return;
}
openTrialVotingPhase(sid, space, { revote: false });
} else {
emitTestimonyOpen(sid, space);
}
}
function endQuizGame(sid, space, message) {
clearSpaceQuizTimers(space);
if (space.quizSession) space.quizSession.active = false;
if (space.detectiveMinigameActive) {
returnDetectiveSpaceToLobbyB(sid, space, message, { allPlayers: true });
space.quizSession = null;
return;
}
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' });
space.quizSession = null;
}
function emitQuizPlayerStates(sid, space) {
const sess = space.quizSession;
if (!sess || !sess.players) return;
space.peers.forEach((_, peerId) => {
const st = sess.players[peerId];
if (st) io.to(peerId).emit('quiz-player-state', { ...st });
});
}
function scheduleQuizReadPhase(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
const idx = sess.qIndex;
const qList = sess.questions;
if (idx >= qList.length) {
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
return;
}
const q = qList[idx];
const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000;
sess.phase = 'read';
sess.phaseEndsAt = Date.now() + readMs;
io.to(sid).emit('quiz-phase', {
phase: 'read',
questionIndex: idx + 1,
questionTotal: qList.length,
text: String(q.text || '').trim(),
endsAt: sess.phaseEndsAt,
});
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
space.quizTimers.push(t);
}
function scheduleQuizAnswerPhase(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000;
sess.phase = 'answer';
sess.phaseEndsAt = Date.now() + answerMs;
io.to(sid).emit('quiz-phase', {
phase: 'answer',
questionIndex: sess.qIndex + 1,
questionTotal: sess.questions.length,
endsAt: sess.phaseEndsAt,
});
emitQuizPlayerStates(sid, space);
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
space.quizTimers.push(t);
}
function resolveQuizRound(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
/* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */
const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md;
if (!mdLive) return;
const idx = sess.qIndex;
const q = sess.questions[idx];
const correctTrue = !!q.answerTrue;
const tGrid = mdLive.quizTrueArea || [];
const fGrid = mdLive.quizFalseArea || [];
let eligibleCount = 0;
let anyRight = false;
const results = [];
const ejectMoves = [];
space.peers.forEach((p, peerId) => {
const st = sess.players[peerId];
if (!st) return;
eligibleCount++;
const tx = Math.floor(Number(p.x));
const ty = Math.floor(Number(p.y));
const inT = quizCellOn(tGrid, tx, ty);
const inF = quizCellOn(fGrid, tx, ty);
let choice = null;
if (inT && !inF) choice = true;
else if (inF && !inT) choice = false;
else if (inT && inF) choice = true;
let right = false;
if (choice === null) right = false;
else right = (choice === correctTrue);
if (right) {
st.score = (typeof st.score === 'number' ? st.score : 0) + QUIZ_TF_POINTS_PER_CORRECT;
anyRight = true;
}
/* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */
if (!right) {
st.cannotTrue = true;
st.cannotFalse = true;
const joinOrd = typeof p.spawnJoinOrder === 'number' && Number.isFinite(p.spawnJoinOrder)
? Math.max(0, Math.floor(p.spawnJoinOrder))
: [...space.peers.keys()].indexOf(peerId);
const pos = quizWrongAnswerRespawnPosition(mdLive, joinOrd);
p.x = pos.x;
p.y = pos.y;
ejectMoves.push({
id: peerId,
x: p.x,
y: p.y,
direction: p.direction || 'down',
characterId: p.characterId ?? null,
});
}
results.push({
id: peerId,
nickname: p.nickname || '',
right,
choice,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : 0,
});
});
ejectMoves.forEach((ev) => {
io.to(sid).emit('user-move', ev);
try {
const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id);
if (sock) sock.emit('user-move', ev);
} catch (e) { /* ignore */ }
});
const allWrong = eligibleCount > 0 && !anyRight;
const scores = {};
space.peers.forEach((_, peerId) => {
const st = sess.players[peerId];
scores[peerId] = st && typeof st.score === 'number' ? st.score : 0;
});
io.to(sid).emit('quiz-result', {
questionIndex: idx + 1,
correctTrue,
results,
allWrong,
scores,
});
emitQuizPlayerStates(sid, space);
/** ภารกิจสืบสวน / mng8a80o — เล่นครบ quizRoundQuestionCount แม้ทุกคนผิดข้อนี้ (เหมือน preview ไม่จบกลางรอบ) */
const playFullQuizRound =
!!space.detectiveMinigameActive || isQuizQuestionMissionShellSpace(space);
if (allWrong && !playFullQuizRound) {
endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้');
return;
}
sess.qIndex++;
if (sess.qIndex >= sess.questions.length) {
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
return;
}
const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500;
const mapRef = sess.quizMapMd || md;
const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs);
space.quizTimers.push(t);
}
function startQuizGame(sid, space, md) {
clearSpaceQuizTimers(space);
const settings = loadQuizSettings();
const pool = getQuizQuestionPool(md);
const shuffled = shuffleQuizQuestions(pool);
const cap = clampQuizRoundQuestionCount(settings.quizRoundQuestionCount, 10);
const picked = shuffled.slice(0, Math.min(cap, shuffled.length));
const players = {};
space.peers.forEach((_, peerId) => {
players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
});
space.quizSession = {
active: true,
questions: picked,
qIndex: 0,
phase: 'idle',
phaseEndsAt: 0,
readMs: settings.readMs,
answerMs: settings.answerMs,
betweenMs: settings.betweenMs,
players,
quizMapMd: md,
};
scheduleQuizReadPhase(sid, space, md);
}
function safeMapId(id) {
return (id || '').replace(/[^a-z0-9_-]/gi, '') || null;
}
/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 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;
}
function normalizeQuizBattlePathAreaOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
const w = m.width || 20, h = m.height || 15;
const src = m.quizBattlePathArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizBattlePathArea = rows;
}
function quizBattlePathModeActiveServer(m) {
if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
const g = m.quizBattlePathArea;
for (let y = 0; y < g.length; y++) {
const row = g[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
function quizBattleFootprintFullyOnPathServer(m, px, py) {
if (!quizBattlePathModeActiveServer(m)) return true;
const g = m.quizBattlePathArea;
for (const k of quizCarryFootprintTileKeys(m, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (!g[ty] || g[ty][tx] !== 1) return false;
}
return true;
}
function serverFootprintClearOfWalls(md, px, py) {
const w = md.width || 20, h = md.height || 15;
for (const k of serverWallCollisionTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = md.objects && md.objects[ty];
if (!row || row[tx] === 1) return false;
}
return true;
}
/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
function snapPositionOntoQuizBattlePathServer(md, px, py) {
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py };
const w = md.width || 20, h = md.height || 15;
const g = md.quizBattlePathArea;
let bestX = null, bestY = null, bestD = Infinity;
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const nx = tx + 0.01;
const ny = ty + 0.01;
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const row = md.objects && md.objects[ty];
if (!row || row[tx] === 1) continue;
const nx = tx + 0.5;
const ny = ty + 0.5;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
return { x: px, y: py };
}
function normalizeJumpSurvivePlatformAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const src = m.jumpSurvivePlatformArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.jumpSurvivePlatformArea = rows;
}
function normalizeJumpSurvivePlatformVariantAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const pa = m.jumpSurvivePlatformArea || [];
const src = m.jumpSurvivePlatformVariantArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const row = [];
for (let x = 0; x < w; x++) {
const has = pa[y] && pa[y][x] === 1;
let v = has ? Math.floor(Number(src[y] && src[y][x])) : 0;
if (has && (!Number.isFinite(v) || v < 1)) v = 1;
if (v > 3) v = 3;
if (!has) v = 0;
row.push(v);
}
rows.push(row);
}
m.jumpSurvivePlatformVariantArea = rows;
}
function normalizeJumpSurviveHazardAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const src = m.jumpSurviveHazardArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.jumpSurviveHazardArea = rows;
}
/**
* แมปภารกิจ Stack Tower (`mnn93hpi`): ปิดกล้องตามหอเสมอ · คลีน/จำกัดค่า stackTowerBgScroll (intro+loop แนวตั้ง) จาก JSON
*/
function applyStackTowerMissionMapPlayPolicy(safeId, m) {
if (safeId !== STACK_TOWER_MISSION_MAP_ID || !m || m.gameType !== 'stack') return;
const raw = m.stackTowerBgScroll && typeof m.stackTowerBgScroll === 'object' ? m.stackTowerBgScroll : {};
const dirRaw = String(raw.scrollDirection || raw.direction || 'down').toLowerCase();
const scrollDirection = (dirRaw === 'down' || dirRaw === 'top' || dirRaw === 'toptobottom') ? 'down' : 'up';
const spNum = Number(raw.speedPxPerSec);
const speedPxPerSec = Number.isFinite(spNum)
? Math.max(0, Math.min(400, Math.floor(spNum)))
: 0;
const sel = Math.floor(Number(raw.stepEveryLayers));
const stepEveryLayers = Number.isFinite(sel) ? Math.max(0, Math.min(200, sel)) : 0;
const ssp = Math.floor(Number(raw.stepScrollPx));
const stepScrollPx = Number.isFinite(ssp) ? Math.max(0, Math.min(8000, ssp)) : 0;
const sam = Math.floor(Number(raw.stepAnimMs));
const stepAnimMs = Number.isFinite(sam) ? Math.max(120, Math.min(4000, sam)) : 520;
const st = {
enabled: raw.enabled === true,
speedPxPerSec,
scrollDirection,
stepEveryLayers,
stepScrollPx,
stepAnimMs,
};
if (typeof raw.introImage === 'string' && raw.introImage.length) st.introImage = raw.introImage;
if (typeof raw.loopImage === 'string' && raw.loopImage.length) st.loopImage = raw.loopImage;
const rg = Number(raw.releaseGapWorldPx);
if (Number.isFinite(rg) && rg >= 0) st.releaseGapWorldPx = Math.min(800, Math.floor(rg));
m.stackTowerBgScroll = st;
const rawCf = m.stackTowerCameraFollow && typeof m.stackTowerCameraFollow === 'object' ? m.stackTowerCameraFollow : {};
const pxPerLayer = Math.max(0, Math.min(80, Math.floor(Number(rawCf.pxPerLayer)) || 12));
const maxPx = Math.max(0, Math.min(800, Math.floor(Number(rawCf.maxPx)) || 260));
m.stackTowerCameraFollow = { enabled: false, pxPerLayer, maxPx };
}
function loadMaps() {
try {
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(MAPS_DIR);
for (const f of files) {
if (!f.endsWith('.json')) continue;
const id = f.slice(0, -5);
if (!safeMapId(id)) continue;
try {
const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
normalizeJumpSurviveHazardAreaOnMap(data);
normalizeQuizBattleDomeAreaOnMap(data);
normalizeQuizBattlePathAreaOnMap(data);
applyStackTowerMissionMapPlayPolicy(id, data);
maps.set(id, data);
} catch (e) { console.error('load map', f, e.message); }
}
} catch (e) { console.error('loadMaps', e.message); }
}
/**
* อ่านแมปหนึ่งไฟล์จากดิสก์เข้า `maps` — ใช้ตอน GET /api/maps/:id เพื่อให้ deploy แก้ JSON แล้วมีผลทันที
* (เดิมโหลดครั้งเดียวตอนบูต · ถ้าแก้ไฟล์แล้วไม่รีสตาร์ท Node ลูกค้าได้ข้อมูลเก่า)
*/
function rehydrateSingleMapFromDisk(safeId) {
if (!safeId) return false;
const fp = path.join(MAPS_DIR, safeId + '.json');
if (!fs.existsSync(fp)) return false;
try {
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeJumpSurvivePlatformVariantAreaOnMap(data);
normalizeJumpSurviveHazardAreaOnMap(data);
normalizeQuizBattleDomeAreaOnMap(data);
normalizeQuizBattlePathAreaOnMap(data);
applyStackTowerMissionMapPlayPolicy(safeId, data);
maps.set(safeId, data);
return true;
} catch (e) {
console.error('rehydrate map', safeId, e.message);
return false;
}
}
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
function ensurePostCaseLobbyMapLoaded() {
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
try {
const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
if (!fs.existsSync(fp)) return false;
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
maps.set(POST_CASE_LOBBY_SPACE_ID, data);
return true;
} catch (e) {
console.error('ensurePostCaseLobbyMapLoaded', e.message);
return false;
}
}
function saveMap(id, m) {
const safe = safeMapId(id);
if (!safe) return;
try {
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
} catch (e) { console.error('saveMap', id, e.message); }
}
function isMapNameTaken(name, excludeId) {
const n = (name || '').trim().toLowerCase();
for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
return false;
}
function applyDevCors(req, res) {
const origin = req.headers.origin;
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Vary', 'Origin');
}
}
const server = http.createServer((req, res) => {
applyDevCors(req, res);
if (req.method === 'OPTIONS' && req.url && req.url.indexOf(BASE_PATH + '/api') === 0) {
res.writeHead(204);
res.end();
return;
}
let url = req.url;
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
if (url.startsWith(BASE_PATH + '/api/maps')) {
let id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
if (id.includes('?')) id = id.split('?')[0];
if (req.method === 'GET' && id) {
const sid = safeMapId(decodeURIComponent(id));
if (!sid) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
rehydrateSingleMapFromDisk(sid);
const m = maps.get(sid);
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
}
if (req.method === 'GET' && !id) {
const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
}
if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || 'ฉากใหม่';
if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const id = Date.now().toString(36);
const m = { ...defaultMap(), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
normalizeJumpSurviveHazardAreaOnMap(m);
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
if (req.method === 'PUT' && id && maps.has(id)) {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || maps.get(id).name;
if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const m = { ...maps.get(id), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.carryEmbedCountdownArea) m.carryEmbedCountdownArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
if (!m.jumpSurvivePlatformVariantArea) m.jumpSurvivePlatformVariantArea = [];
if (!m.jumpSurviveHazardArea) m.jumpSurviveHazardArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeJumpSurvivePlatformVariantAreaOnMap(m);
normalizeJumpSurviveHazardAreaOnMap(m);
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
applyStackTowerMissionMapPlayPolicy(id, m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
}
const quizCarryDiskUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
const quizCarryDiskPaths = new Set([
BASE_PATH + '/api/quiz-carry-from-disk',
BASE_PATH + '/api-quiz-carry-from-disk.php',
]);
if (quizCarryDiskPaths.has(quizCarryDiskUrlPath)) {
if (req.method === 'GET') {
const g = loadQuizSettings();
const slice = {
carryMapPanelTheme: g.carryMapPanelTheme,
carryEmbedCountdownTheme: g.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: g.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: g.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: g.carryChoicePlaqueMapScale,
carryReadMs: g.carryReadMs,
carryAnswerMs: g.carryAnswerMs,
carrySessionLength: g.carrySessionLength,
carryWalkSpeedMultForMapId: g.carryWalkSpeedMultForMapId,
carryWalkSpeedMult: g.carryWalkSpeedMult,
carryQuestions: g.carryQuestions,
};
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(slice));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadQuizSettings()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveQuizSettings(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
battleQuizMcqSaved: saved.battleQuizMcqSaved,
carryQuestionsSaved: saved.carryQuestionsSaved,
specialQuizSets: saved.specialQuizSets,
carryMapPanelTheme: saved.carryMapPanelTheme,
carryEmbedCountdownTheme: saved.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: saved.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: saved.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: saved.carryChoicePlaqueMapScale,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const evidenceCardsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (evidenceCardsUrlPath === BASE_PATH + '/api/evidence-cards') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadEvidenceCards()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveEvidenceCards(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const evidenceImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (evidenceImagesUrlPath === BASE_PATH + '/api/evidence-card-images') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify({ images: listEvidenceCardImages(), pools: evidencePoolCounts(), dirs: EVIDENCE_IMAGE_DIRS }));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaUrlPath === BASE_PATH + '/api/case-media') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadCaseMedia()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveCaseMedia(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, cases: saved.cases }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const caseMediaImagesUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (caseMediaImagesUrlPath === BASE_PATH + '/api/case-media-images') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify({ images: listCaseMediaImages(), count: CASE_MEDIA_COUNT }));
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
});
return res.end(JSON.stringify({ ...runtimeGameTiming }));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
try {
const d = JSON.parse(body || '{}');
const saved = saveGameTimingToFile(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
const { ok: _ok, error: _err, ...timingRest } = saved;
return res.end(JSON.stringify({ ok: true, ...timingRest }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureGauntletAssetsDir();
const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
let fname;
if (replaceFilename) {
const s = safeGauntletStoredFilename(replaceFilename);
if (!s) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
}
const cur = path.join(GAUNTLET_ASSETS_DIR, s);
if (!fs.existsSync(cur)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
}
const norm = (e) => {
let x = String(e || '').toLowerCase();
if (x.startsWith('.')) x = x.slice(1);
return x === 'jpeg' ? 'jpg' : x;
};
if (norm(path.extname(s)) !== norm(ext)) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
}
fname = s;
} else {
fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
}
fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
const meta = loadGauntletAssetsMeta();
const prev = meta[fname] || {};
meta[fname] = {
...prev,
label: labelIn || prev.label || fname,
updatedAt: Date.now(),
};
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/gauntlet-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
const qCarryPlaqueUploadPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (qCarryPlaqueUploadPath === BASE_PATH + '/api/quiz-carry-plaque-upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureQuizCarryPlaqueAssetsDir();
const fname = `qcarry-${crypto.randomBytes(8).toString('hex')}.${ext}`;
fs.writeFileSync(path.join(QUIZ_CARRY_PLAQUE_ASSETS_DIR, fname), buf);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/quiz-carry-plaque-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const fname = safeGauntletStoredFilename(d.filename);
if (!fname) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (!fs.existsSync(fp)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
}
const meta = loadGauntletAssetsMeta();
const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
try {
const q = url.includes('?') ? url.split('?')[1] : '';
const sp = new URLSearchParams(q);
const rawFn = sp.get('file');
const fname = safeGauntletStoredFilename(rawFn);
if (!fname) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
const meta = loadGauntletAssetsMeta();
delete meta[fname];
saveGauntletAssetsMeta(meta);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
}
}
if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const ids = new Set();
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
files.forEach(f => {
let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)_idle(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
});
const idArr = [...ids];
const oldestMsById = {};
idArr.forEach((id) => {
let oldest = Infinity;
const prefix = id + '_';
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
try {
const st = fs.statSync(path.join(CHARACTERS_DIR, f));
const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
if (t < oldest) oldest = t;
} catch (e) { /* ignore */ }
});
oldestMsById[id] = oldest === Infinity ? 0 : oldest;
});
idArr.sort((a, b) => {
const ta = oldestMsById[a] || 0;
const tb = oldestMsById[b] || 0;
if (ta !== tb) return ta - tb;
return String(a).localeCompare(String(b));
});
const list = idArr.map((id) => {
const prefix = id + '_';
const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
const layerNameSet = new Set();
if (hasLayerFiles) {
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
let m = f.match(/_(?:up|down|left|right)_idle(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
if (m) {
layerNameSet.add(m[1]);
return;
}
m = f.match(/_(?:up|down|left|right)(?:_\d+)?_layer_([a-zA-Z]+)\.[^.]+$/i);
if (m) layerNameSet.add(m[1]);
});
}
const layers = layerNameSet.size ? [...layerNameSet].sort() : [];
const layerManifest = hasLayerFiles ? buildCharacterLayerManifest(id, files) : null;
return { id, name: id, hasLayerFiles, layers, layerManifest };
});
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(list));
} catch (e) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify([]));
}
}
const urlPath = url.split('?')[0];
{
const lm = urlPath.match(new RegExp('^' + String(BASE_PATH).replace(/\//g, '\\/') + '\\/api\\/characters\\/([^/]+)\\/layer-manifest$'));
if (lm && req.method === 'GET') {
const id = decodeURIComponent(lm[1] || '').replace(/[^a-z0-9_-]/gi, '');
if (!id) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง', byDir: {}, byDirIdle: {} }));
}
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const manifest = buildCharacterLayerManifest(id, files);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(manifest));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: e.message || 'manifest fail', byDir: {}, byDirIdle: {} }));
}
}
}
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
if (/\/layer-manifest$/i.test(urlPath)) {
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ใช้ GET เท่านั้น' }));
}
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
if (!id) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
}
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
const idleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
const layerIdleRe = new RegExp('^' + esc + '_(up|down|left|right)_idle(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
let removed = 0;
files.forEach(f => {
if (re.test(f) || idleRe.test(f) || layerRe.test(f) || layerIdleRe.test(f)) {
try {
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
removed++;
} catch (e) {
// ignore single file errors
}
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, removed }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
}
return;
}
if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
if (!body || body.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
}
const d = JSON.parse(body);
const dirs = ['up', 'down', 'left', 'right'];
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
let walkWritten = 0;
for (const dir of dirs) {
let data = d[dir];
if (!data) continue;
const list = Array.isArray(data) ? data : [data];
let frameIndex = 0;
for (const dataUrl of list) {
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
// เขียนไฟล์เฟรม (สำหรับ animation)
const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <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('/').filter(Boolean);
const spaceId = parts[0];
if (req.method === 'GET' && !spaceId) {
const now = Date.now();
for (const [id, s] of [...spaces.entries()]) {
if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
const n = s.peers ? s.peers.size : 0;
if (n !== 0) continue;
const created = s.createdAt;
const stale = created == null || now - created > SPACE_EMPTY_TTL_MS;
if (stale) spaces.delete(id);
}
const list = [...spaces]
.filter(([, s]) => !s.isPrivate)
.filter(([, s]) => (s.peers && s.peers.size > 0))
.map(([id, s]) => ({
spaceId: id,
spaceName: s.spaceName || id,
mapName: (s.mapData && s.mapData.name) || '—',
peerCount: s.peers ? s.peers.size : 0,
maxPlayers: s.maxPlayers || 10
}));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
}
if (req.method === 'GET' && spaceId) {
const s = spaces.get(spaceId);
if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name }));
}
if (req.method === 'POST' && !spaceId) {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body || '{}');
const mapData = d.mapId ? maps.get(d.mapId) : null;
if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' }));
const isPrivate = !!d.isPrivate;
let sid;
if (isPrivate) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code;
for (let i = 0; i < 20; i++) {
code = '';
for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)];
if (!spaces.has(code)) { sid = code; break; }
}
if (!sid) sid = 'priv-' + Date.now();
} else {
const base = (d.name || '').trim() || 'space';
sid = base + '-' + Date.now();
}
const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid);
let maxPlayers = parseInt(d.maxPlayers, 10);
if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10;
maxPlayers = Math.min(10, Math.max(1, maxPlayers));
if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6);
const mapId = d.mapId ? String(d.mapId).trim() : null;
const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview';
let botSlotCount = parseInt(d.botSlotCount, 10);
if (isNaN(botSlotCount) || botSlotCount < 0) botSlotCount = 0;
botSlotCount = effectiveBotSlotCount({ botSlotCount, maxPlayers });
spaces.set(sid, {
mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(),
previewEditorTest,
botSlotCount,
suspectCardMinigames: null,
suspectPhaseActive: false,
suspectPickIndex: 0,
detectiveMinigameActive: false,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
}
if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') {
function sendIceServers(iceServers) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ iceServers: iceServers || [] }));
}
function fallback() {
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
const turnUser = process.env.TURN_USER;
const turnCred = process.env.TURN_CRED;
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
sendIceServers(iceServers);
}
const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY;
const meteredAppId = process.env.METERED_TURN_APP_ID;
let meteredBase = process.env.METERED_TURN_URL;
if (!meteredBase) {
let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret';
if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live';
meteredBase = 'https://' + meteredHost;
}
meteredBase = meteredBase.replace(/\/$/, '');
if (meteredKey) {
function tryNext(credUrl) {
return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) {
if (data && (data.error || data.message)) throw new Error(data.error || data.message);
const list = Array.isArray(data) ? data : (data && data.iceServers);
if (list && list.length) return sendIceServers(list);
if (data && data.data && Array.isArray(data.data) && data.data.length) {
const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca';
const built = data.data.map(function (c) {
return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password };
});
return sendIceServers(built);
}
throw new Error('No iceServers');
});
}
const urlsToTry = [
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey),
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey),
meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey)
];
let p = tryNext(urlsToTry[0]);
for (let i = 1; i < urlsToTry.length; i++) {
p = p.catch(function () { return tryNext(urlsToTry[i]); });
}
p.catch(fallback);
return;
}
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
const turnUser = process.env.TURN_USER;
const turnCred = process.env.TURN_CRED;
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
sendIceServers(iceServers);
return;
}
if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
try {
const d = JSON.parse(body || '{}');
const password = (d.password || '').trim();
if (password !== ADMIN_PASSWORD) {
return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' }));
}
const token = require('crypto').randomBytes(24).toString('hex');
adminTokens.add(token);
res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax');
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') {
const token = getAdminToken(req.headers.cookie);
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
const s = loadAiSettings();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
model: s.model || 'gpt-4o-mini',
models: AI_MODELS,
hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0),
intent: s.intent != null ? s.intent : '',
rag_enabled: !!s.rag_enabled,
rag_context: s.rag_context != null ? s.rag_context : '',
}));
return;
}
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') {
const token = getAdminToken(req.headers.cookie);
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
try {
const d = JSON.parse(body || '{}');
const s = loadAiSettings();
if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim();
if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini';
if (d.intent !== undefined) s.intent = String(d.intent);
if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled;
if (d.rag_context !== undefined) s.rag_context = String(d.rag_context);
saveAiSettings(s);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return;
}
if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const message = (d.message || d.text || '').trim();
if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
const s = loadAiSettings();
if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
const model = s.model || 'gpt-4o-mini';
const messages = [];
const systemParts = [];
if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
}
if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
messages.push({ role: 'user', content: message });
const payload = JSON.stringify({
model: model,
messages: messages,
max_tokens: 500,
});
const urlObj = new URL('https://api.openai.com/v1/chat/completions');
const opts = {
hostname: urlObj.hostname,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + s.openai_api_key,
'Content-Length': Buffer.byteLength(payload, 'utf8'),
},
};
const reqOut = require('https').request(opts, (resOut) => {
let data = '';
resOut.on('data', chunk => data += chunk);
resOut.on('end', () => {
try {
const parsed = JSON.parse(data);
const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
if (text != null) {
res.writeHead(200);
res.end(JSON.stringify({ response: String(text).trim() }));
} else {
res.writeHead(502);
res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
}
} catch (e) {
res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
}
});
});
reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
reqOut.write(payload);
reqOut.end();
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
if (url.startsWith(BASE_PATH + '/img/characters/') && (req.method === 'GET' || req.method === 'HEAD')) {
const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
if (fname && fname.length > 0) {
const fp = path.join(CHARACTERS_DIR, fname);
if (fs.existsSync(fp)) {
return fs.readFile(fp, (err, data) => {
if (err) return res.writeHead(500), res.end();
res.writeHead(200, { 'Content-Type': 'image/png' });
if (req.method === 'HEAD') return res.end();
res.end(data);
});
}
}
}
if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
const fname = safeGauntletStoredFilename(sub);
if (!fname) {
res.writeHead(400);
return res.end();
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (!fs.existsSync(fp)) {
res.writeHead(404);
return res.end();
}
const ext = path.extname(fname);
return fs.readFile(fp, (err, data) => {
if (err) return res.writeHead(500), res.end();
res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
res.end(data);
});
}
if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
const filePath = path.join(__dirname, 'public', url);
const ext = path.extname(filePath);
const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
fs.readFile(filePath, (err, data) => {
if (err) return res.writeHead(404), res.end('Not Found');
res.writeHead(200, {
'Content-Type': types[ext] || 'application/octet-stream',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.end(data);
});
});
const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
const spaces = new Map();
const GAUNTLET_MAX_PLAYERS = 6;
/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */
/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */
const GAUNTLET_LASER_SPAWN_CHANCE = 0.14;
/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */
const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34;
/** Last Light / museum gauntlet — scoring, mission UI, how-to flow */
const GAUNTLET_CROWN_HEIST_MAP_ID = 'mno9kb07';
/** Mega Virus — balloon_boss ฉากภารกิจ (flow เดียวกับ crown) */
const BALLOON_BOSS_MISSION_MAP_ID = 'mnq1eml7';
/** Space Shooter ภารกิจ Violent Crime — lobby READY / START เดียวกับ Jumper */
const SPACE_SHOOTER_MISSION_MAP_ID = 'mnpz6rkp';
function isGauntletCrownHeistMapBySpace(space) {
return !!(space && space.mapId && String(space.mapId) === GAUNTLET_CROWN_HEIST_MAP_ID);
}
function isBalloonBossMissionShellSpace(space) {
if (!space || !space.mapId || String(space.mapId) !== BALLOON_BOSS_MISSION_MAP_ID) return false;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return !!(md && md.gameType === 'balloon_boss');
}
/** ภารกิจคำถาม quiz + แมป mng8a80o — lobby READY เดียวกับ Mega Virus */
function isQuizQuestionMissionShellSpace(space) {
if (!space || !space.mapId || String(space.mapId) !== SUSPECT_INVESTIGATION_QUIZ_MAP_ID) return false;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return !!(md && md.gameType === 'quiz');
}
function isStackTowerMissionShellSpace(space) {
if (!space || !space.mapId || String(space.mapId) !== STACK_TOWER_MISSION_MAP_ID) return false;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return !!(md && md.gameType === 'stack');
}
function isJumpSurviveMissionShellSpace(space) {
if (!space || !space.mapId || String(space.mapId) !== JUMP_SURVIVE_MISSION_MAP_ID) return false;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return !!(md && md.gameType === 'jump_survive');
}
function isSpaceShooterMissionShellSpace(space) {
if (!space || !space.mapId || String(space.mapId) !== SPACE_SHOOTER_MISSION_MAP_ID) return false;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
return !!(md && md.gameType === 'space_shooter');
}
function isCrownLobbyShellSpace(space) {
return isGauntletCrownHeistMapBySpace(space) || isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space);
}
function newBalloonBossShellGauntletRunState() {
return {
obstacles: [],
nextObsId: 1,
spawnAcc: 0,
nextSpawnIn: 999,
crownRunHeld: true,
timerId: null,
endsAt: null,
};
}
function emitBalloonBossMissionShellGauntletSync(sid, space) {
if (!isBalloonBossMissionShellSpace(space)) return;
const gr = space.gauntletRun;
io.to(sid).emit('gauntlet-sync', {
gauntletCrownRunHeld: !!(gr && gr.crownRunHeld),
});
}
function rollGauntletCrownRewardCard(grade) {
const r = Math.random();
if (grade === 'A') {
if (r < 0.3) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.8) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (grade === 'B') {
if (r < 0.2) return { kind: 'culprit', th: 'การ์ดชี้คนร้าย', en: 'Culprit card' };
if (r < 0.5) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
if (r < 0.2) return { kind: 'rare', th: 'การ์ด Rare', en: 'Rare card' };
return { kind: 'common', th: 'การ์ดธรรมดา', en: 'Common card' };
}
function gauntletCrownRankBonus(rankOrdinal) {
if (rankOrdinal === 1) return 100;
if (rankOrdinal === 2) return 80;
if (rankOrdinal === 3) return 60;
return 0;
}
function buildGauntletCrownMissionPayload(space) {
const peersArr = [...space.peers.values()];
const rows = peersArr.map((p) => ({
id: p.id,
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
characterId: p.characterId ?? null,
baseScore: p.gauntletEliminated ? 0 : Math.max(0, p.gauntletScore | 0),
eliminated: !!p.gauntletEliminated,
}));
rows.sort((a, b) => {
if (b.baseScore !== a.baseScore) return b.baseScore - a.baseScore;
return String(a.nickname).localeCompare(String(b.nickname), 'th');
});
let lastScore = null;
let rankOrdinal = 0;
const ranked = [];
for (let i = 0; i < rows.length; i++) {
const pos = i + 1;
if (lastScore !== rows[i].baseScore) {
rankOrdinal = pos;
lastScore = rows[i].baseScore;
}
const bonus = gauntletCrownRankBonus(rankOrdinal);
const finalScore = rows[i].baseScore + bonus;
const rankLabel = rankOrdinal === 1 ? '1st' : rankOrdinal === 2 ? '2nd' : rankOrdinal === 3 ? '3rd' : String(rankOrdinal);
ranked.push({
id: rows[i].id,
nickname: rows[i].nickname,
characterId: rows[i].characterId,
baseScore: rows[i].baseScore,
eliminated: rows[i].eliminated,
rank: rankOrdinal,
rankLabel,
rankBonus: bonus,
finalScore,
});
}
const totalSum = ranked.reduce((s, r) => s + r.finalScore, 0);
const n = ranked.length || 1;
const averageScore = Math.floor(totalSum / n);
const grade = averageScore >= 80 ? 'A' : averageScore >= 60 ? 'B' : 'C';
const rewardCard = rollGauntletCrownRewardCard(grade);
return {
ranked,
totalSum,
averageScore,
grade,
rewardCard,
totalParts: ranked.map((r) => r.finalScore),
};
}
function gauntletSpawnYs(md, playerCount) {
const h = md.height || 15;
const lo = 1;
const hi = Math.max(lo, h - 2);
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
if (hi <= lo) return Array.from({ length: n }, () => lo);
const ys = [];
for (let i = 0; i < n; i++) {
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
}
return ys;
}
/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */
function collectGauntletSpawnSlotsFromSpawnArea(md) {
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return [];
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y });
}
}
slots.sort((a, b) => a.y - b.y || a.x - b.x);
return slots;
}
/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */
function collectGauntletSpawnSlotsFromMap(md) {
const explicit = md.gauntletPlayerSpawns;
if (Array.isArray(explicit) && explicit.length > 0) {
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (const raw of explicit) {
if (slots.length >= GAUNTLET_MAX_PLAYERS) break;
const x = Math.floor(Number(raw && raw.x));
const y = Math.floor(Number(raw && raw.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
if (!isMapTileWalkableForSpawn(md, x, y)) continue;
slots.push({ x, y });
}
if (slots.length > 0) return slots;
}
return collectGauntletSpawnSlotsFromSpawnArea(md);
}
function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) {
const w = md.width || 20;
if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX;
if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback;
for (let d = 0; d < w; d++) {
if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d;
if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d;
}
return Math.max(0, Math.min(w - 1, spawnColX));
}
/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */
function gauntletSpawnPositions(md, playerCount) {
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
const slots = collectGauntletSpawnSlotsFromMap(md);
const fallbackYs = gauntletSpawnYs(md, n);
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
const out = [];
for (let i = 0; i < n; i++) {
const y = i < slots.length
? slots[i].y
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
const slotX = i < slots.length ? slots[i].x : null;
const x = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX);
out.push({ x, y });
}
return out;
}
function stopGauntletTicker(space) {
if (space.gauntletRun && space.gauntletRun.timerId) {
clearInterval(space.gauntletRun.timerId);
space.gauntletRun.timerId = null;
}
}
/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */
function ensureGauntletEndsAtIfNeeded(space) {
const gr = space.gauntletRun;
if (!gr) return;
if (gr.crownRunHeld) return;
const lim = getGauntletTimeLimitSec();
if (lim > 0 && gr.endsAt == null) {
gr.endsAt = Date.now() + lim * 1000;
}
}
function newGauntletRunState(space) {
const crown = !!(space && isGauntletCrownHeistMapBySpace(space));
return {
obstacles: [],
nextObsId: 1,
spawnAcc: 0,
nextSpawnIn: 3,
crownRunHeld: crown,
};
}
function emitGauntletSync(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
const gr = space.gauntletRun;
if (!gr) return;
const h = md.height || 15;
const obsOut = gr.obstacles.map((o) => {
if (o.kind === 'laser') {
const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.floor(Number(o.y0)) : 0;
const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.floor(Number(o.y1)) : h - 1;
return { id: o.id, kind: 'laser', x: o.x, y: null, y0, y1 };
}
return { id: o.id, kind: o.kind, x: o.x, y: o.y };
});
const playersOut = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
characterId: p.characterId ?? null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
gauntletEliminated: !!p.gauntletEliminated,
}));
io.to(sid).emit('gauntlet-sync', {
obstacles: obsOut,
players: playersOut,
gauntletTickMs: getGauntletTickMs(),
gauntletJumpTicks: getGauntletJumpTicks(),
gauntletTimeLimitSec: getGauntletTimeLimitSec(),
gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null,
gauntletCrownRunHeld: !!gr.crownRunHeld,
...getGauntletVisualsForClient(),
});
}
function startGauntletTicker(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.gauntletRun && space.gauntletRun.timerId) return;
if (!space.gauntletRun) {
space.gauntletRun = newGauntletRunState(space);
}
ensureGauntletEndsAtIfNeeded(space);
space.gauntletRun.timerId = setInterval(() => {
runGauntletTick(sid, space);
}, getGauntletTickMs());
}
function initGauntletPeersAll(space, md) {
const list = [...space.peers.values()];
const pos = gauntletSpawnPositions(md, list.length);
const crown = isGauntletCrownHeistMapBySpace(space);
list.forEach((p, i) => {
const pt = pos[i] || pos[pos.length - 1];
p.x = pt.x;
p.y = pt.y;
p.gauntletJumpTicks = 0;
p.gauntletScore = crown ? 100 : 0;
p.gauntletJumpPending = false;
p.gauntletEliminated = false;
});
space.gauntletRun = newGauntletRunState(space);
if (crown) {
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.peers.forEach((_p, id) => {
space.gauntletCrownLobbyReady[id] = false;
});
}
space.gauntletPreviewRowsBySocket = new Map();
}
function findFallbackLobbyMapForGauntletEnd() {
for (const [id, m] of maps) {
if (m && m.gameType === 'lobby') return { id, m };
}
return null;
}
/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */
function endGauntletGame(sid, space, reason) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.detectiveMinigameActive) {
const msg = reason === 'time' ? 'หมดเวลา — จบมินิเกม' : 'จบมินิเกม';
returnDetectiveSpaceToLobbyB(sid, space, msg, { allPlayers: true });
return;
}
stopGauntletTicker(space);
const wasCrown = isGauntletCrownHeistMapBySpace(space);
const crownMission = wasCrown ? buildGauntletCrownMissionPayload(space) : null;
const rankings = crownMission
? crownMission.ranked.map((r) => ({
id: r.id,
nickname: r.nickname,
score: r.finalScore,
}))
: [...space.peers.values()].map((p) => ({
id: p.id,
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
score: Math.max(0, p.gauntletScore | 0),
})).sort((a, b) => b.score - a.score);
let retId = space.gauntletReturnMapId;
let retMap = retId && maps.has(retId) ? maps.get(retId) : null;
if (!retMap || retMap.gameType === 'gauntlet') {
const fb = findFallbackLobbyMapForGauntletEnd();
if (fb) {
retId = fb.id;
retMap = fb.m;
}
}
if (retMap && retMap.gameType !== 'gauntlet') {
space.mapId = retId;
space.mapData = retMap;
let gi = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(retMap, gi++);
p.x = sp.x;
p.y = sp.y;
p.gauntletJumpTicks = 0;
p.gauntletScore = 0;
p.gauntletJumpPending = false;
p.gauntletEliminated = false;
});
} else {
console.warn('endGauntletGame: no lobby map to return to', sid);
}
space.gauntletReturnMapId = null;
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear();
space.gauntletRun = null;
const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว';
io.to(sid).emit('gauntlet-ended', {
reason: reason || 'time',
message: msg,
rankings,
crownMission: crownMission || undefined,
});
}
/** แถว y บน–ล่างของเลเซอร์จากแมป (null ทั้งคู่ = เต็มความสูง) */
function gauntletLaserVerticalSpanFromMap(md) {
const hRaw = md && md.height != null ? Number(md.height) : 15;
const h = Math.max(1, Math.floor(Number.isFinite(hRaw) ? hRaw : 15));
const rs = md && md.gauntletLaserRowStart;
const re = md && md.gauntletLaserRowEnd;
if (rs == null || re == null || !Number.isFinite(Number(rs)) || !Number.isFinite(Number(re))) {
return { y0: 0, y1: h - 1 };
}
let y0 = Math.floor(Number(rs));
let y1 = Math.floor(Number(re));
y0 = Math.max(0, Math.min(h - 1, y0));
y1 = Math.max(0, Math.min(h - 1, y1));
if (y1 < y0) {
const t = y0;
y0 = y1;
y1 = t;
}
return { y0, y1 };
}
/**
* แถว grid ของ lane obstacle: แถวเท้า (ขอบล่าง footprint; แถวบนของตัว = player y)
* — สูงกว่าแบบ top+ch หนึ่งช่อง (ไม่ให้ลงเกินพรม)
*/
function gauntletLaneGridRowFromPlayerTop(md, topY, h) {
const { ch } = getCharacterFootprintWHForMove(md);
const top = Math.floor(Number(topY));
if (!Number.isFinite(top) || top < 0 || top >= h) return null;
const lr = top + ch - 1;
if (lr < 0 || lr >= h) return null;
return lr;
}
function runGauntletTick(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') {
stopGauntletTicker(space);
return;
}
const w = md.width || 20;
const h = md.height || 15;
const gr = space.gauntletRun;
if (!gr) return;
ensureGauntletEndsAtIfNeeded(space);
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
endGauntletGame(sid, space, 'time');
return;
}
if (gr.crownRunHeld) {
space.peers.forEach((p) => {
p.gauntletJumpPending = false;
});
emitGauntletSync(sid, space);
return;
}
/* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */
space.peers.forEach((p) => {
if (p.gauntletJumpPending) {
if ((p.gauntletJumpTicks || 0) === 0) {
p.gauntletJumpTicks = getGauntletJumpTicks();
}
p.gauntletJumpPending = false;
}
});
for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1;
gr.obstacles = gr.obstacles.filter((o) => o.x >= 0);
const laneSpawnRows = new Set();
space.peers.forEach((peer) => {
const lr = gauntletLaneGridRowFromPlayerTop(md, peer.y, h);
if (lr != null) laneSpawnRows.add(lr);
});
/* แถวบนที่ client แจ้ง (บอท preview) — แปลงเป็นแถว lane เดียวกับ peer */
const previewRows = space.gauntletPreviewRowsBySocket;
if (previewRows && previewRows.size) {
previewRows.forEach((set) => {
set.forEach((y) => {
const lr = gauntletLaneGridRowFromPlayerTop(md, y, h);
if (lr != null) laneSpawnRows.add(lr);
});
});
}
/* lane obstacle เฉพาะแถวที่ยังมีผู้เล่น/บอทในเลนนั้น — ลบชิ้นที่ไม่มีใครแล้ว */
gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || laneSpawnRows.has(o.y));
gr.spawnAcc = (gr.spawnAcc || 0) + 1;
if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) {
gr.spawnAcc = 0;
gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6);
/* ประเภท 2: เลเซอร์ — ทุกแถว */
if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) {
const span = gauntletLaserVerticalSpanFromMap(md);
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1, y0: span.y0, y1: span.y1 });
}
/* ประเภท 1: แยกเลน — แถวเท้า (ไม่ใช่แถวหัว; ไม่ใช่แถวใต้เท้าอีกช่อง) */
laneSpawnRows.forEach((ly) => {
if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) {
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly });
}
});
}
/* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */
const crownHeist = isGauntletCrownHeistMapBySpace(space);
const { ch: gauntletCh } = getCharacterFootprintWHForMove(md);
space.peers.forEach((p) => {
if (crownHeist && p.gauntletEliminated) {
return;
}
let px = Math.floor(Number(p.x)) || 0;
let py = Math.floor(Number(p.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const air = (p.gauntletJumpTicks || 0) > 0;
let advanceX = false;
let hitBack = false;
for (const o of gr.obstacles) {
if (o.kind === 'lane' && o.x === px && o.y >= py && o.y < py + gauntletCh) {
if (air) advanceX = true;
else hitBack = true;
}
if (o.kind === 'laser' && o.x === px) {
const y0 = o.y0 != null && Number.isFinite(Number(o.y0)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y0)))) : 0;
const y1 = o.y1 != null && Number.isFinite(Number(o.y1)) ? Math.max(0, Math.min(h - 1, Math.floor(Number(o.y1)))) : h - 1;
const lo = Math.min(y0, y1);
const hi = Math.max(y0, y1);
if (py < lo || py > hi) {
/* เลเซอร์ไม่ครอบแถวนี้ */
} else if (air) {
advanceX = true;
} else {
hitBack = true;
}
}
}
if (advanceX) {
px = Math.min(w - 2, px + 1);
p.gauntletJumpTicks = 0;
if (!crownHeist) {
p.gauntletScore = (p.gauntletScore || 0) + 1;
}
} else if (hitBack) {
if (crownHeist) {
if (px <= 0) {
p.gauntletEliminated = true;
p.gauntletScore = 0;
} else {
p.gauntletScore = Math.max(0, (p.gauntletScore || 0) - 10);
px = Math.max(0, px - 1);
}
} else {
px = Math.max(0, px - 1);
}
}
if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--;
p.x = px;
p.y = py;
});
emitGauntletSync(sid, space);
}
function rescheduleAllGauntletTickers() {
for (const [sid, space] of spaces) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') continue;
if (space.gauntletRun && space.gauntletRun.timerId) {
clearInterval(space.gauntletRun.timerId);
space.gauntletRun.timerId = null;
}
if (space.peers.size === 0) continue;
if (!space.gauntletRun) {
space.gauntletRun = newGauntletRunState(space);
}
ensureGauntletEndsAtIfNeeded(space);
space.gauntletRun.timerId = setInterval(() => {
runGauntletTick(sid, space);
}, getGauntletTickMs());
}
}
function getLobbyLayoutMapForSpace(space) {
return (space.mapId && maps.get(space.mapId)) || space.mapData;
}
/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */
function serverMapIsPostCaseLobbyB(space) {
if (!space) return false;
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
const md = getLobbyLayoutMapForSpace(space);
if (!md || md.gameType !== 'lobby') return false;
const nm = String(md.name || '').trim().toLowerCase();
return nm === 'lobbyb';
}
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
function normalizeLobbyNickname(nickname) {
const raw = String(nickname || '').trim();
if (!raw) return 'ผู้เล่น';
try {
return raw.normalize('NFKC').toLowerCase();
} catch (e) {
return raw.toLowerCase();
}
}
/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */
function lobbySpaceAlreadyLobbyB(space, md) {
if (!md || md.gameType !== 'lobby') return false;
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
if (String(md.name || '').trim() === 'LobbyB') return true;
return false;
}
function lobbyMapHasStartGameArea(md) {
if (!md) return false;
const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya';
if (md.gameType !== 'lobby' && !namedLobbyA) return false;
const area = md.startGameArea;
if (!area || !Array.isArray(area)) return false;
for (let y = 0; y < area.length; y++) {
const row = area[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */
function lobbyHostStandingInStartArea(space, hostId) {
const md = getLobbyLayoutMapForSpace(space);
if (!lobbyMapHasStartGameArea(md)) return true;
const p = space.peers.get(hostId);
if (!p || p.x == null || p.y == null) return false;
const tx = Math.floor(p.x);
const ty = Math.floor(p.y);
const row = md.startGameArea[ty];
return !!(row && row[tx] === 1);
}
function isMapTileWalkableForSpawn(md, x, y) {
const w = md.width || 20;
const h = md.height || 15;
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
if (row && row[x] === 1) return false;
return true;
}
function mapCharacterFootprintWH(md) {
const cw = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsW) || Number(md && md.characterCells) || 1)));
const ch = Math.max(1, Math.min(4, Math.floor(Number(md && md.characterCellsH) || Number(md && md.characterCells) || 1)));
return { cw, ch };
}
/** จุดเกิด = มุมซ้ายบนของ footprint — ต้องเดินได้ทั้งกล่อง (ตรง editor / room-lobby) */
function isMapSpawnAnchorWalkable(md, x, y) {
if (!isMapTileWalkableForSpawn(md, x, y)) return false;
const { cw, ch } = mapCharacterFootprintWH(md);
const w = md.width || 20;
const h = md.height || 15;
const maxX = Math.min(w, x + cw);
const maxY = Math.min(h, y + ch);
for (let ty = y; ty < maxY; ty++) {
for (let tx = x; tx < maxX; tx++) {
if (!isMapTileWalkableForSpawn(md, tx, ty)) return false;
}
}
return true;
}
/** แปลง lobbyPlayerSpawns จากแมปเป็นอาร์เรย์ยาว 6 ช่อง (null หรือ {x,y}) */
function parseLobbyPlayerSpawnsFromMap(md) {
const w = md.width || 20;
const h = md.height || 15;
const out = [null, null, null, null, null, null];
const raw = md && md.lobbyPlayerSpawns;
if (!Array.isArray(raw)) return out;
for (let i = 0; i < 6 && i < raw.length; i++) {
const cell = raw[i];
if (!cell || typeof cell !== 'object') continue;
const x = Math.floor(Number(cell.x));
const y = Math.floor(Number(cell.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
if (x < 0 || x >= w || y < 0 || y >= h) continue;
out[i] = { x, y };
}
return out;
}
/** jump_survive: ช่อง P บนกริด (shooterSpawnSlots) เติม slots6 เมื่อ lobbyPlayerSpawns ว่าง */
function augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots6) {
if (!md || md.gameType !== 'jump_survive' || !Array.isArray(slots6)) return;
const g = md.shooterSpawnSlots;
if (!g) return;
const w = md.width || 20, h = md.height || 15;
for (let slot = 1; slot <= 6; slot++) {
const idx = slot - 1;
if (slots6[idx]) continue;
let found = false;
for (let yy = 0; yy < h && !found; yy++) {
const row = g[yy];
if (!row) continue;
for (let xx = 0; xx < w; xx++) {
if (row[xx] === slot) {
slots6[idx] = { x: xx, y: yy };
found = true;
break;
}
}
}
}
}
/**
* จุดเกิดตอน join — random = สุ่มใน spawnArea / fixed = ปุ่มตั้งจุดเกิด / slots6 = P ตามลำดับเข้า (0=คนแรก)
* โหมดพรมแดงยังถูกทับด้วย gauntletSpawnPositions หลัง join ตามเดิม
*/
function pickSpawnForJoin(md, joinOrderIndex) {
if (!md) return { x: 1, y: 1 };
const mode = md.lobbySpawnMode;
const ord = joinOrderIndex | 0;
if (mode === 'slots6' && ord >= 6) return pickRandomSpawnFromMap(md);
const j = Math.min(Math.max(0, ord), 5);
if (mode === 'fixed' && md.spawn) {
const fx = Number.isFinite(Number(md.spawn.x)) ? Math.floor(Number(md.spawn.x)) : 1;
const fy = Number.isFinite(Number(md.spawn.y)) ? Math.floor(Number(md.spawn.y)) : 1;
const w = md.width || 20;
const h = md.height || 15;
const x = Math.max(0, Math.min(w - 1, fx));
const y = Math.max(0, Math.min(h - 1, fy));
if (isMapSpawnAnchorWalkable(md, x, y)) return { x, y };
return pickRandomSpawnFromMap(md);
}
if (mode === 'slots6') {
const slots = parseLobbyPlayerSpawnsFromMap(md);
augmentLobbySlotsFromShooterPaintJumpSurvive(md, slots);
const pick = slots[j];
if (pick && isMapSpawnAnchorWalkable(md, pick.x, pick.y)) return { x: pick.x, y: pick.y };
return pickRandomSpawnFromMap(md);
}
return pickRandomSpawnFromMap(md);
}
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
function pickRandomSpawnFromMap(md) {
const fallback = md.spawn || { x: 1, y: 1 };
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
const w = md.width || 20;
const h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y });
}
}
if (!pool.length) return { x: fx, y: fy };
const idx = typeof crypto.randomInt === 'function'
? crypto.randomInt(0, pool.length)
: Math.floor(Math.random() * pool.length);
const pick = pool[idx];
return { x: pick.x, y: pick.y };
}
/** ตอบผิดในเกมคำถาม — กลับจุดเกิดตามลำดับเข้า (เดียวกับ pickSpawnForJoin) แล้วดีดออกนอกโซนถ้าทับโซนตอบ */
function quizWrongAnswerRespawnPosition(md, joinOrderIndex) {
const ord = joinOrderIndex | 0;
const sp = pickSpawnForJoin(md, ord);
return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5);
}
function initTroublesomeState(space) {
if (!space.troublesomeEligible) space.troublesomeEligible = new Set();
if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false;
if (space.suspectPickIndex == null) space.suspectPickIndex = 0;
if (space.suspectPhaseActive == null) space.suspectPhaseActive = false;
}
function attemptTroublesomeRoll(sid, force) {
const space = spaces.get(sid);
if (!space || space.troublesomeOfferSent) return;
const peerIds = [...space.peers.keys()];
if (peerIds.length === 0) return;
if (!force && space.troublesomeEligible.size < peerIds.length) return;
space.troublesomeOfferSent = true;
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
let pool = peerIds.filter((id) => space.troublesomeEligible.has(id));
if (pool.length === 0) pool = peerIds;
const chosen = pool[Math.floor(Math.random() * pool.length)];
space.troublesomeTargetId = chosen;
space.troublesomeResponseReceived = false;
io.to(chosen).emit('troublesome-offer', { seconds: 15 });
}
function scheduleTroublesomeRoll(sid) {
const space = spaces.get(sid);
if (!space || space.troublesomeOfferSent) return;
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450);
if (!space.troublesomeMaxTimer) {
space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000);
}
}
/**
* ห้องทดสอบเอดิเตอร์ / join ด้วย mapId: mapData บน space อาจยังเป็น lobby แต่ไคลเอนต์เล่น quiz_carry จากพารามิเตอร์
* — ให้ lobby Ready/START ทำงานถ้า mapId ชี้แมป quiz_carry หรือเป็นห้อง preview
*/
function spaceAllowsQuizCarryLobbyRelaxed(space) {
if (!space) return false;
if (space.mapId) {
const byId = maps.get(space.mapId);
if (byId && byId.gameType === 'quiz_carry') return true;
}
const md = space.mapData;
if (md && md.gameType === 'quiz_carry') return true;
return !!(space.previewEditorTest
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
}
io.on('connection', (socket) => {
socket.on('join-space', ({ spaceId, nickname, characterId, playMapId, desiredLobbyColorThemeIndex, desiredLobbySkinToneIndex }, cb) => {
const space = spaces.get(spaceId);
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
const maxPlayers = space.maxPlayers || 10;
const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' });
const joinNickNorm = normalizeLobbyNickname(nickname);
if (space.joinLocked) {
const wl = space.caseParticipantNicknames;
if (wl && wl.size > 0) {
if (!wl.has(joinNickNorm)) {
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
}
} else {
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
}
}
socket.join(spaceId);
socket.data.spaceId = spaceId;
if (!space.hostId) space.hostId = socket.id;
if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space);
ensurePeerLobbyThemes(space);
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnJoinOrder = space.peers.size;
const spawnPt = pickSpawnForJoin(mdJoin, spawnJoinOrder);
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 3));
/* ใช้สีที่ client เลือกไว้ใน Main-Lobby — คนจริงสำคัญกว่าบอท */
let chosenThemeIdx = parseInt(desiredLobbyColorThemeIndex, 10);
if (chosenThemeIdx >= 1 && chosenThemeIdx <= LOBBY_THEME_COUNT) {
/* ถ้ามี "ผู้เล่นจริงคนอื่น" ถือสีนี้แล้ว → fallback สุ่มใหม่ */
let takenByHuman = false;
space.peers.forEach((p) => {
const ti = parseInt(p.lobbyColorThemeIndex, 10);
if (ti === chosenThemeIdx) takenByHuman = true;
});
if (takenByHuman) {
chosenThemeIdx = pickFreeLobbyThemeIndex(space);
} else if (space.lobbyBotThemeBySlot && typeof space.lobbyBotThemeBySlot === 'object') {
/* ถ้าบอทใช้สีนี้ → ดันบอทไปสีอื่น (เคลียร์ slot.themeIndex; syncLobbyBotThemeSlots ใน joinCb จะหาสีใหม่ที่ไม่ชนกับ peer ใหม่) */
Object.keys(space.lobbyBotThemeBySlot).forEach((k) => {
const slot = space.lobbyBotThemeBySlot[k];
if (slot && parseInt(slot.themeIndex, 10) === chosenThemeIdx) slot.themeIndex = 0;
});
}
} else {
chosenThemeIdx = pickFreeLobbyThemeIndex(space);
}
let chosenSkinIdx = parseInt(desiredLobbySkinToneIndex, 10);
if (!(chosenSkinIdx >= 1 && chosenSkinIdx <= LOBBY_SKIN_COUNT)) {
chosenSkinIdx = pickLobbySkinIndexForSlot(spawnJoinOrder);
}
const peer = {
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
spawnJoinOrder,
lobbyColorThemeIndex: chosenThemeIdx,
lobbySkinToneIndex: chosenSkinIdx,
gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, gauntletEliminated: false, spaceShooterScore: 0,
balloonBossScore: 0, balloonBossBossDmg: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false,
};
if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) {
const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5);
peer.x = sn.x;
peer.y = sn.y;
}
space.peers.set(socket.id, peer);
/* กลับ LobbyB เฉพาะเมื่อไม่ได้เข้า play ฉากมินิเกมที่กำลังเล่น (เช่น refresh room-lobby) */
if (space.detectiveMinigameActive) {
const clientMap = playMapId != null ? String(playMapId).trim() : '';
const activeMap = space.mapId != null ? String(space.mapId).trim() : '';
if (!clientMap || !activeMap || clientMap !== activeMap) {
returnDetectiveSpaceToLobbyB(spaceId, space, 'กลับ LobbyB');
}
}
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {};
space.quizCarryLobbyReady[socket.id] = false;
io.to(spaceId).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } });
}
if (mdJoin.gameType === 'gauntlet') {
const ord = [...space.peers.keys()].indexOf(socket.id);
const pos = gauntletSpawnPositions(mdJoin, space.peers.size);
const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1];
peer.x = pt.x;
peer.y = pt.y;
peer.gauntletJumpTicks = 0;
if (isGauntletCrownHeistMapBySpace(space)) {
peer.gauntletScore = 100;
peer.gauntletEliminated = false;
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.gauntletCrownLobbyReady[socket.id] = false;
io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
}
startGauntletTicker(spaceId, space);
} else if (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) {
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.gauntletCrownLobbyReady[socket.id] = false;
if (!space.gauntletRun) space.gauntletRun = newBalloonBossShellGauntletRunState();
io.to(spaceId).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
}
const peersList = [...space.peers.values()];
const mapDataOut = (space.mapId && maps.get(space.mapId)) || space.mapData || mdJoin;
const joinCb = {
ok: true,
mapData: mapDataOut,
mapId: space.mapId || null,
peers: peersList,
hostId: space.hostId,
spaceName: space.spaceName || spaceId,
isPrivate: !!space.isPrivate,
roomCode: space.isPrivate ? spaceId : null,
maxPlayers: space.maxPlayers || 10,
suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space),
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
botSlotCount: effectiveBotSlotCount(space),
lobbyBotThemes: syncLobbyBotThemeSlots(space),
joinLocked: !!space.joinLocked,
};
if (mdJoin.gameType === 'quiz_carry' || spaceAllowsQuizCarryLobbyRelaxed(space)) {
try {
const qs = loadQuizSettings();
joinCb.quizCarrySettingsSnap = {
carryMapPanelTheme: qs.carryMapPanelTheme,
carryEmbedCountdownTheme: qs.carryEmbedCountdownTheme,
carryChoicePlaqueThemes: qs.carryChoicePlaqueThemes,
carryChoicePlaqueTheme: qs.carryChoicePlaqueTheme,
carryChoicePlaqueMapScale: qs.carryChoicePlaqueMapScale,
carryReadMs: qs.carryReadMs,
carryAnswerMs: qs.carryAnswerMs,
carrySessionLength: qs.carrySessionLength,
carryWalkSpeedMultForMapId: qs.carryWalkSpeedMultForMapId,
carryWalkSpeedMult: qs.carryWalkSpeedMult,
};
} catch (e) { /* ignore */ }
}
if (mdJoin.gameType === 'quiz') {
try {
const qsQuiz = loadQuizSettings();
joinCb.quizSettingsSnap = {
quizMapPanelTheme: qsQuiz.quizMapPanelTheme,
};
} catch (e) { /* ignore */ }
}
if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) {
joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
}
if ((isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space)) && space.gauntletRun) {
joinCb.gauntletCrownRunHeld = !!space.gauntletRun.crownRunHeld;
}
/* ไอคอนคำถามพิเศษของรอบนี้ (ถ้ามีและยังไม่ถูกทริก) */
const sqIcon = specialQuizIconPayload(space);
if (sqIcon) joinCb.specialQuizIcon = sqIcon;
if (typeof cb === 'function') cb(joinCb);
emitLobbyTintSync(spaceId, space);
socket.to(spaceId).emit('user-joined', peer);
if (space.voiceInits) {
for (const [peerId, initData] of Object.entries(space.voiceInits)) {
if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData });
}
}
});
/** ผู้เล่นเดินชนไอคอนคำถามพิเศษ — ใครชนก่อนเป็นคนเปิดเซสชันให้ทั้งห้อง */
/** debug: บังคับ spawn ไอคอนทุกรอบในเซสชันนี้ (เปิดด้วย ?sqForce=1) */
socket.on('special-quiz-force', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
space.forceSpecialQuiz = !!(data && data.on);
console.log('[special-quiz] force flag =', space.forceSpecialQuiz, 'by', socket.id);
});
/** Test Mode (จาก localStorage ฝั่ง client) — เปิด shortcut ทดสอบ เช่น Ctrl+Q ตอบถูก */
socket.on('special-quiz-testmode', (data) => {
socket.data.testMode = !!(data && data.on);
});
socket.on('special-quiz-collide', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
const sq = space.specialQuiz;
if (!sq || sq.triggered || sq.done) return reply({ ok: false });
const started = startSpecialQuizSession(sid, space);
reply({ ok: !!started });
});
/** ผู้เล่นส่งคำตอบของข้อปัจจุบัน */
socket.on('special-quiz-answer', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
const sess = space.specialQuiz && space.specialQuiz.session;
if (!sess || sess.resolving) return reply({ ok: false });
const q = sess.questions[sess.qIndex];
if (!q) return reply({ ok: false });
/* Test Mode: Ctrl+Q เติมคำตอบที่ถูกให้ (เฉพาะ socket ที่เปิด test mode) */
let choice;
if (data && data.debugCorrect && socket.data.testMode) {
choice = q.correctIndex;
} else {
choice = Number(data && data.choiceIndex);
}
if (!Number.isInteger(choice) || choice < 0 || choice >= q.choices.length) {
return reply({ ok: false });
}
if (Number.isInteger(sess.answers[socket.id])) return reply({ ok: false, error: 'ตอบไปแล้ว' });
sess.answers[socket.id] = choice;
reply({ ok: true });
/* แจ้งทุกคนว่าคนนี้ตอบแล้ว (ยังไม่เปิดเผยว่าตอบอะไรจนกว่าจะเฉลย) */
io.to(sid).emit('special-quiz-answer-update', { id: socket.id, answered: true });
/* ตอบครบทุกคน (จริง+บอท) แล้วตัดสินทันที ไม่ต้องรอหมดเวลา */
maybeResolveSpecialQuizAllAnswered(sid, space);
});
/** ผู้เล่นกด «เล่นต่อ» หลังโชว์การ์ดพิเศษ — เล่นรอบ quiz ต่อ (คนแรกที่กดพอ) */
socket.on('special-quiz-continue', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
finishSpecialQuizContinue(sid, space);
reply({ ok: true });
});
/** เปลี่ยนสีเสื้อ/ผิวใน lobby — ห้ามเลือกสีที่คนอื่นใช้แล้ว */
socket.on('lobby-tint-update', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
const peer = space && space.peers.get(socket.id);
if (!space || !peer) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
let themeIndex = Math.floor(Number(data && data.themeIndex));
let skinIndex = Math.floor(Number(data && data.skinToneIndex));
if (!Number.isFinite(themeIndex) || themeIndex < 1 || themeIndex > LOBBY_THEME_COUNT) {
return reply({ ok: false, error: 'ธีมสีไม่ถูกต้อง' });
}
if (!Number.isFinite(skinIndex) || skinIndex < 1 || skinIndex > LOBBY_SKIN_COUNT) {
skinIndex = peer.lobbySkinToneIndex || 1;
}
const taken = getTakenLobbyThemeIndices(space, socket.id);
if (taken.has(themeIndex)) return reply({ ok: false, error: 'สีเสื้อนี้มีคนใช้แล้ว' });
peer.lobbyColorThemeIndex = themeIndex;
peer.lobbySkinToneIndex = skinIndex;
emitLobbyTintSync(sid, space);
reply({
ok: true,
lobbyColorThemeIndex: themeIndex,
lobbySkinToneIndex: skinIndex,
lobbyBotThemes: space.lobbyBotThemeBySlot ? syncLobbyBotThemeSlots(space) : [],
});
});
/** เปลี่ยนชื่อที่แสดงใน lobby / เกม (ซิงก์ทุก client ในห้อง) */
socket.on('update-display-name', ({ nickname }, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const p = space.peers.get(socket.id);
if (!p) return reply({ ok: false, error: 'ไม่พบผู้เล่น' });
let safe = String(nickname || '').trim();
if (!safe) return reply({ ok: false, error: 'ชื่อว่าง' });
try {
safe = safe.normalize('NFKC');
} catch (e) { /* ignore */ }
if (safe.length > 32) safe = safe.slice(0, 32);
const oldNorm = normalizeLobbyNickname(p.nickname);
const newNorm = normalizeLobbyNickname(safe);
p.nickname = safe;
if (space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0) {
if (space.caseParticipantNicknames.has(oldNorm)) {
space.caseParticipantNicknames.delete(oldNorm);
space.caseParticipantNicknames.add(newNorm);
}
}
io.to(sid).emit('peer-display-name', { id: socket.id, nickname: safe });
reply({ ok: true, nickname: safe });
});
/** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */
socket.on('preview-claim-host', (cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const isPreviewEditorRoom = !!(space.previewEditorTest
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' });
space.hostId = socket.id;
io.to(sid).emit('host-changed', { hostId: socket.id });
return reply({ ok: true });
});
socket.on('troublesome-eligible', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return;
initTroublesomeState(space);
space.troublesomeEligible.add(socket.id);
scheduleTroublesomeRoll(sid);
});
socket.on('troublesome-response', (data) => {
const accept = !!(data && data.accept);
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space)) return;
if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return;
if (space.troublesomeResponseReceived) return;
space.troublesomeResponseReceived = true;
space.troublesomeAccept = accept;
let idx = Math.floor(Number(space.suspectPickIndex));
if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0;
space.suspectPickIndex = idx;
if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) {
assignDetectiveSuspectCardMinigames(space);
}
space.suspectPhaseActive = true;
const phaseBase = {
hostId: space.hostId,
selectedIndex: space.suspectPickIndex,
disruptorAccepted: accept,
cardMinigames: space.suspectCardMinigames || [],
};
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('suspect-phase-open', {
...phaseBase,
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
socket.emit('troublesome-ack', { ok: true, accept });
});
socket.on('suspect-pick-select', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return;
if (space.hostId !== socket.id) return;
let idx = Math.floor(Number(data && data.index));
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
space.suspectPickIndex = idx;
io.to(sid).emit('suspect-pick-update', { selectedIndex: idx });
});
socket.on('suspect-pick-start', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (space && Array.isArray(space.suspectCardMinigames)) {
space.suspectCardMinigames = space.suspectCardMinigames.map(normalizeDetectiveCardEntry);
}
if (!space || !serverMapIsPostCaseLobbyB(space)) {
return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' });
}
if (!space.suspectPhaseActive) {
return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' });
}
if (space.hostId !== socket.id) {
return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' });
}
if (!space.suspectCardMinigames || space.suspectCardMinigames.length < 3) {
assignDetectiveSuspectCardMinigames(space);
}
const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0;
const cardEntry = space.suspectCardMinigames[selectedIndex];
if (!cardEntry || !cardEntry.mapId) {
return reply({ ok: false, error: 'ยังไม่ได้สุ่มมินิเกมสำหรับการ์ดนี้' });
}
/* Card 3 Ban — ก่อนเริ่มมินิเกมรอบหน้า โหวตเลือกผู้เล่น 1 คนที่ห้ามเล่น */
const pend = space.pendingSpecialCard;
if (pend && Number(pend.cardId) === 3 && !pend.consumed && !space.pickVote) {
pend.consumed = true;
io.to(sid).emit('special-card-applied', { card: specialCardClientPayload(pend.card), context: 'ban' });
startPlayerPickVote(sid, space, 'ban', (targetId) => {
const sp = spaces.get(sid);
if (!sp) return;
sp.bannedPlayerId = targetId || null;
const res = beginDetectiveSuspectMinigame(sid, sp, cardEntry, selectedIndex);
if (!res || !res.ok) {
io.to(sid).emit('quiz-ended', { message: (res && res.error) || 'เริ่มมินิเกมไม่สำเร็จ', returnToLobbyB: true });
}
});
return reply({ ok: true, banVote: true });
}
const started = beginDetectiveSuspectMinigame(sid, space, cardEntry, selectedIndex);
reply(started);
});
socket.on('detective-minigame-finished', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
}
if (!space.detectiveMinigameActive) {
if (serverMapIsPostCaseLobbyB(space)) {
const grant = grantDetectiveEvidenceForCurrentRun(sid, space, [socket.id]);
return reply({
ok: true,
alreadyOnLobbyB: true,
suspectIndex: grant.suspectIdx,
awardedCard: grant.awarded && grant.awarded[socket.id] != null ? grant.awarded[socket.id] : null,
awardedCards: (grant.awardedList && grant.awardedList[socket.id]) || [],
grade: grant.grade || null,
freeEvidenceCount: grant.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
}
return reply({ ok: false, error: 'ไม่ได้อยู่ในมินิเกมสืบสวน' });
}
// เล่นมินิเกม 1 ครั้ง = ได้การ์ด 1 ใบ (ไม่ขึ้นเกรด/คะแนน)
returnDetectiveSpaceToLobbyB(sid, space, 'จบมินิเกม — กลับ LobbyB', { allPlayers: true });
const g = space.lastEvidenceGrant || {};
reply({
ok: true,
suspectIndex: typeof g.suspectIdx === 'number' ? g.suspectIdx : -1,
awardedCard: (g.awarded && g.awarded[socket.id] != null) ? g.awarded[socket.id] : null,
awardedCards: (g.awardedList && g.awardedList[socket.id]) || [],
grade: g.grade || null,
freeEvidenceCount: g.freeEvidenceCount || 0,
myPlayerEvidence: clonePlayerEvidence(space, socket.id),
suspectProgress: playerSuspectProgressFromEvidence(space, socket.id),
});
});
/**
* DEBUG/TEST hotkey — เก็บหลักฐาน 1 ใบให้ผู้ต้องสงสัย (suspectIndex 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: 'เลือกผู้ต้องสงสัยไม่ถูกต้อง' });
space.trialVotes = space.trialVotes || {};
space.trialVotes[socket.id] = idx;
emitTrialVoteUpdate(sid, space);
const totalTV = trialEligibleVoterTotal(space);
if (Object.keys(space.trialVotes).length >= totalTV && totalTV > 0) {
computeAndEmitTrialResult(sid, space);
}
reply({ ok: true, index: idx });
});
// Host บังคับเปิดเผยผล (เช่นมีคนยังไม่โหวต)
socket.on('trial-reveal', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะ host' });
if (space.trialPhase !== 'voting') return reply({ ok: false, error: 'ไม่อยู่ในขั้นโหวต' });
computeAndEmitTrialResult(sid, space);
reply({ ok: true });
});
/* โหวตเลือกผู้เล่น 1 คน (Silence/Ban) */
socket.on('player-pick-vote', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const pv = space.pickVote;
if (!pv || pv.resolved) return reply({ ok: false, error: 'ไม่มีการโหวตที่เปิดอยู่' });
const tid = String((data && data.targetId) || '');
if (!pv.candidates.some((c) => c.id === tid)) return reply({ ok: false, error: 'เป้าหมายไม่ถูกต้อง' });
pv.votes[socket.id] = tid;
reply({ ok: true, targetId: tid });
emitPickVoteUpdate(sid, space);
maybeResolvePickVote(sid, space);
});
/** Host Console — ตั้งจำนวนรวม (คน+บอท) ไม่เกิน LOBBY_SLOT_TOTAL */
socket.on('host-console-apply', (data, cb) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) {
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
}
const humans = space.peers.size;
let total = parseInt(data && data.totalSlots, 10);
if (!Number.isFinite(total)) total = humans + effectiveBotSlotCount(space);
total = Math.min(LOBBY_SLOT_TOTAL, Math.max(humans, total));
let bots = parseInt(data && data.botSlotCount, 10);
if (!Number.isFinite(bots)) bots = total - humans;
bots = Math.max(0, Math.min(LOBBY_SLOT_TOTAL - humans, bots));
total = humans + bots;
space.botSlotCount = bots;
space.maxPlayers = Math.max(humans, total - bots);
const lobbyBotThemes = syncLobbyBotThemeSlots(space);
io.to(sid).emit('host-console-settings', {
maxPlayers: space.maxPlayers,
botSlotCount: space.botSlotCount,
totalSlots: total,
lobbyBotThemes,
});
emitLobbyTintSync(sid, space);
cb && cb({ ok: true, maxPlayers: space.maxPlayers, botSlotCount: space.botSlotCount, totalSlots: total, lobbyBotThemes });
});
socket.on('host-console-kick-peer', ({ targetId }, cb) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || space.hostId !== socket.id) {
return cb && cb({ ok: false, error: 'เฉพาะ Host เท่านั้น' });
}
const tid = targetId != null ? String(targetId) : '';
if (!tid || tid === socket.id || tid === space.hostId) {
return cb && cb({ ok: false, error: 'ไม่สามารถลบผู้เล่นนี้ได้' });
}
if (!space.peers.has(tid)) {
return cb && cb({ ok: false, error: 'ไม่พบผู้เล่นในห้อง' });
}
const targetSock = io.sockets.sockets.get(tid);
if (targetSock) {
targetSock.emit('host-console-kicked', { message: 'คุณถูก Host นำออกจากห้อง' });
targetSock.leave(sid);
delete targetSock.data.spaceId;
}
space.peers.delete(tid);
io.to(sid).emit('user-left', { id: tid });
cb && cb({ ok: true });
});
/* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */
socket.on('set-ready', ({ ready }) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
if (p) p.ready = !!ready;
io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
break;
}
}
});
socket.on('start-game', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id) && space.hostId === socket.id) {
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
/* 15 คดี (5 ระดับ × 3 คดี) — caseId 1-15 */
if (caseId && !/^(?:[1-9]|1[0-5])$/.test(caseId)) caseId = '';
ensurePostCaseLobbyMapLoaded();
const mdBefore = getLobbyLayoutMapForSpace(space);
if (mdBefore && mdBefore.gameType === 'quiz') {
const qErr = validateQuizMap(mdBefore);
if (qErr) return reply({ ok: false, error: qErr });
clearSpaceQuizTimers(space);
startQuizGame(sid, space, mdBefore);
io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true });
return reply({ ok: true });
}
const detectiveB = !!(data && data.detectiveLobbyBStart);
if (detectiveB && (!lobbyLevel || !caseId)) {
return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' });
}
const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : '';
const isNamedLobbyA = mapNameNorm === 'lobbya';
const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet');
/* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */
const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore)
&& lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID)
&& (
mdBefore.gameType === 'lobby'
|| isNamedLobbyA
|| (detectiveB && notZepOrFrogger)
));
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
}
if (!lobbyHostStandingInStartArea(space, socket.id)) {
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
}
if (isLobbyADetectiveStart) {
assignDetectiveSuspectCardMinigames(space);
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
const caseNickWl = new Set();
space.peers.forEach((p) => {
caseNickWl.add(normalizeLobbyNickname(p.nickname));
});
space.caseParticipantNicknames = caseNickWl;
space.joinLocked = true;
space.troublesomeOfferSent = false;
space.troublesomeTargetId = null;
space.troublesomeResponseReceived = false;
space.suspectPhaseActive = false;
space.suspectPickIndex = 0;
space.detectiveLobbyLevel = Number(lobbyLevel) || 1;
space.detectiveLobbyCaseId = Number(caseId) || 1;
space.caseId = Number(caseId) || 1;
if (space.troublesomeEligible) space.troublesomeEligible.clear();
else space.troublesomeEligible = new Set();
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
let li = 0;
space.peers.forEach((p) => {
const sp = pickSpawnForJoin(space.mapData, li++);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
});
const peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null
}));
space.peers.forEach((_p, pid) => {
const sock = io.sockets.sockets.get(pid);
if (!sock) return;
sock.emit('game-start', {
stayInRoomLobby: true,
mapId: POST_CASE_LOBBY_SPACE_ID,
lobbyLevel,
caseId,
peersSnap,
cardMinigames: space.suspectCardMinigames || [],
myPlayerEvidence: clonePlayerEvidence(space, pid),
suspectProgress: playerSuspectProgressFromEvidence(space, pid),
});
});
return reply({ ok: true });
}
const mapId = (data && data.mapId) ? String(data.mapId).trim() : null;
let peersSnap = null;
let gauntletEndsAtEmit = undefined;
if (mapId && maps.has(mapId)) {
const prevMapIdForReturn = space.mapId;
space.mapId = mapId;
space.mapData = maps.get(mapId);
const md = space.mapData;
if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
space.peers.forEach((p) => {
const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y));
p.x = sn.x;
p.y = sn.y;
});
}
if (!md || md.gameType !== 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = null;
space.gauntletRun = null;
}
if (md && md.gameType === 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = prevMapIdForReturn;
initGauntletPeersAll(space, md);
ensureGauntletEndsAtIfNeeded(space);
startGauntletTicker(sid, space);
gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null
? space.gauntletRun.endsAt
: null;
peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
gauntletEliminated: !!p.gauntletEliminated,
}));
}
if (md && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
space.peers.forEach((_p, id) => {
space.gauntletCrownLobbyReady[id] = false;
});
space.gauntletRun = newBalloonBossShellGauntletRunState();
}
}
const gameStartPayload = {
mapId: mapId || undefined,
lobbyLevel: lobbyLevel || undefined,
caseId: caseId || undefined,
peersSnap: peersSnap || undefined,
};
if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null;
if (mapId && maps.has(mapId) && (isBalloonBossMissionShellSpace(space) || isQuizQuestionMissionShellSpace(space) || isStackTowerMissionShellSpace(space) || isJumpSurviveMissionShellSpace(space) || isSpaceShooterMissionShellSpace(space))) {
const grs = space.gauntletRun;
gameStartPayload.gauntletCrownRunHeld = !!(grs && grs.crownRunHeld);
}
io.to(sid).emit('game-start', gameStartPayload);
return reply({ ok: true });
}
}
reply({ ok: false, error: 'ไม่ใช่ host' });
});
socket.on('disconnect', () => {
let sid = socket.data.spaceId;
let space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
for (const [s, sp] of spaces) {
if (sp.peers.has(socket.id)) { sid = s; space = sp; break; }
}
}
if (!space || !space.peers.has(socket.id)) return;
const wasHost = space.hostId === socket.id;
if (space.voiceInits) delete space.voiceInits[socket.id];
if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id);
if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id];
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id);
if (space.quizCarryLobbyReady && typeof space.quizCarryLobbyReady === 'object') {
delete space.quizCarryLobbyReady[socket.id];
if (spaceAllowsQuizCarryLobbyRelaxed(space)) {
io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } });
}
}
if (space.gauntletCrownLobbyReady && typeof space.gauntletCrownLobbyReady === 'object') {
delete space.gauntletCrownLobbyReady[socket.id];
if (isCrownLobbyShellSpace(space)) {
io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
}
}
space.peers.delete(socket.id);
if (space.peers.size === 0) {
stopGauntletTicker(space);
clearSpaceQuizTimers(space);
space.quizSession = null;
/* ระหว่างมินิเกมสืบสวน / คดีล็อก — อย่าลบห้องเมื่อเปลี่ยนหน้า room-lobby → play.html (รอ join กลับ) */
const keepCaseSpace = !!(space.detectiveMinigameActive
|| (space.joinLocked && space.caseParticipantNicknames && space.caseParticipantNicknames.size > 0));
if (keepCaseSpace) {
space.hostId = null;
return;
}
spaces.delete(sid);
} else {
if (wasHost) {
const firstPeer = space.peers.values().next().value;
if (firstPeer) {
space.hostId = firstPeer.id;
io.to(sid).emit('host-changed', { hostId: firstPeer.id });
}
}
io.to(sid).emit('user-left', { id: socket.id });
}
});
socket.on('gauntlet-jump', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.gauntletRun && space.gauntletRun.crownRunHeld) return;
if (isGauntletCrownHeistMapBySpace(space) && p.gauntletEliminated) return;
if ((p.gauntletJumpTicks || 0) > 0) return;
p.gauntletJumpPending = true;
});
socket.on('gauntlet-crown-begin-run', (cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false });
const gr = space.gauntletRun;
if (!gr) return reply({ ok: false });
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
const okGauntletCrown = md && md.gameType === 'gauntlet' && isGauntletCrownHeistMapBySpace(space);
const okBalloonShell = md && md.gameType === 'balloon_boss' && isBalloonBossMissionShellSpace(space);
if (!okGauntletCrown && !okBalloonShell) return reply({ ok: false });
const previewRoom = !!(space.previewEditorTest
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
if (!previewRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'host' });
if (!gr.crownRunHeld) return reply({ ok: true, already: true });
gr.crownRunHeld = false;
if (okGauntletCrown) {
ensureGauntletEndsAtIfNeeded(space);
emitGauntletSync(sid, space);
} else {
emitBalloonBossMissionShellGauntletSync(sid, space);
}
return reply({ ok: true });
});
/** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */
socket.on('gauntlet-preview-rows', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.gauntletRun && space.gauntletRun.crownRunHeld) return;
const hh = md.height || 15;
if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map();
const raw = data && Array.isArray(data.ys) ? data.ys : [];
const set = new Set();
const cap = Math.min(raw.length, 48);
for (let i = 0; i < cap; i++) {
const fy = Math.floor(Number(raw[i]));
if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy);
}
space.gauntletPreviewRowsBySocket.set(socket.id, set);
});
socket.on('quiz-carry-lobby-sync-request', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return;
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') {
space.quizCarryLobbyReady = {};
}
for (const id of space.peers.keys()) {
if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false;
}
socket.emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } });
});
socket.on('quiz-carry-lobby-ready', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return;
if (!space.quizCarryLobbyReady || typeof space.quizCarryLobbyReady !== 'object') space.quizCarryLobbyReady = {};
for (const id of space.peers.keys()) {
if (space.quizCarryLobbyReady[id] === undefined) space.quizCarryLobbyReady[id] = false;
}
space.quizCarryLobbyReady[socket.id] = !!(data && data.ready);
io.to(sid).emit('quiz-carry-lobby-sync', { readyMap: { ...space.quizCarryLobbyReady } });
});
socket.on('quiz-carry-lobby-start', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (!spaceAllowsQuizCarryLobbyRelaxed(space)) return reply({ ok: false, error: 'ห้องนี้ไม่ใช่โหมด lobby หยิบมาวาง / พรีวิว' });
if (space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' });
const rm = space.quizCarryLobbyReady || {};
const humanIds = [...space.peers.keys()];
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
io.to(sid).emit('quiz-carry-lobby-started', {});
reply({ ok: true });
});
socket.on('gauntlet-crown-lobby-sync-request', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
if (!isCrownLobbyShellSpace(space)) return;
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') {
space.gauntletCrownLobbyReady = {};
}
for (const id of space.peers.keys()) {
if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false;
}
socket.emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
});
socket.on('gauntlet-crown-lobby-ready', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
if (!isCrownLobbyShellSpace(space)) return;
if (!space.gauntletCrownLobbyReady || typeof space.gauntletCrownLobbyReady !== 'object') space.gauntletCrownLobbyReady = {};
for (const id of space.peers.keys()) {
if (space.gauntletCrownLobbyReady[id] === undefined) space.gauntletCrownLobbyReady[id] = false;
}
space.gauntletCrownLobbyReady[socket.id] = !!(data && data.ready);
io.to(sid).emit('gauntlet-crown-lobby-sync', { readyMap: { ...space.gauntletCrownLobbyReady } });
});
socket.on('gauntlet-crown-lobby-start', (_data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
if (!isCrownLobbyShellSpace(space)) return reply({ ok: false, error: 'ไม่ใช่แมป crown / Mega Virus ภารกิจ' });
const soloRoom = space.peers.size === 1;
if (!soloRoom && space.hostId !== socket.id) return reply({ ok: false, error: 'เฉพาะโฮสต์กด START' });
const gr = space.gauntletRun;
if (!gr || !gr.crownRunHeld) return reply({ ok: false, error: 'เริ่มแล้ว' });
const rm = space.gauntletCrownLobbyReady || {};
const humanIds = [...space.peers.keys()];
const allReady = humanIds.length > 0 && humanIds.every((id) => rm[id]);
if (!allReady) return reply({ ok: false, error: 'ยังมีผู้เล่นที่ยังไม่ Ready' });
if (isQuizQuestionMissionShellSpace(space)) {
const mdQuiz = maps.get(space.mapId) || space.mapData;
if (mdQuiz && mdQuiz.gameType === 'quiz') {
clearSpaceQuizTimers(space);
startQuizGame(sid, space, mdQuiz);
}
}
io.to(sid).emit('gauntlet-crown-lobby-started', {});
reply({ ok: true });
});
socket.on('move', (data) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (md && md.gameType === 'gauntlet') {
const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId };
socket.emit('user-move', out);
break;
}
let nx = data.x;
let ny = data.y;
const sess = space.quizSession;
const mdQuiz = (sess && sess.quizMapMd) || md;
if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') {
const st = sess.players && sess.players[socket.id];
if (st && !st.eliminated) {
const tx = Math.floor(Number(nx));
const ty = Math.floor(Number(ny));
const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty);
const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty);
if (blockTrue || blockFalse) {
nx = p.x;
ny = p.y;
}
}
}
if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) {
const txN = Number(nx);
const tyN = Number(ny);
if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) {
nx = p.x;
ny = p.y;
}
}
if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
const txN = Number(nx);
const tyN = Number(ny);
if (!Number.isFinite(txN) || !Number.isFinite(tyN)) {
nx = p.x;
ny = p.y;
} else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) {
nx = p.x;
ny = p.y;
}
const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny));
nx = sn.x;
ny = sn.y;
}
if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) {
const ns = Math.floor(Number(data.spaceShooterScore));
if (Number.isFinite(ns) && ns >= 0) {
const prev = Math.max(0, p.spaceShooterScore | 0);
if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns);
}
}
if (p && md && md.gameType === 'balloon_boss' && data) {
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
if (data.balloonBossScore != null) {
const ns = Math.floor(Number(data.balloonBossScore));
if (Number.isFinite(ns) && ns >= 0) {
const prev = Math.max(0, p.balloonBossScore | 0);
if (ns <= prev + 35) p.balloonBossScore = Math.max(prev, ns);
}
}
if (data.balloonBossBossDmg != null) {
const nd = Math.floor(Number(data.balloonBossBossDmg));
if (Number.isFinite(nd) && nd >= 0) {
const prevD = Math.max(0, p.balloonBossBossDmg | 0);
if (nd <= prevD + 8) p.balloonBossBossDmg = Math.max(prevD, nd);
}
}
if (data.balloonBossBalloons != null) {
const nb = Math.floor(Number(data.balloonBossBalloons));
if (Number.isFinite(nb) && nb >= 0) {
const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
? Math.floor(p.balloonBossBalloons)
: bbDefaultBalloons;
if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb;
}
}
if (data.balloonBossEliminated) p.balloonBossEliminated = true;
}
if (p) {
const prevX = Number(p.x);
const prevY = Number(p.y);
const prevDir = p.direction || 'down';
p.x = nx;
p.y = ny;
p.direction = data.direction || p.direction;
const posUnchanged = Math.abs(nx - prevX) < 1e-5 && Math.abs(ny - prevY) < 1e-5 && p.direction === prevDir;
if (!posUnchanged) {
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
if (md && md.gameType === 'balloon_boss') {
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 3));
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
out.balloonBossBossDmg = Math.max(0, p.balloonBossBossDmg | 0);
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
? Math.max(0, Math.floor(p.balloonBossBalloons))
: bbDef;
out.balloonBossEliminated = !!p.balloonBossEliminated;
}
if (nx !== data.x || ny !== data.y) socket.emit('user-move', out);
socket.to(sid).emit('user-move', out);
}
}
break;
}
}
});
socket.on('lobby-interact', (data, ack) => {
const reply = typeof ack === 'function' ? ack : () => {};
const tx = Math.floor(Number(data && data.x));
const ty = Math.floor(Number(data && data.y));
for (const [sid, space] of spaces) {
if (!space.peers.has(socket.id)) continue;
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' });
const w = md.width || 20;
const h = md.height || 15;
if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) {
return reply({ ok: false, error: 'จุดไม่ถูกต้อง' });
}
if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) {
return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' });
}
const px = Math.floor(Number(p.x));
const py = Math.floor(Number(p.y));
const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]];
let near = false;
for (const [dx, dy] of neighbors) {
if (px + dx === tx && py + dy === ty) { near = true; break; }
}
if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' });
if (!p.ready) return reply({ ok: false, error: 'กดพร้อมก่อน แล้วค่อยกด F ที่ช่องสีเขียว' });
const nick = (p.nickname || '').trim() || 'ผู้เล่น';
io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty });
return reply({ ok: true });
}
reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' });
});
socket.on('chat', (text) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
const nick = space.peers.get(socket.id)?.nickname || '';
io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick });
break;
}
}
});
socket.on('voice-state', ({ micOn }) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
if (p) p.voiceMicOn = micOn !== false;
if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id];
io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false });
break;
}
}
});
socket.on('voice-activity', ({ level }) => {
const sid = socket.data.spaceId;
if (!sid || !spaces.get(sid)) return;
const until = Date.now() + 400;
io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until });
});
socket.on('voice-init', (data) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
if (!space.voiceInits) space.voiceInits = {};
space.voiceInits[socket.id] = data;
io.to(sid).emit('voice-init', { from: socket.id, data });
break;
}
}
});
socket.on('voice-chunk', (data) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
socket.to(sid).emit('voice-chunk', { from: socket.id, data });
break;
}
}
});
socket.on('webrtc-signal', (data) => {
const { to, type, sdp, candidate } = data;
if (!to) return;
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid) && space.peers.has(to)) {
io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate });
break;
}
}
});
});
loadMaps();
server.on('error', (err) => {
if (err && (err.code === 'EACCES' || err.code === 'EADDRINUSE')) {
console.error('');
console.error('[Game] Cannot listen on', HOST + ':' + PORT, '(' + err.code + ').');
console.error(' Windows AppServ:');
console.error(' set HOST=127.0.0.1');
console.error(' set PORT=13010');
console.error(' node server.js');
console.error(' Or double-click start-dev.cmd');
console.error('');
process.exit(1);
}
throw err;
});
server.listen(PORT, HOST, () => console.log('Game server listening on http://' + HOST + ':' + PORT + BASE_PATH));